Skip to content

Commit beadfa4

Browse files
committed
feat(web): agents copy the person on handoff mail (CL-8577)
1 parent 943bbde commit beadfa4

5 files changed

Lines changed: 50 additions & 14 deletions

File tree

‎agents/myra/src/system-prompt.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,8 @@ export const ASSISTANT_SYSTEM_PROMPT =
3737
"form), which the hub mints when the agent is deployed and which " +
3838
'shows up only in the "Participants:" block of an incoming ' +
3939
"message. Never construct an address from an agent's name or slug " +
40-
"to reach it — that address does not route.";
40+
"to reach it — that address does not route.\n" +
41+
"\n" +
42+
"When you hand a task to another participant, copy the person's " +
43+
'address from that same "Participants:" block in `to` so they can ' +
44+
"follow the conversation.";

‎apps/web/src/chat/room-roster.ts‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,19 @@
33
// Participants panel already reads them off the deployment/run listing).
44
// Appending them as a trailing block on every room send gives every agent
55
// in the room everyone else's address, so one agent can mail another
6-
// directly. `stripRoster` is the inverse, used only to keep the block out
6+
// directly. Agent-to-agent mail goes run to run and never lands in the
7+
// person's mailbox on its own, so the block also carries the person's own
8+
// address and a line telling agents to copy it on any handoff — the mail
9+
// tools have no `cc` field, so that means naming it as another `to`
10+
// recipient. `stripRoster` is the inverse, used only to keep the block out
711
// of what a person sees echoed back as their own sent message.
812

913
const ROSTER_HEADING = "Participants:";
1014

1115
export type RosterEntry = {
1216
readonly name: string;
1317
readonly address: string;
18+
readonly kind: "person" | "agent";
1419
};
1520

1621
/** The trailing block marker, including the blank-line separator that
@@ -19,11 +24,28 @@ function rosterBlock(entries: readonly RosterEntry[]): string {
1924
return [ROSTER_HEADING, ...entries.map((entry) => `${entry.name} <${entry.address}>`)].join("\n");
2025
}
2126

27+
/** The trailing instruction naming every person in the roster, so an agent
28+
* knows to copy them on a handoff to another participant. `undefined` when
29+
* the roster has no person entry (there is nobody to copy). */
30+
function ccInstruction(entries: readonly RosterEntry[]): string | undefined {
31+
const people = entries.filter((entry) => entry.kind === "person");
32+
if (people.length === 0) return undefined;
33+
const addresses = people.map((entry) => entry.address).join(", ");
34+
return `Copy ${addresses} in \`to\` on any mail you send another participant, so they can follow along.`;
35+
}
36+
2237
/** Appends a `Participants:` block listing every entry's name and address,
23-
* separated from the message by a blank line. A no-op with no entries. */
38+
* plus a `cc` instruction naming any person in the roster, separated from
39+
* the message by a blank line. A no-op with no entries. */
2440
export function appendRoster(body: string, entries: readonly RosterEntry[]): string {
2541
if (entries.length === 0) return body;
26-
return `${body}\n\n${rosterBlock(entries)}`;
42+
const instruction = ccInstruction(entries);
43+
return [
44+
body,
45+
"",
46+
rosterBlock(entries),
47+
...(instruction !== undefined ? ["", instruction] : []),
48+
].join("\n");
2749
}
2850

2951
/** Removes a trailing `Participants:` block appended by `appendRoster`, so

‎apps/web/src/chat/threads-api.ts‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -777,25 +777,35 @@ export function ancestorChain(
777777
* agent in it. The hub triggers each addressed run and keeps the Sent
778778
* copy, so the person's own turn comes back out of the mailbox like any
779779
* other. The body carries a trailing roster of every agent's name and
780-
* run address (the same rows the Participants panel reads), so an agent
781-
* can hand a task to another agent in the room — the hub only delivers to
782-
* a run address, which only the client otherwise knows. */
780+
* run address plus the person's own (the same rows the Participants panel
781+
* reads), so an agent can hand a task to another agent in the room — the
782+
* hub only delivers to a run address, which only the client otherwise
783+
* knows — and can copy the person on that handoff so they can follow it. */
783784
export async function sendToRoom(input: {
784785
readonly roomTenantId: string;
785-
readonly agents: readonly RoomParticipant[];
786+
readonly participants: readonly RoomParticipant[];
786787
readonly content: string;
787788
/** The turn this reply threads onto — a sub-thread's parent. */
788789
readonly inReplyTo?: string;
789790
}): Promise<void> {
790-
const live = input.agents.filter((agent) => agent.address.includes("@"));
791+
const live = input.participants.filter(
792+
(participant) => participant.kind === "agent" && participant.address.includes("@"),
793+
);
791794
if (live.length === 0) {
792795
throw new ChatApiError("No agent is in this workbench yet, so there is nobody to send to.");
793796
}
794797
const to = live.map((agent) => agent.address);
795-
const body = appendRoster(
796-
input.content,
797-
live.map((agent) => ({ name: agent.name, address: agent.address })),
798+
const people = input.participants.filter(
799+
(participant) => participant.kind === "person" && participant.address.includes("@"),
798800
);
801+
const body = appendRoster(input.content, [
802+
...people.map((person) => ({
803+
name: person.name,
804+
address: person.address,
805+
kind: "person" as const,
806+
})),
807+
...live.map((agent) => ({ name: agent.name, address: agent.address, kind: "agent" as const })),
808+
]);
799809
let response: Response;
800810
try {
801811
response = await fetch(`${mailboxPath(input.roomTenantId)}/send`, {

‎apps/web/src/pages/workbench-room-page.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ function Room({ roomTenantId }: { readonly roomTenantId: string }) {
280280
}) =>
281281
sendToRoom({
282282
roomTenantId,
283-
agents,
283+
participants: participants.data ?? [],
284284
content,
285285
...(inReplyTo !== undefined ? { inReplyTo } : {}),
286286
}),

‎apps/web/src/workbench-create.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export async function createWorkbench(input: CreateWorkbenchInput): Promise<stri
118118
// room whose agent has not surfaced yet keeps the opening message
119119
// for the person to send from the room itself.
120120
if (agents.length > 0) {
121-
await sendToRoom({ roomTenantId: tenantId, agents, content: input.openingMessage });
121+
await sendToRoom({ roomTenantId: tenantId, participants, content: input.openingMessage });
122122
}
123123
} catch (cause) {
124124
throw failure(cause, "opening-message", tenantId);

0 commit comments

Comments
 (0)