Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion agents/myra/src/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
29 changes: 27 additions & 2 deletions apps/web/src/chat/room-roster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <run_abc@room.example>\nMyra <run_def@room.example>",
Expand All @@ -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 <usr_sawyer@room.example>\nScribe <run_abc@room.example>\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.",
);
});
});
28 changes: 25 additions & 3 deletions apps/web/src/chat/room-roster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@
// 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:";

export type RosterEntry = {
readonly name: string;
readonly address: string;
readonly kind: "person" | "agent";
};

/** The trailing block marker, including the blank-line separator that
Expand All @@ -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
Expand Down
26 changes: 18 additions & 8 deletions apps/web/src/chat/threads-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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`, {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/pages/workbench-room-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ function Room({ roomTenantId }: { readonly roomTenantId: string }) {
}) =>
sendToRoom({
roomTenantId,
agents,
participants: participants.data ?? [],
content,
...(inReplyTo !== undefined ? { inReplyTo } : {}),
}),
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/workbench-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export async function createWorkbench(input: CreateWorkbenchInput): Promise<stri
// room whose agent has not surfaced yet keeps the opening message
// for the person to send from the room itself.
if (agents.length > 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);
Expand Down
Loading