Skip to content
218 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.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. */
19export const MAX_HOPS = 6;
20
21export type AgentMember = { id: string; handle: string };
22
23export 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 */
32export 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. */
47const 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 */
54export 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. */
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
79/** One earlier message of the conversation, as much of it as addressing needs. */
80export 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. */
88export 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 */
111export 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
134export 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 */
166export 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 */
178export 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. */
187export 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 */
195export 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 */
208export 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}