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