| 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 | */ |
| 12 | import { |
| 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 | |
| 46 | import { MANAGE_REFUSAL, NOT_YOURS_REFUSAL, canArchive, canChange, canManage, canSee, canSeeAgent, creatableScope } from "./access.ts"; |
| 47 | import { DRAFT_OUTPUT_TOKENS, draftSystem, jsonIn, proposalFrom, redraftFrom, redraftSystem, startingBudget, trySystem, tryTurns, wordsOf } from "./builder.ts"; |
| 48 | import { callBuilder } from "./builder-call.ts"; |
| 49 | import { type Definition, applyChanges } from "./definition.ts"; |
| 50 | import { builtinChanges } from "./orchestrator.ts"; |
| 51 | import type { Desk } from "./desk.ts"; |
| 52 | import type { ReplyEnv } from "./reply.ts"; |
| 53 | import { ensureBuiltin } from "./builtin.ts"; |
| 54 | import { type Row, definitionOf, insertAgent, isPersonal, periods, selectAgents, toAgent, updateAgent, versionStatement } from "./store.ts"; |
| 55 | import { TEMPLATES, TEMPLATE_IDS } from "./templates.ts"; |
| 56 | import { readPolicy } from "./policy.ts"; |
| 57 | import { runDue } from "./routines.ts"; |
| 58 | import { onEvents } from "./triggers.ts"; |
| 59 | import { cardAction } from "./cards.ts"; |
| 60 | import { type SessionEnv, sweep } from "./sessions.ts"; |
| 61 | import { EFFORT_NAMES, checkDue, effortCostsOf, markResolved, outcomesSince, readRecommendations, recommendationRow, sinceWindow, toRecommendation } from "./recommend.ts"; |
| 62 | import { moveAgentTeams } from "./team-move.ts"; |
| 63 | import { effortOf } from "./routing.ts"; |
| 64 | import * as views from "./views.ts"; |
| 65 | import { monthKey } from "./budget.ts"; |
| 66 | import * as extensions from "./extensions.ts"; |
| 67 | import { skillPushes, skillRpc } from "./skill-rpc.ts"; |
| 68 | import { type Answered, type Person, answerLine, findListing, listRequests, listingPath, openRequest, requestsPath, resolveListing, resolveRequest } from "./installs.ts"; |
| 69 | |
| 70 | export { Desk } from "./desk.ts"; |
| 71 | |
| 72 | type 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. */ |
| 80 | const workspaceIds = new Map<string, { id: string | null; until: number }>(); |
| 81 | |
| 82 | class 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. */ |
| 859 | async 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 | |
| 963 | export 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>; |