| 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 | */ |
| 16 | import { type Ability, type AbilitySection, type AbilitySource, type McpServer, type MessageCard, type ServiceBinding, type User, chatClient, identityClient, integrationsClient, newId, notifyClient } from "@g1t/contracts"; |
| 17 | |
| 18 | import { CONNECTORS, connectorPath, connectorView } from "../../../packages/contracts/src/connectors.ts"; |
| 19 | import { findAbility, resolveAbilities } from "../../../packages/contracts/src/abilities.ts"; |
| 20 | import { abilityCard, connectCard, requestCard } from "./card-views.ts"; |
| 21 | import { computerPorts } from "./computer.ts"; |
| 22 | export { abilitiesSection, saidText } from "./abilities-prompt.ts"; |
| 23 | import type { Definition } from "./definition.ts"; |
| 24 | import { callMcpTool } from "./mcp-client.ts"; |
| 25 | import { type Row, isPersonal } from "./store.ts"; |
| 26 | import type { AbilityPorts, OutsideDone, OutsideItem } from "./tools.ts"; |
| 27 | |
| 28 | export 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. */ |
| 42 | export 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 | |
| 63 | const iso = () => new Date().toISOString(); |
| 64 | |
| 65 | /** The site's address, without a trailing slash. */ |
| 66 | export 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 | */ |
| 74 | export 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. */ |
| 84 | export 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. */ |
| 90 | export function abilitiesPath(workspace: string, handle: string): string { |
| 91 | return `/${workspace}/-/agents/${handle}/abilities`; |
| 92 | } |
| 93 | |
| 94 | const 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 | |
| 96 | const 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 | */ |
| 103 | export 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. */ |
| 197 | function 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 | */ |
| 206 | export 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. */ |
| 238 | export 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. */ |
| 269 | export 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 | */ |
| 279 | export 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. */ |
| 351 | export 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. */ |
| 381 | export 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 | } |