Skip to content
359 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.

Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how.1/**
2 * An agent's abilities at work (docs.g1t.sh/guides/agent-abilities/): what
3 * the workspace has connected, the agent's level for each ability
4 * (@g1t/contracts abilities.ts), and the ports that carry a call out once
5 * the tool box's gate allowed it: the integrations service, an MCP server,
6 * and the cards in chat that ask first, offer to connect, or request
7 * something from the owners.
8 *
9 * Asked first: the call is kept in `agent_ability_requests` with its
10 * arguments and a card is posted. Whoever may allow it (the person the
11 * agent acts for, or an owner) presses Allow, the call runs as them, and
12 * the agent hears the result: a session reads it at its next step
13 * (cards.ts). Every refusal names its rule in the transcript, through the
14 * tool's result, and in the audit log, through `refused`.
15 */
16import { type Ability, type AbilitySection, type AbilitySource, type McpServer, type MessageCard, type ServiceBinding, type User, chatClient, identityClient, integrationsClient, newId, notifyClient } from "@g1t/contracts";
17
18import { CONNECTORS, connectorPath, connectorView } from "../../../packages/contracts/src/connectors.ts";
19import { findAbility, resolveAbilities } from "../../../packages/contracts/src/abilities.ts";
20import { abilityCard, connectCard, requestCard } from "./card-views.ts";
21export { abilitiesSection, saidText } from "./abilities-prompt.ts";
22import type { Definition } from "./definition.ts";
23import { callMcpTool } from "./mcp-client.ts";
24import { type Row, isPersonal } from "./store.ts";
25import type { AbilityPorts, OutsideDone, OutsideItem } from "./tools.ts";
26
27export type AbilityEnv = {
28 DB: D1Database;
29 INTEGRATIONS: ServiceBinding;
30 CHAT: ServiceBinding;
31 IDENTITY: ServiceBinding;
32 EVENTS: ServiceBinding;
33 NOTIFY?: ServiceBinding;
34 /** The site's address (https://g1t.sh), for links that leave g1t. */
35 SITE_URL?: string;
36};
37
38/** A call waiting to be allowed, as kept. */
39export type AbilityRequestRow = {
40 id: string;
41 agent_id: string;
42 workspace_id: string;
43 workspace: string;
44 channel_id: string;
45 message_id: string | null;
46 session_id: string | null;
47 ability: string;
48 tool: string;
49 input: string;
50 summary: string;
51 asked_by: string | null;
52 status: string;
53 decided_by: string | null;
54 decided_at: string | null;
55 result: string | null;
56 created_at: string;
57 updated_at: string;
58};
59
60const iso = () => new Date().toISOString();
61
62/** The site's address, without a trailing slash. */
63export function siteUrl(env: { SITE_URL?: string }): string {
64 return (env.SITE_URL ?? "").trim().replace(/\/+$/, "") || "https://g1t.sh";
65}
66
67/**
68 * The connector ids the workspace has connected, as the integrations
69 * service lists its connections to a member. Empty when it can't say.
70 */
71export async function connectedConnectors(env: Pick<AbilityEnv, "INTEGRATIONS">, workspace: string, viewer: User): Promise<string[]> {
72 const listed = await integrationsClient(env.INTEGRATIONS)
73 .list(workspace, viewer)
74 .catch(() => null);
75 if (!listed?.ok) return [];
76 const providers = new Set(listed.value.map((connection) => connection.provider));
77 return CONNECTORS.filter((connector) => connector.provider && providers.has(connector.provider)).map((connector) => connector.id);
78}
79
80/** The agent's abilities for a turn: resolved against what is connected. */
81export async function abilitiesFor(env: Pick<AbilityEnv, "INTEGRATIONS">, input: { agent: Row; definition: Definition; workspace: string; asker: User }): Promise<AbilitySection[]> {
82 const connected = await connectedConnectors(env, input.workspace, input.asker);
83 return resolveAbilities({ connectors: CONNECTORS, abilities: input.definition.abilities, autonomy: input.definition.autonomy, connected, personal: isPersonal(input.agent) });
84}
85
86/** Where a request's card and a session's wait point: the Abilities tab. */
87export function abilitiesPath(workspace: string, handle: string): string {
88 return `/${workspace}/-/agents/${handle}/abilities`;
89}
90
91const item = (c: { provider: string; key: string; title: string; url: string; status: string | null; body: string }): OutsideItem => ({ provider: c.provider, key: c.key, title: c.title, url: c.url, status: c.status, body: c.body });
92
93const outcome = <T, U>(result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } }, map: (value: T) => U): OutsideDone<U> =>
94 result.ok ? { ok: true, value: map(result.value) } : { ok: false, code: result.error.code, message: result.error.message };
95
96/**
97 * The ports for one turn. `postCard` posts where the agent is working (a
98 * reply's conversation, a session's thread) and gives the message id.
99 */
100export function abilityPorts(
101 env: AbilityEnv,
102 input: {
103 agent: Row;
104 workspace: string;
105 channel_id: string;
106 session: { id: string; title: string } | null;
107 asker: { id: string | null; username: string | null };
108 postCard: (card: MessageCard) => Promise<string | null>;
109 },
110): AbilityPorts {
111 const integrations = integrationsClient(env.INTEGRATIONS);
112 const { agent, workspace } = input;
113 const link = `${siteUrl(env)}/${workspace}/-/chat/${input.channel_id}`;
114 return {
115 lookup: async (asker, reference) => outcome(await integrations.resolve(workspace, asker, reference), item),
116 import: async (asker, repo, reference) =>
117 outcome(await integrations.import(asker, { namespace: repo.namespace, name: repo.name }, reference, false), (v) => ({ number: v.number, item: item(v.item), created: v.created })),
118 act: async (asker, reference, action, text) => {
119 const signed = `${text}\n\n— ${agent.display_name} (@${agent.handle}), a g1t agent, for @${asker.username}.`;
120 const done = action === "resolve" ? await integrations.close(asker, workspace, reference, signed, link) : await integrations.comment(asker, workspace, reference, signed, link);
121 return outcome(done, item);
122 },
123 // Personal connections to Linear, Jira and Sentry aren't available yet (connectors.ts says so), so nobody has one.
124 askerConnected: async () => false,
125 mcp: async (server, tool, args) => {
126 const done = await callMcpTool(server.url, tool.name, args);
127 return done.ok ? { ok: true, value: done.text } : { ok: false, code: "error", message: done.message };
128 },
129 async askFirst({ ability, source, tool, args, summary, note }) {
130 const id = newId("abr");
131 const now = iso();
132 const row: AbilityRequestRow = {
133 id,
134 agent_id: agent.id,
135 workspace_id: agent.workspace_id,
136 workspace,
137 channel_id: input.channel_id,
138 message_id: null,
139 session_id: input.session?.id ?? null,
140 ability: ability.id,
141 tool,
142 input: JSON.stringify(args).slice(0, 20_000),
143 summary: summary.slice(0, 300),
144 asked_by: input.asker.id,
145 status: "pending",
146 decided_by: null,
147 decided_at: null,
148 result: null,
149 created_at: now,
150 updated_at: now,
151 };
152 await env.DB.prepare(
153 `INSERT INTO agent_ability_requests (id, agent_id, workspace_id, workspace, channel_id, session_id, ability, tool, input, summary, asked_by, status, created_at, updated_at)
154 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
155 )
156 .bind(id, row.agent_id, row.workspace_id, row.workspace, row.channel_id, row.session_id, row.ability, row.tool, row.input, row.summary, row.asked_by, now, now)
157 .run();
158 const messageId = await input.postCard(abilityCard(row, { agent: agent.display_name, asker: input.asker.username, rule: `${source.name}: ${ability.label}`, level: ability.level, note, body: bodyOf(args) }));
159 if (!messageId) {
160 await env.DB.prepare("UPDATE agent_ability_requests SET status = 'failed', result = ?, updated_at = ? WHERE id = ?").bind("The card couldn't be posted.", iso(), id).run();
161 return null;
162 }
163 await env.DB.prepare("UPDATE agent_ability_requests SET message_id = ? WHERE id = ?").bind(messageId, id).run();
164 return id;
165 },
166 async connect(source, ability) {
167 const connector = CONNECTORS.find((c) => c.id === source.id);
168 const view = connector ? connectorView(connector, "personal") : null;
169 const href = view?.href ? connectorPath(view.href, workspace) : "/settings/integrations";
170 const messageId = await input.postCard(connectCard({ connector: source.name, agent: agent.display_name, ability: ability.label, asker: input.asker.username, href }));
171 return messageId !== null;
172 },
173 async request({ connector, ability, source, why }) {
174 const found = connector ? CONNECTORS.find((c) => c.id === connector) : null;
175 if (!ability && !found) return false;
176 const card = requestCard({
177 agent: { id: agent.id, handle: agent.handle, display_name: agent.display_name },
178 workspace,
179 connector: found ? { id: found.id, name: found.name, available: found.status === "available" && found.scopes.includes("workspace") } : null,
180 ability: ability && source ? { id: ability.id, label: `${source.name}: ${ability.label}` } : null,
181 why,
182 status: "open",
183 by: null,
184 });
185 return (await input.postCard(card)) !== null;
186 },
187 refused({ ability, source, rule, call }) {
188 recordRefusal(env, { agent, workspace, asker: input.asker.username, rule, message: `Refused "${call}": ${source.name}: ${ability.label} is ${rule.split("=")[1] ?? ability.level}.` });
189 },
190 };
191}
192
193/** A card's preview of what a call would write: the text of a comment or note. */
194function bodyOf(args: Record<string, unknown>): string | null {
195 const text = typeof args.text === "string" ? args.text.trim() : "";
196 return text ? text.slice(0, 900) : null;
197}
198
199/**
200 * A refusal in the workspace's audit log, by the agent, naming the rule
201 * (`integration:linear:comment=never`). Never fails the turn.
202 */
203export function recordRefusal(env: Pick<AbilityEnv, "EVENTS">, input: { agent: Row; workspace: string; asker: string | null; rule: string; message: string }): void {
204 const entry = {
205 actorKind: "agent",
206 actor: input.agent.handle,
207 actorId: input.agent.id,
208 agent: input.agent.handle,
209 onBehalfOf: input.asker,
210 runId: null,
211 runKind: null,
212 credentialId: null,
213 action: "ability_refused",
214 // The audit log knows the surfaces people use; an agent acts through the site on their behalf.
215 surface: "web",
216 workspace: input.workspace.toLowerCase(),
217 repo: null,
218 number: null,
219 gitRef: null,
220 path: `agents/${input.agent.handle}`,
221 outcome: "denied",
222 rule: input.rule,
223 result: "refused",
224 message: input.message,
225 requestId: `req_${crypto.randomUUID()}`,
226 };
227 env.EVENTS.fetch("https://service/rpc/audit_record", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ entries: [entry] }) })
228 .then((response) => {
229 if (!response.ok) throw new Error(`status ${response.status}`);
230 })
231 .catch((error: unknown) => console.error("agents: a refusal was not written to the audit log", String(error)));
232}
233
234/** An allowed or denied call in the audit log, by the person who decided. */
235export function recordDecision(env: Pick<AbilityEnv, "EVENTS">, input: { by: User; agent: Row; workspace: string; request: AbilityRequestRow; allowed: boolean }): void {
236 const entry = {
237 actorKind: "person",
238 actor: input.by.username,
239 actorId: input.by.id,
240 agent: input.agent.handle,
241 onBehalfOf: null,
242 runId: null,
243 runKind: null,
244 credentialId: null,
245 action: input.allowed ? "ability_allowed" : "ability_denied",
246 surface: "web",
247 workspace: input.workspace.toLowerCase(),
248 repo: null,
249 number: null,
250 gitRef: null,
251 path: `agents/${input.agent.handle}`,
252 outcome: "allowed",
253 rule: `${input.request.ability}=ask`,
254 result: "ok",
255 message: `${input.allowed ? "Allowed" : "Denied"} @${input.agent.handle}: ${input.request.summary}`,
256 requestId: `req_${crypto.randomUUID()}`,
257 };
258 env.EVENTS.fetch("https://service/rpc/audit_record", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ entries: [entry] }) })
259 .then((response) => {
260 if (!response.ok) throw new Error(`status ${response.status}`);
261 })
262 .catch((error: unknown) => console.error("agents: a decision was not written to the audit log", String(error)));
263}
264
265/** Pending requests of a session: what it waits on. */
266export async function pendingRequests(db: D1Database, sessionId: string): Promise<AbilityRequestRow[]> {
267 const { results } = await db.prepare("SELECT * FROM agent_ability_requests WHERE session_id = ? AND status = 'pending' ORDER BY created_at").bind(sessionId).all<AbilityRequestRow>();
268 return results;
269}
270
271/**
272 * Runs an allowed call as `by`, the person who allowed it, with the
273 * agent's abilities as they are now (a server or a connection may have
274 * gone since). What the agent is told.
275 */
276export async function runAllowed(env: AbilityEnv, request: AbilityRequestRow, agent: Row, definition: Definition, by: User): Promise<{ ok: boolean; message: string }> {
277 const sections = await abilitiesFor(env, { agent, definition, workspace: request.workspace, asker: by });
278 const found = findAbility(sections, request.ability);
279 if (!found) return { ok: false, message: `The ability ${request.ability} is no longer there.` };
280 if (!found.source.connected) return { ok: false, message: `${found.source.name} is no longer connected.` };
281 if (found.ability.level === "never") return { ok: false, message: `${found.source.name}: ${found.ability.label} is Never now.` };
282 let args: Record<string, unknown> = {};
283 try {
284 args = JSON.parse(request.input) as Record<string, unknown>;
285 } catch {
286 return { ok: false, message: "The call's arguments couldn't be read." };
287 }
288 const ports = abilityPorts(env, { agent, workspace: request.workspace, channel_id: request.channel_id, session: null, asker: { id: by.id, username: by.username }, postCard: async () => null });
289 const reference = String(args.reference ?? "").trim();
290 try {
291 switch (request.tool) {
292 case "lookup_outside": {
293 const done = await ports.lookup(by, reference);
294 return done.ok ? { ok: true, message: `${done.value.key}: ${done.value.title}${done.value.status ? ` [${done.value.status}]` : ""}\n${done.value.url}\n\n${done.value.body}`.slice(0, 20_000) } : { ok: false, message: done.message };
295 }
296 case "import_outside": {
297 const repo = String(args.repo ?? "").trim().toLowerCase();
298 const [namespace, name] = repo.includes("/") ? repo.split("/") : [request.workspace, repo];
299 if (!namespace || !name) return { ok: false, message: "The call named no repository." };
300 const done = await ports.import(by, { id: "", namespace, name, isPrivate: true, defaultBranch: "main" }, reference);
301 return done.ok ? { ok: true, message: `${done.value.created ? "Opened" : "Already imported as"} ${namespace}/${name}#${done.value.number} from ${done.value.item.key}.` } : { ok: false, message: done.message };
302 }
303 case "act_outside": {
304 const action = args.action === "resolve" ? "resolve" : "comment";
305 const done = await ports.act(by, reference, action, String(args.text ?? "").trim().slice(0, 8000));
306 return done.ok ? { ok: true, message: `${action === "resolve" ? "Resolved" : "Commented on"} ${done.value.key} (${done.value.url}).` } : { ok: false, message: done.message };
307 }
308 default: {
309 // An MCP tool: `<server>__<tool>`.
310 const [, serverId, ...rest] = request.ability.split(":");
311 const server = (definition.abilities.mcp_servers ?? []).find((s: McpServer) => s.id === serverId);
312 const tool = server?.tools.find((t) => t.name === rest.join(":"));
313 if (!server || !tool) return { ok: false, message: "That MCP tool is no longer there." };
314 const done = await ports.mcp(server, tool, args);
315 return done.ok ? { ok: true, message: done.value.slice(0, 20_000) } : { ok: false, message: done.message };
316 }
317 }
318 } catch (error) {
319 return { ok: false, message: `It failed: ${String(error).slice(0, 200)}` };
320 }
321}
322
323/** Tells the owners (and whoever asked, for a request on their behalf) that a Request card was pressed. */
324export async function tellOwnersOfRequest(env: AbilityEnv, input: { workspace: string; by: User; title: string; body: string; href: string; id: string }): Promise<void> {
325 if (!env.NOTIFY) return;
326 const members = await identityClient(env.IDENTITY)
327 .listMembers(input.workspace, input.by)
328 .catch(() => null);
329 if (!members?.ok) return;
330 const notify = notifyClient(env.NOTIFY);
331 const owners = members.value.filter((member) => member.role === "owner").slice(0, 20);
332 await Promise.all(
333 owners.map((owner) =>
334 notify
335 .notify(
336 { username: owner.username },
337 {
338 id: `ability-request:${input.id}:${owner.username}`,
339 kind: "approval",
340 workspace: input.workspace,
341 title: input.title,
342 body: input.body,
343 href: input.href,
344 actor: { kind: "user", id: input.by.id, name: input.by.username, avatar: input.by.avatar ?? null, avatar_seed: null },
345 created_at: iso(),
346 },
347 )
348 .catch(() => undefined),
349 ),
350 );
351}
352
353/** Posts a card in a conversation as the agent; its message id, or null. */
354export async function postCardAsAgent(env: Pick<AbilityEnv, "CHAT">, workspace: string, channelId: string, agentId: string, card: MessageCard, threadRoot: string | null, askedBy: string | null): Promise<string | null> {
355 const posted = await chatClient(env.CHAT)
356 .postAsAgent(workspace, channelId, agentId, { body: "", card, thread_root: threadRoot, asked_by: askedBy })
357 .catch(() => null);
358 return posted?.ok ? posted.value.id : null;
359}