| 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> = { hops: number; asked_by: string; asker: A | null }; |
| 31 | |
| 32 | /** What the agents service is handed for one wake (AgentDelivery), on g1t's own chat. */ |
| 33 | export function delivery<P extends object, A>( |
| 34 | place: P, |
| 35 | wake: Wake, |
| 36 | message: { id: string; thread_root: string | null }, |
| 37 | chain: Chain<A>, |
| 38 | ) { |
| 39 | return { |
| 40 | ...place, |
| 41 | agent_id: wake.agent_id, |
| 42 | message_id: message.id, |
| 43 | thread_root: message.thread_root, |
| 44 | asked_by: chain.asked_by, |
| 45 | asker: chain.asker, |
| 46 | hops: wake.hops, |
| 47 | surface: "g1t" as const, |
| 48 | }; |
| 49 | } |
| 50 | |
| 51 | export function deliveries(input: { |
| 52 | /** Who wrote it: `user:<id>` or `agent:<id>`. */ |
| 53 | author: string; |
| 54 | /** For an agent's message: the hops of the delivery it answers. */ |
| 55 | hops: number; |
| 56 | channelKind: "channel" | "dm"; |
| 57 | /** The channel's agent members (archived ones left out). */ |
| 58 | agents: AgentMember[]; |
| 59 | /** Handles the message @mentions, lowercased. */ |
| 60 | mentioned: string[]; |
| 61 | }): Wake[] { |
| 62 | const mentioned = new Set(input.mentioned.map((h) => h.toLowerCase())); |
| 63 | const named = (agent: AgentMember) => mentioned.has(agent.handle.toLowerCase()); |
| 64 | if (input.author.startsWith("user:")) { |
| 65 | const woken = input.channelKind === "dm" ? input.agents : input.agents.filter(named); |
| 66 | return woken.map((agent) => ({ agent_id: agent.id, hops: 0 })); |
| 67 | } |
| 68 | const hops = Math.max(0, Math.floor(input.hops || 0)) + 1; |
| 69 | if (hops > MAX_HOPS) return []; |
| 70 | return input.agents |
| 71 | .filter((agent) => named(agent) && `agent:${agent.id}` !== input.author) |
| 72 | .map((agent) => ({ agent_id: agent.id, hops })); |
| 73 | } |