Skip to content
86 linesCodeBlameRaw
1/**
2 * Handing work to a colleague from chat (docs.g1t.sh/guides/agents/,
3 * "Hand off"): 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}