Skip to content
235 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Chat and workspace agents: channels, DMs and named agents you talk to1/**
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 *
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.5 * The rules (docs.g1t.sh/guides/agents/, "Talk to an agent"):
Merge branch 'worktree-agent-a1398e81ad1a64c5f'6 * - A person's message in a channel wakes the agent members it @mentions.
In a group message with several agents, a message that names no agent goes to the one you are talking with: the agents that answered you last, else the ones you last mentioned, else, before anyone has been addressed, all of them; a message that mentions agents still goes only to those, and a conversation with one agent is as it was. Each agent is also told, when several are here, who you are talking with, so one that is woken alongside answers only what is clearly for it and does not redo a colleague's work. And an agent shows as typing for as long as it works, not for eight seconds: its desk says so every five seconds until the answer is posted, and after ten seconds the line reads that it is still working on it. The chat and agents guides say so.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.
Merge branch 'worktree-agent-a1398e81ad1a64c5f'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.
Chat and workspace agents: channels, DMs and named agents you talk to15 * - 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 */
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)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[];
Merge branch 'worktree-agent-a1398e81ad1a64c5f'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;
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)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}
Chat and workspace agents: channels, DMs and named agents you talk to58
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,
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)73 chain: chain.chain,
Chat and workspace agents: channels, DMs and named agents you talk to74 hops: wake.hops,
75 surface: "g1t" as const,
76 };
77}
78
In a group message with several agents, a message that names no agent goes to the one you are talking with: the agents that answered you last, else the ones you last mentioned, else, before anyone has been addressed, all of them; a message that mentions agents still goes only to those, and a conversation with one agent is as it was. Each agent is also told, when several are here, who you are talking with, so one that is woken alongside answers only what is clearly for it and does not redo a colleague's work. And an agent shows as typing for as long as it works, not for eight seconds: its desk says so every five seconds until the answer is posted, and after ten seconds the line reads that it is still working on it. The chat and agents guides say so.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
An archived agent can be found, brought back, or deleted for good. The Agents page ends with Archived, folded, where owners see every archived agent and members their own personal ones, each with when and by whom it was archived and what deleting it would take; Restore brings it back, under a new handle when a live agent has taken its old one, and Delete permanently asks for the handle to be typed and then removes its sessions, replies, memories, routines, versions, drafts, skills and its computer's home, leaving its messages in chat under its saved name, the workspace's spend totals and the audit log. In Chat an archived agent is deactivated the way people expect: never offered as someone to message or mention, never woken, a conversation with it kept in the list, dimmed and marked Archived, open to read with a note in place of the composer and a way to restore it for owners; after deletion the conversation still reads, its messages marked as a deleted agent's. The agents, chat and agent budgets guides say so.134/** An agent as the agents service hands it to chat, as far as waking reads it. */
135export type KnownAgent = { id: string; handle: string; workspace_id: string; archived_at: string | null };
136
137/**
138 * The agents of a channel that may be woken at all: members the agents
139 * service knows, of this workspace, and not archived
140 * (docs.g1t.sh/guides/chat/, "Archived and deleted agents"). An archived
141 * agent stays a member of its conversations, so they can be read, but it
142 * never answers, types or is addressed; one deleted for good is unknown
143 * to the agents service and is left out the same way.
144 */
145export function wakeable<A extends KnownAgent>(found: Iterable<A | null | undefined>, workspaceId: string): A[] {
146 const out: A[] = [];
147 for (const agent of found) if (agent && agent.workspace_id === workspaceId && !agent.archived_at) out.push(agent);
148 return out;
149}
150
Chat and workspace agents: channels, DMs and named agents you talk to151export function deliveries(input: {
152 /** Who wrote it: `user:<id>` or `agent:<id>`. */
153 author: string;
154 /** For an agent's message: the hops of the delivery it answers. */
155 hops: number;
156 channelKind: "channel" | "dm";
157 /** The channel's agent members (archived ones left out). */
158 agents: AgentMember[];
159 /** Handles the message @mentions, lowercased. */
160 mentioned: string[];
In a group message with several agents, a message that names no agent goes to the one you are talking with: the agents that answered you last, else the ones you last mentioned, else, before anyone has been addressed, all of them; a message that mentions agents still goes only to those, and a conversation with one agent is as it was. Each agent is also told, when several are here, who you are talking with, so one that is woken alongside answers only what is clearly for it and does not redo a colleague's work. And an agent shows as typing for as long as it works, not for eight seconds: its desk says so every five seconds until the answer is posted, and after ten seconds the line reads that it is still working on it. The chat and agents guides say so.161 /**
162 * In a direct message with several agents: the conversation before this
163 * message, oldest first (`addressedAgents`). Absent or empty, an
164 * unaddressed message goes to every agent in it.
165 */
166 recent?: RecentMessage[];
Chat and workspace agents: channels, DMs and named agents you talk to167}): Wake[] {
Merge branch 'worktree-agent-a1398e81ad1a64c5f'168 // Only a person's message wakes anyone; agents reach each other by hand-off.
169 if (!input.author.startsWith("user:")) return [];
Chat and workspace agents: channels, DMs and named agents you talk to170 const mentioned = new Set(input.mentioned.map((h) => h.toLowerCase()));
Merge branch 'worktree-agent-a1398e81ad1a64c5f'171 const named = input.agents.filter((agent) => mentioned.has(agent.handle.toLowerCase()));
In a group message with several agents, a message that names no agent goes to the one you are talking with: the agents that answered you last, else the ones you last mentioned, else, before anyone has been addressed, all of them; a message that mentions agents still goes only to those, and a conversation with one agent is as it was. Each agent is also told, when several are here, who you are talking with, so one that is woken alongside answers only what is clearly for it and does not redo a colleague's work. And an agent shows as typing for as long as it works, not for eight seconds: its desk says so every five seconds until the answer is posted, and after ten seconds the line reads that it is still working on it. The chat and agents guides say so.172 const woken = input.channelKind === "dm" && !named.length ? addressedAgents(input.agents, input.author, input.recent ?? []) : named;
Merge branch 'worktree-agent-a1398e81ad1a64c5f'173 return woken.map((agent) => ({ agent_id: agent.id, hops: 0 }));
174}
175
176/**
177 * Where a hand-off's brief goes: here, when the colleague is already a
178 * member of this channel or group direct message (everyone here sees the
179 * work move); otherwise a group direct message of the person who asked,
180 * the agent and the colleague, so the colleague reads only what it was
181 * handed and works for that person, with their access.
182 */
183export function handOffPlace(input: { channelKind: "channel" | "dm"; members: number; colleagueHere: boolean }): "here" | "group_dm" {
184 const shared = input.channelKind === "channel" || input.members > 2;
185 return input.colleagueHere && shared ? "here" : "group_dm";
186}
187
188/**
189 * Why an agent may not hand work to `colleague`, or null when it may. The
190 * colleague is already of the workspace and not archived; this decides the
191 * rest of the rails: not itself, never @g1t (no agent puts g1t to work),
192 * never an agent already on this request (no ping-pong), and within the
193 * hop limit.
194 */
195export function handOffRefusal(input: { agent: string; colleague: { id: string; builtin?: boolean }; chain: string[]; hops: number }): string | null {
196 if (input.colleague.id === input.agent) return "An agent can't hand work to itself.";
197 if (input.colleague.builtin) return "An agent can't hand work to @g1t. The person can ask @g1t themselves.";
198 if (input.chain.includes(input.colleague.id)) return "That agent has already handled this request: no handing work back.";
199 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.";
200 return null;
Chat and workspace agents: channels, DMs and named agents you talk to201}
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)202
203/** The built-in orchestrator's handle. Same as BUILTIN_AGENT_HANDLE in @g1t/contracts. */
204export const ORCHESTRATOR = "g1t";
205
206/**
207 * Whether a message brings @g1t into the conversation: every workspace has
208 * it, so mentioning it in a channel adds it as a member the first time,
209 * with no invite. A direct message is made with its members and never
210 * gains one; to talk to @g1t alone, open a DM with it.
211 */
212export function addsOrchestrator(input: { channelKind: "channel" | "dm"; mentioned: string[]; orchestratorIsMember: boolean }): boolean {
213 if (input.channelKind !== "channel" || input.orchestratorIsMember) return false;
214 return input.mentioned.some((handle) => handle.toLowerCase() === ORCHESTRATOR);
215}
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.216
217/**
218 * Where a member's personal agent may be (docs.g1t.sh/guides/agents/,
219 * "Personal agents"): only in the direct message of the two of them. Not
220 * in a channel, not in a group direct message, never handed work by
221 * another agent. `members` are the conversation's principal keys
222 * (`user:<id>`, `agent:<id>`); null when it is a channel. Null: allowed;
223 * otherwise why not, as people are told.
224 */
225export function personalAgentRefusal(
226 agent: { id: string; handle: string; scope?: string | null; personal_owner_id?: string | null },
227 place: { kind: "channel" | "dm" | "hand_off"; members: string[] | null },
228): string | null {
229 if (agent.scope !== "personal") return null;
230 const mine = `user:${agent.personal_owner_id ?? ""}`;
231 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.`;
232 if (place.kind !== "dm" || !place.members) return refusal;
233 const others = place.members.filter((key) => key !== `agent:${agent.id}`);
234 return others.length === 1 && others[0] === mine ? null : refusal;
235}

This file's history is long; its oldest lines are credited to the oldest commit read.