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 | */ | |
| 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 | export { abilitiesSection, saidText } from "./abilities-prompt.ts"; | |
| 22 | import type { Definition } from "./definition.ts"; | |
| 23 | import { callMcpTool } from "./mcp-client.ts"; | |
| 24 | import { type Row, isPersonal } from "./store.ts"; | |
| 25 | import type { AbilityPorts, OutsideDone, OutsideItem } from "./tools.ts"; | |
| 26 | ||
| 27 | export 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. */ | |
| 39 | export 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 | ||
| 60 | const iso = () => new Date().toISOString(); | |
| 61 | ||
| 62 | /** The site's address, without a trailing slash. */ | |
| 63 | export 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 | */ | |
| 71 | export 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. */ | |
| 81 | export 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. */ | |
| 87 | export function abilitiesPath(workspace: string, handle: string): string { | |
| 88 | return `/${workspace}/-/agents/${handle}/abilities`; | |
| 89 | } | |
| 90 | ||
| 91 | 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 }); | |
| 92 | ||
| 93 | const 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 | */ | |
| 100 | export 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. */ | |
| 194 | function 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 | */ | |
| 203 | export 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. */ | |
| 235 | export 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. */ | |
| 266 | export 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 | */ | |
| 276 | export 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. */ | |
| 324 | export 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. */ | |
| 354 | 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> { | |
| 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 | } |