diff --git a/server/src/__tests__/built-in-agents.test.ts b/server/src/__tests__/built-in-agents.test.ts new file mode 100644 index 00000000000..28867a97ec9 --- /dev/null +++ b/server/src/__tests__/built-in-agents.test.ts @@ -0,0 +1,311 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { + activityLog, + agentConfigRevisions, + agents, + companies, + createDb, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { HttpError } from "../errors.ts"; +import { agentService } from "../services/agents.ts"; +import { + builtInAgentService, + deriveBuiltInAgentStatus, + listBuiltInAgentDefinitions, + reconcileBuiltInAgentsOnStartup, + validateBuiltInAgentDefinitions, +} from "../services/built-in-agents.ts"; +import { readBuiltInAgentMarker, withBuiltInAgentMarker } from "../services/built-in-agent-metadata.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +function issuePrefix(id: string) { + return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`; +} + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres built-in agent tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("built-in agents", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-built-in-agents-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(agentConfigRevisions); + await db.delete(activityLog); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedCompany() { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: issuePrefix(companyId), + requireBoardApprovalForNewAgents: true, + }); + return companyId; + } + + it("validates the static registry and rejects invalid definitions", () => { + expect(listBuiltInAgentDefinitions().map((definition) => definition.key).sort()).toEqual(["briefs", "learning"]); + expect(() => validateBuiltInAgentDefinitions([ + { + key: "briefs", + displayName: "Briefs Agent", + featureKeys: ["briefs"], + shortPurpose: "One", + defaultInstructions: "Do work", + defaultRole: "general", + }, + { + key: "briefs", + displayName: "Duplicate", + featureKeys: ["duplicate"], + shortPurpose: "Two", + defaultInstructions: "Do work", + defaultRole: "general", + }, + ])).toThrow("Duplicate built-in agent key"); + expect(() => validateBuiltInAgentDefinitions([ + { + key: "Bad Key", + displayName: "Bad", + featureKeys: ["bad"], + shortPurpose: "Bad", + defaultInstructions: "Bad", + defaultRole: "general", + }, + ])).toThrow("Invalid built-in agent key"); + }); + + it("lazily provisions one agent per company/key and updates the same row on setup", async () => { + const companyId = await seedCompany(); + const svc = builtInAgentService(db); + + const created = await svc.ensure(companyId, "briefs"); + expect(created.status).toBe("needs_setup"); + expect(created.agentId).toBeTruthy(); + expect(created.agent).toMatchObject({ + companyId, + name: "Briefs Agent", + adapterConfig: {}, + status: "idle", + }); + expect(readBuiltInAgentMarker(created.agent?.metadata)).toEqual({ + key: "briefs", + featureKeys: ["briefs"], + }); + + const configured = await svc.ensure(companyId, "briefs", { + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + }); + expect(configured.status).toBe("ready"); + expect(configured.agentId).toBe(created.agentId); + expect(configured.agent).toMatchObject({ + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + }); + + const rows = await db.select().from(agents).where(eq(agents.companyId, companyId)); + expect(rows).toHaveLength(1); + }); + + it("recovers an orphaned marked row instead of creating a duplicate", async () => { + const companyId = await seedCompany(); + const orphanId = randomUUID(); + await db.insert(agents).values({ + id: orphanId, + companyId, + name: "Old Briefs", + role: "general", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + metadata: withBuiltInAgentMarker({ source: "orphan" }, { key: "briefs", featureKeys: ["briefs"] }), + }); + + const state = await builtInAgentService(db).ensure(companyId, "briefs"); + + expect(state.status).toBe("ready"); + expect(state.agentId).toBe(orphanId); + const rows = await db.select().from(agents).where(eq(agents.companyId, companyId)); + expect(rows).toHaveLength(1); + }); + + it("derives not_provisioned, needs_setup, ready, and paused states", async () => { + const companyId = await seedCompany(); + const builtIns = builtInAgentService(db); + + await expect(builtIns.get(companyId, "learning")).resolves.toMatchObject({ status: "not_provisioned" }); + + const needsSetup = await builtIns.ensure(companyId, "learning"); + expect(needsSetup.status).toBe("needs_setup"); + expect(deriveBuiltInAgentStatus(needsSetup.agent)).toBe("needs_setup"); + + const ready = await builtIns.ensure(companyId, "learning", { + adapterType: "claude_local", + adapterConfig: { model: "claude-sonnet-4" }, + }); + expect(ready.status).toBe("ready"); + + await agentService(db).pause(ready.agentId!, "manual"); + await expect(builtIns.get(companyId, "learning")).resolves.toMatchObject({ + status: "paused", + agentId: ready.agentId, + pauseReason: "manual", + }); + }); + + it("blocks deleting a built-in agent", async () => { + const companyId = await seedCompany(); + const state = await builtInAgentService(db).ensure(companyId, "briefs"); + + await expect(agentService(db).remove(state.agentId!)).rejects.toMatchObject({ + status: 409, + details: { + code: "built_in_agent_undeletable", + key: "briefs", + }, + }); + }); + + it("prevents direct marker add, remove, or mutation", async () => { + const companyId = await seedCompany(); + const builtIn = await builtInAgentService(db).ensure(companyId, "briefs"); + const normal = await agentService(db).create(companyId, { + name: "Normal", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + }); + + await expect(agentService(db).create(companyId, { + name: "Spoof", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + metadata: withBuiltInAgentMarker({}, { key: "briefs", featureKeys: ["briefs"] }), + })).rejects.toMatchObject({ status: 409, details: { code: "built_in_agent_marker_readonly" } }); + + await expect(agentService(db).update(normal.id, { + metadata: withBuiltInAgentMarker({}, { key: "briefs", featureKeys: ["briefs"] }), + })).rejects.toMatchObject({ status: 409, details: { code: "built_in_agent_marker_readonly" } }); + + await expect(agentService(db).update(builtIn.agentId!, { + metadata: { other: "metadata" }, + })).rejects.toMatchObject({ status: 409, details: { code: "built_in_agent_marker_readonly" } }); + + await expect(agentService(db).update(builtIn.agentId!, { + metadata: withBuiltInAgentMarker({}, { key: "learning", featureKeys: ["learning"] }), + })).rejects.toMatchObject({ status: 409, details: { code: "built_in_agent_marker_readonly" } }); + + await expect(agentService(db).update(builtIn.agentId!, { + metadata: withBuiltInAgentMarker({ note: "allowed" }, { key: "briefs", featureKeys: ["briefs"] }), + })).resolves.toMatchObject({ + id: builtIn.agentId, + metadata: { + note: "allowed", + paperclipBuiltInAgent: { key: "briefs", featureKeys: ["briefs"] }, + }, + }); + }); + + it("repairs display/default drift for marked rows during startup reconciliation", async () => { + const companyId = await seedCompany(); + const agentId = randomUUID(); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Old Name", + role: "engineer", + title: "Old title", + capabilities: "Old purpose", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + metadata: withBuiltInAgentMarker({}, { key: "briefs", featureKeys: ["old-briefs"] }), + }); + + const result = await reconcileBuiltInAgentsOnStartup(db); + expect(result).toMatchObject({ scanned: 1, reconciled: 1, unknown: 0, duplicates: 0 }); + + const [row] = await db.select().from(agents).where(eq(agents.id, agentId)); + expect(row).toMatchObject({ + name: "Briefs Agent", + role: "general", + title: null, + capabilities: "Prepares concise operational briefs for the board and agent company.", + }); + expect(readBuiltInAgentMarker(row?.metadata)).toEqual({ key: "briefs", featureKeys: ["briefs"] }); + }); + + it("reports duplicate active instances for a company/key", async () => { + const companyId = await seedCompany(); + await db.insert(agents).values([ + { + id: randomUUID(), + companyId, + name: "Briefs One", + role: "general", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + metadata: withBuiltInAgentMarker({}, { key: "briefs", featureKeys: ["briefs"] }), + }, + { + id: randomUUID(), + companyId, + name: "Briefs Two", + role: "general", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + metadata: withBuiltInAgentMarker({}, { key: "briefs", featureKeys: ["briefs"] }), + }, + ]); + + await expect(builtInAgentService(db).ensure(companyId, "briefs")).rejects.toMatchObject({ + status: 409, + details: { + code: "built_in_agent_duplicate_instance", + key: "briefs", + }, + } satisfies Partial); + }); +}); diff --git a/server/src/index.ts b/server/src/index.ts index 3056d02b86c..bed988dfeda 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -42,6 +42,7 @@ import { environmentCustomImageService, heartbeatService, instanceSettingsService, + reconcileBuiltInAgentsOnStartup, reconcileCloudUpstreamRunsOnStartup, reconcileCodexLocalManagedHomesOnStartup, reconcilePersistedRuntimeServicesOnStartup, @@ -779,6 +780,19 @@ export async function startServer(): Promise { logger.error({ err }, "startup reconciliation of codex_local managed homes failed"); }); + void reconcileBuiltInAgentsOnStartup(db as any) + .then((result) => { + if (result.reconciled > 0 || result.unknown > 0 || result.duplicates > 0) { + logger.warn( + result, + "startup reconciliation of built-in agents complete", + ); + } + }) + .catch((err) => { + logger.error({ err }, "startup reconciliation of built-in agents failed"); + }); + // Force the instance onto the Kubernetes sandbox provider when configured via // env (PAPERCLIP_EXECUTION_MODE=kubernetes). Runs BEFORE the heartbeat resumes // queued runs so the policy + managed k8s environments are in place. A bad diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index ac983715736..44ce39c5760 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -30,6 +30,10 @@ import { syncAgentAdapterEnvBindings } from "./agent-secret-bindings.js"; import { normalizeAgentPermissions } from "./agent-permissions.js"; import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js"; import { secretService } from "./secrets.js"; +import { + builtInAgentMarkersEqual, + readBuiltInAgentMarker, +} from "./built-in-agent-metadata.js"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); @@ -65,6 +69,11 @@ interface RevisionMetadata { interface UpdateAgentOptions { recordRevision?: RevisionMetadata; + allowBuiltInAgentMetadata?: boolean; +} + +interface CreateAgentOptions { + allowBuiltInAgentMetadata?: boolean; } interface AgentShortnameRow { @@ -390,6 +399,21 @@ export function agentService(db: Db) { }); } + function assertBuiltInAgentMetadataMutationAllowed( + beforeMetadata: unknown, + afterMetadata: unknown, + options?: { allowBuiltInAgentMetadata?: boolean }, + ) { + if (options?.allowBuiltInAgentMetadata) return; + const beforeMarker = readBuiltInAgentMarker(beforeMetadata); + const afterMarker = readBuiltInAgentMarker(afterMetadata); + if (builtInAgentMarkersEqual(beforeMarker, afterMarker)) return; + throw conflict("Built-in agent marker is managed by Paperclip and cannot be edited directly", { + code: "built_in_agent_marker_readonly", + key: beforeMarker?.key ?? afterMarker?.key ?? null, + }); + } + async function updateAgent( id: string, data: Partial, @@ -425,6 +449,10 @@ export function agentService(db: Db) { } } + if (Object.prototype.hasOwnProperty.call(data, "metadata")) { + assertBuiltInAgentMetadataMutationAllowed(existing.metadata, data.metadata, options); + } + const normalizedPatch = { ...data } as Partial; if (data.permissions !== undefined) { const role = (data.role ?? existing.role) as string; @@ -501,7 +529,8 @@ export function agentService(db: Db) { getById, - create: async (companyId: string, data: Omit) => { + create: async (companyId: string, data: Omit, options?: CreateAgentOptions) => { + assertBuiltInAgentMetadataMutationAllowed(null, data.metadata, options); if (data.reportsTo) { await ensureManager(companyId, data.reportsTo); } @@ -645,6 +674,14 @@ export function agentService(db: Db) { remove: async (id: string) => { const existing = await getById(id); if (!existing) return null; + const builtInMarker = readBuiltInAgentMarker(existing.metadata); + if (builtInMarker) { + throw conflict("Built-in agents cannot be deleted; pause them instead", { + code: "built_in_agent_undeletable", + key: builtInMarker.key, + featureKeys: builtInMarker.featureKeys, + }); + } return db.transaction(async (tx) => { await tx.update(agents).set({ reportsTo: null }).where(eq(agents.reportsTo, id)); diff --git a/server/src/services/built-in-agent-metadata.ts b/server/src/services/built-in-agent-metadata.ts new file mode 100644 index 00000000000..d0e64fca588 --- /dev/null +++ b/server/src/services/built-in-agent-metadata.ts @@ -0,0 +1,45 @@ +export const BUILT_IN_AGENT_METADATA_KEY = "paperclipBuiltInAgent"; + +export interface BuiltInAgentMarker { + key: string; + featureKeys: string[]; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeFeatureKeys(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + const featureKeys = value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0); + return featureKeys.length === value.length ? featureKeys : null; +} + +export function readBuiltInAgentMarker(metadata: unknown): BuiltInAgentMarker | null { + if (!isPlainRecord(metadata)) return null; + const marker = metadata[BUILT_IN_AGENT_METADATA_KEY]; + if (!isPlainRecord(marker)) return null; + const key = marker.key; + const featureKeys = normalizeFeatureKeys(marker.featureKeys); + if (typeof key !== "string" || key.trim().length === 0 || !featureKeys) return null; + return { key, featureKeys }; +} + +export function withBuiltInAgentMarker( + metadata: Record | null | undefined, + marker: BuiltInAgentMarker, +): Record { + return { + ...(metadata ?? {}), + [BUILT_IN_AGENT_METADATA_KEY]: { + key: marker.key, + featureKeys: [...marker.featureKeys], + }, + }; +} + +export function builtInAgentMarkersEqual(left: BuiltInAgentMarker | null, right: BuiltInAgentMarker | null) { + if (!left && !right) return true; + if (!left || !right) return false; + return left.key === right.key && JSON.stringify(left.featureKeys) === JSON.stringify(right.featureKeys); +} diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts new file mode 100644 index 00000000000..b3f19eca12b --- /dev/null +++ b/server/src/services/built-in-agents.ts @@ -0,0 +1,380 @@ +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 { logActivity } from "./activity-log.js"; +import { agentService } from "./agents.js"; +import { + readBuiltInAgentMarker, + withBuiltInAgentMarker, +} from "./built-in-agent-metadata.js"; + +export type BuiltInAgentStatus = "not_provisioned" | "needs_setup" | "ready" | "paused"; + +export interface BuiltInAgentDefinition { + key: string; + displayName: string; + featureKeys: string[]; + shortPurpose: string; + defaultInstructions: string; + defaultRole: string; + allowedAdapterTypes?: string[]; + defaultBudgetMonthlyCents?: number; +} + +export interface BuiltInAgentState { + definition: BuiltInAgentDefinition; + status: BuiltInAgentStatus; + agentId: string | null; + agent: Agent | null; + pauseReason: string | null; +} + +export interface BuiltInAgentProvisionInput { + adapterType?: string; + adapterConfig?: Record; +} + +const BUILT_IN_AGENT_KEY_PATTERN = /^[a-z][a-z0-9_-]*$/; + +const DEFINITIONS = validateBuiltInAgentDefinitions([ + { + key: "briefs", + displayName: "Briefs Agent", + featureKeys: ["briefs"], + shortPurpose: "Prepares concise operational briefs for the board and agent company.", + defaultInstructions: + "You are Paperclip's built-in Briefs agent. Produce concise, sourced operational briefs that help the board understand current company work, risks, and next actions.", + defaultRole: "general", + allowedAdapterTypes: ["codex_local", "claude_local", "gemini_local", "opencode_local", "process"], + defaultBudgetMonthlyCents: 0, + }, + { + 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, + }, +]); + +const DEFINITIONS_BY_KEY = new Map(DEFINITIONS.map((definition) => [definition.key, definition])); + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown) { + return typeof value === "string" && value.trim().length > 0; +} + +function uniqueNonEmptyStrings(values: string[]) { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const normalized = value.trim(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + result.push(normalized); + } + return result; +} + +export function validateBuiltInAgentDefinitions(definitions: BuiltInAgentDefinition[]) { + const seenKeys = new Set(); + for (const definition of definitions) { + if (!BUILT_IN_AGENT_KEY_PATTERN.test(definition.key)) { + throw new Error(`Invalid built-in agent key: ${definition.key}`); + } + if (seenKeys.has(definition.key)) { + throw new Error(`Duplicate built-in agent key: ${definition.key}`); + } + seenKeys.add(definition.key); + if (!definition.displayName.trim()) { + throw new Error(`Built-in agent ${definition.key} requires a displayName`); + } + if (!definition.shortPurpose.trim()) { + throw new Error(`Built-in agent ${definition.key} requires a shortPurpose`); + } + if (!definition.defaultInstructions.trim()) { + throw new Error(`Built-in agent ${definition.key} requires defaultInstructions`); + } + if (!definition.defaultRole.trim()) { + throw new Error(`Built-in agent ${definition.key} requires a defaultRole`); + } + if (uniqueNonEmptyStrings(definition.featureKeys).length !== definition.featureKeys.length) { + throw new Error(`Built-in agent ${definition.key} featureKeys must be unique non-empty strings`); + } + if (definition.featureKeys.length === 0) { + throw new Error(`Built-in agent ${definition.key} requires at least one featureKey`); + } + if ( + definition.allowedAdapterTypes + && uniqueNonEmptyStrings(definition.allowedAdapterTypes).length !== definition.allowedAdapterTypes.length + ) { + throw new Error(`Built-in agent ${definition.key} allowedAdapterTypes must be unique non-empty strings`); + } + if ( + definition.defaultBudgetMonthlyCents !== undefined + && (!Number.isInteger(definition.defaultBudgetMonthlyCents) || definition.defaultBudgetMonthlyCents < 0) + ) { + throw new Error(`Built-in agent ${definition.key} defaultBudgetMonthlyCents must be a non-negative integer`); + } + } + return definitions.map((definition) => ({ + ...definition, + featureKeys: [...definition.featureKeys], + allowedAdapterTypes: definition.allowedAdapterTypes ? [...definition.allowedAdapterTypes] : undefined, + })); +} + +export function listBuiltInAgentDefinitions() { + return DEFINITIONS.map((definition) => ({ ...definition, featureKeys: [...definition.featureKeys] })); +} + +export function getBuiltInAgentDefinition(key: string) { + return DEFINITIONS_BY_KEY.get(key) ?? null; +} + +export function requireBuiltInAgentDefinition(key: string) { + const definition = getBuiltInAgentDefinition(key); + if (!definition) throw notFound(`Built-in agent definition not found: ${key}`); + return definition; +} + +function defaultAdapterType(definition: BuiltInAgentDefinition) { + return definition.allowedAdapterTypes?.[0] ?? "process"; +} + +function assertAdapterAllowed(definition: BuiltInAgentDefinition, adapterType: string) { + if (definition.allowedAdapterTypes && !definition.allowedAdapterTypes.includes(adapterType)) { + throw unprocessable(`Adapter type ${adapterType} is not allowed for built-in agent ${definition.key}`, { + code: "built_in_agent_adapter_not_allowed", + key: definition.key, + allowedAdapterTypes: definition.allowedAdapterTypes, + }); + } +} + +function hasCompleteAdapterConfig(adapterType: string, adapterConfig: unknown) { + if (!isPlainRecord(adapterConfig)) return false; + if (["process", "command"].includes(adapterType)) { + return nonEmptyString(adapterConfig.command) || nonEmptyString(adapterConfig.script); + } + if (adapterType === "http") { + return nonEmptyString(adapterConfig.url) || nonEmptyString(adapterConfig.endpoint) || nonEmptyString(adapterConfig.webhookUrl); + } + if (adapterType === "openclaw_gateway" || adapterType === "hermes_gateway") { + return nonEmptyString(adapterConfig.baseUrl) || nonEmptyString(adapterConfig.url); + } + return nonEmptyString(adapterConfig.model); +} + +export function deriveBuiltInAgentStatus(agent: Pick | null): BuiltInAgentStatus { + if (!agent) return "not_provisioned"; + if (agent.status === "paused" || agent.pausedAt) return "paused"; + return hasCompleteAdapterConfig(agent.adapterType, agent.adapterConfig) ? "ready" : "needs_setup"; +} + +function builtInMetadata(definition: BuiltInAgentDefinition, existing?: Record | null) { + return withBuiltInAgentMarker(existing, { + key: definition.key, + featureKeys: definition.featureKeys, + }); +} + +function definitionPatch(definition: BuiltInAgentDefinition, input: BuiltInAgentProvisionInput = {}) { + const adapterType = input.adapterType ?? defaultAdapterType(definition); + assertAdapterAllowed(definition, adapterType); + return { + name: definition.displayName, + role: definition.defaultRole, + title: null, + capabilities: definition.shortPurpose, + adapterType, + adapterConfig: input.adapterConfig ?? {}, + budgetMonthlyCents: definition.defaultBudgetMonthlyCents ?? 0, + }; +} + +function rowIsBuiltInAgent(row: typeof agents.$inferSelect, key: string) { + const marker = readBuiltInAgentMarker(row.metadata); + return marker?.key === key; +} + +export function builtInAgentService(db: Db) { + const agentSvc = agentService(db); + + async function ensureCompany(companyId: string) { + const company = await db + .select({ id: companies.id }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + if (!company) throw notFound("Company not found"); + } + + async function findMarkedRows(companyId: string, key: string) { + const rows = await db + .select() + .from(agents) + .where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"))); + return rows + .filter((row) => rowIsBuiltInAgent(row, key)) + .sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime() || left.id.localeCompare(right.id)); + } + + async function findSingleAgent(companyId: string, definition: BuiltInAgentDefinition) { + const markedRows = await findMarkedRows(companyId, definition.key); + if (markedRows.length > 1) { + throw conflict(`Multiple built-in agents found for ${definition.key}`, { + code: "built_in_agent_duplicate_instance", + key: definition.key, + agentIds: markedRows.map((row) => row.id), + }); + } + if (markedRows.length === 0) return null; + const agent = await agentSvc.getById(markedRows[0]!.id); + return agent as Agent | null; + } + + function state(definition: BuiltInAgentDefinition, agent: Agent | null): BuiltInAgentState { + return { + definition, + status: deriveBuiltInAgentStatus(agent), + agentId: agent?.id ?? null, + agent, + pauseReason: agent?.pauseReason ?? null, + }; + } + + async function get(companyId: string, key: string) { + const definition = requireBuiltInAgentDefinition(key); + await ensureCompany(companyId); + return state(definition, await findSingleAgent(companyId, definition)); + } + + async function ensure(companyId: string, key: string, input: BuiltInAgentProvisionInput = {}) { + const definition = requireBuiltInAgentDefinition(key); + await ensureCompany(companyId); + const existing = await findSingleAgent(companyId, definition); + if (existing) { + const patch: Partial = { + metadata: builtInMetadata(definition, existing.metadata), + }; + if (input.adapterType !== undefined || input.adapterConfig !== undefined) { + const adapterType = input.adapterType ?? existing.adapterType; + assertAdapterAllowed(definition, adapterType); + patch.adapterType = adapterType; + patch.adapterConfig = input.adapterConfig ?? existing.adapterConfig; + } + const updated = await agentSvc.update(existing.id, patch, { + allowBuiltInAgentMetadata: true, + recordRevision: { source: "built-in-agent:ensure" }, + }); + if (!updated) throw notFound("Built-in agent not found"); + return state(definition, updated as Agent); + } + + const created = await agentSvc.create(companyId, { + ...definitionPatch(definition, input), + status: "idle", + metadata: builtInMetadata(definition), + runtimeConfig: {}, + permissions: {}, + spentMonthlyCents: 0, + lastHeartbeatAt: null, + }, { allowBuiltInAgentMetadata: true }) as Agent; + + await logActivity(db, { + companyId, + actorType: "system", + actorId: "built-in-agents", + action: "built_in_agent.provisioned", + entityType: "agent", + entityId: created.id, + details: { + key: definition.key, + featureKeys: definition.featureKeys, + status: deriveBuiltInAgentStatus(created), + }, + }); + + return state(definition, created); + } + + async function list(companyId: string) { + await ensureCompany(companyId); + return Promise.all(DEFINITIONS.map(async (definition) => state(definition, await findSingleAgent(companyId, definition)))); + } + + async function reconcileDefinitionDefaults(companyId: string, key: string) { + const definition = requireBuiltInAgentDefinition(key); + await ensureCompany(companyId); + const existing = await findSingleAgent(companyId, definition); + if (!existing) return state(definition, null); + const patch = { + name: definition.displayName, + role: definition.defaultRole, + title: null, + capabilities: definition.shortPurpose, + metadata: builtInMetadata(definition, existing.metadata), + }; + const updated = await agentSvc.update(existing.id, patch, { + allowBuiltInAgentMetadata: true, + recordRevision: { source: "built-in-agent:reconcile-defaults" }, + }); + if (!updated) throw notFound("Built-in agent not found"); + return state(definition, updated as Agent); + } + + return { + definitions: listBuiltInAgentDefinitions, + get, + ensure, + list, + reconcileDefinitionDefaults, + }; +} + +export async function reconcileBuiltInAgentsOnStartup(db: Db) { + const svc = builtInAgentService(db); + const rows = await db + .select({ + companyId: agents.companyId, + metadata: agents.metadata, + status: agents.status, + }) + .from(agents) + .where(ne(agents.status, "terminated")); + const seen = new Set(); + let scanned = 0; + let reconciled = 0; + let unknown = 0; + let duplicates = 0; + + for (const row of rows) { + const marker = readBuiltInAgentMarker(row.metadata); + if (!marker) continue; + scanned += 1; + if (!getBuiltInAgentDefinition(marker.key)) { + unknown += 1; + continue; + } + const instanceKey = `${row.companyId}:${marker.key}`; + if (seen.has(instanceKey)) { + duplicates += 1; + continue; + } + seen.add(instanceKey); + await svc.reconcileDefinitionDefaults(row.companyId, marker.key); + reconciled += 1; + } + + return { scanned, reconciled, unknown, duplicates }; +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 4a351f3e4a7..8e59a53f3a4 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -4,6 +4,17 @@ export { companySearchService } from "./company-search.js"; export { feedbackService } from "./feedback.js"; export { companySkillService } from "./company-skills.js"; export { agentService, deduplicateAgentName } from "./agents.js"; +export { + builtInAgentService, + deriveBuiltInAgentStatus, + getBuiltInAgentDefinition, + listBuiltInAgentDefinitions, + reconcileBuiltInAgentsOnStartup, + validateBuiltInAgentDefinitions, + type BuiltInAgentDefinition, + type BuiltInAgentState, + type BuiltInAgentStatus, +} from "./built-in-agents.js"; export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js"; export { assetService } from "./assets.js"; export { documentService, extractLegacyPlanBody } from "./documents.js";