From 752b4a32a5051bb29fd27a792a5eca15a52693b2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 8 Jul 2026 17:55:33 +0000 Subject: [PATCH] Implement Reflection Coach built-in bundle server support Co-Authored-By: Paperclip --- .../0136_built_in_managed_resources.sql | 26 + .../src/schema/built_in_managed_resources.ts | 31 + packages/db/src/schema/index.ts | 1 + packages/shared/src/index.ts | 3 + packages/shared/src/validators/agent.ts | 10 + packages/shared/src/validators/index.ts | 3 + server/src/__tests__/built-in-agents.test.ts | 227 ++++- server/src/index.ts | 9 +- server/src/routes/built-in-agents.ts | 65 +- server/src/services/built-in-agents.ts | 913 +++++++++++++++++- server/src/services/index.ts | 2 + 11 files changed, 1253 insertions(+), 37 deletions(-) create mode 100644 packages/db/src/migrations/0136_built_in_managed_resources.sql create mode 100644 packages/db/src/schema/built_in_managed_resources.ts diff --git a/packages/db/src/migrations/0136_built_in_managed_resources.sql b/packages/db/src/migrations/0136_built_in_managed_resources.sql new file mode 100644 index 00000000000..91f8c3302c3 --- /dev/null +++ b/packages/db/src/migrations/0136_built_in_managed_resources.sql @@ -0,0 +1,26 @@ +CREATE TABLE "built_in_managed_resources" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "bundle_key" text NOT NULL, + "resource_kind" text NOT NULL, + "resource_key" text NOT NULL, + "resource_id" uuid NOT NULL, + "stock_version" text NOT NULL, + "stock_hash" text NOT NULL, + "defaults_json" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +ALTER TABLE "built_in_managed_resources" + ADD CONSTRAINT "built_in_managed_resources_company_id_companies_id_fk" + FOREIGN KEY ("company_id") REFERENCES "companies"("id") ON DELETE cascade ON UPDATE no action; + +CREATE INDEX "built_in_managed_resources_company_idx" + ON "built_in_managed_resources" ("company_id"); + +CREATE INDEX "built_in_managed_resources_resource_idx" + ON "built_in_managed_resources" ("resource_kind", "resource_id"); + +CREATE UNIQUE INDEX "built_in_managed_resources_company_bundle_resource_uq" + ON "built_in_managed_resources" ("company_id", "bundle_key", "resource_kind", "resource_key"); diff --git a/packages/db/src/schema/built_in_managed_resources.ts b/packages/db/src/schema/built_in_managed_resources.ts new file mode 100644 index 00000000000..8c04da34c79 --- /dev/null +++ b/packages/db/src/schema/built_in_managed_resources.ts @@ -0,0 +1,31 @@ +import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; + +export const builtInManagedResources = pgTable( + "built_in_managed_resources", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id") + .notNull() + .references(() => companies.id, { onDelete: "cascade" }), + bundleKey: text("bundle_key").notNull(), + resourceKind: text("resource_kind").notNull(), + resourceKey: text("resource_key").notNull(), + resourceId: uuid("resource_id").notNull(), + stockVersion: text("stock_version").notNull(), + stockHash: text("stock_hash").notNull(), + defaultsJson: jsonb("defaults_json").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyIdx: index("built_in_managed_resources_company_idx").on(table.companyId), + resourceIdx: index("built_in_managed_resources_resource_idx").on(table.resourceKind, table.resourceId), + companyBundleResourceUq: uniqueIndex("built_in_managed_resources_company_bundle_resource_uq").on( + table.companyId, + table.bundleKey, + table.resourceKind, + table.resourceKey, + ), + }), +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 7d1180376c8..aadaaf63db3 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -6,6 +6,7 @@ export { cloudUpstreamConnections, cloudUpstreamRuns } from "./cloud_upstreams.j export { instanceUserRoles } from "./instance_user_roles.js"; export { userSidebarPreferences } from "./user_sidebar_preferences.js"; export { agents } from "./agents.js"; +export { builtInManagedResources } from "./built_in_managed_resources.js"; export { agentMemberships } from "./agent_memberships.js"; export { boardApiKeys } from "./board_api_keys.js"; export { cliAuthChallenges } from "./cli_auth_challenges.js"; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f554486d339..261e6301db5 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1163,7 +1163,9 @@ export { agentSkillSyncSchema, type AgentSkillSync, createAgentSchema, + builtInAgentEmptyMutationSchema, builtInAgentProvisionSchema, + builtInAgentResetSchema, createAgentHireSchema, updateAgentSchema, agentInstructionsBundleModeSchema, @@ -1184,6 +1186,7 @@ export { updateAgentPermissionsSchema, type CreateAgent, type BuiltInAgentProvision, + type BuiltInAgentReset, type CreateAgentHire, type UpdateAgent, type UpdateAgentInstructionsBundle, diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 4b89d22fa79..a0d38cd9218 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -96,6 +96,16 @@ export const builtInAgentProvisionSchema = z.object({ export type BuiltInAgentProvision = z.infer; +export const builtInAgentEmptyMutationSchema = z.object({}).strict().default({}); + +export type BuiltInAgentEmptyMutation = z.infer; + +export const builtInAgentResetSchema = z.object({ + resources: z.array(z.enum(["agent", "instructions", "skill", "routine"])).optional(), +}).strict().default({}); + +export type BuiltInAgentReset = 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 e5c7ca348b0..eb610f933a8 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -247,7 +247,9 @@ export { export { createAgentSchema, + builtInAgentEmptyMutationSchema, builtInAgentProvisionSchema, + builtInAgentResetSchema, createAgentHireSchema, updateAgentSchema, agentRuntimeConfigSchema, @@ -269,6 +271,7 @@ export { updateAgentPermissionsSchema, type CreateAgent, type BuiltInAgentProvision, + type BuiltInAgentReset, type CreateAgentHire, type UpdateAgent, type UpdateAgentInstructionsBundle, diff --git a/server/src/__tests__/built-in-agents.test.ts b/server/src/__tests__/built-in-agents.test.ts index b1d6fc742e2..82d4016eec7 100644 --- a/server/src/__tests__/built-in-agents.test.ts +++ b/server/src/__tests__/built-in-agents.test.ts @@ -7,14 +7,22 @@ import { agents, approvals, budgetPolicies, + builtInManagedResources, companies, + companySkillVersions, + companySkills, createDb, + principalPermissionGrants, + routines, + routineTriggers, } from "@paperclipai/db"; +import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { HttpError } from "../errors.ts"; +import { agentInstructionsService } from "../services/agent-instructions.ts"; import { agentService } from "../services/agents.ts"; import { approvalService } from "../services/approvals.ts"; import { @@ -49,6 +57,12 @@ describeEmbeddedPostgres("built-in agents", () => { }, 20_000); afterEach(async () => { + await db.delete(routineTriggers); + await db.delete(routines); + await db.delete(builtInManagedResources); + await db.delete(companySkillVersions); + await db.delete(companySkills); + await db.delete(principalPermissionGrants); await db.delete(agentConfigRevisions); await db.delete(activityLog); await db.delete(approvals); @@ -67,13 +81,14 @@ describeEmbeddedPostgres("built-in agents", () => { id: companyId, name: "Paperclip", issuePrefix: issuePrefix(companyId), + defaultResponsibleUserId: "responsible-user", requireBoardApprovalForNewAgents: true, }); return companyId; } it("validates the static registry and rejects invalid definitions", () => { - expect(listBuiltInAgentDefinitions().map((definition) => definition.key).sort()).toEqual(["briefs", "learning"]); + expect(listBuiltInAgentDefinitions().map((definition) => definition.key).sort()).toEqual(["briefs", "learning", "reflection-coach"]); expect(() => validateBuiltInAgentDefinitions([ { key: "briefs", @@ -393,6 +408,108 @@ describeEmbeddedPostgres("built-in agents", () => { }); }); + it("auto-provisions a paused Reflection Coach bundle with skill sync and a disabled routine", async () => { + const companyId = await seedCompany(); + await agentService(db).create(companyId, { + name: "CEO", + role: "ceo", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4", apiKey: "do-not-copy" }, + runtimeConfig: {}, + permissions: {}, + }); + + const result = await reconcileBuiltInAgentsOnStartup(db); + expect(result.autoEnsured).toBeGreaterThanOrEqual(1); + + const state = await builtInAgentService(db).get(companyId, "reflection-coach"); + expect(state).toMatchObject({ + status: "paused", + agent: { + companyId, + name: "Reflection Coach", + role: "general", + title: "Reflection Coach", + icon: "eye", + adapterType: "codex_local", + permissions: { + canCreateAgents: false, + canCreateSkills: false, + }, + }, + }); + expect(state.agent?.adapterConfig).toMatchObject({ + instructionsBundleMode: "managed", + instructionsEntryFile: "AGENTS.md", + }); + expect(state.agent?.adapterConfig).not.toMatchObject({ model: "gpt-5.4", apiKey: "do-not-copy" }); + expect(state.resources.map((resource) => [resource.resourceKind, resource.stockStatus])).toEqual([ + ["instructions", "stock_current"], + ["skill", "stock_current"], + ["routine", "stock_current"], + ]); + + const agentRows = await db.select().from(agents).where(eq(agents.companyId, companyId)); + expect(agentRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach")).toHaveLength(1); + + const [skill] = await db + .select() + .from(companySkills) + .where(eq(companySkills.key, "paperclipai/bundled/paperclip-operations/reflection-coach")); + expect(skill).toMatchObject({ + key: "paperclipai/bundled/paperclip-operations/reflection-coach", + slug: "reflection-coach", + }); + expect(readPaperclipSkillSyncPreference(state.agent!.adapterConfig as Record).desiredSkills).toContain( + "paperclipai/bundled/paperclip-operations/reflection-coach", + ); + + const [routine] = await db.select().from(routines).where(eq(routines.companyId, companyId)); + expect(routine).toMatchObject({ + title: "Review recent agent trajectories for coaching proposals", + status: "paused", + assigneeAgentId: state.agentId, + }); + const [trigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, routine!.id)); + expect(trigger).toMatchObject({ + kind: "schedule", + enabled: false, + cronExpression: "0 9 * * 1", + timezone: "UTC", + }); + }); + + it("preserves Reflection Coach instruction drift on reconcile and restores it on reset", async () => { + const companyId = await seedCompany(); + const builtIns = builtInAgentService(db); + const created = await builtIns.ensure(companyId, "reflection-coach"); + const instructions = agentInstructionsService(); + + await instructions.writeFile(created.agent!, "AGENTS.md", "# Custom Reflection Coach\n\nOperator edit.\n"); + + const reconciled = await builtIns.ensure(companyId, "reflection-coach"); + const drift = reconciled.resources.find((resource) => resource.resourceKind === "instructions"); + expect(drift).toMatchObject({ + stockStatus: "operator_modified", + updateAvailable: true, + resetAvailable: true, + changedFiles: ["AGENTS.md"], + }); + await expect(instructions.readFile(reconciled.agent!, "AGENTS.md")).resolves.toMatchObject({ + content: "# Custom Reflection Coach\n\nOperator edit.\n", + }); + + const reset = await builtIns.reset(companyId, "reflection-coach"); + expect(reset.resources.find((resource) => resource.resourceKind === "instructions")).toMatchObject({ + stockStatus: "stock_current", + resetAvailable: false, + }); + const resetFile = await instructions.readFile(reset.agent!, "AGENTS.md"); + expect(resetFile.content).toContain("Reflection Coach"); + expect(resetFile.content).not.toContain("Operator edit."); + }); + it("blocks deleting a built-in agent", async () => { const companyId = await seedCompany(); const state = await builtInAgentService(db).ensure(companyId, "briefs"); @@ -472,7 +589,9 @@ describeEmbeddedPostgres("built-in agents", () => { }); const result = await reconcileBuiltInAgentsOnStartup(db); - expect(result).toMatchObject({ scanned: 1, reconciled: 1, unknown: 0, duplicates: 0 }); + expect(result).toMatchObject({ unknown: 0, duplicates: 0 }); + expect(result.scanned).toBeGreaterThanOrEqual(1); + expect(result.reconciled).toBeGreaterThanOrEqual(1); const [row] = await db.select().from(agents).where(eq(agents.id, agentId)); expect(row).toMatchObject({ @@ -521,4 +640,108 @@ describeEmbeddedPostgres("built-in agents", () => { }, } satisfies Partial); }); + + it("automatically materializes the Reflection Coach bundle without enabling background work", async () => { + const companyId = await seedCompany(); + const root = await agentService(db).create(companyId, { + name: "CEO", + role: "ceo", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + }); + + const state = await builtInAgentService(db).ensure(companyId, "reflection-coach"); + + expect(state.agent).toMatchObject({ + companyId, + name: "Reflection Coach", + title: "Reflection Coach", + icon: "eye", + reportsTo: root.id, + adapterType: "codex_local", + budgetMonthlyCents: 0, + }); + expect(state.status).toBe("paused"); + expect(readBuiltInAgentMarker(state.agent?.metadata)).toEqual({ + key: "reflection-coach", + featureKeys: ["reflection-coach"], + }); + expect(state.resources.map((resource) => [resource.resourceKind, resource.stockStatus])).toEqual([ + ["instructions", "stock_current"], + ["skill", "stock_current"], + ["routine", "stock_current"], + ]); + + const [skill] = await db + .select() + .from(companySkills) + .where(eq(companySkills.key, "paperclipai/bundled/paperclip-operations/reflection-coach")); + expect(skill).toMatchObject({ + key: "paperclipai/bundled/paperclip-operations/reflection-coach", + slug: "reflection-coach", + }); + expect(readPaperclipSkillSyncPreference(state.agent!.adapterConfig).desiredSkills).toContain(skill!.key); + + const [routine] = await db.select().from(routines).where(eq(routines.companyId, companyId)); + expect(routine).toMatchObject({ + title: "Review recent agent trajectories for coaching proposals", + status: "paused", + assigneeAgentId: state.agentId, + originKind: "built_in_agent_bundle", + originId: "reflection-coach:recent-agent-reflection", + }); + const [trigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, routine!.id)); + expect(trigger).toMatchObject({ + kind: "schedule", + enabled: false, + cronExpression: "0 9 * * 1", + timezone: "UTC", + }); + + const grants = await db + .select() + .from(principalPermissionGrants) + .where(eq(principalPermissionGrants.principalId, state.agentId!)); + expect(grants.map((grant) => grant.permissionKey)).not.toContain("tasks:assign"); + }); + + it("preserves Reflection Coach stock drift until explicit reset", async () => { + const companyId = await seedCompany(); + const created = await builtInAgentService(db).ensure(companyId, "reflection-coach"); + const agent = created.agent!; + + const instructionsSvc = agentInstructionsService(); + await instructionsSvc.writeFile(agent, "AGENTS.md", "# Custom Reflection Coach\n\nDo not overwrite me.\n"); + await db + .update(companySkills) + .set({ markdown: "---\nname: reflection-coach\n---\n\n# Custom skill\n" }) + .where(eq(companySkills.companyId, companyId)); + + const drifted = await builtInAgentService(db).ensure(companyId, "reflection-coach"); + expect(drifted.resources.find((resource) => resource.resourceKind === "instructions")).toMatchObject({ + stockStatus: "operator_modified", + resetAvailable: true, + }); + expect(drifted.resources.find((resource) => resource.resourceKind === "skill")).toMatchObject({ + stockStatus: "operator_modified", + resetAvailable: true, + }); + expect((await instructionsSvc.readFile(drifted.agent!, "AGENTS.md")).content).toContain("Do not overwrite me."); + + const reset = await builtInAgentService(db).reset(companyId, "reflection-coach", { + resources: ["instructions"], + }); + expect(reset.resources.find((resource) => resource.resourceKind === "instructions")).toMatchObject({ + stockStatus: "stock_current", + resetAvailable: false, + }); + expect(reset.resources.find((resource) => resource.resourceKind === "skill")).toMatchObject({ + stockStatus: "operator_modified", + resetAvailable: true, + }); + expect((await instructionsSvc.readFile(reset.agent!, "AGENTS.md")).content).toContain("built-in Reflection Coach"); + }); }); diff --git a/server/src/index.ts b/server/src/index.ts index d2c7c36d0e7..e547bd9d973 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -780,14 +780,9 @@ export async function startServer(): Promise { logger.error({ err }, "startup reconciliation of codex_local managed homes failed"); }); - void instanceSettingsService(db as any).getExperimental() - .then((experimental) => { - if (experimental.enableBuiltInAgents !== true) return null; - return reconcileBuiltInAgentsOnStartup(db as any); - }) + void reconcileBuiltInAgentsOnStartup(db as any) .then((result) => { - if (!result) return; - if (result.reconciled > 0 || result.unknown > 0 || result.duplicates > 0) { + if (result.reconciled > 0 || result.unknown > 0 || result.duplicates > 0 || result.autoEnsured > 0) { logger.warn( result, "startup reconciliation of built-in agents complete", diff --git a/server/src/routes/built-in-agents.ts b/server/src/routes/built-in-agents.ts index 3dab8c475c1..9299ab5232a 100644 --- a/server/src/routes/built-in-agents.ts +++ b/server/src/routes/built-in-agents.ts @@ -1,6 +1,6 @@ import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; -import { builtInAgentProvisionSchema } from "@paperclipai/shared"; +import { builtInAgentEmptyMutationSchema, builtInAgentProvisionSchema, builtInAgentResetSchema } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { forbidden, notFound } from "../errors.js"; import { accessService, instanceSettingsService, logActivity } from "../services/index.js"; @@ -10,9 +10,36 @@ import { assertCompanyAccess, getActorInfo } from "./authz.js"; import type { BuiltInAgentState } from "../services/built-in-agents.js"; function redactBuiltInAgentListState(state: BuiltInAgentState): BuiltInAgentState { - if (!state.agent) return state; + const definition = { + ...state.definition, + defaultInstructions: state.definition.defaultInstructions ? "[file-backed]" : "", + bundle: state.definition.bundle + ? { + stockVersion: state.definition.bundle.stockVersion, + instructions: { + entryFile: state.definition.bundle.instructions.entryFile, + files: Object.keys(state.definition.bundle.instructions.files), + }, + skill: { + skillKey: state.definition.bundle.skill.skillKey, + displayName: state.definition.bundle.skill.displayName, + slug: state.definition.bundle.skill.slug, + canonicalKey: state.definition.bundle.skill.canonicalKey, + files: Object.keys(state.definition.bundle.skill.files), + }, + routine: { + routineKey: state.definition.bundle.routine.routineKey, + title: state.definition.bundle.routine.title, + status: state.definition.bundle.routine.status, + triggerCount: state.definition.bundle.routine.triggers.length, + }, + } + : undefined, + } as BuiltInAgentState["definition"]; + if (!state.agent) return { ...state, definition }; return { ...state, + definition, agent: { ...state.agent, adapterConfig: {}, @@ -49,7 +76,7 @@ export function builtInAgentRoutes(db: Db) { req: Request, input: { companyId: string; - action: "built_in_agent.provision_requested" | "built_in_agent.reset" | "approval.created"; + action: "built_in_agent.provision_requested" | "built_in_agent.reconcile" | "built_in_agent.reset" | "approval.created"; key: string; agentId: string | null; status: string; @@ -82,6 +109,30 @@ export function builtInAgentRoutes(db: Db) { res.json(states.map(redactBuiltInAgentListState)); }); + router.get("/companies/:companyId/built-in-agents/:key/status", async (req, res) => { + const companyId = req.params.companyId as string; + const key = req.params.key as string; + assertCompanyAccess(req, companyId); + await assertBuiltInAgentsEnabled(); + res.json(redactBuiltInAgentListState(await svc.get(companyId, key))); + }); + + router.post("/companies/:companyId/built-in-agents/:key/reconcile", validate(builtInAgentEmptyMutationSchema), async (req, res) => { + const companyId = req.params.companyId as string; + const key = req.params.key as string; + await assertBuiltInAgentsEnabled(); + await assertCanProvisionBuiltInAgents(req, companyId); + const state = await svc.ensure(companyId, key); + await logBuiltInAgentMutation(req, { + companyId, + action: "built_in_agent.reconcile", + key, + agentId: state.agentId, + status: state.status, + }); + res.json(redactBuiltInAgentListState(state)); + }); + router.post( "/companies/:companyId/built-in-agents/:key/provision", validate(builtInAgentProvisionSchema), @@ -113,16 +164,16 @@ export function builtInAgentRoutes(db: Db) { approvalId: approval.id, }); } - res.status(approval ? 202 : 200).json({ ...state, approval }); + res.status(approval ? 202 : 200).json(redactBuiltInAgentListState({ ...state, approval })); }, ); - router.post("/companies/:companyId/built-in-agents/:key/reset", async (req, res) => { + router.post("/companies/:companyId/built-in-agents/:key/reset", validate(builtInAgentResetSchema), async (req, res) => { const companyId = req.params.companyId as string; const key = req.params.key as string; await assertBuiltInAgentsEnabled(); await assertCanProvisionBuiltInAgents(req, companyId); - const state = await svc.reset(companyId, key); + const state = await svc.reset(companyId, key, req.body); await logBuiltInAgentMutation(req, { companyId, action: "built_in_agent.reset", @@ -130,7 +181,7 @@ export function builtInAgentRoutes(db: Db) { agentId: state.agentId, status: state.status, }); - res.json(state); + res.json(redactBuiltInAgentListState(state)); }); return router; diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts index 8ce039fd948..9837118f3c4 100644 --- a/server/src/services/built-in-agents.ts +++ b/server/src/services/built-in-agents.ts @@ -1,15 +1,23 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils"; import { and, eq, ne } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { agents, companies } from "@paperclipai/db"; -import type { Agent, Approval } from "@paperclipai/shared"; +import { agents, builtInManagedResources, companies, routines, routineTriggers } from "@paperclipai/db"; +import type { Agent, Approval, CompanySkill, Routine, RoutineTrigger, RoutineVariable } from "@paperclipai/shared"; import { conflict, HttpError, notFound, unprocessable } from "../errors.js"; import { logActivity } from "./activity-log.js"; +import { agentInstructionsService } from "./agent-instructions.js"; import { agentService } from "./agents.js"; import { approvalService } from "./approvals.js"; import { readBuiltInAgentMarker, withBuiltInAgentMarker, } from "./built-in-agent-metadata.js"; +import { companySkillService } from "./company-skills.js"; +import { routineService } from "./routines.js"; export type BuiltInAgentStatus = "not_provisioned" | "pending_approval" | "needs_setup" | "ready" | "paused"; @@ -20,8 +28,14 @@ export interface BuiltInAgentDefinition { shortPurpose: string; defaultInstructions: string; defaultRole: string; + defaultTitle?: string | null; + defaultIcon?: string | null; + defaultPermissions?: Record; + defaultStatus?: "idle" | "paused"; + defaultManager?: "single_root_agent" | null; allowedAdapterTypes?: string[]; defaultBudgetMonthlyCents?: number; + bundle?: BuiltInAgentBundleDefinition; } export interface BuiltInAgentState { @@ -30,6 +44,8 @@ export interface BuiltInAgentState { agentId: string | null; agent: Agent | null; pauseReason: string | null; + resources: BuiltInManagedResourceState[]; + approval?: Approval | null; } export interface BuiltInAgentProvisionInput { @@ -48,6 +64,58 @@ export interface BuiltInAgentProvisionResult { approval: Approval | null; } +export type BuiltInManagedResourceKind = "instructions" | "skill" | "routine"; +export type BuiltInManagedResourceStockStatus = + | "missing" + | "stock_current" + | "stock_update_available" + | "operator_modified"; + +export interface BuiltInManagedResourceState { + resourceKind: BuiltInManagedResourceKind; + resourceKey: string; + resourceId: string | null; + stockVersion: string; + stockHash: string; + currentHash: string | null; + stockStatus: BuiltInManagedResourceStockStatus; + updateAvailable: boolean; + resetAvailable: boolean; + changedFiles?: string[]; +} + +export interface BuiltInAgentBundleDefinition { + stockVersion: string; + instructions: { + entryFile: string; + files: Record; + }; + skill: { + skillKey: string; + displayName: string; + slug: string; + canonicalKey: string; + files: Record; + }; + routine: { + routineKey: string; + title: string; + description: string; + status: "active" | "paused"; + priority: "critical" | "high" | "medium" | "low"; + concurrencyPolicy: "always_enqueue" | "coalesce_if_active" | "skip_if_active"; + catchUpPolicy: "enqueue_missed_with_cap" | "skip_missed"; + variables: RoutineVariable[]; + triggers: Array<{ + kind: "schedule"; + label: string | null; + enabled: boolean; + cronExpression: string; + timezone: string; + }>; + }; +} + export interface RequiredBuiltInAgentWarning { code: "built_in_agent_paused"; key: string; @@ -64,6 +132,22 @@ export interface RequiredBuiltInAgent { const BUILT_IN_AGENT_KEY_PATTERN = /^[a-z][a-z0-9_-]*$/; +const BUILT_INS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../built-ins/agents"); + +function readBuiltInText(relativePath: string) { + return readFileSync(path.join(BUILT_INS_DIR, relativePath), "utf8"); +} + +const REFLECTION_COACH_INSTRUCTIONS = readBuiltInText("reflection-coach/AGENTS.md"); +const REFLECTION_COACH_ROUTINE = readBuiltInText("reflection-coach/routines/recent-agent-reflection.md"); +const REFLECTION_COACH_SKILL = readFileSync( + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../packages/skills-catalog/catalog/bundled/paperclip-operations/reflection-coach/SKILL.md", + ), + "utf8", +); + const DEFINITIONS = validateBuiltInAgentDefinitions([ { key: "briefs", @@ -87,6 +171,79 @@ const DEFINITIONS = validateBuiltInAgentDefinitions([ allowedAdapterTypes: ["codex_local", "claude_local", "gemini_local", "opencode_local", "process"], defaultBudgetMonthlyCents: 0, }, + { + key: "reflection-coach", + displayName: "Reflection Coach", + featureKeys: ["reflection-coach"], + shortPurpose: + "Runs evidence-backed reflection loops on recent agent work, proposes small instruction and skill improvements, and requests approval before changes are applied.", + defaultInstructions: REFLECTION_COACH_INSTRUCTIONS, + defaultRole: "general", + defaultTitle: "Reflection Coach", + defaultIcon: "eye", + defaultPermissions: { + canCreateAgents: false, + canCreateSkills: false, + builtInMutationPolicy: { + requiresDisplayedDiff: true, + requiresAcceptedTaskInteraction: true, + applyInSeparateFollowUpRun: true, + }, + }, + defaultStatus: "paused", + defaultManager: "single_root_agent", + allowedAdapterTypes: ["claude_local", "codex_local", "gemini_local", "opencode_local", "process"], + defaultBudgetMonthlyCents: 0, + bundle: { + stockVersion: "2026-07-08", + instructions: { + entryFile: "AGENTS.md", + files: { + "AGENTS.md": REFLECTION_COACH_INSTRUCTIONS, + }, + }, + skill: { + skillKey: "reflection-coach", + displayName: "Reflection Coach", + slug: "reflection-coach", + canonicalKey: "paperclipai/bundled/paperclip-operations/reflection-coach", + files: { + "reflection-coach/SKILL.md": REFLECTION_COACH_SKILL, + }, + }, + routine: { + routineKey: "recent-agent-reflection", + title: "Review recent agent trajectories for coaching proposals", + description: REFLECTION_COACH_ROUTINE, + status: "paused", + priority: "medium", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [ + { name: "lookbackDays", label: "Lookback days", type: "number", defaultValue: 7, required: true, options: [] }, + { name: "maxTargetAgents", label: "Max target agents", type: "number", defaultValue: 8, required: true, options: [] }, + { + name: "targetAgentMode", + label: "Target agent mode", + type: "select", + defaultValue: "recent_active", + required: true, + options: ["recent_active", "recent_blocked", "recent_completed"], + }, + { name: "excludeAgentIds", label: "Excluded agent ids", type: "text", defaultValue: "", required: false, options: [] }, + ], + triggers: [ + { + kind: "schedule", + label: "Weekly reflection review", + enabled: false, + cronExpression: "0 9 * * 1", + timezone: "UTC", + }, + ], + }, + }, + }, ]); const DEFINITIONS_BY_KEY = new Map(DEFINITIONS.map((definition) => [definition.key, definition])); @@ -111,6 +268,72 @@ function uniqueNonEmptyStrings(values: string[]) { return result; } +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function stockHash(value: unknown) { + return `sha256:${createHash("sha256").update(stableJson(value)).digest("hex")}`; +} + +function changedFileList(currentFiles: Record, stockFiles: Record) { + const paths = new Set([...Object.keys(currentFiles), ...Object.keys(stockFiles)]); + return [...paths] + .filter((filePath) => (currentFiles[filePath] ?? null) !== (stockFiles[filePath] ?? null)) + .sort((left, right) => left.localeCompare(right)); +} + +function resourceStatus(input: { + resourceId: string | null; + currentHash: string | null; + bindingStockHash: string | null; + latestStockHash: string; +}): BuiltInManagedResourceStockStatus { + if (!input.resourceId || !input.currentHash) return "missing"; + if (input.currentHash === input.latestStockHash) return "stock_current"; + if (input.bindingStockHash && input.currentHash === input.bindingStockHash) { + return "stock_update_available"; + } + return "operator_modified"; +} + +function stockState(input: { + resourceKind: BuiltInManagedResourceKind; + resourceKey: string; + resourceId: string | null; + stockVersion: string; + latestStockHash: string; + currentHash: string | null; + bindingStockHash: string | null; + changedFiles?: string[]; +}): BuiltInManagedResourceState { + const status = resourceStatus({ + resourceId: input.resourceId, + currentHash: input.currentHash, + bindingStockHash: input.bindingStockHash, + latestStockHash: input.latestStockHash, + }); + return { + resourceKind: input.resourceKind, + resourceKey: input.resourceKey, + resourceId: input.resourceId, + stockVersion: input.stockVersion, + stockHash: input.latestStockHash, + currentHash: input.currentHash, + stockStatus: status, + updateAvailable: status === "stock_update_available" || status === "operator_modified", + resetAvailable: status !== "stock_current", + ...(input.changedFiles && input.changedFiles.length > 0 ? { changedFiles: input.changedFiles } : {}), + }; +} + export function validateBuiltInAgentDefinitions(definitions: BuiltInAgentDefinition[]) { const seenKeys = new Set(); for (const definition of definitions) { @@ -151,11 +374,41 @@ export function validateBuiltInAgentDefinitions(definitions: BuiltInAgentDefinit ) { throw new Error(`Built-in agent ${definition.key} defaultBudgetMonthlyCents must be a non-negative integer`); } + if (definition.bundle) { + if (!definition.bundle.stockVersion.trim()) { + throw new Error(`Built-in agent ${definition.key} bundle requires a stockVersion`); + } + if (!definition.bundle.instructions.files[definition.bundle.instructions.entryFile]) { + throw new Error(`Built-in agent ${definition.key} bundle instructions require the entry file`); + } + if (!definition.bundle.skill.files[`${definition.bundle.skill.slug}/SKILL.md`]) { + throw new Error(`Built-in agent ${definition.key} bundle skill requires SKILL.md`); + } + if (!definition.bundle.routine.description.trim()) { + throw new Error(`Built-in agent ${definition.key} bundle routine requires a description`); + } + } } return definitions.map((definition) => ({ ...definition, featureKeys: [...definition.featureKeys], allowedAdapterTypes: definition.allowedAdapterTypes ? [...definition.allowedAdapterTypes] : undefined, + bundle: definition.bundle ? { + ...definition.bundle, + instructions: { + ...definition.bundle.instructions, + files: { ...definition.bundle.instructions.files }, + }, + skill: { + ...definition.bundle.skill, + files: { ...definition.bundle.skill.files }, + }, + routine: { + ...definition.bundle.routine, + variables: definition.bundle.routine.variables.map((variable) => ({ ...variable, options: [...variable.options] })), + triggers: definition.bundle.routine.triggers.map((trigger) => ({ ...trigger })), + }, + } : undefined, })); } @@ -177,6 +430,30 @@ function defaultAdapterType(definition: BuiltInAgentDefinition) { return definition.allowedAdapterTypes?.[0] ?? "process"; } +function normalizeAdapterType(value: unknown) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function selectPreferredAdapterType( + definition: BuiltInAgentDefinition, + usage: Array<{ adapterType: string; count: number }>, +) { + const fallback = defaultAdapterType(definition); + const preference = definition.allowedAdapterTypes ?? []; + if (preference.length === 0) return fallback; + + const rank = new Map(preference.map((adapterType, index) => [adapterType, index])); + let selected: { adapterType: string; count: number; rank: number } | null = null; + for (const entry of usage) { + const adapterRank = rank.get(entry.adapterType); + if (adapterRank === undefined) continue; + if (!selected || entry.count > selected.count || (entry.count === selected.count && adapterRank < selected.rank)) { + selected = { ...entry, rank: adapterRank }; + } + } + return selected?.adapterType ?? fallback; +} + 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}`, { @@ -221,10 +498,12 @@ function definitionPatch(definition: BuiltInAgentDefinition, input: BuiltInAgent return { name: definition.displayName, role: definition.defaultRole, - title: null, + title: definition.defaultTitle ?? null, + icon: definition.defaultIcon ?? null, capabilities: definition.shortPurpose, adapterType, adapterConfig: input.adapterConfig ?? {}, + permissions: definition.defaultPermissions ?? {}, budgetMonthlyCents: input.budgetMonthlyCents ?? definition.defaultBudgetMonthlyCents ?? 0, }; } @@ -251,6 +530,558 @@ function rowIsBuiltInAgent(row: typeof agents.$inferSelect, key: string) { export function builtInAgentService(db: Db) { const agentSvc = agentService(db); const approvalSvc = approvalService(db); + const instructionsSvc = agentInstructionsService(); + const skillSvc = companySkillService(db); + const routineSvc = routineService(db); + + async function findSingleRootManager(companyId: string) { + const roots = await db + .select() + .from(agents) + .where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"))); + const nonBuiltInRoots = roots.filter((agent) => !readBuiltInAgentMarker(agent.metadata) && !agent.reportsTo); + return nonBuiltInRoots.length === 1 ? nonBuiltInRoots[0]!.id : null; + } + + async function defaultProvisionInput(companyId: string, definition: BuiltInAgentDefinition, input: BuiltInAgentProvisionInput) { + if (input.adapterType || input.adapterConfig) return input; + if (!definition.bundle) return input; + const rows = await db + .select({ + adapterType: agents.adapterType, + adapterConfig: agents.adapterConfig, + }) + .from(agents) + .where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"))); + const candidate = rows.find((row) => + definition.allowedAdapterTypes?.includes(row.adapterType) + && hasCompleteAdapterConfig(row.adapterType, row.adapterConfig) + ); + if (!candidate) return input; + return { + ...input, + adapterType: candidate.adapterType, + adapterConfig: {}, + }; + } + + async function getManagedResourceBinding( + companyId: string, + bundleKey: string, + resourceKind: BuiltInManagedResourceKind, + resourceKey: string, + ) { + return db + .select() + .from(builtInManagedResources) + .where(and( + eq(builtInManagedResources.companyId, companyId), + eq(builtInManagedResources.bundleKey, bundleKey), + eq(builtInManagedResources.resourceKind, resourceKind), + eq(builtInManagedResources.resourceKey, resourceKey), + )) + .then((rows) => rows[0] ?? null); + } + + async function upsertManagedResourceBinding(input: { + companyId: string; + bundleKey: string; + resourceKind: BuiltInManagedResourceKind; + resourceKey: string; + resourceId: string; + stockVersion: string; + stockHash: string; + defaultsJson: Record; + }) { + const existing = await getManagedResourceBinding( + input.companyId, + input.bundleKey, + input.resourceKind, + input.resourceKey, + ); + if (existing) { + return db + .update(builtInManagedResources) + .set({ + resourceId: input.resourceId, + stockVersion: input.stockVersion, + stockHash: input.stockHash, + defaultsJson: input.defaultsJson, + updatedAt: new Date(), + }) + .where(eq(builtInManagedResources.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? null); + } + return db + .insert(builtInManagedResources) + .values(input) + .returning() + .then((rows) => rows[0] ?? null); + } + + async function currentInstructionFiles(agent: Agent, bundle: BuiltInAgentBundleDefinition) { + const currentFiles: Record = {}; + for (const filePath of Object.keys(bundle.instructions.files)) { + try { + currentFiles[filePath] = (await instructionsSvc.readFile(agent, filePath)).content; + } catch { + currentFiles[filePath] = null; + } + } + return currentFiles; + } + + async function materializeInstructions(agent: Agent, definition: BuiltInAgentDefinition, mode: "reconcile" | "reset") { + const bundle = definition.bundle!; + const stock = stockHash(bundle.instructions.files); + const binding = await getManagedResourceBinding(agent.companyId, definition.key, "instructions", "AGENTS.md"); + const currentFiles = await currentInstructionFiles(agent, bundle); + const currentHash = Object.values(currentFiles).some((value) => value === null) ? null : stockHash(currentFiles); + const currentState = stockState({ + resourceKind: "instructions", + resourceKey: "AGENTS.md", + resourceId: agent.id, + stockVersion: bundle.stockVersion, + latestStockHash: stock, + currentHash, + bindingStockHash: binding?.stockHash ?? null, + changedFiles: changedFileList(currentFiles, bundle.instructions.files), + }); + + const shouldWrite = + mode === "reset" + || currentState.stockStatus === "missing" + || currentState.stockStatus === "stock_update_available"; + if (!shouldWrite) { + if (!binding && currentHash === stock) { + await upsertManagedResourceBinding({ + companyId: agent.companyId, + bundleKey: definition.key, + resourceKind: "instructions", + resourceKey: "AGENTS.md", + resourceId: agent.id, + stockVersion: bundle.stockVersion, + stockHash: stock, + defaultsJson: { + entryFile: bundle.instructions.entryFile, + files: Object.keys(bundle.instructions.files), + }, + }); + } + return currentState; + } + + const materialized = await instructionsSvc.materializeManagedBundle(agent, bundle.instructions.files, { + entryFile: bundle.instructions.entryFile, + replaceExisting: true, + clearLegacyPromptTemplate: true, + }); + const updated = await agentSvc.update(agent.id, { + adapterConfig: materialized.adapterConfig, + }, { + allowBuiltInAgentMetadata: true, + recordRevision: { source: `built-in-bundle:${mode}:instructions` }, + }); + if (!updated) throw notFound("Built-in agent not found"); + await upsertManagedResourceBinding({ + companyId: agent.companyId, + bundleKey: definition.key, + resourceKind: "instructions", + resourceKey: "AGENTS.md", + resourceId: agent.id, + stockVersion: bundle.stockVersion, + stockHash: stock, + defaultsJson: { + entryFile: bundle.instructions.entryFile, + files: Object.keys(bundle.instructions.files), + }, + }); + return stockState({ + resourceKind: "instructions", + resourceKey: "AGENTS.md", + resourceId: agent.id, + stockVersion: bundle.stockVersion, + latestStockHash: stock, + currentHash: stock, + bindingStockHash: stock, + }); + } + + async function getCurrentSkillFiles(companyId: string, skill: CompanySkill | null, bundle: BuiltInAgentBundleDefinition) { + const currentFiles: Record = {}; + const stockFiles = bundle.skill.files; + for (const packagePath of Object.keys(stockFiles)) { + const relativePath = packagePath.split("/").slice(1).join("/") || "SKILL.md"; + if (!skill) { + currentFiles[packagePath] = null; + continue; + } + if (relativePath === "SKILL.md") { + currentFiles[packagePath] = skill.markdown; + continue; + } + try { + currentFiles[packagePath] = (await skillSvc.readFile(companyId, skill.id, relativePath))?.content ?? null; + } catch { + currentFiles[packagePath] = null; + } + } + return currentFiles; + } + + async function importBundledSkill(companyId: string, definition: BuiltInAgentDefinition) { + const results = await skillSvc.importPackageFiles(companyId, definition.bundle!.skill.files, { onConflict: "replace" }); + const imported = results.find((result) => result.skill.key === definition.bundle!.skill.canonicalKey)?.skill + ?? results[0]?.skill + ?? await skillSvc.getByKey(companyId, definition.bundle!.skill.canonicalKey); + if (!imported) throw notFound("Built-in bundled skill was not imported"); + return imported; + } + + async function syncBundledSkillToAgent(agent: Agent, skill: CompanySkill) { + const desired = readPaperclipSkillSyncPreference(agent.adapterConfig as Record).desiredSkillEntries; + const nextDesired = [ + ...desired.filter((entry) => entry.key !== skill.key), + { key: skill.key, versionId: skill.currentVersionId ?? null }, + ]; + const adapterConfig = writePaperclipSkillSyncPreference(agent.adapterConfig as Record, nextDesired); + const updated = await agentSvc.update(agent.id, { adapterConfig }, { + allowBuiltInAgentMetadata: true, + recordRevision: { source: "built-in-bundle:skill-sync" }, + }); + if (!updated) throw notFound("Built-in agent not found"); + return updated as Agent; + } + + async function materializeSkill(agent: Agent, definition: BuiltInAgentDefinition, mode: "reconcile" | "reset") { + const bundle = definition.bundle!; + const stock = stockHash(bundle.skill.files); + const binding = await getManagedResourceBinding(agent.companyId, definition.key, "skill", bundle.skill.skillKey); + const boundSkill = binding ? await skillSvc.getById(agent.companyId, binding.resourceId) : null; + const existingByKey = await skillSvc.getByKey(agent.companyId, bundle.skill.canonicalKey); + const skill = boundSkill ?? existingByKey; + const currentFiles = await getCurrentSkillFiles(agent.companyId, skill, bundle); + const currentHash = skill && Object.values(currentFiles).every((value) => value !== null) + ? stockHash(currentFiles) + : null; + const currentState = stockState({ + resourceKind: "skill", + resourceKey: bundle.skill.skillKey, + resourceId: skill?.id ?? null, + stockVersion: bundle.stockVersion, + latestStockHash: stock, + currentHash, + bindingStockHash: binding?.stockHash ?? null, + changedFiles: changedFileList(currentFiles, bundle.skill.files), + }); + + const shouldWrite = + mode === "reset" + || currentState.stockStatus === "missing" + || currentState.stockStatus === "stock_update_available"; + const nextSkill = shouldWrite ? await importBundledSkill(agent.companyId, definition) : skill!; + await upsertManagedResourceBinding({ + companyId: agent.companyId, + bundleKey: definition.key, + resourceKind: "skill", + resourceKey: bundle.skill.skillKey, + resourceId: nextSkill.id, + stockVersion: bundle.stockVersion, + stockHash: shouldWrite ? stock : binding?.stockHash ?? stock, + defaultsJson: { + canonicalKey: bundle.skill.canonicalKey, + slug: bundle.skill.slug, + files: Object.keys(bundle.skill.files), + }, + }); + await syncBundledSkillToAgent(agent, nextSkill); + return stockState({ + resourceKind: "skill", + resourceKey: bundle.skill.skillKey, + resourceId: nextSkill.id, + stockVersion: bundle.stockVersion, + latestStockHash: stock, + currentHash: shouldWrite ? stock : currentHash, + bindingStockHash: shouldWrite ? stock : binding?.stockHash ?? stock, + changedFiles: shouldWrite ? [] : changedFileList(currentFiles, bundle.skill.files), + }); + } + + function routineDefaultsHash(routine: BuiltInAgentBundleDefinition["routine"], triggers: BuiltInAgentBundleDefinition["routine"]["triggers"]) { + return stockHash({ + title: routine.title, + description: routine.description, + status: routine.status, + priority: routine.priority, + concurrencyPolicy: routine.concurrencyPolicy, + catchUpPolicy: routine.catchUpPolicy, + variables: routine.variables, + triggers: triggers.map((trigger) => ({ + kind: trigger.kind, + label: trigger.label, + enabled: trigger.enabled, + cronExpression: trigger.cronExpression, + timezone: trigger.timezone, + })), + }); + } + + async function getRoutineByBinding(companyId: string, definition: BuiltInAgentDefinition) { + const binding = await getManagedResourceBinding(companyId, definition.key, "routine", definition.bundle!.routine.routineKey); + const routine = binding + ? await db + .select() + .from(routines) + .where(and(eq(routines.companyId, companyId), eq(routines.id, binding.resourceId))) + .then((rows) => rows[0] as Routine | undefined ?? null) + : await db + .select() + .from(routines) + .where(and( + eq(routines.companyId, companyId), + eq(routines.originKind, "built_in_agent_bundle"), + eq(routines.originId, `${definition.key}:${definition.bundle!.routine.routineKey}`), + )) + .then((rows) => rows[0] as Routine | undefined ?? null); + const triggers = routine + ? await db + .select() + .from(routineTriggers) + .where(eq(routineTriggers.routineId, routine.id)) + .then((rows) => rows as RoutineTrigger[]) + : []; + return { binding, routine, triggers }; + } + + async function createOrResetRoutine(agent: Agent, definition: BuiltInAgentDefinition, existing: Routine | null, mode: "reconcile" | "reset") { + const routine = definition.bundle!.routine; + const actor = { agentId: null, userId: "built-in-bundles" }; + const nextRoutine = existing + ? await routineSvc.update(existing.id, { + title: routine.title, + description: routine.description, + assigneeAgentId: agent.id, + priority: routine.priority, + status: routine.status, + concurrencyPolicy: routine.concurrencyPolicy, + catchUpPolicy: routine.catchUpPolicy, + variables: routine.variables, + }, actor) + : await routineSvc.create(agent.companyId, { + title: routine.title, + description: routine.description, + assigneeAgentId: agent.id, + priority: routine.priority, + status: routine.status, + concurrencyPolicy: routine.concurrencyPolicy, + catchUpPolicy: routine.catchUpPolicy, + variables: routine.variables, + }, actor); + if (!nextRoutine) throw notFound("Built-in routine not found"); + await db + .update(routines) + .set({ + originKind: "built_in_agent_bundle", + originId: `${definition.key}:${routine.routineKey}`, + updatedAt: new Date(), + }) + .where(eq(routines.id, nextRoutine.id)); + + const currentTriggers = await db + .select() + .from(routineTriggers) + .where(eq(routineTriggers.routineId, nextRoutine.id)) + .then((rows) => rows as RoutineTrigger[]); + const firstSchedule = currentTriggers.find((trigger) => trigger.kind === "schedule"); + const stockTrigger = routine.triggers[0]; + if (stockTrigger && firstSchedule) { + await routineSvc.updateTrigger(firstSchedule.id, { + label: stockTrigger.label, + enabled: stockTrigger.enabled, + cronExpression: stockTrigger.cronExpression, + timezone: stockTrigger.timezone, + }, actor); + } else if (stockTrigger) { + await routineSvc.createTrigger(nextRoutine.id, { + kind: "schedule", + label: stockTrigger.label, + enabled: stockTrigger.enabled, + cronExpression: stockTrigger.cronExpression, + timezone: stockTrigger.timezone, + }, actor); + } + await logActivity(db, { + companyId: agent.companyId, + actorType: "system", + actorId: "built-in-bundles", + action: mode === "reset" ? "built_in_agent.routine_reset" : "built_in_agent.routine_reconciled", + entityType: "routine", + entityId: nextRoutine.id, + details: { + key: definition.key, + routineKey: routine.routineKey, + status: routine.status, + }, + }); + return nextRoutine; + } + + async function materializeRoutine(agent: Agent, definition: BuiltInAgentDefinition, mode: "reconcile" | "reset") { + const bundle = definition.bundle!; + const stock = routineDefaultsHash(bundle.routine, bundle.routine.triggers); + const { binding, routine, triggers } = await getRoutineByBinding(agent.companyId, definition); + const currentHash = routine ? routineDefaultsHash({ + ...bundle.routine, + title: routine.title, + description: routine.description ?? "", + status: routine.status as "active" | "paused", + priority: routine.priority as "critical" | "high" | "medium" | "low", + concurrencyPolicy: routine.concurrencyPolicy as "always_enqueue" | "coalesce_if_active" | "skip_if_active", + catchUpPolicy: routine.catchUpPolicy as "enqueue_missed_with_cap" | "skip_missed", + variables: routine.variables ?? [], + }, triggers.filter((trigger) => trigger.kind === "schedule").map((trigger) => ({ + kind: "schedule" as const, + label: trigger.label, + enabled: trigger.enabled, + cronExpression: trigger.cronExpression ?? "", + timezone: trigger.timezone ?? "UTC", + }))) : null; + const currentState = stockState({ + resourceKind: "routine", + resourceKey: bundle.routine.routineKey, + resourceId: routine?.id ?? null, + stockVersion: bundle.stockVersion, + latestStockHash: stock, + currentHash, + bindingStockHash: binding?.stockHash ?? null, + }); + const shouldWrite = + mode === "reset" + || currentState.stockStatus === "missing" + || currentState.stockStatus === "stock_update_available"; + const nextRoutine = shouldWrite + ? await createOrResetRoutine(agent, definition, routine, mode) + : routine!; + await upsertManagedResourceBinding({ + companyId: agent.companyId, + bundleKey: definition.key, + resourceKind: "routine", + resourceKey: bundle.routine.routineKey, + resourceId: nextRoutine.id, + stockVersion: bundle.stockVersion, + stockHash: shouldWrite ? stock : binding?.stockHash ?? stock, + defaultsJson: { + title: bundle.routine.title, + status: bundle.routine.status, + triggerCount: bundle.routine.triggers.length, + }, + }); + return stockState({ + resourceKind: "routine", + resourceKey: bundle.routine.routineKey, + resourceId: nextRoutine.id, + stockVersion: bundle.stockVersion, + latestStockHash: stock, + currentHash: shouldWrite ? stock : currentHash, + bindingStockHash: shouldWrite ? stock : binding?.stockHash ?? stock, + }); + } + + async function bundleResourceStates(companyId: string, definition: BuiltInAgentDefinition, agent: Agent | null) { + if (!definition.bundle || !agent) return []; + const bundle = definition.bundle; + const [instructionBinding, skillBinding, routineBinding] = await Promise.all([ + getManagedResourceBinding(companyId, definition.key, "instructions", "AGENTS.md"), + getManagedResourceBinding(companyId, definition.key, "skill", bundle.skill.skillKey), + getManagedResourceBinding(companyId, definition.key, "routine", bundle.routine.routineKey), + ]); + const instructionFiles = await currentInstructionFiles(agent, bundle); + const skill = skillBinding + ? await skillSvc.getById(companyId, skillBinding.resourceId) + : await skillSvc.getByKey(companyId, bundle.skill.canonicalKey); + const skillFiles = await getCurrentSkillFiles(companyId, skill, bundle); + const { routine, triggers } = await getRoutineByBinding(companyId, definition); + const instructionHash = Object.values(instructionFiles).every((value) => value !== null) + ? stockHash(instructionFiles) + : null; + const skillHash = skill && Object.values(skillFiles).every((value) => value !== null) + ? stockHash(skillFiles) + : null; + const routineHash = routine ? routineDefaultsHash({ + ...bundle.routine, + title: routine.title, + description: routine.description ?? "", + status: routine.status as "active" | "paused", + priority: routine.priority as "critical" | "high" | "medium" | "low", + concurrencyPolicy: routine.concurrencyPolicy as "always_enqueue" | "coalesce_if_active" | "skip_if_active", + catchUpPolicy: routine.catchUpPolicy as "enqueue_missed_with_cap" | "skip_missed", + variables: routine.variables ?? [], + }, triggers.filter((trigger) => trigger.kind === "schedule").map((trigger) => ({ + kind: "schedule" as const, + label: trigger.label, + enabled: trigger.enabled, + cronExpression: trigger.cronExpression ?? "", + timezone: trigger.timezone ?? "UTC", + }))) : null; + return [ + stockState({ + resourceKind: "instructions", + resourceKey: "AGENTS.md", + resourceId: agent.id, + stockVersion: bundle.stockVersion, + latestStockHash: stockHash(bundle.instructions.files), + currentHash: instructionHash, + bindingStockHash: instructionBinding?.stockHash ?? null, + changedFiles: changedFileList(instructionFiles, bundle.instructions.files), + }), + stockState({ + resourceKind: "skill", + resourceKey: bundle.skill.skillKey, + resourceId: skill?.id ?? null, + stockVersion: bundle.stockVersion, + latestStockHash: stockHash(bundle.skill.files), + currentHash: skillHash, + bindingStockHash: skillBinding?.stockHash ?? null, + changedFiles: changedFileList(skillFiles, bundle.skill.files), + }), + stockState({ + resourceKind: "routine", + resourceKey: bundle.routine.routineKey, + resourceId: routine?.id ?? null, + stockVersion: bundle.stockVersion, + latestStockHash: routineDefaultsHash(bundle.routine, bundle.routine.triggers), + currentHash: routineHash, + bindingStockHash: routineBinding?.stockHash ?? null, + }), + ]; + } + + async function reconcileBundleResources( + agent: Agent, + definition: BuiltInAgentDefinition, + mode: "reconcile" | "reset", + resources?: Array<"instructions" | "skill" | "routine">, + ) { + if (!definition.bundle) return []; + const selected = new Set(resources ?? ["instructions", "skill", "routine"]); + const existingStates = await bundleResourceStates(agent.companyId, definition, agent); + const byKind = new Map(existingStates.map((state) => [state.resourceKind, state])); + const instruction = selected.has("instructions") + ? await materializeInstructions(agent, definition, mode) + : byKind.get("instructions")!; + const refreshedAgent = await agentSvc.getById(agent.id) as Agent | null; + if (!refreshedAgent) throw notFound("Built-in agent not found"); + const skill = selected.has("skill") + ? await materializeSkill(refreshedAgent, definition, mode) + : byKind.get("skill")!; + const refreshedAfterSkill = await agentSvc.getById(agent.id) as Agent | null; + if (!refreshedAfterSkill) throw notFound("Built-in agent not found"); + const routine = selected.has("routine") + ? await materializeRoutine(refreshedAfterSkill, definition, mode) + : byKind.get("routine")!; + return [instruction, skill, routine]; + } async function ensureCompany(companyId: string) { const company = await db @@ -289,13 +1120,18 @@ export function builtInAgentService(db: Db) { return agent as Agent | null; } - function state(definition: BuiltInAgentDefinition, agent: Agent | null): BuiltInAgentState { + async function state( + definition: BuiltInAgentDefinition, + agent: Agent | null, + resources?: BuiltInManagedResourceState[], + ): Promise { return { definition, status: deriveBuiltInAgentStatus(agent), agentId: agent?.id ?? null, agent, pauseReason: agent?.pauseReason ?? null, + resources: resources ?? await bundleResourceStates(agent?.companyId ?? "", definition, agent), }; } @@ -308,34 +1144,42 @@ export function builtInAgentService(db: Db) { async function ensure(companyId: string, key: string, input: BuiltInAgentProvisionInput = {}) { const definition = requireBuiltInAgentDefinition(key); await ensureCompany(companyId); + const resolvedInput = await defaultProvisionInput(companyId, definition, input); 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; + if (resolvedInput.adapterType !== undefined || resolvedInput.adapterConfig !== undefined) { + const adapterType = resolvedInput.adapterType ?? existing.adapterType; assertAdapterAllowed(definition, adapterType); patch.adapterType = adapterType; - patch.adapterConfig = input.adapterConfig ?? existing.adapterConfig; + patch.adapterConfig = resolvedInput.adapterConfig ?? existing.adapterConfig; } - if (input.budgetMonthlyCents !== undefined) { - patch.budgetMonthlyCents = input.budgetMonthlyCents; + if (resolvedInput.budgetMonthlyCents !== undefined) { + patch.budgetMonthlyCents = resolvedInput.budgetMonthlyCents; } 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 resources = await reconcileBundleResources(updated as Agent, definition, "reconcile"); + return state(definition, await agentSvc.getById(existing.id) as Agent, resources); } + const reportsTo = definition.defaultManager === "single_root_agent" + ? await findSingleRootManager(companyId) + : null; const created = await agentSvc.create(companyId, { - ...definitionPatch(definition, input), - status: "idle", + ...definitionPatch(definition, resolvedInput), + status: definition.defaultStatus ?? "idle", + pauseReason: definition.defaultStatus === "paused" ? "Built-in Reflection Coach is disabled until explicitly configured." : null, + pausedAt: definition.defaultStatus === "paused" ? new Date() : null, + reportsTo, metadata: builtInMetadata(definition), runtimeConfig: {}, - permissions: {}, + permissions: definition.defaultPermissions ?? {}, spentMonthlyCents: 0, lastHeartbeatAt: null, }, { allowBuiltInAgentMetadata: true }) as Agent; @@ -354,7 +1198,8 @@ export function builtInAgentService(db: Db) { }, }); - return state(definition, created); + const resources = await reconcileBundleResources(created, definition, "reconcile"); + return state(definition, await agentSvc.getById(created.id) as Agent, resources); } async function provision( @@ -381,7 +1226,7 @@ export function builtInAgentService(db: Db) { } const approval = await approvalSvc.findOpenHireApprovalForAgent(companyId, existing.id); return { - state: state(definition, existing), + state: await state(definition, existing), approval: approval as Approval | null, }; } @@ -394,7 +1239,7 @@ export function builtInAgentService(db: Db) { }); } - return { state: state(definition, existing), approval: null }; + return { state: await state(definition, existing), approval: null }; } const pending = await agentSvc.create(companyId, { @@ -434,7 +1279,7 @@ export function builtInAgentService(db: Db) { updatedAt: new Date(), }) as Approval; - return { state: state(definition, pending), approval }; + return { state: await state(definition, pending), approval }; } async function list(companyId: string) { @@ -450,7 +1295,8 @@ export function builtInAgentService(db: Db) { const patch = { name: definition.displayName, role: definition.defaultRole, - title: null, + title: definition.defaultTitle ?? null, + icon: definition.defaultIcon ?? null, capabilities: definition.shortPurpose, metadata: builtInMetadata(definition, existing.metadata), }; @@ -462,8 +1308,23 @@ export function builtInAgentService(db: Db) { return state(definition, updated as Agent); } - async function reset(companyId: string, key: string) { - return reconcileDefinitionDefaults(companyId, key); + async function reset(companyId: string, key: string, input: { resources?: Array<"agent" | "instructions" | "skill" | "routine"> } = {}) { + const definition = requireBuiltInAgentDefinition(key); + const resetAgentDefaults = !input.resources || input.resources.includes("agent"); + const current = resetAgentDefaults + ? await reconcileDefinitionDefaults(companyId, key) + : await get(companyId, key); + if (!current.agent || !definition.bundle) return current; + const selectedBundleResources = input.resources?.filter( + (resource): resource is "instructions" | "skill" | "routine" => resource !== "agent", + ); + const resources = await reconcileBundleResources( + current.agent, + definition, + "reset", + input.resources ? selectedBundleResources ?? [] : undefined, + ); + return state(definition, await agentSvc.getById(current.agent.id) as Agent, resources); } async function requireBuiltInAgent(companyId: string, key: string): Promise { @@ -502,6 +1363,16 @@ export function builtInAgentService(db: Db) { export async function reconcileBuiltInAgentsOnStartup(db: Db) { const svc = builtInAgentService(db); + const companyRows = await db + .select({ id: companies.id }) + .from(companies); + let autoEnsured = 0; + for (const company of companyRows) { + for (const definition of DEFINITIONS.filter((entry) => entry.bundle)) { + await svc.ensure(company.id, definition.key); + autoEnsured += 1; + } + } const rows = await db .select({ companyId: agents.companyId, @@ -534,5 +1405,5 @@ export async function reconcileBuiltInAgentsOnStartup(db: Db) { reconciled += 1; } - return { scanned, reconciled, unknown, duplicates }; + return { scanned, reconciled, unknown, duplicates, autoEnsured }; } diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 8e59a53f3a4..43524a5642d 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -12,6 +12,8 @@ export { reconcileBuiltInAgentsOnStartup, validateBuiltInAgentDefinitions, type BuiltInAgentDefinition, + type BuiltInManagedResourceState, + type BuiltInManagedResourceStockStatus, type BuiltInAgentState, type BuiltInAgentStatus, } from "./built-in-agents.js";