Skip to content
315 linesCodeBlameRaw
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 */
23import {
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
38import { type AbilityRequestRow, abilitiesPath, recordDecision, runAllowed, tellOwnersOfRequest } from "./abilities.ts";
39import { canManage } from "./access.ts";
40import { abilityCard, draftCard, parseMoney, requestCard } from "./card-views.ts";
41import { findAbility, resolveAbilities } from "../../../packages/contracts/src/abilities.ts";
42import { CONNECTORS } from "../../../packages/contracts/src/connectors.ts";
43import { findListing, openRequest } from "./installs.ts";
44import { type Row, definitionOf, isPersonal, periods, selectAgents } from "./store.ts";
45
46export { draftCard, parseMoney } from "./card-views.ts";
47import { dollars } from "./money.ts";
48import { type SessionEnv, approve, pushInbox, resumeAfterDecision, sessionRow, steer, stop } from "./sessions.ts";
49
50export 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. */
72export 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
92const done = (message: string | null): Result<CardActionResult> => ok({ ok: true, message });
93const no = (message: string): Result<CardActionResult> => ok({ ok: false, message });
94
95/** A person pressed an action on one of agents' cards. */
96export 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. */
107async 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. */
115async 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. */
162function 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
171async 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 */
184async 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
227async 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
256async 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}