| 1 | /** |
| 2 | * Which agents a new message is handed to, and how many agent-to-agent |
| 3 | * hops along it is. Pure, so it is tested apart from the service. |
| 4 | * |
| 5 | * The rules (docs/WORKSPACE.md, "Talking to each other"): |
| 6 | * - A person's message in a channel wakes the agent members it @mentions. |
| 7 | * In a direct message it wakes the agents in it that it @mentions, or |
| 8 | * all of them when it mentions none. That starts a chain at hop 0. |
| 9 | * - An agent's message wakes nobody, @mentions or not. An agent gets a |
| 10 | * colleague working only by handing off (`handOffPlace`), which wakes |
| 11 | * that one colleague, one hop further along the chain it was answering. |
| 12 | * No loops, and no agent summoned because its name came up. |
| 13 | * - A chain stops after `MAX_HOPS` hops, and asks a person instead. |
| 14 | */ |
| 15 | |
| 16 | /** The most agent-to-agent hops one person's request may start. Same as CHAT_MAX_HOPS in @g1t/contracts. */ |
| 17 | export const MAX_HOPS = 6; |
| 18 | |
| 19 | export type AgentMember = { id: string; handle: string }; |
| 20 | |
| 21 | export type Wake = { agent_id: string; hops: number }; |
| 22 | |
| 23 | /** |
| 24 | * Who a chain was started by, carried along every hop of it: the person's |
| 25 | * id and what they may do. A person's message starts one with their own |
| 26 | * access (`askerAccess` in @g1t/contracts); an agent's message carries on |
| 27 | * the one it was answering, so an agent woken three hops along never does |
| 28 | * more than the person who asked could. |
| 29 | */ |
| 30 | export type Chain<A> = { |
| 31 | hops: number; |
| 32 | asked_by: string; |
| 33 | asker: A | null; |
| 34 | /** The agents that handled the request so far, by id, oldest first: for an agent's message, ending with its author. */ |
| 35 | chain: string[]; |
| 36 | /** |
| 37 | * Set for a message written with a workflow job's token (`G1T_TOKEN`): |
| 38 | * it wakes no agent, or a workflow that posts on a failing check could |
| 39 | * start one whose push runs the workflow again, without end. |
| 40 | */ |
| 41 | quiet?: boolean; |
| 42 | }; |
| 43 | |
| 44 | /** The most agent ids a chain carries: the hop limit's worth, and some. */ |
| 45 | const MAX_CHAIN = 16; |
| 46 | |
| 47 | /** |
| 48 | * An agent's post's chain: the one it was answering, as the agents service |
| 49 | * passed it back, with the agent itself added. Anything that is not a list |
| 50 | * of ids is no chain. |
| 51 | */ |
| 52 | export function chainFor(given: unknown, author: string): string[] { |
| 53 | const before = Array.isArray(given) ? given.filter((id): id is string => typeof id === "string" && !!id).slice(-MAX_CHAIN) : []; |
| 54 | return [...before, author]; |
| 55 | } |
| 56 | |
| 57 | /** What the agents service is handed for one wake (AgentDelivery), on g1t's own chat. */ |
| 58 | export function delivery<P extends object, A>( |
| 59 | place: P, |
| 60 | wake: Wake, |
| 61 | message: { id: string; thread_root: string | null }, |
| 62 | chain: Chain<A>, |
| 63 | ) { |
| 64 | return { |
| 65 | ...place, |
| 66 | agent_id: wake.agent_id, |
| 67 | message_id: message.id, |
| 68 | thread_root: message.thread_root, |
| 69 | asked_by: chain.asked_by, |
| 70 | asker: chain.asker, |
| 71 | chain: chain.chain, |
| 72 | hops: wake.hops, |
| 73 | surface: "g1t" as const, |
| 74 | }; |
| 75 | } |
| 76 | |
| 77 | export function deliveries(input: { |
| 78 | /** Who wrote it: `user:<id>` or `agent:<id>`. */ |
| 79 | author: string; |
| 80 | /** For an agent's message: the hops of the delivery it answers. */ |
| 81 | hops: number; |
| 82 | channelKind: "channel" | "dm"; |
| 83 | /** The channel's agent members (archived ones left out). */ |
| 84 | agents: AgentMember[]; |
| 85 | /** Handles the message @mentions, lowercased. */ |
| 86 | mentioned: string[]; |
| 87 | }): Wake[] { |
| 88 | // Only a person's message wakes anyone; agents reach each other by hand-off. |
| 89 | if (!input.author.startsWith("user:")) return []; |
| 90 | const mentioned = new Set(input.mentioned.map((h) => h.toLowerCase())); |
| 91 | const named = input.agents.filter((agent) => mentioned.has(agent.handle.toLowerCase())); |
| 92 | const woken = input.channelKind === "dm" && !named.length ? input.agents : named; |
| 93 | return woken.map((agent) => ({ agent_id: agent.id, hops: 0 })); |
| 94 | } |
| 95 | |
| 96 | /** |
| 97 | * Where a hand-off's brief goes: here, when the colleague is already a |
| 98 | * member of this channel or group direct message (everyone here sees the |
| 99 | * work move); otherwise a group direct message of the person who asked, |
| 100 | * the agent and the colleague, so the colleague reads only what it was |
| 101 | * handed and works for that person, with their access. |
| 102 | */ |
| 103 | export function handOffPlace(input: { channelKind: "channel" | "dm"; members: number; colleagueHere: boolean }): "here" | "group_dm" { |
| 104 | const shared = input.channelKind === "channel" || input.members > 2; |
| 105 | return input.colleagueHere && shared ? "here" : "group_dm"; |
| 106 | } |
| 107 | |
| 108 | /** |
| 109 | * Why an agent may not hand work to `colleague`, or null when it may. The |
| 110 | * colleague is already of the workspace and not archived; this decides the |
| 111 | * rest of the rails: not itself, never @g1t (no agent puts g1t to work), |
| 112 | * never an agent already on this request (no ping-pong), and within the |
| 113 | * hop limit. |
| 114 | */ |
| 115 | export function handOffRefusal(input: { agent: string; colleague: { id: string; builtin?: boolean }; chain: string[]; hops: number }): string | null { |
| 116 | if (input.colleague.id === input.agent) return "An agent can't hand work to itself."; |
| 117 | if (input.colleague.builtin) return "An agent can't hand work to @g1t. The person can ask @g1t themselves."; |
| 118 | if (input.chain.includes(input.colleague.id)) return "That agent has already handled this request: no handing work back."; |
| 119 | if (Math.max(0, Math.floor(input.hops || 0)) + 1 > MAX_HOPS) return "This request has been passed along too many times. Ask a person to step in."; |
| 120 | return null; |
| 121 | } |
| 122 | |
| 123 | /** The built-in orchestrator's handle. Same as BUILTIN_AGENT_HANDLE in @g1t/contracts. */ |
| 124 | export const ORCHESTRATOR = "g1t"; |
| 125 | |
| 126 | /** |
| 127 | * Whether a message brings @g1t into the conversation: every workspace has |
| 128 | * it, so mentioning it in a channel adds it as a member the first time, |
| 129 | * with no invite. A direct message is made with its members and never |
| 130 | * gains one; to talk to @g1t alone, open a DM with it. |
| 131 | */ |
| 132 | export function addsOrchestrator(input: { channelKind: "channel" | "dm"; mentioned: string[]; orchestratorIsMember: boolean }): boolean { |
| 133 | if (input.channelKind !== "channel" || input.orchestratorIsMember) return false; |
| 134 | return input.mentioned.some((handle) => handle.toLowerCase() === ORCHESTRATOR); |
| 135 | } |