Skip to content
86 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Workspace agents are told where they are and who is in the conversation every turn, and hand work to a colleague with a hand_off tool instead of an @mention: the brief is posted where the colleague already is, or in a group message of the person who asked, the agent and the colleague with a card linking to it; an agent's messages no longer wake other agents, its mentions of anyone outside the conversation show as plain names, and the agents and chat guides and the workspace plan say how.1/**
2 * Handing work to a colleague from chat (docs/WORKSPACE.md, "Agents know
3 * each other"): the `hand_off` tool's port. Pure apart from what it is
4 * given, so its refusals are tested on their own.
5 *
6 * The agent's words never wake a colleague; this does. It checks who the
7 * colleague is here (an agent of the workspace, available, not the agent
8 * itself, not @g1t, not one already on this request) and that the person
9 * who asked may use the workspace's agents, then asks the surface to hand
10 * it over. The chat service checks the same rails again, and decides
11 * where the brief goes: here, when the colleague is in this channel or
12 * group message, or the group message of the person, the agent and the
13 * colleague. The colleague answers through its own reply, paid from its
14 * own budget, with the asker's access.
15 */
16import type { AgentStatus, AskerAccess } from "@g1t/contracts";
17
18import type { HandedOff } from "./surface.ts";
19
20/** A colleague as the hand-off sees it. */
21export type Colleague = { id: string; handle: string; display_name: string; builtin: boolean; status: AgentStatus };
22
23export type HandOffPorts = {
24 /** The agent itself. */
25 self: { id: string; handle: string };
26 /** The agents that handled this request before, oldest first. */
27 chain: string[];
28 /** What the person who asked may do; null when the chat service didn't say. */
29 asker: AskerAccess | null;
30 /** An agent of the workspace by handle, not archived, or null. */
31 agent(handle: string): Promise<Colleague | null>;
32 /** Whether a person of the workspace has this username. */
33 person(handle: string): Promise<boolean>;
34 /** Hands the work over (Surface.handOff). */
35 handOff(colleagueId: string, brief: string): Promise<HandedOff>;
36};
37
38const UNAVAILABLE: Partial<Record<AgentStatus, string>> = {
39 paused: "is paused",
40 out_of_budget: "is out of budget this month",
41};
42
43/** Why the work can't go to `colleague`, or null when it can. */
44export function handOffRefusal(handle: string, colleague: Colleague | null, ports: Pick<HandOffPorts, "self" | "chain" | "asker">, isPerson: boolean): string | null {
45 if (!colleague) {
46 return isPerson
47 ? `@${handle} is a person, not an agent: hand_off is only for agents. Tell them who to ask, by name.`
48 : `There is no agent called @${handle} in this workspace. Check the names in your colleagues list.`;
49 }
50 if (colleague.id === ports.self.id) return "That's you. Do the work yourself, or hand it to someone else.";
51 if (colleague.builtin) return "@g1t can't be handed work by an agent. If the person wants g1t, they can ask it themselves.";
52 if (ports.chain.includes(colleague.id)) return `@${handle} has already handled this request: don't hand it back. Answer with what you have.`;
53 if (ports.asker?.role === "outside") return "The person you're working for isn't a member of this workspace, so its agents can't take work on for them.";
54 const why = UNAVAILABLE[colleague.status];
55 if (why) return `@${handle} ${why}, so they can't take this on. Tell the person they're unavailable and why.`;
56 return null;
57}
58
59/** The brief as it is posted: addressed to the colleague, by @mention if it isn't already. */
60export function addressed(handle: string, brief: string): string {
61 // Handles are letters, digits, `_` and `-`: nothing to escape.
62 const named = new RegExp(`(^|[^a-z0-9_.@-])@${handle}(?![a-z0-9_/-])`, "i");
63 return named.test(brief) ? brief : `@${handle} ${brief}`;
64}
65
66/** What the agent is told once the work is handed over. */
67export function handedMessage(colleague: Colleague, asker: string | null, done: Extract<HandedOff, { ok: true }>): string {
68 const who = asker ? `@${asker}` : "the person who asked";
69 if (done.where === "here") {
70 return `Handed to @${colleague.handle}: your brief is posted in this conversation and they're on it; they answer ${who} here. Say so in a sentence; don't repeat the brief.`;
71 }
72 const dm = done.opened ? "a new group message" : "the group message you three already have";
73 return `Handed to @${colleague.handle} in ${dm} with ${who}, you and them; your brief is there, and a card here links to it. Tell ${who} in a sentence that ${colleague.display_name} has it there. Don't repeat the brief.`;
74}
75
76/** The `hand_off` port (ActionPorts.handOff). */
77export function handOffPort(ports: HandOffPorts): (handle: string, brief: string) => Promise<{ ok: boolean; message: string }> {
78 return async (handle, brief) => {
79 const colleague = await ports.agent(handle);
80 const refused = handOffRefusal(handle, colleague, ports, colleague ? false : await ports.person(handle).catch(() => false));
81 if (refused) return { ok: false, message: refused };
82 const done = await ports.handOff(colleague!.id, addressed(colleague!.handle, brief));
83 if (!done.ok) return { ok: false, message: `It couldn't be handed over: ${done.message}` };
84 return { ok: true, message: handedMessage(colleague!, ports.asker?.username ?? null, done) };
85 };
86}