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