Skip to content

Commit

A new agent says nothing until you do. Creating one still opens your direct message with it, but no introduction is written there, nothing is charged to its budget and nothing waits in Chat; the desk's special first message, its fixed fallback and the prompt that asked for it are gone. The agents guide says so.

syntaqxcommitted Parent3827b3bBrowse files
6 files+117−1900/6 viewed
+4−6
123123 [model limits](#model-routing) and [budget](#budgets), then choose
124124 **Create agent**.
125125
126−### Its hello
126+### After that
127127
128128 The agent appears on the Agents page as **Idle**, and a direct message
129−between you and it opens in Chat, where it says hello in its own voice: who
130−it is, what it will do, and a thing or two to ask it first.
131−That hello is a reply like any other, charged to its budget; when the
132−workspace has no model it can use, it sends a short fixed hello instead.
133−Invite a workspace agent to the channels its team works in.
129+between you and it opens in Chat. It says nothing until you do: no
130+introduction, no welcome, nothing charged to its budget until you ask it
131+something. Invite a workspace agent to the channels its team works in.
134132
135133 ### Example: hire Margo from the QA template
136134
+1−34
304304 throw error;
305305 }
306306 this.audit(viewer, a.workspace, "create_agent", definition.handle, `Created ${personal ? "the personal agent " : ""}@${definition.handle} (version 1)`, "agents", personal ? "member" : "owner");
307− // On its teams before it says hello, so it knows them.
307+ // On its teams as it is made, so it knows them from its first message.
308308 if (teams.value.length) {
309309 const identity = identityClient(this.env.IDENTITY);
310310 await Promise.all(
316316 ),
317317 );
318318 }
319− this.defer(this.hello(a.workspace, workspaceId, id, viewer));
320319 const row = await this.row(workspaceId, definition.handle);
321320 return ok(toAgent(row!, new Date()));
322321 }
704703 },
705704 )
706705 .catch((error: unknown) => console.error("agents: a requester was not told", request.id, String(error)));
707− }
708−
709− /**
710− * A new agent's first words: the DM with the person who made it is
711− * opened, and the agent says hello there in its own voice, as a reply
712− * billed like any other (a fixed hello when no model can be used).
713− * After the answer; a failure only logs.
714− */
715− private async hello(workspace: string, workspaceId: string, agentId: string, creator: User): Promise<void> {
716− if ((creator.kind ?? "user") !== "user") return;
717− try {
718− const dm = await chatClient(this.env.CHAT).openDm(workspace, creator, [{ kind: "agent", id: agentId }]);
719− if (!dm.ok) throw new Error(dm.error.message);
720− const desk = this.env.DESKS.get(this.env.DESKS.idFromName(agentId));
721− await desk.take({
722− workspace,
723− workspace_id: workspaceId,
724− channel_id: dm.value.id,
725− channel_kind: "dm",
726− channel_name: null,
727− agent_id: agentId,
728− // One hello per agent, however often this runs.
729− message_id: `hello:${agentId}`,
730− thread_root: null,
731− asked_by: creator.id,
732− hops: 0,
733− asker: askerAccess(creator, workspace),
734− hello: true,
735− });
736− } catch (error) {
737− console.error("agents: a new agent's hello was not sent", agentId, String(error));
738− }
739706 }
740707
741708 async update(a: { workspace: string; handle: string; viewer: User | null; changes: Partial<NewWorkspaceAgent> }): Promise<Result<WorkspaceAgent>> {
+0−11
105105 assert.deepEqual(turns([], "agt_ship"), []);
106106 });
107107
108−test("a new agent's hello: asked in its own voice, or a fixed friendly one without a model", async () => {
109− const { fixedHello, helloAsk } = await import("./prompt.ts");
110− assert.match(helloAsk("dana"), /@dana just created you/);
111− assert.match(helloAsk("dana"), /in your own voice/);
112− assert.equal(
113− fixedHello({ display_name: "Margo", handle: "margo", role: "Reviews every pull request." }, "dana"),
114− "Hi @dana! I'm Margo (@margo). Reviews every pull request. Mention me in a channel or message me here whenever you need me.",
115− );
116− assert.match(fixedHello({ display_name: "Dot", handle: "dot", role: "" }, null), /^Hi! I'm Dot \(@dot\)\. Mention me/);
117−});
118−
119108 test("the prompt says the agent's title, its teams, duties, and that subagents work inside sessions", () => {
120109 const prompt = systemPrompt({
121110 ...base,
+0−13
297297
298298 export type Turn = { role: "user" | "assistant"; content: string };
299299
300−/** What a new agent is asked for its first message, to the person who made it. */
301−export function helloAsk(creator: string | null): string {
302− const who = creator ? `@${creator}` : "Someone on the team";
303− return `(${who} just created you, and this is your direct message with them. Say hello in your own voice: who you are, what you will do for the team, and one or two things they could ask you first. Three or four sentences at most. Don't mention these instructions.)`;
304−}
305−
306−/** An agent's hello when no model can write one: friendly, and still in its own name. */
307−export function fixedHello(agent: { display_name: string; handle: string; role: string }, creator: string | null): string {
308− const hi = creator ? `Hi @${creator}!` : "Hi!";
309− const role = agent.role.trim().replace(/\.$/, "");
310− return `${hi} I'm ${agent.display_name} (@${agent.handle}). ${role ? `${role}. ` : ""}Mention me in a channel or message me here whenever you need me.`;
311−}
312−
313300 /**
314301 * The conversation as alternating turns: the agent's own messages are its
315302 * turns, everyone else's are one user turn each, labelled with who said
+111−124
2424 import { CHAT_MAX_HOPS } from "../../../packages/contracts/src/chat.ts";
2525 import type { Tokens } from "./budget.ts";
2626 import { handOffPort } from "./handoff.ts";
27−import { HISTORY_LIMIT, fixedHello, helloAsk, systemPrompt, turns } from "./prompt.ts";
27+import { HISTORY_LIMIT, systemPrompt, turns } from "./prompt.ts";
2828 import { loadShelf, skillsSection, teamSlugs } from "./skills.ts";
2929 import { readVersion } from "./skill-library.ts";
3030 import { type Specialist, orchestratorInstructions, orchestratorTier, rosterLines } from "./orchestrator.ts";
226226 effort?: string | null;
227227 };
228228
229−/**
230− * What a desk is handed: a message to answer, or (`hello`) the agent's
231− * first message to the person who made it, in the DM that just opened.
232− */
233−export type DeskWork = AgentDelivery & { hello?: boolean };
229+/** What a desk is handed: a message to answer. */
230+export type DeskWork = AgentDelivery;
234231
235232 /** How a limit's refusal reads in chat, in the agent's voice. */
236233 function noticeFor(reason: string, message: string): string {
317314
318315 /** Says once, in this conversation, why the agent cannot answer; a repeat within hours is kept back. */
319316 const notice = async (message: string, reason: string) => {
320− // An agent's first words are never an excuse: without a model, a fixed hello.
321− if (delivery.hello) {
322− const posted = await surface.post(fixedHello(row, delivery.asker?.username ?? null)).catch(() => null);
323− return await finish({ status: "blocked", error: reason, reply_id: posted });
324− }
325317 const since = new Date(now.getTime() - NOTICE_QUIET_MS).toISOString();
326318 const recent = await db
327319 .prepare(
336328
337329 try {
338330 // A reply under one of its session cards steers that session; nothing is answered here.
339− const steered = delivery.hello ? null : await steeredSession(db, row.id, delivery.thread_root);
331+ const steered = await steeredSession(db, row.id, delivery.thread_root);
340332 if (steered) {
341333 const history = await surface.history(HISTORY_LIMIT).catch(() => [] as SurfaceMessage[]);
342334 const message = history.find((m) => m.id === delivery.message_id);
347339 }
348340
349341 // Read the conversation while showing that the agent is on it.
350− // A hello has no conversation yet: it is asked to introduce itself.
351342 // And who is here: said every turn, so the agent knows who reads it and who doesn't.
352343 // And its teams, from their pages: who leads, who owns what, who is around.
353344 const [, history, conversationHere, teamsHere] = await Promise.all([
354345 surface.typing(),
355− delivery.hello ? Promise.resolve([]) : surface.history(HISTORY_LIMIT),
356− delivery.hello ? Promise.resolve(null) : surface.conversation(),
346+ surface.history(HISTORY_LIMIT),
347+ surface.conversation(),
357348 loadTeams(env, slug, row.workspace_id, { id: row.id }).catch(() => null),
358349 ]);
359350 // A personal agent answers only its member, in the DM of the two of them (access.ts).
363354 members: conversationHere ? conversationHere.people + conversationHere.agents : null,
364355 });
365356 if (refused) return await notice(refused, "personal_agent");
366− const conversation = delivery.hello ? [{ role: "user" as const, content: helloAsk(delivery.asker?.username ?? null) }] : turns(history, row.id);
357+ const conversation = turns(history, row.id);
367358 if (!conversation.length) return await finish({ status: "skipped", error: "nothing to answer" });
368359 const author = askerIn(history, delivery);
369360 const askerName = delivery.asker?.username ?? author?.name ?? null;
387378 // The "Your abilities outside g1t" section, once the tool box has them.
388379 let abilitiesText: string | null = null;
389380 let place: RecallPlace = { channel_id: delivery.channel_id, kind: delivery.channel_kind === "dm" ? "dm" : "private", people: [delivery.asked_by] };
390− if (!delivery.hello) {
391− try {
392− const audience = await Audience.build(slug, delivery.asked_by, audiencePorts(env, slug, delivery.channel_id));
393− place = { channel_id: delivery.channel_id, kind: audience.kind, people: audience.shared ? [delivery.asked_by] : audience.members.map((m) => m.id) };
394− const ports = (consult: ToolPorts["consult"]) => toolPorts(env, slug, row.workspace_id, delivery.channel_id, consult, row.id);
395− audienceHash = audience.hash;
396− const consult = consulting({
397− db,
398− row,
399− delivery,
400− send: model.send,
401− policy: model.policy,
402− ownModel: model.ownModel,
403− sessionModel: model.sessionModel,
404− own: model.own,
405− ports,
406− hops,
407− now,
408− surface,
409− asker,
410− spent: consulted,
411− });
412− const where = delivery.channel_kind === "dm" ? "a direct message" : `#${delivery.channel_name ?? "a channel"}`;
413− const actions = actionPorts(env as unknown as SessionEnv, {
414− agent: row,
415− place,
416− source: { kind: "message", ref: delivery.message_id, label: askerName ? `@${askerName} in ${where}` : where, channel_id: delivery.channel_id },
417− asker: { id: delivery.asked_by, username: askerName },
418− workspace: slug,
419− postCard: (card) => surface.post("", card).catch(() => null),
420− // Real work becomes a session, with its card in this conversation.
421− spinOff: async (title, goal) => {
422− if (spinOffs >= MAX_SPIN_OFFS) return { ok: false, message: "You've started enough sessions from this message." };
423− spinOffs++;
424− const session = await startSession(env as unknown as SessionEnv, {
381+ try {
382+ const audience = await Audience.build(slug, delivery.asked_by, audiencePorts(env, slug, delivery.channel_id));
383+ place = { channel_id: delivery.channel_id, kind: audience.kind, people: audience.shared ? [delivery.asked_by] : audience.members.map((m) => m.id) };
384+ const ports = (consult: ToolPorts["consult"]) => toolPorts(env, slug, row.workspace_id, delivery.channel_id, consult, row.id);
385+ audienceHash = audience.hash;
386+ const consult = consulting({
387+ db,
388+ row,
389+ delivery,
390+ send: model.send,
391+ policy: model.policy,
392+ ownModel: model.ownModel,
393+ sessionModel: model.sessionModel,
394+ own: model.own,
395+ ports,
396+ hops,
397+ now,
398+ surface,
399+ asker,
400+ spent: consulted,
401+ });
402+ const where = delivery.channel_kind === "dm" ? "a direct message" : `#${delivery.channel_name ?? "a channel"}`;
403+ const actions = actionPorts(env as unknown as SessionEnv, {
404+ agent: row,
405+ place,
406+ source: { kind: "message", ref: delivery.message_id, label: askerName ? `@${askerName} in ${where}` : where, channel_id: delivery.channel_id },
407+ asker: { id: delivery.asked_by, username: askerName },
408+ workspace: slug,
409+ postCard: (card) => surface.post("", card).catch(() => null),
410+ // Real work becomes a session, with its card in this conversation.
411+ spinOff: async (title, goal) => {
412+ if (spinOffs >= MAX_SPIN_OFFS) return { ok: false, message: "You've started enough sessions from this message." };
413+ spinOffs++;
414+ const session = await startSession(env as unknown as SessionEnv, {
415+ agent: row,
416+ kind: "chat",
417+ title,
418+ goal: `${askerName ? `@${askerName}` : "Someone"} asked in ${where}:\n\n${goal}`,
419+ workspace: slug,
420+ channel_id: delivery.channel_id,
421+ channel_kind: delivery.channel_kind,
422+ channel_name: delivery.channel_name,
423+ thread_root: delivery.thread_root,
424+ message_id: delivery.message_id,
425+ asked_by: delivery.asked_by,
426+ asked_by_username: askerName,
427+ asker: delivery.asker ?? null,
428+ chain: [...chain],
429+ hops,
430+ });
431+ const cap = session.cap_micros ? ` with a cap of ${dollars(session.cap_micros)}` : "";
432+ return { ok: true, message: `Started the session "${session.title}"${cap}. Its card is in the conversation and it reports back there. Tell them in a sentence; don't do the work here.` };
433+ },
434+ });
435+ // Work handed to a colleague: the only way an agent gets another working (handoff.ts).
436+ const viewer = audience.asker;
437+ actions.handOff = handOffPort({
438+ self: { id: row.id, handle: row.handle },
439+ chain,
440+ asker: delivery.asker ?? null,
441+ agent: async (handle) => {
442+ const found = await db
443+ .prepare(selectAgents("a.workspace_id = ?3 AND a.handle = ?4 AND a.archived_at IS NULL AND a.scope = 'workspace'"))
444+ .bind(...periods(now), row.workspace_id, handle)
445+ .first<Row>();
446+ if (!found) return null;
447+ const agent = toAgent(found, now);
448+ return { id: agent.id, handle: agent.handle, display_name: agent.display_name, builtin: !!found.builtin, status: agent.status };
449+ },
450+ person: async (handle) => {
451+ if (!viewer) return false;
452+ const members = await identityClient(env.IDENTITY).listMembers(slug, viewer);
453+ return members.ok && members.value.some((m) => m.username.toLowerCase() === handle);
454+ },
455+ handOff: (colleagueId, brief) => surface.handOff(colleagueId, brief),
456+ });
457+ toolbox = new ToolBox(
458+ audience,
459+ ports(consult.ask),
460+ { agentId: row.id, notConsult: [row.handle, ...(sender ? [sender.handle] : [])], hops, maxHops: CHAT_MAX_HOPS },
461+ [],
462+ actions,
463+ );
464+ consult.attach(toolbox);
465+ // Its abilities outside g1t (abilities.ts): offered and enforced by the tool box, for the person it acts for.
466+ if (viewer) {
467+ const sections = await abilitiesFor(env as unknown as AbilityEnv, { agent: row, definition, workspace: slug, asker: viewer }).catch(() => null);
468+ if (sections) {
469+ const saidByPeople = saidText([...history].reverse().filter((m) => m.author.kind === "user").slice(0, 3).map((m) => m.body));
470+ toolbox.useAbilities(
471+ sections,
472+ abilityPorts(env as unknown as AbilityEnv, {
425473 agent: row,
426− kind: "chat",
427− title,
428− goal: `${askerName ? `@${askerName}` : "Someone"} asked in ${where}:\n\n${goal}`,
429474 workspace: slug,
430475 channel_id: delivery.channel_id,
431− channel_kind: delivery.channel_kind,
432− channel_name: delivery.channel_name,
433− thread_root: delivery.thread_root,
434− message_id: delivery.message_id,
435− asked_by: delivery.asked_by,
436− asked_by_username: askerName,
437− asker: delivery.asker ?? null,
438− chain: [...chain],
439− hops,
440− });
441− const cap = session.cap_micros ? ` with a cap of ${dollars(session.cap_micros)}` : "";
442− return { ok: true, message: `Started the session "${session.title}"${cap}. Its card is in the conversation and it reports back there. Tell them in a sentence; don't do the work here.` };
443− },
444− });
445− // Work handed to a colleague: the only way an agent gets another working (handoff.ts).
446− const viewer = audience.asker;
447− actions.handOff = handOffPort({
448− self: { id: row.id, handle: row.handle },
449− chain,
450− asker: delivery.asker ?? null,
451− agent: async (handle) => {
452− const found = await db
453− .prepare(selectAgents("a.workspace_id = ?3 AND a.handle = ?4 AND a.archived_at IS NULL AND a.scope = 'workspace'"))
454− .bind(...periods(now), row.workspace_id, handle)
455− .first<Row>();
456− if (!found) return null;
457− const agent = toAgent(found, now);
458− return { id: agent.id, handle: agent.handle, display_name: agent.display_name, builtin: !!found.builtin, status: agent.status };
459− },
460− person: async (handle) => {
461− if (!viewer) return false;
462− const members = await identityClient(env.IDENTITY).listMembers(slug, viewer);
463− return members.ok && members.value.some((m) => m.username.toLowerCase() === handle);
464− },
465− handOff: (colleagueId, brief) => surface.handOff(colleagueId, brief),
466− });
467− toolbox = new ToolBox(
468− audience,
469− ports(consult.ask),
470− { agentId: row.id, notConsult: [row.handle, ...(sender ? [sender.handle] : [])], hops, maxHops: CHAT_MAX_HOPS },
471− [],
472− actions,
473− );
474− consult.attach(toolbox);
475− // Its abilities outside g1t (abilities.ts): offered and enforced by the tool box, for the person it acts for.
476− if (viewer && !delivery.hello) {
477− const sections = await abilitiesFor(env as unknown as AbilityEnv, { agent: row, definition, workspace: slug, asker: viewer }).catch(() => null);
478− if (sections) {
479− const saidByPeople = saidText([...history].reverse().filter((m) => m.author.kind === "user").slice(0, 3).map((m) => m.body));
480− toolbox.useAbilities(
481− sections,
482− abilityPorts(env as unknown as AbilityEnv, {
483− agent: row,
484− workspace: slug,
485− channel_id: delivery.channel_id,
486− session: null,
487− asker: { id: delivery.asked_by, username: askerName },
488− postCard: (card) => surface.post("", card).catch(() => null),
489− }),
490− saidByPeople,
491− definition.abilities.mcp_servers,
492− );
493− abilitiesText = abilitiesSection(sections);
494− }
476+ session: null,
477+ asker: { id: delivery.asked_by, username: askerName },
478+ postCard: (card) => surface.post("", card).catch(() => null),
479+ }),
480+ saidByPeople,
481+ definition.abilities.mcp_servers,
482+ );
483+ abilitiesText = abilitiesSection(sections);
495484 }
496− } catch (error) {
497− // Without an audience nothing may be read: the reply goes on with this conversation only.
498− console.error("agents: no audience for a reply, so no tools", row.id, String(error));
499485 }
486+ } catch (error) {
487+ // Without an audience nothing may be read: the reply goes on with this conversation only.
488+ console.error("agents: no audience for a reply, so no tools", row.id, String(error));
500489 }
501490 // What people said last, for recalling what the workspace's artifacts say about it.
502491 const said = [...history].reverse().filter((m) => m.author.kind === "user").slice(0, 3).map((m) => m.body);
503− const [facts, recent, passages, shelf] = delivery.hello
504− ? [[], null, [], []]
505− : await Promise.all([
492+ const [facts, recent, passages, shelf] = await Promise.all([
506493 recall(db, row.id, place).catch(() => []),
507494 sessionsHere(db, row.id, delivery.channel_id).catch(() => null),
508495 toolbox ? toolbox.recall(recallQuery(said), definition.reading ?? []) : Promise.resolve([]),
+1−2
155155 async acknowledge() {
156156 acknowledged = (async () => {
157157 try {
158− // A first message's hello answers nothing; a DM's people decide the rest.
159− if (delivery.message_id.startsWith("hello:")) return false;
158+ // A DM's people decide.
160159 if (delivery.channel_kind === "dm") {
161160 const audience = await client.audience(workspace, channel);
162161 if (!audience.ok || !reactsIn("dm", audience.value.member_count)) return false;