Skip to content
135 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 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. */
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 * 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. */
45const 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 */
52export 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. */
58export 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
77export 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 */
103export 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 */
115export 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. */
124export 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 */
132export 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}