Skip to content
887 linesCodeBlameRaw
1/**
2 * The agents service: a workspace's own agents
3 * (docs.g1t.sh/guides/agents/). Their definitions and every version of
4 * them, the templates they start from, each agent's desk, and their replies
5 * in chat.
6 *
7 * `@g1t`, the platform's own agent, is not one of these.
8 *
9 * Reached through service bindings: `POST /rpc/<method>`, with snake_case
10 * JSON (@g1t/contracts workspace-agents.ts).
11 */
12import {
13 type AgentCardAction,
14 type AgentProposal,
15 type AgentRedraft,
16 type DraftReply,
17 MAX_PERSONAL_AGENTS,
18 type AgentDelivery,
19 type CardActionResult,
20 type G1tEvent,
21 type ExtensionInstall,
22 type InstallRequest,
23 type InstallRequests,
24 type NewWorkspaceAgent,
25 type Result,
26 type ServiceBinding,
27 type User,
28 type WorkspaceAgent,
29 UNVERIFIED,
30 askerAccess,
31 awaitsConfirmation,
32 chatClient,
33 cleanRequestNote,
34 extensionById,
35 fail,
36 identityClient,
37 newId,
38 notifyClient,
39 ok,
40 openD1,
41} from "@g1t/contracts";
42
43import { MANAGE_REFUSAL, NOT_YOURS_REFUSAL, canArchive, canChange, canManage, canSee, canSeeAgent, creatableScope } from "./access.ts";
44import { DRAFT_OUTPUT_TOKENS, draftSystem, jsonIn, proposalFrom, redraftFrom, redraftSystem, startingBudget, trySystem, tryTurns, wordsOf } from "./builder.ts";
45import { callBuilder } from "./builder-call.ts";
46import { type Definition, applyChanges } from "./definition.ts";
47import { builtinChanges } from "./orchestrator.ts";
48import type { Desk } from "./desk.ts";
49import type { ReplyEnv } from "./reply.ts";
50import { ensureBuiltin } from "./builtin.ts";
51import { type Row, definitionOf, insertAgent, isPersonal, periods, selectAgents, toAgent, updateAgent, versionStatement } from "./store.ts";
52import { TEMPLATES, TEMPLATE_IDS } from "./templates.ts";
53import { readPolicy } from "./policy.ts";
54import { runDue } from "./routines.ts";
55import { onEvents } from "./triggers.ts";
56import { cardAction } from "./cards.ts";
57import { type SessionEnv, sweep } from "./sessions.ts";
58import * as views from "./views.ts";
59import { monthKey } from "./budget.ts";
60import * as extensions from "./extensions.ts";
61import { type Answered, type Person, answerLine, findListing, listRequests, listingPath, openRequest, requestsPath, resolveListing, resolveRequest } from "./installs.ts";
62
63export { Desk } from "./desk.ts";
64
65type Env = ReplyEnv & {
66 IDENTITY: ServiceBinding;
67 /** The audit log. */
68 EVENTS: ServiceBinding;
69 DESKS: DurableObjectNamespace<Desk>;
70};
71
72/** Workspace ids by slug, kept a minute: every call names a workspace by slug. */
73const workspaceIds = new Map<string, { id: string | null; until: number }>();
74
75class Agents {
76 private readonly env: Env;
77 private readonly db: D1Database;
78 private readonly defer: (work: Promise<unknown>) => void;
79
80 // Plain fields, not parameter properties: Node's type stripping does not take those.
81 constructor(env: Env, defer: (work: Promise<unknown>) => void) {
82 this.env = env;
83 this.db = env.DB;
84 this.defer = defer;
85 }
86
87 private async workspaceId(slug: string): Promise<string | null> {
88 const key = slug.toLowerCase();
89 const kept = workspaceIds.get(key);
90 if (kept && kept.until > Date.now()) return kept.id;
91 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(key);
92 if (workspaceIds.size > 5_000) workspaceIds.clear();
93 workspaceIds.set(key, { id: workspace?.id ?? null, until: Date.now() + 60_000 });
94 return workspace?.id ?? null;
95 }
96
97 /** The workspace's id, if the viewer may see its agents. */
98 private async seen(workspace: string, viewer: User | null): Promise<Result<string>> {
99 if (!canSee(viewer, workspace)) return fail("not_found", "There is no such workspace.");
100 const id = await this.workspaceId(workspace);
101 return id ? ok(id) : fail("not_found", "There is no such workspace.");
102 }
103
104 /** Internal, from chat: a person pressed an action on one of agents' cards. */
105 async cardAction(a: AgentCardAction): Promise<Result<CardActionResult>> {
106 if (a?.viewer && awaitsConfirmation(a.viewer)) return UNVERIFIED;
107 if (!a?.viewer || !canSee(a.viewer, a.workspace ?? "")) return fail("not_found", "No such card.");
108 return cardAction(this.env as unknown as SessionEnv, a);
109 }
110
111 /** What the views need: the workspace, the viewer, and whether they own it. */
112 private async context(workspace: string, viewer: User | null): Promise<Result<views.ViewContext>> {
113 if (viewer && awaitsConfirmation(viewer)) return UNVERIFIED;
114 const seen = await this.seen(workspace, viewer);
115 if (!seen.ok) return seen;
116 return ok({
117 env: this.env as unknown as SessionEnv,
118 db: this.db,
119 slug: workspace.toLowerCase(),
120 workspaceId: seen.value,
121 viewer: viewer!,
122 owner: canManage(viewer, workspace),
123 audit: (action, handle, message) => this.audit(viewer!, workspace, action, handle, message),
124 });
125 }
126
127 /** Runs `view` with the context, or answers why it can't. */
128 async view<T>(a: { workspace: string; viewer: User | null }, view: (ctx: views.ViewContext) => Promise<Result<T>>): Promise<Result<T>> {
129 const ctx = await this.context(a?.workspace ?? "", a?.viewer ?? null);
130 if (!ctx.ok) return ctx;
131 return view(ctx.value);
132 }
133
134 /** The workspace's id, if the viewer may change its agents. */
135 private async managed(workspace: string, viewer: User | null): Promise<Result<string>> {
136 if (awaitsConfirmation(viewer)) return UNVERIFIED;
137 const seen = await this.seen(workspace, viewer);
138 if (!seen.ok) return seen;
139 return canManage(viewer, workspace) ? seen : fail("forbidden", MANAGE_REFUSAL);
140 }
141
142 private async row(workspaceId: string, handle: unknown): Promise<Row | null> {
143 if (typeof handle !== "string") return null;
144 const now = new Date();
145 return this.db
146 .prepare(selectAgents("a.workspace_id = ?3 AND a.handle = ?4 AND a.archived_at IS NULL"))
147 .bind(...periods(now), workspaceId, handle.trim().replace(/^@/, "").toLowerCase())
148 .first<Row>();
149 }
150
151 /** Whether `team` (a slug, or none) is one of the workspace's teams, as the person changing the agent sees them. */
152 private async teamExists(workspace: string, viewer: User, team: string | null): Promise<Result<null>> {
153 if (!team) return ok(null);
154 const found = await identityClient(this.env.IDENTITY)
155 .getTeam(viewer, workspace.toLowerCase(), team)
156 .catch(() => null);
157 return found?.ok ? ok(null) : fail("invalid", `${workspace} has no team called ${team}.`);
158 }
159
160 private async handleTaken(workspaceId: string, handle: string, except: string | null): Promise<boolean> {
161 const found = await this.db
162 .prepare("SELECT id FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL")
163 .bind(workspaceId, handle)
164 .first<{ id: string }>();
165 return !!found && found.id !== except;
166 }
167
168 /**
169 * The workspace's agents, and personal agents when asked for: `mine`, the
170 * viewer's own; `all`, every member's for an owner.
171 */
172 async list(a: { workspace: string; viewer: User | null; personal?: unknown }): Promise<Result<WorkspaceAgent[]>> {
173 const seen = await this.seen(a.workspace, a.viewer);
174 if (!seen.ok) return seen;
175 const now = new Date();
176 await this.ensureBuiltin(seen.value);
177 const all = a.personal === "all" && canManage(a.viewer, a.workspace);
178 const mine = (a.personal === "mine" || a.personal === "all") && (a.viewer?.kind ?? "user") === "user" ? (a.viewer?.id ?? "") : "";
179 const rows = await this.db
180 .prepare(
181 `${selectAgents(
182 `a.workspace_id = ?3 AND a.archived_at IS NULL AND (a.scope = 'workspace'${all ? " OR a.scope = 'personal'" : mine ? " OR (a.scope = 'personal' AND a.owner_id = ?4)" : ""})`,
183 )} ORDER BY a.builtin DESC, a.handle`,
184 )
185 .bind(...periods(now), seen.value, ...(mine && !all ? [mine] : []))
186 .all<Row>();
187 return ok(rows.results.map((row) => toAgent(row, now)));
188 }
189
190 async get(a: { workspace: string; handle: string; viewer: User | null }): Promise<Result<WorkspaceAgent>> {
191 const seen = await this.seen(a.workspace, a.viewer);
192 if (!seen.ok) return seen;
193 await this.ensureBuiltin(seen.value);
194 const row = await this.row(seen.value, a.handle);
195 // Another member's personal agent is theirs: it isn't there for anyone else but owners.
196 return row && canSeeAgent(a.viewer, a.workspace, row) ? ok(toAgent(row, new Date())) : fail("not_found", `There is no agent called @${a.handle}.`);
197 }
198
199 /** The agent by handle, if the viewer may see it. */
200 private async visible(workspace: string, viewer: User | null, handle: unknown): Promise<Result<{ workspaceId: string; row: Row }>> {
201 if (awaitsConfirmation(viewer)) return UNVERIFIED;
202 const seen = await this.seen(workspace, viewer);
203 if (!seen.ok) return seen;
204 const row = await this.row(seen.value, handle);
205 if (!row || !canSeeAgent(viewer, workspace, row)) return fail("not_found", `There is no agent called @${String(handle ?? "")}.`);
206 return ok({ workspaceId: seen.value, row });
207 }
208
209 /** Internal: agents by id, archived ones too, so old messages still show who wrote them. */
210 async byIds(a: { ids: string[] }): Promise<WorkspaceAgent[]> {
211 const ids = [...new Set((Array.isArray(a.ids) ? a.ids : []).filter((id) => typeof id === "string"))].slice(0, 100);
212 if (!ids.length) return [];
213 const now = new Date();
214 const rows = await this.db
215 .prepare(selectAgents(`a.id IN (${ids.map((_, i) => `?${i + 3}`).join(", ")})`))
216 .bind(...periods(now), ...ids)
217 .all<Row>();
218 return rows.results.map((row) => toAgent(row, now));
219 }
220
221 /**
222 * Who may create an agent, and of which scope: the workspace's id and
223 * policy, and the scope it gets (access.ts `creatableScope`).
224 */
225 private async creatable(workspace: string, viewer: User | null, asked: unknown): Promise<Result<{ workspaceId: string; scope: "workspace" | "personal"; policy: Awaited<ReturnType<typeof readPolicy>> }>> {
226 if (awaitsConfirmation(viewer)) return UNVERIFIED;
227 const seen = await this.seen(workspace, viewer);
228 if (!seen.ok) return seen;
229 const policy = await readPolicy(this.db, seen.value, monthKey(new Date()));
230 const scope = creatableScope(viewer, workspace, asked, policy.members_create_agents);
231 if (!scope.ok) return fail("forbidden", scope.message);
232 return ok({ workspaceId: seen.value, scope: scope.scope, policy });
233 }
234
235 async create(a: { workspace: string; viewer: User | null; input: NewWorkspaceAgent }): Promise<Result<WorkspaceAgent>> {
236 const allowed = await this.creatable(a.workspace, a.viewer, a.input?.scope);
237 if (!allowed.ok) return allowed;
238 const { workspaceId, scope, policy } = allowed.value;
239 const personal = scope === "personal";
240 const viewer = a.viewer!;
241 if (personal) {
242 const count = await this.db
243 .prepare("SELECT COUNT(*) AS n FROM agents WHERE workspace_id = ? AND owner_id = ? AND scope = 'personal' AND archived_at IS NULL")
244 .bind(workspaceId, viewer.id)
245 .first<{ n: number }>();
246 if ((count?.n ?? 0) >= MAX_PERSONAL_AGENTS) {
247 return fail("invalid", `You have ${MAX_PERSONAL_AGENTS} personal agents here, the most one person keeps. Archive one to make another.`);
248 }
249 }
250 // A new agent starts with a budget, unless one was given: a member's own
251 // $20 a month and $2 a session, or the workspace's default for its agents.
252 const given = a.input?.budget ?? {};
253 const start = startingBudget(scope, policy.default_agent_monthly_micros);
254 const budget = {
255 monthly_micros: given.monthly_micros !== undefined ? given.monthly_micros : start.monthly_micros,
256 daily_micros: given.daily_micros !== undefined ? given.daily_micros : start.daily_micros,
257 task_micros: given.task_micros !== undefined ? given.task_micros : start.task_micros,
258 };
259 const { scope: _scope, ...rest } = a.input ?? ({} as NewWorkspaceAgent);
260 // A personal agent is on no team: teams are shared, and it answers only its member.
261 const input = { ...rest, budget, ...(personal ? { team: null } : {}) };
262 const checked = applyChanges(null, input, TEMPLATE_IDS);
263 if (!checked.ok) return fail("invalid", checked.message);
264 const definition = checked.value;
265 const team = await this.teamExists(a.workspace, viewer, definition.team);
266 if (!team.ok) return team;
267 if (await this.handleTaken(workspaceId, definition.handle, null)) {
268 return fail("conflict", `${a.workspace} already has an agent called @${definition.handle}.`);
269 }
270 const now = new Date().toISOString();
271 const id = newId("agt");
272 const by = viewer.username;
273 const owned: Record<string, string> = personal ? { scope: "personal", owner_id: viewer.id, owner_username: viewer.username.toLowerCase() } : {};
274 try {
275 await this.db.batch([
276 insertAgent(this.db, id, workspaceId, definition, { version: 1, created_by: by, created_at: now, updated_at: now, ...owned }),
277 this.versionStatement(id, 1, definition, by, now),
278 ]);
279 } catch (error) {
280 if (String(error).includes("UNIQUE")) return fail("conflict", `${a.workspace} already has an agent called @${definition.handle}.`);
281 throw error;
282 }
283 this.audit(viewer, a.workspace, "create_agent", definition.handle, `Created ${personal ? "the personal agent " : ""}@${definition.handle} (version 1)`, "agents", personal ? "member" : "owner");
284 this.defer(this.hello(a.workspace, workspaceId, id, viewer));
285 const row = await this.row(workspaceId, definition.handle);
286 return ok(toAgent(row!, new Date()));
287 }
288
289 // ── The builder: describe it, try it, change it in words (./builder.ts) ──
290
291 /** Drafts a whole agent from a description, charged to the viewer. Nothing is saved. */
292 async draft(a: { workspace: string; viewer: User | null; description: unknown; scope?: unknown }): Promise<Result<AgentProposal>> {
293 const allowed = await this.creatable(a?.workspace ?? "", a?.viewer ?? null, a?.scope);
294 if (!allowed.ok) return allowed;
295 const words = wordsOf(a.description, "description");
296 if (!words.ok) return fail("invalid", words.message);
297 const { workspaceId, scope, policy } = allowed.value;
298 const slug = a.workspace.toLowerCase();
299 const [answer, handles] = await Promise.all([
300 callBuilder(this.env, {
301 slug,
302 workspaceId,
303 viewer: a.viewer!,
304 system: draftSystem(slug, scope),
305 messages: [{ role: "user", content: `What the agent should do:\n\n${words.value}` }],
306 maxOutput: DRAFT_OUTPUT_TOKENS,
307 }),
308 this.db.prepare("SELECT handle FROM agents WHERE workspace_id = ? AND archived_at IS NULL").bind(workspaceId).all<{ handle: string }>(),
309 ]);
310 if (!answer.ok) return fail(answer.code, answer.message);
311 const proposal = proposalFrom(jsonIn(answer.text), {
312 scope,
313 taken: new Set(handles.results.map((row) => row.handle)),
314 budget: startingBudget(scope, policy.default_agent_monthly_micros),
315 });
316 if (!proposal.ok) return fail("invalid", proposal.message);
317 return ok({ ...proposal.value, charged_micros: answer.charged });
318 }
319
320 /** Try it: the unsaved definition answers the conversation so far. Charged to the viewer; nothing is saved. */
321 async tryDraft(a: { workspace: string; viewer: User | null; definition: unknown; messages: unknown }): Promise<Result<DraftReply>> {
322 const given = (a?.definition ?? {}) as NewWorkspaceAgent;
323 const allowed = await this.creatable(a?.workspace ?? "", a?.viewer ?? null, given?.scope);
324 if (!allowed.ok) return allowed;
325 const { scope: _scope, ...rest } = given;
326 // A handle someone has already taken is no reason not to try it.
327 const checked = applyChanges(null, { ...rest, handle: rest.handle || "preview" }, TEMPLATE_IDS);
328 if (!checked.ok) return fail("invalid", checked.message);
329 const turns = tryTurns(a.messages);
330 if (!turns.ok) return fail("invalid", turns.message);
331 const viewer = a.viewer!;
332 const answer = await callBuilder(this.env, {
333 slug: a.workspace.toLowerCase(),
334 workspaceId: allowed.value.workspaceId,
335 viewer,
336 system: trySystem({ workspace: a.workspace.toLowerCase(), definition: checked.value, asker: { username: viewer.username, display_name: viewer.display_username ?? null } }),
337 messages: turns.value,
338 routing: { floor: checked.value.routing.floor, ceiling: checked.value.routing.ceiling },
339 });
340 if (!answer.ok) return fail(answer.code, answer.message);
341 return ok({ text: answer.text, charged_micros: answer.charged });
342 }
343
344 /** Drafts changes from a request in words, for whoever may change the agent. Saved only through `update`. */
345 async redraft(a: { workspace: string; handle: string; viewer: User | null; request: unknown }): Promise<Result<AgentRedraft>> {
346 const found = await this.visible(a?.workspace ?? "", a?.viewer ?? null, a?.handle);
347 if (!found.ok) return found;
348 const { workspaceId, row } = found.value;
349 if (!canChange(a.viewer, a.workspace, row)) return fail("forbidden", isPersonal(row) ? NOT_YOURS_REFUSAL : MANAGE_REFUSAL);
350 const words = wordsOf(a.request, "request");
351 if (!words.ok) return fail("invalid", words.message);
352 const before = definitionOf(row);
353 const slug = a.workspace.toLowerCase();
354 const answer = await callBuilder(this.env, {
355 slug,
356 workspaceId,
357 viewer: a.viewer!,
358 system: redraftSystem(slug, before, !!row.builtin),
359 messages: [{ role: "user", content: `The change to make to @${row.handle}:\n\n${words.value}` }],
360 maxOutput: DRAFT_OUTPUT_TOKENS,
361 });
362 if (!answer.ok) return fail(answer.code, answer.message);
363 const drafted = redraftFrom(jsonIn(answer.text), before, !!row.builtin);
364 if (!drafted.ok) return fail("invalid", drafted.message);
365 return ok({ ...drafted.value, from_version: row.version, charged_micros: answer.charged });
366 }
367
368 /**
369 * Owners make a personal agent a workspace agent: a new workspace agent
370 * with the same handle, definition and every version, and one more
371 * version saying so; the personal one is archived in the same step, its
372 * memory and direct messages kept with it (owners can't read a member's
373 * memory to choose from it, so none is carried).
374 */
375 async promote(a: { workspace: string; handle: string; viewer: User | null }): Promise<Result<WorkspaceAgent>> {
376 const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null);
377 if (!managed.ok) return managed.error.code === "forbidden" ? fail("forbidden", "Only the workspace's owners promote personal agents.") : managed;
378 const row = await this.row(managed.value, a.handle);
379 if (!row) return fail("not_found", `There is no agent called @${a.handle}.`);
380 if (!isPersonal(row)) return fail("invalid", `@${row.handle} is already a workspace agent.`);
381 const versions = await this.db
382 .prepare("SELECT version, definition, changed_by, created_at FROM agent_versions WHERE agent_id = ? ORDER BY version")
383 .bind(row.id)
384 .all<{ version: number; definition: string; changed_by: string; created_at: string }>();
385 const definition = definitionOf(row);
386 const now = new Date().toISOString();
387 const id = newId("agt");
388 const version = row.version + 1;
389 const by = a.viewer!.username;
390 try {
391 const [archived] = await this.db.batch([
392 // Archived first, from the version read, so the handle is free for the new one in the same step.
393 this.db.prepare("UPDATE agents SET archived_at = ?, updated_at = ? WHERE id = ? AND version = ? AND archived_at IS NULL").bind(now, now, row.id, row.version),
394 insertAgent(this.db, id, managed.value, definition, { version, created_by: row.created_by, created_at: now, updated_at: now }),
395 ...versions.results.map((v) =>
396 this.db.prepare("INSERT INTO agent_versions (agent_id, version, definition, changed_by, created_at) VALUES (?, ?, ?, ?, ?)").bind(id, v.version, v.definition, v.changed_by, v.created_at),
397 ),
398 this.versionStatement(id, version, definition, by, now),
399 ]);
400 if (!archived.meta.changes) throw new Error("changed meanwhile");
401 } catch (error) {
402 console.error("agents: a promotion failed", row.id, String(error));
403 return fail("conflict", `@${row.handle} was changed meanwhile. Reload it and try again.`);
404 }
405 this.audit(a.viewer!, a.workspace, "promote_agent", row.handle, `Promoted @${row.owner_username ?? "a member"}'s personal agent @${row.handle} to a workspace agent (version ${version})`);
406 const saved = await this.row(managed.value, row.handle);
407 return ok(toAgent(saved!, new Date()));
408 }
409
410 // ── The Marketplace's install requests (./installs.ts) ─────────────────
411
412 /** Requests as the viewer sees them: every one for an owner, their own for anyone else. */
413 async installRequests(a: { workspace: string; viewer: User | null }): Promise<Result<InstallRequests>> {
414 if (a?.viewer && awaitsConfirmation(a.viewer)) return UNVERIFIED;
415 const seen = await this.seen(a?.workspace ?? "", a?.viewer ?? null);
416 if (!seen.ok) return seen;
417 const owner = canManage(a.viewer, a.workspace);
418 const requests = await listRequests(this.db, seen.value, a.viewer!, owner);
419 return ok({ requests, can_resolve: owner });
420 }
421
422 /** A member asks the owners to add something; each owner is notified. */
423 async requestInstall(a: { workspace: string; viewer: User | null; listing: unknown; note?: unknown }): Promise<Result<InstallRequest>> {
424 if (a?.viewer && awaitsConfirmation(a.viewer)) return UNVERIFIED;
425 const seen = await this.seen(a?.workspace ?? "", a?.viewer ?? null);
426 if (!seen.ok) return seen;
427 const viewer = a.viewer!;
428 if ((viewer.kind ?? "user") !== "user") return fail("forbidden", "Only people ask the workspace's owners to add things.");
429 if (canManage(viewer, a.workspace)) return fail("invalid", "You're an owner of this workspace: add it yourself.");
430 const listing = findListing(a.listing);
431 if (!listing) return fail("not_found", "That isn't something a workspace can add yet.");
432 const opened = await openRequest(this.db, seen.value, newId("ins"), listing, viewer, cleanRequestNote(a.note));
433 if (!opened.ok) return opened;
434 const slug = a.workspace.toLowerCase();
435 this.audit(viewer, slug, "request_install", listing.ref, `Asked the owners to add ${listing.name}`, "marketplace");
436 this.defer(this.tellOwners(slug, viewer, opened.value));
437 return opened;
438 }
439
440 /** An owner adds or turns down a request; whoever asked is told. */
441 async resolveInstallRequest(a: { workspace: string; viewer: User | null; id: unknown; status: unknown }): Promise<Result<InstallRequest>> {
442 const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null);
443 if (!managed.ok) {
444 return managed.error.code === "forbidden" ? fail("forbidden", "Only the workspace's owners answer requests.") : managed;
445 }
446 if (a.status !== "done" && a.status !== "declined") return fail("invalid", "Answer a request with done or declined.");
447 const answered = await resolveRequest(this.db, managed.value, String(a.id ?? ""), a.status, a.viewer!);
448 if (!answered.ok) return answered;
449 const slug = a.workspace.toLowerCase();
450 const { request } = answered.value;
451 this.audit(a.viewer!, slug, "answer_install_request", request.listing, `${request.status === "done" ? "Added" : "Turned down"} ${request.name} for @${request.requested_by}`, "marketplace");
452 this.defer(this.tellRequester(slug, a.viewer!, answered.value));
453 return ok(request);
454 }
455
456 // ── Extensions installed in the workspace (./extensions.ts) ──────────────
457
458 /** The workspace's installs; any member sees them. */
459 async extensionInstalls(a: { workspace: string; viewer: User | null }): Promise<Result<ExtensionInstall[]>> {
460 if (a?.viewer && awaitsConfirmation(a.viewer)) return UNVERIFIED;
461 const seen = await this.seen(a?.workspace ?? "", a?.viewer ?? null);
462 if (!seen.ok) return seen;
463 return ok(await extensions.listInstalls(this.db, seen.value));
464 }
465
466 /** Owners install a published extension; whoever asked for it is told. */
467 async installExtension(a: { workspace: string; viewer: User | null; extension: unknown }): Promise<Result<ExtensionInstall>> {
468 const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null);
469 if (!managed.ok) return managed.error.code === "forbidden" ? fail("forbidden", "Only the workspace's owners install extensions.") : managed;
470 const installed = await extensions.install(this.db, extensionById, managed.value, newId("ins"), String(a.extension ?? ""), a.viewer!.username);
471 if (!installed.ok) return installed;
472 const slug = a.workspace.toLowerCase();
473 this.audit(a.viewer!, slug, "install_extension", installed.value.listing, `Installed ${installed.value.listing} ${installed.value.version}`, "marketplace");
474 this.defer(this.answerListing(slug, managed.value, installed.value.listing, a.viewer!));
475 return installed;
476 }
477
478 /** Owners switch an install on or off. */
479 async setExtensionEnabled(a: { workspace: string; viewer: User | null; listing: unknown; enabled: unknown }): Promise<Result<ExtensionInstall>> {
480 const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null);
481 if (!managed.ok) return managed;
482 const listing = String(a.listing ?? "");
483 if (!extensions.extensionIdOf(listing)) return fail("invalid", "Name an extension as extension:<id>.");
484 const changed = await extensions.setEnabled(this.db, managed.value, listing, a.enabled === true, a.viewer!.username);
485 if (changed.ok) this.audit(a.viewer!, a.workspace, a.enabled === true ? "enable_extension" : "disable_extension", listing, `${a.enabled === true ? "Switched on" : "Switched off"} ${listing}`, "marketplace");
486 return changed;
487 }
488
489 /** Owners cap what an install spends a month. */
490 async setExtensionBudget(a: { workspace: string; viewer: User | null; listing: unknown; monthly_micros: unknown }): Promise<Result<ExtensionInstall>> {
491 const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null);
492 if (!managed.ok) return managed;
493 const listing = String(a.listing ?? "");
494 const micros = a.monthly_micros == null ? null : Number(a.monthly_micros);
495 const changed = await extensions.setBudget(this.db, managed.value, listing, micros);
496 if (changed.ok) this.audit(a.viewer!, a.workspace, "set_extension_budget", listing, `Set ${listing}'s monthly budget`, "marketplace");
497 return changed;
498 }
499
500 /** Owners remove an install. */
501 async uninstallExtension(a: { workspace: string; viewer: User | null; listing: unknown }): Promise<Result<null>> {
502 const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null);
503 if (!managed.ok) return managed;
504 const listing = String(a.listing ?? "");
505 const removed = await extensions.uninstall(this.db, managed.value, listing, a.viewer!.username);
506 if (removed.ok) this.audit(a.viewer!, a.workspace, "uninstall_extension", listing, `Uninstalled ${listing}`, "marketplace");
507 return removed;
508 }
509
510 /** Marks every open request for `listing` added, and tells each person who asked. */
511 private async answerListing(slug: string, workspaceId: string, listing: string, by: User): Promise<void> {
512 try {
513 const answered = await resolveListing(this.db, workspaceId, listing, by as Person);
514 await Promise.all(answered.map((one) => this.tellRequester(slug.toLowerCase(), by, one)));
515 } catch (error) {
516 console.error("agents: requests for a listing were not answered", listing, String(error));
517 }
518 }
519
520 /** Every owner hears of a new request (at most 20 of them). */
521 private async tellOwners(slug: string, asker: User, request: InstallRequest): Promise<void> {
522 if (!this.env.NOTIFY) return;
523 try {
524 const members = await identityClient(this.env.IDENTITY).listMembers(slug, asker);
525 if (!members.ok) throw new Error(members.error.message);
526 const owners = members.value.filter((member) => member.role === "owner").slice(0, 20);
527 const notify = notifyClient(this.env.NOTIFY);
528 await Promise.all(
529 owners.map((owner) =>
530 notify
531 .notify(
532 { username: owner.username },
533 {
534 id: `install-request:${request.id}:${owner.username}`,
535 kind: "approval",
536 workspace: slug,
537 title: `@${asker.username} asks you to add ${request.name}`,
538 body: request.note ?? (request.kind === "extension" ? "An extension. Install it, or turn the request down." : "An integration. Connect it, or turn the request down."),
539 href: requestsPath(slug),
540 actor: { kind: "user", id: asker.id, name: asker.username, avatar: asker.avatar ?? null, avatar_seed: null },
541 created_at: new Date().toISOString(),
542 },
543 )
544 .catch(() => undefined),
545 ),
546 );
547 } catch (error) {
548 console.error("agents: owners were not told of an install request", request.id, String(error));
549 }
550 }
551
552 /** The person who asked hears their request was answered. */
553 private async tellRequester(slug: string, by: User, answered: Answered): Promise<void> {
554 if (!this.env.NOTIFY) return;
555 const { request } = answered;
556 const line = answerLine(request, `@${by.username}`);
557 await notifyClient(this.env.NOTIFY)
558 .notify(
559 { user_id: answered.requested_by_id },
560 {
561 id: `install-answer:${request.id}`,
562 kind: "inbox",
563 workspace: slug,
564 title: line.title,
565 body: line.body,
566 href: request.status === "done" ? listingPath(slug, request.listing) : requestsPath(slug),
567 actor: { kind: "user", id: by.id, name: by.username, avatar: by.avatar ?? null, avatar_seed: null },
568 created_at: new Date().toISOString(),
569 },
570 )
571 .catch((error: unknown) => console.error("agents: a requester was not told", request.id, String(error)));
572 }
573
574 /**
575 * A new agent's first words: the DM with the person who made it is
576 * opened, and the agent says hello there in its own voice, as a reply
577 * billed like any other (a fixed hello when no model can be used).
578 * After the answer; a failure only logs.
579 */
580 private async hello(workspace: string, workspaceId: string, agentId: string, creator: User): Promise<void> {
581 if ((creator.kind ?? "user") !== "user") return;
582 try {
583 const dm = await chatClient(this.env.CHAT).openDm(workspace, creator, [{ kind: "agent", id: agentId }]);
584 if (!dm.ok) throw new Error(dm.error.message);
585 const desk = this.env.DESKS.get(this.env.DESKS.idFromName(agentId));
586 await desk.take({
587 workspace,
588 workspace_id: workspaceId,
589 channel_id: dm.value.id,
590 channel_kind: "dm",
591 channel_name: null,
592 agent_id: agentId,
593 // One hello per agent, however often this runs.
594 message_id: `hello:${agentId}`,
595 thread_root: null,
596 asked_by: creator.id,
597 hops: 0,
598 asker: askerAccess(creator, workspace),
599 hello: true,
600 });
601 } catch (error) {
602 console.error("agents: a new agent's hello was not sent", agentId, String(error));
603 }
604 }
605
606 async update(a: { workspace: string; handle: string; viewer: User | null; changes: Partial<NewWorkspaceAgent> }): Promise<Result<WorkspaceAgent>> {
607 const found = await this.visible(a.workspace, a.viewer, a.handle);
608 if (!found.ok) return found;
609 const { row } = found.value;
610 // Owners change the workspace's agents; a member changes their own personal one.
611 if (!canChange(a.viewer, a.workspace, row)) return fail("forbidden", isPersonal(row) ? NOT_YOURS_REFUSAL : MANAGE_REFUSAL);
612 const workspaceId = found.value.workspaceId;
613 const before = definitionOf(row);
614 const { scope: _scope, ...asked } = (a.changes ?? {}) as Partial<NewWorkspaceAgent>;
615 // The built-in @g1t keeps who it is and its job; the rest is the workspace's.
616 // A personal agent stays on no team.
617 const allowed = row.builtin
618 ? builtinChanges(before, asked)
619 : { ok: true as const, value: isPersonal(row) && asked.team !== undefined ? { ...asked, team: null } : asked };
620 if (!allowed.ok) return fail("invalid", allowed.message);
621 const checked = applyChanges(before, allowed.value, TEMPLATE_IDS, { builtin: !!row.builtin });
622 if (!checked.ok) return fail("invalid", checked.message);
623 const definition = checked.value;
624 if (JSON.stringify(definition) === JSON.stringify(before)) return ok(toAgent(row, new Date()));
625 if (definition.team !== before.team) {
626 const team = await this.teamExists(a.workspace, a.viewer!, definition.team);
627 if (!team.ok) return team;
628 }
629 if (definition.handle !== before.handle && (await this.handleTaken(workspaceId, definition.handle, row.id))) {
630 return fail("conflict", `${a.workspace} already has an agent called @${definition.handle}.`);
631 }
632 const now = new Date().toISOString();
633 const version = row.version + 1;
634 const by = a.viewer!.username;
635 try {
636 const [updated] = await this.db.batch([
637 // Only from the version read: two owners saving at once never lose
638 // one's change silently; the second is told to look again.
639 updateAgent(this.db, row.id, row.version, definition, { version, updated_at: now }),
640 this.db
641 .prepare(
642 `INSERT INTO agent_versions (agent_id, version, definition, changed_by, created_at)
643 SELECT ?1, ?2, ?3, ?4, ?5 WHERE EXISTS (SELECT 1 FROM agents WHERE id = ?1 AND version = ?2 AND updated_at = ?5)`,
644 )
645 .bind(row.id, version, JSON.stringify(definition), by, now),
646 ]);
647 if (!updated.meta.changes) return fail("conflict", `@${before.handle} was changed meanwhile. Reload it and try again.`);
648 } catch (error) {
649 if (String(error).includes("UNIQUE")) return fail("conflict", `${a.workspace} already has an agent called @${definition.handle}.`);
650 throw error;
651 }
652 const renamed = definition.handle !== before.handle ? ` (was @${before.handle})` : "";
653 this.audit(a.viewer!, a.workspace, "update_agent", definition.handle, `Changed @${definition.handle}${renamed} to version ${version}`, "agents", isPersonal(row) ? "member" : "owner");
654 const saved = await this.row(workspaceId, definition.handle);
655 return ok(toAgent(saved!, new Date()));
656 }
657
658 async archive(a: { workspace: string; handle: string; viewer: User | null }): Promise<Result<null>> {
659 const found = await this.visible(a.workspace, a.viewer, a.handle);
660 if (!found.ok) return found;
661 const { row } = found.value;
662 // Owners archive any agent; a member their own personal one.
663 if (!canArchive(a.viewer, a.workspace, row)) return fail("forbidden", MANAGE_REFUSAL);
664 if (row.builtin) return fail("invalid", "@g1t is every workspace's orchestrator and can't be archived. You can set its budget to limit it.");
665 const now = new Date().toISOString();
666 await this.db.prepare("UPDATE agents SET archived_at = ?, updated_at = ? WHERE id = ? AND archived_at IS NULL").bind(now, now, row.id).run();
667 this.audit(a.viewer!, a.workspace, "archive_agent", row.handle, `Archived @${row.handle}`);
668 return ok(null);
669 }
670
671 /**
672 * Makes the workspace's built-in @g1t if it does not exist yet: an
673 * ordinary agent row, marked builtin, at version 1. Safe to call on
674 * every request; once seen, an isolate does not write again.
675 */
676 private async ensureBuiltin(workspaceId: string): Promise<void> {
677 await ensureBuiltin(this.db, workspaceId);
678 }
679
680 /** Internal: the workspace's @g1t, made if need be, for the chat service. */
681 async builtin(a: { workspace: string; workspace_id: string }): Promise<Result<WorkspaceAgent>> {
682 if (typeof a?.workspace_id !== "string" || !a.workspace_id) return fail("invalid", "Name the workspace by id.");
683 await this.ensureBuiltin(a.workspace_id);
684 const now = new Date();
685 const row = await this.db
686 .prepare(selectAgents("a.workspace_id = ?3 AND a.builtin = 1"))
687 .bind(...periods(now), a.workspace_id)
688 .first<Row>();
689 return row ? ok(toAgent(row, now)) : fail("not_found", "This workspace's @g1t could not be made.");
690 }
691
692 /**
693 * Hands a message to the agent's desk, which answers it in the
694 * background. Returns as soon as the desk holds it.
695 */
696 async deliver(delivery: AgentDelivery): Promise<Result<null>> {
697 const fields = ["workspace", "workspace_id", "channel_id", "agent_id", "message_id", "asked_by"] as const;
698 if (!delivery || fields.some((field) => typeof delivery[field] !== "string" || !delivery[field])) {
699 return fail("invalid", "A delivery names the workspace, channel, agent, message and who asked.");
700 }
701 if (delivery.channel_kind !== "channel" && delivery.channel_kind !== "dm") return fail("invalid", "channel_kind is channel or dm.");
702 await this.ensureBuiltin(delivery.workspace_id);
703 const desk = this.env.DESKS.get(this.env.DESKS.idFromName(delivery.agent_id));
704 await desk.take({ ...delivery, hops: Math.max(0, Math.floor(Number(delivery.hops) || 0)), thread_root: delivery.thread_root ?? null });
705 return ok(null);
706 }
707
708 private versionStatement(agentId: string, version: number, d: Definition, by: string, at: string): D1PreparedStatement {
709 return versionStatement(this.db, agentId, version, d, by, at);
710 }
711
712 /**
713 * Records a change to an agent in the workspace's audit log, after the
714 * answer. Never fails the change: a log that cannot be written is logged.
715 * The events service's audit contract speaks camelCase (Rust's
716 * `NewAuditEntry`).
717 */
718 private audit(
719 actor: User,
720 workspace: string,
721 action: string,
722 handle: string,
723 message: string,
724 area: "agents" | "marketplace" = "agents",
725 rule: "owner" | "member" | null = null,
726 ): void {
727 const kind = actor.kind === "workspace" ? "workspace" : actor.kind === "agent" ? "agent" : actor.kind === "system" ? "system" : "person";
728 const entry = {
729 actorKind: kind,
730 actor: actor.username,
731 actorId: actor.id,
732 agent: null,
733 onBehalfOf: null,
734 runId: null,
735 runKind: null,
736 credentialId: null,
737 action,
738 surface: "web",
739 workspace: workspace.toLowerCase(),
740 repo: null,
741 number: null,
742 gitRef: null,
743 path: `${area}/${handle}`,
744 outcome: "allowed",
745 rule: rule ?? (area === "marketplace" && action === "request_install" ? "member" : "owner"),
746 result: "ok",
747 message,
748 requestId: `req_${crypto.randomUUID()}`,
749 };
750 this.defer(
751 this.env.EVENTS.fetch("https://service/rpc/audit_record", {
752 method: "POST",
753 headers: { "content-type": "application/json" },
754 body: JSON.stringify({ entries: [entry] }),
755 })
756 .then((response) => {
757 if (!response.ok) throw new Error(`status ${response.status}`);
758 })
759 .catch((error: unknown) => console.error("agents: audit entry not recorded", action, handle, String(error))),
760 );
761 }
762}
763
764/** One RPC method's answer. */
765async function answer(service: Agents, method: string, args: any): Promise<Response> {
766 switch (method) {
767 case "list":
768 return Response.json(await service.list(args));
769 case "get":
770 return Response.json(await service.get(args));
771 case "by_ids":
772 return Response.json(await service.byIds(args));
773 case "create":
774 return Response.json(await service.create(args));
775 case "update":
776 return Response.json(await service.update(args));
777 case "archive":
778 return Response.json(await service.archive(args));
779 case "draft":
780 return Response.json(await service.draft(args));
781 case "try_draft":
782 return Response.json(await service.tryDraft(args));
783 case "redraft":
784 return Response.json(await service.redraft(args));
785 case "promote":
786 return Response.json(await service.promote(args));
787 case "builtin":
788 return Response.json(await service.builtin(args));
789 case "templates":
790 return Response.json(TEMPLATES);
791 case "deliver":
792 return Response.json(await service.deliver(args));
793 case "overview":
794 return Response.json(await service.view(args, (ctx) => views.overview(ctx)));
795 case "sessions":
796 return Response.json(await service.view(args, (ctx) => views.listSessions(ctx, args)));
797 case "session":
798 return Response.json(await service.view(args, (ctx) => views.sessionDetail(ctx, args.id)));
799 case "stop_session":
800 return Response.json(await service.view(args, (ctx) => views.stopSession(ctx, args.id)));
801 case "approve_session":
802 return Response.json(await service.view(args, (ctx) => views.approveSession(ctx, args.id, args.cap_micros)));
803 case "steer_session":
804 return Response.json(await service.view(args, (ctx) => views.steerSession(ctx, args.id, args.body)));
805 case "memories":
806 return Response.json(await service.view(args, (ctx) => views.memories(ctx, args.handle)));
807 case "remember":
808 return Response.json(await service.view(args, (ctx) => views.remember(ctx, args.handle, args.input)));
809 case "update_memory":
810 return Response.json(await service.view(args, (ctx) => views.updateMemory(ctx, args.handle, args.id, args.changes)));
811 case "forget":
812 return Response.json(await service.view(args, (ctx) => views.forget(ctx, args.handle, args.id)));
813 case "routines":
814 return Response.json(await service.view(args, (ctx) => views.routines(ctx, args.handle)));
815 case "save_routine":
816 return Response.json(await service.view(args, (ctx) => views.saveRoutine(ctx, args.handle, args.input, args.id ?? null)));
817 case "delete_routine":
818 return Response.json(await service.view(args, (ctx) => views.deleteRoutine(ctx, args.handle, args.id)));
819 case "run_routine":
820 return Response.json(await service.view(args, (ctx) => views.runRoutineNow(ctx, args.handle, args.id)));
821 case "spend":
822 return Response.json(await service.view(args, (ctx) => views.spend(ctx, args.handle ?? null, { period: args.period, person: args.person })));
823 case "person_budgets":
824 return Response.json(await service.view(args, (ctx) => views.personBudgetsView(ctx)));
825 case "set_person_budget":
826 return Response.json(await service.view(args, (ctx) => views.setPersonBudget(ctx, args.username, args.monthly_micros)));
827 case "team_context":
828 return Response.json(await service.view(args, (ctx) => views.teamContext(ctx, args.handle)));
829 case "activity":
830 return Response.json(await service.view(args, (ctx) => views.activity(ctx, args.handle)));
831 case "versions":
832 return Response.json(await service.view(args, (ctx) => views.versions(ctx, args.handle)));
833 case "card_action":
834 return Response.json(await service.cardAction(args));
835 case "policy":
836 return Response.json(await service.view(args, (ctx) => views.policy(ctx)));
837 case "install_requests":
838 return Response.json(await service.installRequests(args));
839 case "request_install":
840 return Response.json(await service.requestInstall(args));
841 case "resolve_install_request":
842 return Response.json(await service.resolveInstallRequest(args));
843 case "extension_installs":
844 return Response.json(await service.extensionInstalls(args));
845 case "install_extension":
846 return Response.json(await service.installExtension(args));
847 case "set_extension_enabled":
848 return Response.json(await service.setExtensionEnabled(args));
849 case "set_extension_budget":
850 return Response.json(await service.setExtensionBudget(args));
851 case "uninstall_extension":
852 return Response.json(await service.uninstallExtension(args));
853 case "set_policy":
854 return Response.json(await service.view(args, (ctx) => views.setPolicy(ctx, args.policy)));
855 default:
856 return new Response("Unknown method\n", { status: 404 });
857 }
858}
859
860export default {
861 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
862 const match = new URL(request.url).pathname.match(/^\/rpc\/([a-z_]+)$/);
863 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
864 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
865 const opened = openD1(env.DB, request);
866 const service = new Agents(Object.create(env, { DB: { value: opened.db } }) as Env, (work) => ctx.waitUntil(work));
867 const args = (await request.json().catch(() => ({}))) as any;
868 return opened.finish(await answer(service, match[1], args));
869 },
870
871 /** Events routines run on, from the events service (SUBSCRIBER_AGENTS). */
872 async queue(batch: MessageBatch<unknown>, env: Env): Promise<void> {
873 await onEvents(env as unknown as SessionEnv, batch.messages.map((message) => message.body as G1tEvent));
874 batch.ackAll();
875 },
876
877 /** Every few minutes: routines that are due, and session steps a desk lost. */
878 async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
879 const sessions = env as unknown as SessionEnv;
880 ctx.waitUntil(
881 Promise.all([
882 runDue(sessions).catch((error: unknown) => console.error("agents: routines did not run", String(error))),
883 sweep(sessions).catch((error: unknown) => console.error("agents: the session sweep failed", String(error))),
884 ]),
885 );
886 },
887} satisfies ExportedHandler<Env>;