| 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 | */ |
| 16 | import type { AgentStatus, AskerAccess } from "@g1t/contracts"; |
| 17 | |
| 18 | import type { HandedOff } from "./surface.ts"; |
| 19 | |
| 20 | /** A colleague as the hand-off sees it. */ |
| 21 | export type Colleague = { id: string; handle: string; display_name: string; builtin: boolean; status: AgentStatus }; |
| 22 | |
| 23 | export 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 | |
| 38 | const 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. */ |
| 44 | export 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. */ |
| 60 | export 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. */ |
| 67 | export 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). */ |
| 77 | export 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 | } |