Implement Reflection Coach built-in bundle server support

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-09 20:50:34 +00:00
1 parent a56d05ea41
commit 752b4a32a5
11 files changed
+1253 -37

No files matched your search

@@ -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");
@@ -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<Record<string, unknown>>().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,
),
}),
);
+1
View File
@@ -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";
+3
View File
@@ -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,
+10
View File
@@ -96,6 +96,16 @@ export const builtInAgentProvisionSchema = z.object({
export type BuiltInAgentProvision = z.infer<typeof builtInAgentProvisionSchema>;
export const builtInAgentEmptyMutationSchema = z.object({}).strict().default({});
export type BuiltInAgentEmptyMutation = z.infer<typeof builtInAgentEmptyMutationSchema>;
export const builtInAgentResetSchema = z.object({
resources: z.array(z.enum(["agent", "instructions", "skill", "routine"])).optional(),
}).strict().default({});
export type BuiltInAgentReset = z.infer<typeof builtInAgentResetSchema>;
export const createAgentHireSchema = createAgentSchema.extend({
sourceIssueId: z.string().uuid().optional().nullable(),
sourceIssueIds: z.array(z.string().uuid()).optional(),
+3
View File
@@ -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,
+225 -2
View File
@@ -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<string, unknown>).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<HttpError>);
});
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");
});
});
+2 -7
View File
@@ -780,14 +780,9 @@ export async function startServer(): Promise<StartedServer> {
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",
+58 -7
View File
@@ -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;
File diff suppressed because it is too large. Load diff
+2
View File
@@ -12,6 +12,8 @@ export {
reconcileBuiltInAgentsOnStartup,
validateBuiltInAgentDefinitions,
type BuiltInAgentDefinition,
type BuiltInManagedResourceState,
type BuiltInManagedResourceStockStatus,
type BuiltInAgentState,
type BuiltInAgentStatus,
} from "./built-in-agents.js";