From e2c8b2d50222a08f0b2fcba9c171263bcc336876 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 7 Jul 2026 21:12:21 +0000 Subject: [PATCH] Add built-in agent routes and gate Co-Authored-By: Paperclip --- docs/built-in-agents.md | 144 +++++++++++ packages/shared/src/index.ts | 2 + packages/shared/src/validators/agent.ts | 7 + packages/shared/src/validators/index.ts | 2 + .../__tests__/built-in-agent-routes.test.ts | 241 ++++++++++++++++++ server/src/__tests__/built-in-agents.test.ts | 93 +++++++ server/src/app.ts | 2 + server/src/routes/built-in-agents.ts | 95 +++++++ server/src/routes/index.ts | 1 + server/src/services/built-in-agents.ts | 54 +++- 10 files changed, 640 insertions(+), 1 deletion(-) create mode 100644 docs/built-in-agents.md create mode 100644 server/src/__tests__/built-in-agent-routes.test.ts create mode 100644 server/src/routes/built-in-agents.ts diff --git a/docs/built-in-agents.md b/docs/built-in-agents.md new file mode 100644 index 00000000000..21e4e184049 --- /dev/null +++ b/docs/built-in-agents.md @@ -0,0 +1,144 @@ +# Built-in Agents + +Built-in agents are first-party, company-scoped agents that Paperclip can resolve by a stable registry key. They are normal rows in `agents`, but they carry immutable metadata under `metadata.paperclipBuiltInAgent` so services can find them without hardcoding a database id. + +The first built-ins are `briefs` and `learning`. Operators can provision them from the API without going through board hire approval, but the route still requires the same `agents:create` permission as normal agent creation. + +## Runtime Model + +The subsystem has four layers: + +- Registry: `server/src/services/built-in-agents.ts` defines the static `BuiltInAgentDefinition` list. +- Marker: `server/src/services/built-in-agent-metadata.ts` reads and writes `metadata.paperclipBuiltInAgent`. +- Provisioning service: `builtInAgentService(db)` finds, creates, updates, resets, and requires built-ins per company. +- Routes: `server/src/routes/built-in-agents.ts` exposes list, provision, and reset APIs. + +Built-in agent state is derived from the marked agent row: + +- `not_provisioned`: no active marked row exists for the company/key. +- `needs_setup`: a row exists, but adapter config is incomplete for the adapter type. +- `ready`: adapter config is complete and the agent is not paused. +- `paused`: the marked row is paused. Scheduled/background work should log the paused warning and skip queueing work. + +Use `builtInAgentService(db).requireBuiltInAgent(companyId, key)` from backend features that need a built-in agent before scheduling work. It throws HTTP 412 with `code: "built_in_agent_not_configured"` for missing or incomplete agents. Paused agents return the agent plus a `built_in_agent_paused` warning so callers can pass the warning through to logs or API responses without treating the agent as ready for scheduling. + +## API + +All routes are company-scoped: + +- `GET /api/companies/:companyId/built-in-agents` + Lists registry definitions with current company state. +- `POST /api/companies/:companyId/built-in-agents/:key/provision` + Creates or configures the built-in for the company. Body accepts optional `adapterType` and `adapterConfig`. +- `POST /api/companies/:companyId/built-in-agents/:key/reset` + Restores registry-owned display/default fields on the marked row while preserving operator adapter setup. + +Provision and reset require `agents:create`. Provision intentionally skips `requireBoardApprovalForNewAgents` because built-ins are registry-owned system capacity, not ad hoc hires. + +## Add a New Built-in Agent + +1. Add a definition in `DEFINITIONS` inside `server/src/services/built-in-agents.ts`. +2. Pick a stable lowercase `key` using only letters, numbers, `_`, and `-`. Do not rename keys after release. +3. Set `displayName`, `shortPurpose`, `defaultInstructions`, `defaultRole`, and at least one `featureKeys` entry. +4. Set `allowedAdapterTypes` to the smallest set that actually works for this built-in. +5. Decide whether the built-in needs a nonzero `defaultBudgetMonthlyCents`. +6. Add or update tests in `server/src/__tests__/built-in-agents.test.ts`. +7. If the built-in is surfaced in UI or docs, add those changes in the same PR. +8. Run the focused tests listed in the checklist below. + +Do not write built-in markers directly through generic agent create/update routes. The agent service rejects marker add, remove, and mutation unless the built-in service explicitly opts in. + +## Worked Example: `digest` + +Hypothetical registry diff: + +```diff + const DEFINITIONS = validateBuiltInAgentDefinitions([ + { + key: "learning", + displayName: "Learning Agent", + featureKeys: ["learning"], + shortPurpose: "Maintains reusable company learning from completed work and recurring patterns.", + defaultInstructions: + "You are Paperclip's built-in Learning agent. Extract durable lessons from completed work, preserve useful patterns, and keep learning artifacts grounded in source context.", + defaultRole: "general", + allowedAdapterTypes: ["codex_local", "claude_local", "gemini_local", "opencode_local", "process"], + defaultBudgetMonthlyCents: 0, + }, ++ { ++ key: "digest", ++ displayName: "Digest Agent", ++ featureKeys: ["digest"], ++ shortPurpose: "Summarizes recent company activity into a board-readable digest.", ++ defaultInstructions: ++ "You are Paperclip's built-in Digest agent. Produce short, sourced summaries of recent company activity, decisions, blockers, and next actions.", ++ defaultRole: "general", ++ allowedAdapterTypes: ["codex_local", "claude_local", "process"], ++ defaultBudgetMonthlyCents: 0, ++ }, + ]); +``` + +Add focused test coverage: + +```ts +expect(listBuiltInAgentDefinitions().map((definition) => definition.key).sort()).toEqual([ + "briefs", + "digest", + "learning", +]); +``` + +If a background job needs the agent: + +```ts +const { agent, warning } = await builtInAgentService(db).requireBuiltInAgent(companyId, "digest"); +if (warning) { + logger.info({ warning }, "Skipping digest work because built-in agent is paused"); + return; +} + +await heartbeatService(db).wakeup(agent.id, { + source: "automation", + triggerDetail: "system", + reason: "Generate company digest", +}); +``` + +If the agent is missing or not configured, the helper throws: + +```json +{ + "error": "Built-in agent is not configured: digest", + "code": "built_in_agent_not_configured", + "details": { + "code": "built_in_agent_not_configured", + "key": "digest", + "status": "needs_setup", + "agentId": "..." + } +} +``` + +## PR Checklist + +- Registry definition has a stable key and at least one feature key. +- `allowedAdapterTypes` is intentionally narrow. +- Provisioning does not require board hire approval. +- Generic agent create/update cannot forge or remove the marker. +- Routes remain company-scoped and write activity for mutations. +- Background consumers use `requireBuiltInAgent(companyId, key)` instead of open-coding marker lookup. +- Paused built-ins skip scheduled/background work and leave an inspectable log or warning. +- Focused tests pass: + +```sh +pnpm --filter @paperclipai/server test -- built-in-agents built-in-agent-routes +``` + +## Operational Notes + +- One active built-in row per company/key is allowed. Duplicate active markers are treated as a conflict and must be repaired manually. +- Terminated built-in rows are ignored for lookup; provisioning can create a replacement. +- `reset` restores registry-owned defaults but preserves adapter setup so operators do not lose local model or command configuration. +- Unknown marker keys are ignored during startup reconciliation. This prevents removed experimental built-ins from breaking server boot. +- Feature code should treat 412 `built_in_agent_not_configured` as an operator setup problem, not as a 500. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9502219f3e7..f554486d339 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1163,6 +1163,7 @@ export { agentSkillSyncSchema, type AgentSkillSync, createAgentSchema, + builtInAgentProvisionSchema, createAgentHireSchema, updateAgentSchema, agentInstructionsBundleModeSchema, @@ -1182,6 +1183,7 @@ export { agentPermissionsSchema, updateAgentPermissionsSchema, type CreateAgent, + type BuiltInAgentProvision, type CreateAgentHire, type UpdateAgent, type UpdateAgentInstructionsBundle, diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 51338963104..134e712907e 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -88,6 +88,13 @@ export const createAgentSchema = z.object({ export type CreateAgent = z.infer; +export const builtInAgentProvisionSchema = z.object({ + adapterType: agentAdapterTypeSchema.optional(), + adapterConfig: adapterConfigSchema.optional(), +}).strict(); + +export type BuiltInAgentProvision = z.infer; + export const createAgentHireSchema = createAgentSchema.extend({ sourceIssueId: z.string().uuid().optional().nullable(), sourceIssueIds: z.array(z.string().uuid()).optional(), diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index b76d6dee126..e5c7ca348b0 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -247,6 +247,7 @@ export { export { createAgentSchema, + builtInAgentProvisionSchema, createAgentHireSchema, updateAgentSchema, agentRuntimeConfigSchema, @@ -267,6 +268,7 @@ export { agentPermissionsSchema, updateAgentPermissionsSchema, type CreateAgent, + type BuiltInAgentProvision, type CreateAgentHire, type UpdateAgent, type UpdateAgentInstructionsBundle, diff --git a/server/src/__tests__/built-in-agent-routes.test.ts b/server/src/__tests__/built-in-agent-routes.test.ts new file mode 100644 index 00000000000..1c96f8badbf --- /dev/null +++ b/server/src/__tests__/built-in-agent-routes.test.ts @@ -0,0 +1,241 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const companyId = "22222222-2222-4222-8222-222222222222"; +const agentId = "11111111-1111-4111-8111-111111111111"; + +const mockAccessService = vi.hoisted(() => ({ + decide: vi.fn(), +})); + +const mockBuiltInAgentService = vi.hoisted(() => ({ + list: vi.fn(), + ensure: vi.fn(), + reset: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +function allowDecision() { + return { + allowed: true, + action: "agents:create", + reason: "allow_explicit_grant", + explanation: "Allowed.", + }; +} + +function denyDecision() { + return { + allowed: false, + action: "agents:create", + reason: "deny_missing_grant", + explanation: "Missing permission: agents:create.", + }; +} + +function builtInState(overrides: Record = {}) { + return { + definition: { + key: "briefs", + displayName: "Briefs Agent", + featureKeys: ["briefs"], + shortPurpose: "Prepares concise operational briefs.", + defaultInstructions: "Write briefs.", + defaultRole: "general", + allowedAdapterTypes: ["codex_local"], + }, + status: "ready", + agentId, + agent: { + id: agentId, + companyId, + name: "Briefs Agent", + role: "general", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + }, + pauseReason: null, + ...overrides, + }; +} + +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + logActivity: mockLogActivity, + })); + vi.doMock("../services/built-in-agents.js", () => ({ + builtInAgentService: () => mockBuiltInAgentService, + })); +} + +async function createApp(actor: Record) { + const [{ builtInAgentRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/built-in-agents.js"), + vi.importActual("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", builtInAgentRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("built-in agent routes", () => { + beforeEach(() => { + vi.resetModules(); + registerModuleMocks(); + vi.clearAllMocks(); + mockAccessService.decide.mockResolvedValue(allowDecision()); + mockBuiltInAgentService.list.mockResolvedValue([builtInState()]); + mockBuiltInAgentService.ensure.mockResolvedValue(builtInState()); + mockBuiltInAgentService.reset.mockResolvedValue(builtInState()); + }); + + it("lists built-in agent state for actors with company access", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app).get(`/api/companies/${companyId}/built-in-agents`); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockBuiltInAgentService.list).toHaveBeenCalledWith(companyId); + expect(res.body).toEqual([expect.objectContaining({ status: "ready", agentId })]); + }); + + it("denies list requests outside the actor company boundary", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: ["33333333-3333-4333-8333-333333333333"], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app).get(`/api/companies/${companyId}/built-in-agents`); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(mockBuiltInAgentService.list).not.toHaveBeenCalled(); + }); + + it("provisions through the agents:create gate and passes optional adapter overrides", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/built-in-agents/briefs/provision`) + .send({ adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAccessService.decide).toHaveBeenCalledWith({ + actor: expect.objectContaining({ type: "board" }), + action: "agents:create", + resource: { type: "company", companyId }, + }); + expect(mockBuiltInAgentService.ensure).toHaveBeenCalledWith(companyId, "briefs", { + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + }); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + companyId, + actorType: "user", + actorId: "board-user", + action: "built_in_agent.provision_requested", + entityId: agentId, + })); + }); + + it("denies provision when agents:create is not allowed", async () => { + mockAccessService.decide.mockResolvedValue(denyDecision()); + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/built-in-agents/briefs/provision`) + .send({ adapterType: "codex_local" }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.details).toMatchObject({ reason: "deny_missing_grant" }); + expect(mockBuiltInAgentService.ensure).not.toHaveBeenCalled(); + }); + + it("rejects provision bodies with unknown fields", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/built-in-agents/briefs/provision`) + .send({ adapterType: "codex_local", unexpected: true }); + + expect(res.status, JSON.stringify(res.body)).toBe(400); + expect(mockBuiltInAgentService.ensure).not.toHaveBeenCalled(); + }); + + it("resets registry defaults through the same agents:create gate", async () => { + const app = await createApp({ + type: "agent", + agentId: "manager-agent", + companyId, + source: "agent_key", + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/built-in-agents/briefs/reset`) + .send({}); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockBuiltInAgentService.reset).toHaveBeenCalledWith(companyId, "briefs"); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + companyId, + actorType: "agent", + actorId: "manager-agent", + agentId: "manager-agent", + action: "built_in_agent.reset", + entityId: agentId, + })); + }); + + it("denies agent actors from provisioning across company boundaries", async () => { + const app = await createApp({ + type: "agent", + agentId: "manager-agent", + companyId: "33333333-3333-4333-8333-333333333333", + source: "agent_key", + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/built-in-agents/briefs/reset`) + .send({}); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(mockAccessService.decide).not.toHaveBeenCalled(); + expect(mockBuiltInAgentService.reset).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/built-in-agents.test.ts b/server/src/__tests__/built-in-agents.test.ts index 28867a97ec9..05c09a5c489 100644 --- a/server/src/__tests__/built-in-agents.test.ts +++ b/server/src/__tests__/built-in-agents.test.ts @@ -132,6 +132,22 @@ describeEmbeddedPostgres("built-in agents", () => { expect(rows).toHaveLength(1); }); + it("rejects adapter types outside the built-in definition allowlist", async () => { + const companyId = await seedCompany(); + + await expect(builtInAgentService(db).ensure(companyId, "briefs", { + adapterType: "http", + adapterConfig: { url: "https://example.test/webhook" }, + })).rejects.toMatchObject({ + status: 422, + details: { + code: "built_in_agent_adapter_not_allowed", + key: "briefs", + allowedAdapterTypes: ["codex_local", "claude_local", "gemini_local", "opencode_local", "process"], + }, + }); + }); + it("recovers an orphaned marked row instead of creating a duplicate", async () => { const companyId = await seedCompany(); const orphanId = randomUUID(); @@ -180,6 +196,83 @@ describeEmbeddedPostgres("built-in agents", () => { }); }); + it("requires configured built-ins with typed precondition failures and paused warnings", async () => { + const companyId = await seedCompany(); + const builtIns = builtInAgentService(db); + + await expect(builtIns.requireBuiltInAgent(companyId, "briefs")).rejects.toMatchObject({ + status: 412, + details: { + code: "built_in_agent_not_configured", + key: "briefs", + status: "not_provisioned", + agentId: null, + }, + }); + + const needsSetup = await builtIns.ensure(companyId, "briefs"); + await expect(builtIns.requireBuiltInAgent(companyId, "briefs")).rejects.toMatchObject({ + status: 412, + details: { + code: "built_in_agent_not_configured", + key: "briefs", + status: "needs_setup", + agentId: needsSetup.agentId, + }, + }); + + const ready = await builtIns.ensure(companyId, "briefs", { + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + }); + await expect(builtIns.requireBuiltInAgent(companyId, "briefs")).resolves.toMatchObject({ + agent: { id: ready.agentId }, + warning: null, + }); + + await agentService(db).pause(ready.agentId!, "maintenance"); + await expect(builtIns.requireBuiltInAgent(companyId, "briefs")).resolves.toMatchObject({ + agent: { id: ready.agentId }, + warning: { + code: "built_in_agent_paused", + key: "briefs", + agentId: ready.agentId, + pauseReason: "maintenance", + }, + }); + }); + + it("resets marked agents back to registry display defaults without replacing adapter setup", async () => { + const companyId = await seedCompany(); + const builtIns = builtInAgentService(db); + const ready = await builtIns.ensure(companyId, "briefs", { + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + }); + + await agentService(db).update(ready.agentId!, { + name: "Custom Briefs", + role: "engineer", + title: "Custom", + capabilities: "Custom purpose", + }); + + const reset = await builtIns.reset(companyId, "briefs"); + + expect(reset).toMatchObject({ + status: "ready", + agentId: ready.agentId, + agent: { + name: "Briefs Agent", + role: "general", + title: null, + capabilities: "Prepares concise operational briefs for the board and agent company.", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + }, + }); + }); + it("blocks deleting a built-in agent", async () => { const companyId = await seedCompany(); const state = await builtInAgentService(db).ensure(companyId, "briefs"); diff --git a/server/src/app.ts b/server/src/app.ts index 3ee97f25f81..566baa71043 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -14,6 +14,7 @@ import { applyTrustProxy, parseTrustProxyEnv } from "./middleware/trust-proxy.js import { healthRoutes } from "./routes/health.js"; import { companyRoutes } from "./routes/companies.js"; import { companySkillRoutes } from "./routes/company-skills.js"; +import { builtInAgentRoutes } from "./routes/built-in-agents.js"; import { teamsCatalogRoutes } from "./routes/teams-catalog.js"; import { agentRoutes } from "./routes/agents.js"; import { projectRoutes } from "./routes/projects.js"; @@ -226,6 +227,7 @@ export async function createApp( api.use("/companies", companyRoutes(db, opts.storageService)); api.use(llmRoutes(db)); api.use(companySkillRoutes(db)); + api.use(builtInAgentRoutes(db)); api.use(teamsCatalogRoutes(db)); api.use(agentRoutes(db, { pluginWorkerManager: workerManager })); api.use(assetRoutes(db, opts.storageService)); diff --git a/server/src/routes/built-in-agents.ts b/server/src/routes/built-in-agents.ts new file mode 100644 index 00000000000..bda6f5f55cb --- /dev/null +++ b/server/src/routes/built-in-agents.ts @@ -0,0 +1,95 @@ +import { Router, type Request } from "express"; +import type { Db } from "@paperclipai/db"; +import { builtInAgentProvisionSchema } from "@paperclipai/shared"; +import { validate } from "../middleware/validate.js"; +import { forbidden } from "../errors.js"; +import { accessService, logActivity } from "../services/index.js"; +import { builtInAgentService } from "../services/built-in-agents.js"; +import { authorizationDeniedDetails } from "../services/authorization.js"; +import { assertCompanyAccess, getActorInfo } from "./authz.js"; + +export function builtInAgentRoutes(db: Db) { + const router = Router(); + const access = accessService(db); + const svc = builtInAgentService(db); + + async function assertCanProvisionBuiltInAgents(req: Request, companyId: string) { + assertCompanyAccess(req, companyId); + const decision = await access.decide({ + actor: req.actor, + action: "agents:create", + resource: { type: "company", companyId }, + }); + if (decision.allowed) return; + throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); + } + + async function logBuiltInAgentMutation( + req: Request, + input: { + companyId: string; + action: "built_in_agent.provision_requested" | "built_in_agent.reset"; + key: string; + agentId: string | null; + status: string; + }, + ) { + const actor = getActorInfo(req); + await logActivity(db, { + companyId: input.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + action: input.action, + entityType: "agent", + entityId: input.agentId ?? input.key, + ...(actor.agentId ? { agentId: actor.agentId } : {}), + ...(actor.runId ? { runId: actor.runId } : {}), + details: { + key: input.key, + status: input.status, + }, + }); + } + + router.get("/companies/:companyId/built-in-agents", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + res.json(await svc.list(companyId)); + }); + + router.post( + "/companies/:companyId/built-in-agents/:key/provision", + validate(builtInAgentProvisionSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const key = req.params.key as string; + await assertCanProvisionBuiltInAgents(req, companyId); + const state = await svc.ensure(companyId, key, req.body); + await logBuiltInAgentMutation(req, { + companyId, + action: "built_in_agent.provision_requested", + key, + agentId: state.agentId, + status: state.status, + }); + res.json(state); + }, + ); + + router.post("/companies/:companyId/built-in-agents/:key/reset", async (req, res) => { + const companyId = req.params.companyId as string; + const key = req.params.key as string; + await assertCanProvisionBuiltInAgents(req, companyId); + const state = await svc.reset(companyId, key); + await logBuiltInAgentMutation(req, { + companyId, + action: "built_in_agent.reset", + key, + agentId: state.agentId, + status: state.status, + }); + res.json(state); + }); + + return router; +} diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 940047f29f9..c637677c9f1 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -1,6 +1,7 @@ export { healthRoutes } from "./health.js"; export { companyRoutes } from "./companies.js"; export { companySkillRoutes } from "./company-skills.js"; +export { builtInAgentRoutes } from "./built-in-agents.js"; export { teamsCatalogRoutes } from "./teams-catalog.js"; export { agentRoutes } from "./agents.js"; export { projectRoutes } from "./projects.js"; diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts index b3f19eca12b..122ef4a56ff 100644 --- a/server/src/services/built-in-agents.ts +++ b/server/src/services/built-in-agents.ts @@ -2,7 +2,7 @@ import { and, eq, ne } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, companies } from "@paperclipai/db"; import type { Agent } from "@paperclipai/shared"; -import { conflict, notFound, unprocessable } from "../errors.js"; +import { conflict, HttpError, notFound, unprocessable } from "../errors.js"; import { logActivity } from "./activity-log.js"; import { agentService } from "./agents.js"; import { @@ -36,6 +36,20 @@ export interface BuiltInAgentProvisionInput { adapterConfig?: Record; } +export interface RequiredBuiltInAgentWarning { + code: "built_in_agent_paused"; + key: string; + agentId: string; + message: string; + pauseReason: string | null; +} + +export interface RequiredBuiltInAgent { + definition: BuiltInAgentDefinition; + agent: Agent; + warning: RequiredBuiltInAgentWarning | null; +} + const BUILT_IN_AGENT_KEY_PATTERN = /^[a-z][a-z0-9_-]*$/; const DEFINITIONS = validateBuiltInAgentDefinitions([ @@ -202,6 +216,16 @@ function definitionPatch(definition: BuiltInAgentDefinition, input: BuiltInAgent }; } +function builtInAgentNotConfiguredError(state: BuiltInAgentState) { + return new HttpError(412, `Built-in agent is not configured: ${state.definition.key}`, { + code: "built_in_agent_not_configured", + key: state.definition.key, + status: state.status, + agentId: state.agentId, + featureKeys: state.definition.featureKeys, + }); +} + function rowIsBuiltInAgent(row: typeof agents.$inferSelect, key: string) { const marker = readBuiltInAgentMarker(row.metadata); return marker?.key === key; @@ -333,11 +357,39 @@ export function builtInAgentService(db: Db) { return state(definition, updated as Agent); } + async function reset(companyId: string, key: string) { + return reconcileDefinitionDefaults(companyId, key); + } + + async function requireBuiltInAgent(companyId: string, key: string): Promise { + const current = await get(companyId, key); + if (!current.agent) throw builtInAgentNotConfiguredError(current); + if (current.status === "ready") { + return { definition: current.definition, agent: current.agent, warning: null }; + } + if (current.status === "paused") { + return { + definition: current.definition, + agent: current.agent, + warning: { + code: "built_in_agent_paused", + key: current.definition.key, + agentId: current.agent.id, + message: `Built-in agent ${current.definition.key} is paused; scheduled/background work should be skipped.`, + pauseReason: current.pauseReason, + }, + }; + } + throw builtInAgentNotConfiguredError(current); + } + return { definitions: listBuiltInAgentDefinitions, get, ensure, list, + reset, + requireBuiltInAgent, reconcileDefinitionDefaults, }; }