| 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.g1t.sh/guides/agents/, "Talk to an agent"): |
| 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; one |
| 8 | * that mentions none wakes the only agent there, or, with several, the |
| 9 | * agent the person is talking with (`addressedAgents`), or all of them |
| 10 | * when the conversation doesn't say. That starts a chain at hop 0. |
| 11 | * - An agent's message wakes nobody, @mentions or not. An agent gets a |
| 12 | * colleague working only by handing off (`handOffPlace`), which wakes |
| 13 | * that one colleague, one hop further along the chain it was answering. |
| 14 | * No loops, and no agent summoned because its name came up. |
| 15 | * - A chain stops after `MAX_HOPS` hops, and asks a person instead. |
| 16 | */ |
| 17 | |
| 18 | /** The most agent-to-agent hops one person's request may start. Same as CHAT_MAX_HOPS in @g1t/contracts. */ |
| 19 | export const MAX_HOPS = 6; |
| 20 | |
| 21 | export type AgentMember = { id: string; handle: string }; |
| 22 | |
| 23 | export type Wake = { agent_id: string; hops: number }; |
| 24 | |
| 25 | /** |
| 26 | * Who a chain was started by, carried along every hop of it: the person's |
| 27 | * id and what they may do. A person's message starts one with their own |
| 28 | * access (`askerAccess` in @g1t/contracts); an agent's message carries on |
| 29 | * the one it was answering, so an agent woken three hops along never does |
| 30 | * more than the person who asked could. |
| 31 | */ |
| 32 | export type Chain<A> = { |
| 33 | hops: number; |
| 34 | asked_by: string; |
| 35 | asker: A | null; |
| 36 | /** The agents that handled the request so far, by id, oldest first: for an agent's message, ending with its author. */ |
| 37 | chain: string[]; |
| 38 | /** |
| 39 | * Set for a message written with a workflow job's token (`G1T_TOKEN`): |
| 40 | * it wakes no agent, or a workflow that posts on a failing check could |
| 41 | * start one whose push runs the workflow again, without end. |
| 42 | */ |
| 43 | quiet?: boolean; |
| 44 | }; |
| 45 | |
| 46 | /** The most agent ids a chain carries: the hop limit's worth, and some. */ |
| 47 | const MAX_CHAIN = 16; |
| 48 | |
| 49 | /** |
| 50 | * An agent's post's chain: the one it was answering, as the agents service |
| 51 | * passed it back, with the agent itself added. Anything that is not a list |
| 52 | * of ids is no chain. |
| 53 | */ |
| 54 | export function chainFor(given: unknown, author: string): string[] { |
| 55 | const before = Array.isArray(given) ? given.filter((id): id is string => typeof id === "string" && !!id).slice(-MAX_CHAIN) : []; |
| 56 | return [...before, author]; |
| 57 | } |
| 58 | |
| 59 | /** What the agents service is handed for one wake (AgentDelivery), on g1t's own chat. */ |
| 60 | export function delivery<P extends object, A>( |
| 61 | place: P, |
| 62 | wake: Wake, |
| 63 | message: { id: string; thread_root: string | null }, |
| 64 | chain: Chain<A>, |
| 65 | ) { |
| 66 | return { |
| 67 | ...place, |
| 68 | agent_id: wake.agent_id, |
| 69 | message_id: message.id, |
| 70 | thread_root: message.thread_root, |
| 71 | asked_by: chain.asked_by, |
| 72 | asker: chain.asker, |
| 73 | chain: chain.chain, |
| 74 | hops: wake.hops, |
| 75 | surface: "g1t" as const, |
| 76 | }; |
| 77 | } |
| 78 | |
| 79 | /** One earlier message of the conversation, as much of it as addressing needs. */ |
| 80 | export type RecentMessage = { |
| 81 | /** Who wrote it: `user:<id>` or `agent:<id>`. */ |
| 82 | author: string; |
| 83 | /** Handles it @mentions, lowercased. */ |
| 84 | mentioned: string[]; |
| 85 | }; |
| 86 | |
| 87 | /** How many earlier messages decide who an unaddressed message is for. */ |
| 88 | export const ADDRESSING_HISTORY = 30; |
| 89 | |
| 90 | /** |
| 91 | * In a direct message with several agents, which of them a message from |
| 92 | * `author` that mentions none of them is for: the agent the person is |
| 93 | * talking with, read from the conversation so far (`recent`, oldest |
| 94 | * first, the message itself left out). Walking back from the latest |
| 95 | * message: |
| 96 | * |
| 97 | * 1. The agents whose messages come before any person's are the last |
| 98 | * exchange: whoever answered last. The message is for them. |
| 99 | * 2. With no answer yet, the person's own latest message that @mentions |
| 100 | * agents of this conversation says who they addressed: it is for those. |
| 101 | * Their messages that mention none are passed over on the way. |
| 102 | * |
| 103 | * So "@mike make a PDF", Mike's answer, then "now make a fake one" is for |
| 104 | * Mike alone, however many agents are in the conversation, and so is a |
| 105 | * second message sent before Mike answers; after a hand-off to a |
| 106 | * colleague here, the colleague's answer makes the next message theirs. |
| 107 | * Other people's mentions decide nothing, and agents no longer in the |
| 108 | * conversation count for nothing. When nothing in `recent` decides, every |
| 109 | * agent gets it, as every agent got the first message. |
| 110 | */ |
| 111 | export function addressedAgents(agents: AgentMember[], author: string, recent: RecentMessage[]): AgentMember[] { |
| 112 | if (agents.length < 2) return agents; |
| 113 | const byId = new Map(agents.map((agent) => [`agent:${agent.id}`, agent])); |
| 114 | const byHandle = new Map(agents.map((agent) => [agent.handle.toLowerCase(), agent])); |
| 115 | const exchange: AgentMember[] = []; |
| 116 | for (let i = recent.length - 1; i >= 0; i--) { |
| 117 | const message = recent[i]; |
| 118 | const agent = byId.get(message.author); |
| 119 | if (agent) { |
| 120 | if (!exchange.includes(agent)) exchange.push(agent); |
| 121 | continue; |
| 122 | } |
| 123 | if (!message.author.startsWith("user:")) continue; |
| 124 | // A person's message: the agents that answered since are the exchange. |
| 125 | if (exchange.length) break; |
| 126 | if (message.author !== author) continue; |
| 127 | const named = message.mentioned.map((handle) => byHandle.get(handle.toLowerCase())).filter((found): found is AgentMember => !!found); |
| 128 | if (named.length) return agents.filter((agent) => named.includes(agent)); |
| 129 | } |
| 130 | if (exchange.length) return agents.filter((agent) => exchange.includes(agent)); |
| 131 | return agents; |
| 132 | } |
| 133 | |
| 134 | export function deliveries(input: { |
| 135 | /** Who wrote it: `user:<id>` or `agent:<id>`. */ |
| 136 | author: string; |
| 137 | /** For an agent's message: the hops of the delivery it answers. */ |
| 138 | hops: number; |
| 139 | channelKind: "channel" | "dm"; |
| 140 | /** The channel's agent members (archived ones left out). */ |
| 141 | agents: AgentMember[]; |
| 142 | /** Handles the message @mentions, lowercased. */ |
| 143 | mentioned: string[]; |
| 144 | /** |
| 145 | * In a direct message with several agents: the conversation before this |
| 146 | * message, oldest first (`addressedAgents`). Absent or empty, an |
| 147 | * unaddressed message goes to every agent in it. |
| 148 | */ |
| 149 | recent?: RecentMessage[]; |
| 150 | }): Wake[] { |
| 151 | // Only a person's message wakes anyone; agents reach each other by hand-off. |
| 152 | if (!input.author.startsWith("user:")) return []; |
| 153 | const mentioned = new Set(input.mentioned.map((h) => h.toLowerCase())); |
| 154 | const named = input.agents.filter((agent) => mentioned.has(agent.handle.toLowerCase())); |
| 155 | const woken = input.channelKind === "dm" && !named.length ? addressedAgents(input.agents, input.author, input.recent ?? []) : named; |
| 156 | return woken.map((agent) => ({ agent_id: agent.id, hops: 0 })); |
| 157 | } |
| 158 | |
| 159 | /** |
| 160 | * Where a hand-off's brief goes: here, when the colleague is already a |
| 161 | * member of this channel or group direct message (everyone here sees the |
| 162 | * work move); otherwise a group direct message of the person who asked, |
| 163 | * the agent and the colleague, so the colleague reads only what it was |
| 164 | * handed and works for that person, with their access. |
| 165 | */ |
| 166 | export function handOffPlace(input: { channelKind: "channel" | "dm"; members: number; colleagueHere: boolean }): "here" | "group_dm" { |
| 167 | const shared = input.channelKind === "channel" || input.members > 2; |
| 168 | return input.colleagueHere && shared ? "here" : "group_dm"; |
| 169 | } |
| 170 | |
| 171 | /** |
| 172 | * Why an agent may not hand work to `colleague`, or null when it may. The |
| 173 | * colleague is already of the workspace and not archived; this decides the |
| 174 | * rest of the rails: not itself, never @g1t (no agent puts g1t to work), |
| 175 | * never an agent already on this request (no ping-pong), and within the |
| 176 | * hop limit. |
| 177 | */ |
| 178 | export function handOffRefusal(input: { agent: string; colleague: { id: string; builtin?: boolean }; chain: string[]; hops: number }): string | null { |
| 179 | if (input.colleague.id === input.agent) return "An agent can't hand work to itself."; |
| 180 | if (input.colleague.builtin) return "An agent can't hand work to @g1t. The person can ask @g1t themselves."; |
| 181 | if (input.chain.includes(input.colleague.id)) return "That agent has already handled this request: no handing work back."; |
| 182 | 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."; |
| 183 | return null; |
| 184 | } |
| 185 | |
| 186 | /** The built-in orchestrator's handle. Same as BUILTIN_AGENT_HANDLE in @g1t/contracts. */ |
| 187 | export const ORCHESTRATOR = "g1t"; |
| 188 | |
| 189 | /** |
| 190 | * Whether a message brings @g1t into the conversation: every workspace has |
| 191 | * it, so mentioning it in a channel adds it as a member the first time, |
| 192 | * with no invite. A direct message is made with its members and never |
| 193 | * gains one; to talk to @g1t alone, open a DM with it. |
| 194 | */ |
| 195 | export function addsOrchestrator(input: { channelKind: "channel" | "dm"; mentioned: string[]; orchestratorIsMember: boolean }): boolean { |
| 196 | if (input.channelKind !== "channel" || input.orchestratorIsMember) return false; |
| 197 | return input.mentioned.some((handle) => handle.toLowerCase() === ORCHESTRATOR); |
| 198 | } |
| 199 | |
| 200 | /** |
| 201 | * Where a member's personal agent may be (docs.g1t.sh/guides/agents/, |
| 202 | * "Personal agents"): only in the direct message of the two of them. Not |
| 203 | * in a channel, not in a group direct message, never handed work by |
| 204 | * another agent. `members` are the conversation's principal keys |
| 205 | * (`user:<id>`, `agent:<id>`); null when it is a channel. Null: allowed; |
| 206 | * otherwise why not, as people are told. |
| 207 | */ |
| 208 | export function personalAgentRefusal( |
| 209 | agent: { id: string; handle: string; scope?: string | null; personal_owner_id?: string | null }, |
| 210 | place: { kind: "channel" | "dm" | "hand_off"; members: string[] | null }, |
| 211 | ): string | null { |
| 212 | if (agent.scope !== "personal") return null; |
| 213 | const mine = `user:${agent.personal_owner_id ?? ""}`; |
| 214 | const refusal = `@${agent.handle} is someone's personal agent: only the person it belongs to talks to it, in a direct message of the two of them.`; |
| 215 | if (place.kind !== "dm" || !place.members) return refusal; |
| 216 | const others = place.members.filter((key) => key !== `agent:${agent.id}`); |
| 217 | return others.length === 1 && others[0] === mine ? null : refusal; |
| 218 | } |