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