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