| 1 | /** |
| 2 | * Agents' cards in chat that people act on in place |
| 3 | * (docs.g1t.sh/guides/chat/, "Cards you can act on"): a session's card |
| 4 | * (message it, stop it, approve more), and an issue an agent drafted (file |
| 5 | * it, discard it). Chat checks the person can read the conversation and |
| 6 | * that the card offers the action; this decides whether they may, acts as |
| 7 | * them, and updates the card for everyone. |
| 8 | * |
| 9 | * The rules, decided here in code: |
| 10 | * - **Stop, message:** anyone who can read the conversation the session |
| 11 | * reports in, as they could from its page. |
| 12 | * - **Approve more:** the workspace's owners only, to a cap above what it |
| 13 | * has spent. |
| 14 | * - **File issue:** whoever presses it files it as themselves, and only if |
| 15 | * they can read the repository; the agent files nothing. |
| 16 | * - **Discard:** whoever asked for it, or an owner. |
| 17 | * - **Allow, deny** (an Ask-first card, abilities.ts): whoever the agent |
| 18 | * acts for, or an owner. Allowing runs the call as the person who |
| 19 | * pressed it, and the agent hears the result; a session goes on. |
| 20 | * - **Ask the owners** (a Request card): anyone; an integration becomes a |
| 21 | * Marketplace install request, an ability a notification to the owners. |
| 22 | */ |
| 23 | import { |
| 24 | type AgentCardAction, |
| 25 | type CardActionResult, |
| 26 | type MessageCard, |
| 27 | type Result, |
| 28 | type User, |
| 29 | chatClient, |
| 30 | fail, |
| 31 | identityClient, |
| 32 | newId, |
| 33 | ok, |
| 34 | reposClient, |
| 35 | workClient, |
| 36 | } from "@g1t/contracts"; |
| 37 | |
| 38 | import { type AbilityRequestRow, abilitiesPath, recordDecision, runAllowed, tellOwnersOfRequest } from "./abilities.ts"; |
| 39 | import { canManage } from "./access.ts"; |
| 40 | import { abilityCard, draftCard, parseMoney, requestCard } from "./card-views.ts"; |
| 41 | import { findAbility, resolveAbilities } from "../../../packages/contracts/src/abilities.ts"; |
| 42 | import { CONNECTORS } from "../../../packages/contracts/src/connectors.ts"; |
| 43 | import { findListing, openRequest } from "./installs.ts"; |
| 44 | import { type Row, definitionOf, isPersonal, periods, selectAgents } from "./store.ts"; |
| 45 | |
| 46 | export { draftCard, parseMoney } from "./card-views.ts"; |
| 47 | import { dollars } from "./money.ts"; |
| 48 | import { type SessionEnv, approve, pushInbox, resumeAfterDecision, sessionRow, steer, stop } from "./sessions.ts"; |
| 49 | |
| 50 | export type DraftRow = { |
| 51 | id: string; |
| 52 | agent_id: string; |
| 53 | workspace_id: string; |
| 54 | workspace: string; |
| 55 | channel_id: string; |
| 56 | message_id: string | null; |
| 57 | session_id: string | null; |
| 58 | repo_id: string; |
| 59 | repo: string; |
| 60 | title: string; |
| 61 | body: string; |
| 62 | labels: string; |
| 63 | asked_by: string | null; |
| 64 | status: string; |
| 65 | filed_by: string | null; |
| 66 | number: number | null; |
| 67 | created_at: string; |
| 68 | updated_at: string; |
| 69 | }; |
| 70 | |
| 71 | /** Records a draft and posts its card with `post`. Returns the draft's id. */ |
| 72 | export async function postDraft( |
| 73 | env: SessionEnv, |
| 74 | input: { agent_id: string; workspace_id: string; workspace: string; channel_id: string; session_id: string | null; repo_id: string; repo: string; title: string; body: string; labels: string[]; asked_by: string | null }, |
| 75 | post: (card: MessageCard) => Promise<string | null>, |
| 76 | ): Promise<string | null> { |
| 77 | const id = newId("drf"); |
| 78 | const now = new Date().toISOString(); |
| 79 | const row: DraftRow = { ...input, id, labels: JSON.stringify(input.labels), message_id: null, status: "draft", filed_by: null, number: null, created_at: now, updated_at: now }; |
| 80 | await env.DB.prepare( |
| 81 | `INSERT INTO agent_drafts (id, agent_id, workspace_id, workspace, channel_id, session_id, repo_id, repo, title, body, labels, asked_by, status, created_at, updated_at) |
| 82 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?)`, |
| 83 | ) |
| 84 | .bind(id, row.agent_id, row.workspace_id, row.workspace, row.channel_id, row.session_id, row.repo_id, row.repo, row.title, row.body, row.labels, row.asked_by, now, now) |
| 85 | .run(); |
| 86 | const messageId = await post(draftCard(row)); |
| 87 | if (!messageId) return null; |
| 88 | await env.DB.prepare("UPDATE agent_drafts SET message_id = ? WHERE id = ?").bind(messageId, id).run(); |
| 89 | return id; |
| 90 | } |
| 91 | |
| 92 | const done = (message: string | null): Result<CardActionResult> => ok({ ok: true, message }); |
| 93 | const no = (message: string): Result<CardActionResult> => ok({ ok: false, message }); |
| 94 | |
| 95 | /** A person pressed an action on one of agents' cards. */ |
| 96 | export async function cardAction(env: SessionEnv, a: AgentCardAction): Promise<Result<CardActionResult>> { |
| 97 | const viewer = a?.viewer as User | undefined; |
| 98 | if (!viewer || !a.card?.ref) return fail("invalid", "No such card."); |
| 99 | if (a.card.kind === "session") return sessionAction(env, a, viewer); |
| 100 | if (a.card.kind === "draft_issue") return draftAction(env, a, viewer); |
| 101 | if (a.card.kind === "ability") return abilityAction(env, a, viewer); |
| 102 | if (a.card.kind === "request") return requestAction(env, a, viewer); |
| 103 | return fail("invalid", "That card has no such action."); |
| 104 | } |
| 105 | |
| 106 | /** The agent a card belongs to, by id. */ |
| 107 | async function agentById(db: D1Database, id: string): Promise<Row | null> { |
| 108 | return db |
| 109 | .prepare(selectAgents("a.id = ?3")) |
| 110 | .bind(...periods(new Date()), id) |
| 111 | .first<Row>(); |
| 112 | } |
| 113 | |
| 114 | /** Allow or deny an Ask-first call (abilities.ts): whoever the agent acts for, or an owner. */ |
| 115 | async function abilityAction(env: SessionEnv, a: AgentCardAction, viewer: User): Promise<Result<CardActionResult>> { |
| 116 | const db = env.DB; |
| 117 | const request = await db.prepare("SELECT * FROM agent_ability_requests WHERE id = ?").bind(a.card.ref).first<AbilityRequestRow>(); |
| 118 | if (!request || request.workspace !== a.workspace.toLowerCase() || request.channel_id !== a.channel_id) return fail("not_found", "No such request."); |
| 119 | if (a.action_id !== "allow" && a.action_id !== "deny") return fail("invalid", "That card has no such action."); |
| 120 | if (request.status !== "pending") return no(request.status === "denied" ? "It was denied." : "It was already answered."); |
| 121 | if (request.asked_by !== viewer.id && !canManage(viewer, a.workspace)) return no("Only whoever the agent is working for, or an owner, can answer this."); |
| 122 | const agent = await agentById(db, request.agent_id); |
| 123 | if (!agent) return fail("not_found", "The agent is gone."); |
| 124 | const definition = definitionOf(agent); |
| 125 | const sections = resolveAbilities({ connectors: CONNECTORS, abilities: definition.abilities, autonomy: definition.autonomy, connected: [request.ability.split(":")[1] ?? ""], personal: isPersonal(agent) }); |
| 126 | const found = findAbility(sections, request.ability); |
| 127 | const about = { |
| 128 | agent: agent.display_name, |
| 129 | asker: await usernameOf(env, request.asked_by), |
| 130 | rule: found ? `${found.source.name}: ${found.ability.label}` : request.ability, |
| 131 | level: found?.ability.level ?? ("ask" as const), |
| 132 | note: null, |
| 133 | body: bodyOf(request.input), |
| 134 | }; |
| 135 | const now = new Date().toISOString(); |
| 136 | const update = async (row: AbilityRequestRow) => { |
| 137 | if (!row.message_id) return; |
| 138 | await chatClient(env.CHAT) |
| 139 | .updateAsAgent(row.workspace, row.channel_id, row.agent_id, row.message_id, { card: abilityCard(row, about) }) |
| 140 | .catch((error: unknown) => console.error("agents: an ability card was not updated", row.id, String(error))); |
| 141 | }; |
| 142 | // Claimed first, so two presses never run it twice. |
| 143 | const claimed = await db.prepare("UPDATE agent_ability_requests SET status = ?, decided_by = ?, decided_at = ?, updated_at = ? WHERE id = ? AND status = 'pending'").bind(a.action_id === "allow" ? "running" : "denied", viewer.username, now, now, request.id).run(); |
| 144 | if (!claimed.meta.changes) return no("Someone got there first."); |
| 145 | recordDecision(env, { by: viewer, agent, workspace: a.workspace, request, allowed: a.action_id === "allow" }); |
| 146 | if (a.action_id === "deny") { |
| 147 | const denied = { ...request, status: "denied", decided_by: viewer.username, decided_at: now }; |
| 148 | await update(denied); |
| 149 | await resumeAfterDecision(env, request, `@${viewer.username} denied: ${request.summary}. Don't try another way; say so.`); |
| 150 | return done("Denied. It won't be done."); |
| 151 | } |
| 152 | const ran = await runAllowed(env, request, agent, definition, viewer); |
| 153 | const status = ran.ok ? "allowed" : "failed"; |
| 154 | await db.prepare("UPDATE agent_ability_requests SET status = ?, result = ?, updated_at = ? WHERE id = ?").bind(status, ran.message.slice(0, 20_000), new Date().toISOString(), request.id).run(); |
| 155 | const fresh = { ...request, status, decided_by: viewer.username, decided_at: now, result: ran.message.slice(0, 300) }; |
| 156 | await update(fresh); |
| 157 | await resumeAfterDecision(env, request, ran.ok ? `@${viewer.username} allowed "${request.summary}", and it ran:\n${ran.message}` : `@${viewer.username} allowed "${request.summary}", but it didn't work: ${ran.message}`); |
| 158 | return ran.ok ? done(`Allowed. ${ran.message.slice(0, 200)}`) : no(`Allowed, but it didn't work: ${ran.message.slice(0, 300)}`); |
| 159 | } |
| 160 | |
| 161 | /** The text a call would write, for the card. */ |
| 162 | function bodyOf(input: string): string | null { |
| 163 | try { |
| 164 | const args = JSON.parse(input) as { text?: unknown }; |
| 165 | return typeof args.text === "string" && args.text.trim() ? args.text.trim().slice(0, 900) : null; |
| 166 | } catch { |
| 167 | return null; |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | async function usernameOf(env: SessionEnv, id: string | null): Promise<string | null> { |
| 172 | if (!id) return null; |
| 173 | const [user] = await identityClient(env.IDENTITY) |
| 174 | .usersForAudience([id]) |
| 175 | .catch(() => [] as User[]); |
| 176 | return user?.username ?? null; |
| 177 | } |
| 178 | |
| 179 | /** |
| 180 | * Ask the owners, from a Request card: an integration becomes a Marketplace |
| 181 | * install request (the same one the Marketplace opens); an ability, a |
| 182 | * notification to the owners with a link to the agent's Abilities tab. |
| 183 | */ |
| 184 | async function requestAction(env: SessionEnv, a: AgentCardAction, viewer: User): Promise<Result<CardActionResult>> { |
| 185 | if (a.action_id !== "ask") return fail("invalid", "That card has no such action."); |
| 186 | const ref = a.card.ref ?? ""; |
| 187 | const [kind, agentId, ...rest] = ref.split(":"); |
| 188 | const agent = agentId ? await agentById(env.DB, agentId) : null; |
| 189 | if (!agent) return fail("not_found", "No such card."); |
| 190 | const slug = a.workspace.toLowerCase(); |
| 191 | const update = async (card: MessageCard) => { |
| 192 | await chatClient(env.CHAT) |
| 193 | .updateAsAgent(slug, a.channel_id, agent.id, a.message_id, { card }) |
| 194 | .catch((error: unknown) => console.error("agents: a request card was not updated", a.message_id, String(error))); |
| 195 | }; |
| 196 | const who = { id: agent.id, handle: agent.handle, display_name: agent.display_name }; |
| 197 | if (kind === "connector") { |
| 198 | const connector = CONNECTORS.find((c) => c.id === rest[0]); |
| 199 | if (!connector) return fail("not_found", "No such integration."); |
| 200 | if (canManage(viewer, slug)) return no(`You're an owner: connect ${connector.name} from the Marketplace.`); |
| 201 | const listing = findListing(`integration:${connector.id}`); |
| 202 | if (!listing) return no(`${connector.name} can't be connected to a workspace yet.`); |
| 203 | const why = `${agent.display_name} needs it for @${viewer.username}.`; |
| 204 | const opened = await openRequest(env.DB, agent.workspace_id, newId("ins"), listing, viewer, why); |
| 205 | if (!opened.ok && opened.error.code !== "conflict") return no(opened.error.message); |
| 206 | if (opened.ok) { |
| 207 | await tellOwnersOfRequest(env, { workspace: slug, by: viewer, title: `@${viewer.username} asks you to add ${connector.name}`, body: why, href: `/${slug}/-/marketplace/requests`, id: opened.value.id }); |
| 208 | } |
| 209 | await update(requestCard({ agent: who, workspace: slug, connector: { id: connector.id, name: connector.name, available: true }, ability: null, why, status: "asked", by: viewer.username })); |
| 210 | return done(opened.ok ? "Asked. The owners have your request." : "You'd already asked; the owners have it."); |
| 211 | } |
| 212 | if (kind === "ability") { |
| 213 | const abilityId = rest.join(":"); |
| 214 | const definition = definitionOf(agent); |
| 215 | const sections = resolveAbilities({ connectors: CONNECTORS, abilities: definition.abilities, autonomy: definition.autonomy, connected: CONNECTORS.map((c) => c.id), personal: isPersonal(agent) }); |
| 216 | const found = findAbility(sections, abilityId); |
| 217 | const label = found ? `${found.source.name}: ${found.ability.label}` : abilityId; |
| 218 | if (canManage(viewer, slug)) return no(`You're an owner: allow it on ${agent.display_name}'s Abilities tab.`); |
| 219 | const why = `${agent.display_name} needs it for @${viewer.username}.`; |
| 220 | await tellOwnersOfRequest(env, { workspace: slug, by: viewer, title: `@${viewer.username} asks you to let ${agent.display_name} ${found ? found.ability.label.toLowerCase() : "do more"}`, body: `${label}. ${why}`, href: abilitiesPath(slug, agent.handle), id: `${agent.id}:${abilityId}:${viewer.id}` }); |
| 221 | await update(requestCard({ agent: who, workspace: slug, connector: null, ability: { id: abilityId, label }, why, status: "asked", by: viewer.username })); |
| 222 | return done("Asked. The owners have been told."); |
| 223 | } |
| 224 | return fail("invalid", "That card has no such action."); |
| 225 | } |
| 226 | |
| 227 | async function sessionAction(env: SessionEnv, a: AgentCardAction, viewer: User): Promise<Result<CardActionResult>> { |
| 228 | const row = await sessionRow(env.DB, a.card.ref!); |
| 229 | // The card is in the conversation the session reports in; chat checked the person reads it. |
| 230 | if (!row || row.workspace !== a.workspace.toLowerCase() || row.channel_id !== a.channel_id) return fail("not_found", "No such session."); |
| 231 | const live = ["queued", "working", "waiting", "needs_approval"].includes(row.status); |
| 232 | switch (a.action_id) { |
| 233 | case "stop": |
| 234 | if (!live) return no("It already ended."); |
| 235 | await stop(env, row, viewer.username); |
| 236 | return done("Stopped."); |
| 237 | case "steer": { |
| 238 | const body = (a.input ?? "").trim(); |
| 239 | if (!body) return no("Say something to it."); |
| 240 | await steer(env, row, viewer.username, body); |
| 241 | return done(live ? "It'll read that at its next step." : "It's picking up again with that."); |
| 242 | } |
| 243 | case "approve": { |
| 244 | if (!canManage(viewer, a.workspace)) return no("Only the workspace's owners can approve more spend."); |
| 245 | if (row.status !== "needs_approval") return no("It isn't waiting for approval."); |
| 246 | const cap = parseMoney(a.input); |
| 247 | if (!cap || cap <= row.charged_micros) return no(`The new cap must be more than the ${dollars(row.charged_micros)} it has spent.`); |
| 248 | await approve(env, row, viewer.username, cap); |
| 249 | return done(`Approved up to ${dollars(cap)}. It's going on.`); |
| 250 | } |
| 251 | default: |
| 252 | return fail("invalid", "That card has no such action."); |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | async function draftAction(env: SessionEnv, a: AgentCardAction, viewer: User): Promise<Result<CardActionResult>> { |
| 257 | const db = env.DB; |
| 258 | const draft = await db.prepare("SELECT * FROM agent_drafts WHERE id = ?").bind(a.card.ref).first<DraftRow>(); |
| 259 | if (!draft || draft.workspace !== a.workspace.toLowerCase() || draft.channel_id !== a.channel_id) return fail("not_found", "No such draft."); |
| 260 | const update = async (row: DraftRow) => { |
| 261 | if (!row.message_id) return; |
| 262 | await chatClient(env.CHAT) |
| 263 | .updateAsAgent(row.workspace, row.channel_id, row.agent_id, row.message_id, { card: draftCard(row) }) |
| 264 | .catch((error: unknown) => console.error("agents: a draft's card was not updated", row.id, String(error))); |
| 265 | }; |
| 266 | if (draft.status !== "draft") return no(draft.status === "filed" ? `It's already filed as #${draft.number}.` : "It was discarded."); |
| 267 | const now = new Date().toISOString(); |
| 268 | if (a.action_id === "discard") { |
| 269 | if (draft.asked_by !== viewer.id && !canManage(viewer, a.workspace)) return no("Only whoever asked for it, or an owner, can discard it."); |
| 270 | const claimed = await db.prepare("UPDATE agent_drafts SET status = 'discarded', updated_at = ? WHERE id = ? AND status = 'draft'").bind(now, draft.id).run(); |
| 271 | if (!claimed.meta.changes) return no("Someone got there first."); |
| 272 | await update({ ...draft, status: "discarded" }); |
| 273 | return done("Discarded."); |
| 274 | } |
| 275 | if (a.action_id !== "file") return fail("invalid", "That card has no such action."); |
| 276 | // Filed as the person who pressed it, only where they can read. |
| 277 | const [repo] = await reposClient(env.REPOS) |
| 278 | .readable([draft.repo_id], viewer) |
| 279 | .catch(() => []); |
| 280 | if (!repo) return no("You can't file in that repository."); |
| 281 | // Claimed first, so two presses never file it twice. |
| 282 | const claimed = await db.prepare("UPDATE agent_drafts SET status = 'filing', updated_at = ? WHERE id = ? AND status = 'draft'").bind(now, draft.id).run(); |
| 283 | if (!claimed.meta.changes) return no("Someone got there first."); |
| 284 | const agent = await db.prepare("SELECT handle, display_name FROM agents WHERE id = ?").bind(draft.agent_id).first<{ handle: string; display_name: string }>(); |
| 285 | const footer = agent ? `\n\n---\n_Drafted by ${agent.display_name} (@${agent.handle}), filed by @${viewer.username}._` : ""; |
| 286 | let labels: string[] = []; |
| 287 | try { |
| 288 | labels = JSON.parse(draft.labels || "[]"); |
| 289 | } catch { |
| 290 | labels = []; |
| 291 | } |
| 292 | const opened = await workClient(env.WORK).openIssue(viewer, { namespace: repo.namespace, name: repo.name }, { title: draft.title, body: `${draft.body}${footer}`, labels }); |
| 293 | if (!opened.ok) { |
| 294 | await db.prepare("UPDATE agent_drafts SET status = 'draft', updated_at = ? WHERE id = ?").bind(new Date().toISOString(), draft.id).run(); |
| 295 | return no(`It couldn't be filed: ${opened.error.message}`); |
| 296 | } |
| 297 | const filed: DraftRow = { ...draft, status: "filed", filed_by: viewer.username, number: opened.value.number }; |
| 298 | await db.prepare("UPDATE agent_drafts SET status = 'filed', filed_by = ?, number = ?, updated_at = ? WHERE id = ?").bind(viewer.username, filed.number, new Date().toISOString(), draft.id).run(); |
| 299 | if (draft.session_id) { |
| 300 | const row = await sessionRow(db, draft.session_id); |
| 301 | if (row) { |
| 302 | const outputs = (() => { |
| 303 | try { |
| 304 | return JSON.parse(row.outputs || "[]") as unknown[]; |
| 305 | } catch { |
| 306 | return []; |
| 307 | } |
| 308 | })(); |
| 309 | outputs.push({ kind: "issue", repo: draft.repo, number: filed.number, title: draft.title }); |
| 310 | await db.prepare("UPDATE agent_sessions SET outputs = ? WHERE id = ?").bind(JSON.stringify(outputs.slice(-50)), row.id).run(); |
| 311 | } |
| 312 | } |
| 313 | await update(filed); |
| 314 | return done(`Filed ${draft.repo}#${filed.number}.`); |
| 315 | } |