Skip to content
359 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";
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}