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 to | 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 | * | |
| 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 to | 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. */ | |
| 19 | export const MAX_HOPS = 6; | |
| 20 | ||
| 21 | export type AgentMember = { id: string; handle: string }; | |
| 22 | ||
| 23 | export 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) | 32 | export 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. */ | |
| 47 | const 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 | */ | |
| 54 | export 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 to | 58 | |
| 59 | /** What the agents service is handed for one wake (AgentDelivery), on g1t's own chat. */ | |
| 60 | export 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 to | 74 | 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. */ |
| 80 | export 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. */ | |
| 88 | export 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 | */ | |
| 111 | export 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. */ |
| 135 | export 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 | */ | |
| 145 | export 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 to | 151 | export 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 to | 167 | }): 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 to | 170 | 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 | */ | |
| 183 | export 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 | */ | |
| 195 | export 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 to | 201 | } |
| 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. */ | |
| 204 | export 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 | */ | |
| 212 | export 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 | */ | |
| 225 | export 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.