diff --git a/agents/myra/src/system-prompt.ts b/agents/myra/src/system-prompt.ts index 1fc3e7389..c142ae07b 100644 --- a/agents/myra/src/system-prompt.ts +++ b/agents/myra/src/system-prompt.ts @@ -37,4 +37,8 @@ export const ASSISTANT_SYSTEM_PROMPT = "form), which the hub mints when the agent is deployed and which " + 'shows up only in the "Participants:" block of an incoming ' + "message. Never construct an address from an agent's name or slug " + - "to reach it — that address does not route."; + "to reach it — that address does not route.\n" + + "\n" + + "When you hand a task to another participant, copy the person's " + + 'address from that same "Participants:" block in `to` so they can ' + + "follow the conversation."; diff --git a/apps/web/src/chat/room-roster.test.ts b/apps/web/src/chat/room-roster.test.ts index 1f7483d22..c396f90cf 100644 --- a/apps/web/src/chat/room-roster.test.ts +++ b/apps/web/src/chat/room-roster.test.ts @@ -6,8 +6,8 @@ describe("appendRoster / stripRoster", () => { test("round-trips a message through append and strip", () => { const body = "Please pass this to the scribe."; const withRoster = appendRoster(body, [ - { name: "Scribe", address: "run_abc@room.example" }, - { name: "Myra", address: "run_def@room.example" }, + { name: "Scribe", address: "run_abc@room.example", kind: "agent" }, + { name: "Myra", address: "run_def@room.example", kind: "agent" }, ]); expect(withRoster).toBe( "Please pass this to the scribe.\n\nParticipants:\nScribe \nMyra ", @@ -19,4 +19,29 @@ describe("appendRoster / stripRoster", () => { expect(appendRoster("hello", [])).toBe("hello"); expect(stripRoster("hello")).toBe("hello"); }); + + test("appends a cc instruction naming the person, and strips it too", () => { + const body = "Please pass this to the scribe."; + const withRoster = appendRoster(body, [ + { name: "Sawyer", address: "usr_sawyer@room.example", kind: "person" }, + { name: "Scribe", address: "run_abc@room.example", kind: "agent" }, + ]); + expect(withRoster).toBe( + "Please pass this to the scribe.\n\n" + + "Participants:\nSawyer \nScribe \n\n" + + "Copy usr_sawyer@room.example in `to` on any mail you send another participant, so they can follow along.", + ); + expect(stripRoster(withRoster)).toBe(body); + }); + + test("names every person when more than one is in the roster", () => { + const withRoster = appendRoster("hi", [ + { name: "Sawyer", address: "usr_sawyer@room.example", kind: "person" }, + { name: "Alex", address: "usr_alex@room.example", kind: "person" }, + { name: "Scribe", address: "run_abc@room.example", kind: "agent" }, + ]); + expect(withRoster).toContain( + "Copy usr_sawyer@room.example, usr_alex@room.example in `to` on any mail you send another participant, so they can follow along.", + ); + }); }); diff --git a/apps/web/src/chat/room-roster.ts b/apps/web/src/chat/room-roster.ts index be54ec302..04d1cbc69 100644 --- a/apps/web/src/chat/room-roster.ts +++ b/apps/web/src/chat/room-roster.ts @@ -3,7 +3,11 @@ // Participants panel already reads them off the deployment/run listing). // Appending them as a trailing block on every room send gives every agent // in the room everyone else's address, so one agent can mail another -// directly. `stripRoster` is the inverse, used only to keep the block out +// directly. Agent-to-agent mail goes run to run and never lands in the +// person's mailbox on its own, so the block also carries the person's own +// address and a line telling agents to copy it on any handoff — the mail +// tools have no `cc` field, so that means naming it as another `to` +// recipient. `stripRoster` is the inverse, used only to keep the block out // of what a person sees echoed back as their own sent message. const ROSTER_HEADING = "Participants:"; @@ -11,6 +15,7 @@ const ROSTER_HEADING = "Participants:"; export type RosterEntry = { readonly name: string; readonly address: string; + readonly kind: "person" | "agent"; }; /** The trailing block marker, including the blank-line separator that @@ -19,11 +24,28 @@ function rosterBlock(entries: readonly RosterEntry[]): string { return [ROSTER_HEADING, ...entries.map((entry) => `${entry.name} <${entry.address}>`)].join("\n"); } +/** The trailing instruction naming every person in the roster, so an agent + * knows to copy them on a handoff to another participant. `undefined` when + * the roster has no person entry (there is nobody to copy). */ +function ccInstruction(entries: readonly RosterEntry[]): string | undefined { + const people = entries.filter((entry) => entry.kind === "person"); + if (people.length === 0) return undefined; + const addresses = people.map((entry) => entry.address).join(", "); + return `Copy ${addresses} in \`to\` on any mail you send another participant, so they can follow along.`; +} + /** Appends a `Participants:` block listing every entry's name and address, - * separated from the message by a blank line. A no-op with no entries. */ + * plus a `cc` instruction naming any person in the roster, separated from + * the message by a blank line. A no-op with no entries. */ export function appendRoster(body: string, entries: readonly RosterEntry[]): string { if (entries.length === 0) return body; - return `${body}\n\n${rosterBlock(entries)}`; + const instruction = ccInstruction(entries); + return [ + body, + "", + rosterBlock(entries), + ...(instruction !== undefined ? ["", instruction] : []), + ].join("\n"); } /** Removes a trailing `Participants:` block appended by `appendRoster`, so diff --git a/apps/web/src/chat/threads-api.ts b/apps/web/src/chat/threads-api.ts index 206a9574f..c2c1f3893 100644 --- a/apps/web/src/chat/threads-api.ts +++ b/apps/web/src/chat/threads-api.ts @@ -777,25 +777,35 @@ export function ancestorChain( * agent in it. The hub triggers each addressed run and keeps the Sent * copy, so the person's own turn comes back out of the mailbox like any * other. The body carries a trailing roster of every agent's name and - * run address (the same rows the Participants panel reads), so an agent - * can hand a task to another agent in the room — the hub only delivers to - * a run address, which only the client otherwise knows. */ + * run address plus the person's own (the same rows the Participants panel + * reads), so an agent can hand a task to another agent in the room — the + * hub only delivers to a run address, which only the client otherwise + * knows — and can copy the person on that handoff so they can follow it. */ export async function sendToRoom(input: { readonly roomTenantId: string; - readonly agents: readonly RoomParticipant[]; + readonly participants: readonly RoomParticipant[]; readonly content: string; /** The turn this reply threads onto — a sub-thread's parent. */ readonly inReplyTo?: string; }): Promise { - const live = input.agents.filter((agent) => agent.address.includes("@")); + const live = input.participants.filter( + (participant) => participant.kind === "agent" && participant.address.includes("@"), + ); if (live.length === 0) { throw new ChatApiError("No agent is in this workbench yet, so there is nobody to send to."); } const to = live.map((agent) => agent.address); - const body = appendRoster( - input.content, - live.map((agent) => ({ name: agent.name, address: agent.address })), + const people = input.participants.filter( + (participant) => participant.kind === "person" && participant.address.includes("@"), ); + const body = appendRoster(input.content, [ + ...people.map((person) => ({ + name: person.name, + address: person.address, + kind: "person" as const, + })), + ...live.map((agent) => ({ name: agent.name, address: agent.address, kind: "agent" as const })), + ]); let response: Response; try { response = await fetch(`${mailboxPath(input.roomTenantId)}/send`, { diff --git a/apps/web/src/pages/workbench-room-page.tsx b/apps/web/src/pages/workbench-room-page.tsx index abbd8839a..bda6b10a6 100644 --- a/apps/web/src/pages/workbench-room-page.tsx +++ b/apps/web/src/pages/workbench-room-page.tsx @@ -280,7 +280,7 @@ function Room({ roomTenantId }: { readonly roomTenantId: string }) { }) => sendToRoom({ roomTenantId, - agents, + participants: participants.data ?? [], content, ...(inReplyTo !== undefined ? { inReplyTo } : {}), }), diff --git a/apps/web/src/workbench-create.ts b/apps/web/src/workbench-create.ts index 9b18c7d77..96152f731 100644 --- a/apps/web/src/workbench-create.ts +++ b/apps/web/src/workbench-create.ts @@ -118,7 +118,7 @@ export async function createWorkbench(input: CreateWorkbenchInput): Promise 0) { - await sendToRoom({ roomTenantId: tenantId, agents, content: input.openingMessage }); + await sendToRoom({ roomTenantId: tenantId, participants, content: input.openingMessage }); } } catch (cause) { throw failure(cause, "opening-message", tenantId);