Skip to content
117 linesCodeBlameRaw
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. */
17export const MAX_HOPS = 6;
18
19export type AgentMember = { id: string; handle: string };
20
21export 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 */
30export 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. */
39const 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 */
46export 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 */
55export 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. */
60export 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
79export 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. */
106export 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 */
114export 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}