| 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 wakes every agent in a direct message with them, |
| 7 | * and in a channel only the agent members it @mentions. That starts a |
| 8 | * chain at hop 0. |
| 9 | * - An agent's message wakes other agents only when it @mentions them |
| 10 | * (addressed only, never because they were in the room), one hop further |
| 11 | * along the chain it was answering. |
| 12 | * - A chain stops after `MAX_HOPS` hops, and asks a person instead. |
| 13 | * - An agent is never handed its own message. |
| 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 | |
| 38 | /** The most agent ids a chain carries: the hop limit's worth, and some. */ |
| 39 | const MAX_CHAIN = 16; |
| 40 | |
| 41 | /** |
| 42 | * An agent's post's chain: the one it was answering, as the agents service |
| 43 | * passed it back, with the agent itself added. Anything that is not a list |
| 44 | * of ids is no chain. |
| 45 | */ |
| 46 | export function chainFor(given: unknown, author: string): string[] { |
| 47 | const before = Array.isArray(given) ? given.filter((id): id is string => typeof id === "string" && !!id).slice(-MAX_CHAIN) : []; |
| 48 | return [...before, author]; |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * The agent that sent the author its work: the one before the author in |
| 53 | * the chain. The author's message never goes back to it (no ping-pong). |
| 54 | */ |
| 55 | export function sender(chain: string[]): string | null { |
| 56 | return chain.length >= 2 ? chain[chain.length - 2] : null; |
| 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 | export function deliveries(input: { |
| 80 | /** Who wrote it: `user:<id>` or `agent:<id>`. */ |
| 81 | author: string; |
| 82 | /** For an agent's message: the hops of the delivery it answers. */ |
| 83 | hops: number; |
| 84 | channelKind: "channel" | "dm"; |
| 85 | /** The channel's agent members (archived ones left out). */ |
| 86 | agents: AgentMember[]; |
| 87 | /** Handles the message @mentions, lowercased. */ |
| 88 | mentioned: string[]; |
| 89 | /** For an agent's message: the agent that sent it the work, never handed it back. */ |
| 90 | notTo?: string | null; |
| 91 | }): Wake[] { |
| 92 | const mentioned = new Set(input.mentioned.map((h) => h.toLowerCase())); |
| 93 | const named = (agent: AgentMember) => mentioned.has(agent.handle.toLowerCase()); |
| 94 | if (input.author.startsWith("user:")) { |
| 95 | const woken = input.channelKind === "dm" ? input.agents : input.agents.filter(named); |
| 96 | return woken.map((agent) => ({ agent_id: agent.id, hops: 0 })); |
| 97 | } |
| 98 | const hops = Math.max(0, Math.floor(input.hops || 0)) + 1; |
| 99 | if (hops > MAX_HOPS) return []; |
| 100 | return input.agents |
| 101 | .filter((agent) => named(agent) && `agent:${agent.id}` !== input.author && agent.id !== input.notTo) |
| 102 | .map((agent) => ({ agent_id: agent.id, hops })); |
| 103 | } |
| 104 | |
| 105 | /** The built-in orchestrator's handle. Same as BUILTIN_AGENT_HANDLE in @g1t/contracts. */ |
| 106 | export const ORCHESTRATOR = "g1t"; |
| 107 | |
| 108 | /** |
| 109 | * Whether a message brings @g1t into the conversation: every workspace has |
| 110 | * it, so mentioning it in a channel adds it as a member the first time, |
| 111 | * with no invite. A direct message is made with its members and never |
| 112 | * gains one; to talk to @g1t alone, open a DM with it. |
| 113 | */ |
| 114 | export function addsOrchestrator(input: { channelKind: "channel" | "dm"; mentioned: string[]; orchestratorIsMember: boolean }): boolean { |
| 115 | if (input.channelKind !== "channel" || input.orchestratorIsMember) return false; |
| 116 | return input.mentioned.some((handle) => handle.toLowerCase() === ORCHESTRATOR); |
| 117 | } |