diff --git a/server/src/__tests__/plugin-managed-agents.test.ts b/server/src/__tests__/plugin-managed-agents.test.ts index b800680b907..547080c406e 100644 --- a/server/src/__tests__/plugin-managed-agents.test.ts +++ b/server/src/__tests__/plugin-managed-agents.test.ts @@ -26,6 +26,7 @@ import { import { buildHostServices } from "../services/plugin-host-services.js"; import { agentService } from "../services/agents.js"; import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js"; +import { agentInstructionsService } from "../services/agent-instructions.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -399,6 +400,18 @@ describeEmbeddedPostgres("plugin-managed agents", () => { const audit = await db.select().from(activityLog).where(eq(activityLog.action, "agent.instructions_revision_committed")); expect(audit).toHaveLength(2); expect(audit.every((row) => row.actorType === "plugin" && row.actorId === pluginId)).toBe(true); + const bundles = agentInstructionsService(db); + await bundles.writeFile(repeated.agent!, "CUSTOM.md", "# Alternate configured entry\n"); + const switched = await bundles.updateBundle(repeated.agent!, { entryFile: "CUSTOM.md" }); + await agentService(db).update(created.agentId!, { adapterConfig: switched.adapterConfig }); + const resetEntry = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" }); + expect(resetEntry.agent?.adapterConfig.instructionsEntryFile).toBe("AGENTS.md"); + expect(resetEntry.defaultDrift).toBeNull(); + expect(await fs.readFile(instructionsFilePath as string, "utf8")).toBe(content); + const preserved = await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, created.agentId!)); + expect(preserved).toHaveLength(3); + expect(Buffer.from(preserved.find((row) => row.entryFile === "CUSTOM.md")!.contentBase64, "base64").toString()).toBe("# Alternate configured entry\n"); + } finally { if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; @@ -422,8 +435,10 @@ describeEmbeddedPostgres("plugin-managed agents", () => { const target = { companyId, agentId: created.agentId! }; const actor = { type: "plugin" as const, pluginId, pluginKey: pluginManifest.id, agentKey: "wiki-maintainer" }; const revisions = agentInstructionRevisionService(db); - const baseline = (await revisions.readForPluginReset(target, actor))!; - const input = { ...target, entryFile: "AGENTS.md", baseRevisionId: baseline.revision.id, content: "# Reset\n" }; + const baselineRead = await revisions.readForPluginReset(target, actor); + const baseline = baselineRead.snapshot!; + const input = { ...target, entryFile: "AGENTS.md", baseRevisionId: baseline.revision.id, + configuredEntryFile: baselineRead.configuredEntryFile, content: "# Reset\n" }; await expect(revisions.commitPluginReset(input, { ...actor, pluginId: randomUUID() })).rejects.toMatchObject({ status: 403 }); await expect(revisions.commitPluginReset({ ...input, companyId: randomUUID() }, actor)).rejects.toMatchObject({ status: 404 }); await expect(revisions.commitPluginReset(input, { ...actor, agentKey: "foreign-agent" })).rejects.toMatchObject({ status: 403 }); @@ -438,6 +453,13 @@ describeEmbeddedPostgres("plugin-managed agents", () => { await db.update(pluginManagedResources).set({ resourceId: randomUUID() }).where(eq(pluginManagedResources.id, binding.id)); await expect(revisions.commitPluginReset(input, actor)).rejects.toMatchObject({ status: 403 }); await db.update(pluginManagedResources).set({ resourceId: target.agentId }).where(eq(pluginManagedResources.id, binding.id)); + await expect(revisions.commitPluginReset({ ...input, entryFile: "OTHER.md" }, actor)).rejects.toMatchObject({ status: 403 }); + const bundles = agentInstructionsService(db); + await bundles.writeFile(created.agent!, "OTHER.md", "# Other entry\n"); + const switched = await bundles.updateBundle(created.agent!, { entryFile: "OTHER.md" }); + await agentService(db).update(target.agentId, { adapterConfig: switched.adapterConfig }); + await expect(revisions.commitPluginReset(input, actor)).rejects.toMatchObject({ status: 409, details: { code: "INSTRUCTION_ENTRY_CHANGED" } }); + await agentService(db).update(target.agentId, { adapterConfig: created.agent!.adapterConfig }); const results = await Promise.allSettled([ revisions.commitPluginReset(input, actor), revisions.commitPluginReset({ ...input, content: "# Concurrent reset\n" }, actor), diff --git a/server/src/services/agent-instruction-revisions.ts b/server/src/services/agent-instruction-revisions.ts index 2809890b7ed..8ceea0e8f20 100644 --- a/server/src/services/agent-instruction-revisions.ts +++ b/server/src/services/agent-instruction-revisions.ts @@ -148,14 +148,17 @@ export function agentInstructionRevisionService(db: Db) { || marker.resourceKind !== "agent" || marker.resourceKey !== actor.agentKey) { throw forbidden("Plugin reset is limited to its declared, bound managed agent"); } - return actor; + const declared = plugin.manifestJson.agents!.find((declaration) => declaration.agentKey === actor.agentKey)!; + return { ...actor, declaredEntryFile: instructionPath(declared.instructions?.entryFile ?? "AGENTS.md") }; } async function readForPluginReset(target: InstructionTarget, actor: PluginResetActor) { return db.transaction(async (tx) => { const state = await lockTarget(tx, target); - await authorizePluginReset(tx, actor, target, state.agent); - const row = await seed(tx, target, state, actor); - return row ? snapshot(row) : null; + const bound = await authorizePluginReset(tx, actor, target, state.agent); + // Preserve the formerly configured entry before an explicit stock reset. + await seed(tx, target, state, bound); + const row = await seed(tx, target, { ...state, entryFile: bound.declaredEntryFile }, bound); + return { snapshot: row ? snapshot(row) : null, configuredEntryFile: state.entryFile }; }); } async function head(tx: Tx, target: InstructionTarget, entryFile: string) { @@ -302,6 +305,7 @@ export function agentInstructionRevisionService(db: Db) { restoreRevisionId: string; }), actor: RevisionActor, + configuredEntryFile?: string, ): Promise { instructionPath(input.entryFile); const bytes = "content" in input ? instructionBytes(input.content) : null; @@ -310,6 +314,11 @@ export function agentInstructionRevisionService(db: Db) { const bound = actor.type === "plugin" ? await authorizePluginReset(tx, actor, input, state.agent) : await authorizeInstructionCommit(tx, actor, state.agent); + if (bound.type === "plugin") { + if (input.entryFile !== bound.declaredEntryFile) throw forbidden("Plugin reset must restore its declared entry file"); + if (configuredEntryFile !== state.entryFile) throw conflict("Configured instruction entry changed; read the current entry and retry", { code: "INSTRUCTION_ENTRY_CHANGED", entryFile: state.entryFile }); + state.entryFile = input.entryFile; + } if (state.entryFile !== input.entryFile) throw conflict( "Configured instruction entry changed; read the current entry and retry", @@ -328,10 +337,33 @@ export function agentInstructionRevisionService(db: Db) { ) : null; const candidate = bytes ?? Buffer.from(restored!.contentBase64, "base64"); + const bindEntry = async () => { + if ( + !state.agent.adapterConfig.instructionsRootPath || + !state.agent.adapterConfig.instructionsBundleMode || + (bound.type === "plugin" && configuredEntryFile !== input.entryFile) + ) { + await tx + .update(agents) + .set({ + adapterConfig: { + ...state.agent.adapterConfig, + instructionsBundleMode: "managed", + instructionsRootPath: state.root, + instructionsEntryFile: state.entryFile, + instructionsFilePath: `${state.root}/${state.entryFile}`, + }, + updatedAt: new Date(), + }) + .where(eq(agents.id, state.agent.id)); + } + }; // An exact replay can safely return the durable receipt even after its base // advanced. Restore also deduplicates identical content, preserving history. - if (current && current.contentBase64 === candidate.toString("base64")) + if (current && current.contentBase64 === candidate.toString("base64")) { + await bindEntry(); return { row: current, changed: false }; + } if ((current?.id ?? null) !== input.baseRevisionId) { throw conflict( "Instructions changed since the base revision. Read the current entry before saving.", @@ -354,24 +386,7 @@ export function agentInstructionRevisionService(db: Db) { input.baseRevisionId, restored?.id ?? null, ); - if ( - !state.agent.adapterConfig.instructionsRootPath || - !state.agent.adapterConfig.instructionsBundleMode - ) { - await tx - .update(agents) - .set({ - adapterConfig: { - ...state.agent.adapterConfig, - instructionsBundleMode: "managed", - instructionsRootPath: state.root, - instructionsEntryFile: state.entryFile, - instructionsFilePath: `${state.root}/${state.entryFile}`, - }, - updatedAt: new Date(), - }) - .where(eq(agents.id, state.agent.id)); - } + await bindEntry(); return { row, changed: true }; }); let materialization: AgentInstructionCommitReceipt["materialization"] = @@ -493,8 +508,8 @@ export function agentInstructionRevisionService(db: Db) { readCommittedForRuntime, readCurrent, readForPluginReset, - commitPluginReset: (input: Omit, actor: PluginResetActor) => - commitInternal({ ...input, source: "api" }, actor), + commitPluginReset: (input: Omit & { configuredEntryFile: string }, actor: PluginResetActor) => + commitInternal({ ...input, source: "api" }, actor, input.configuredEntryFile), readRevision, history, diff, diff --git a/server/src/services/plugin-managed-agents.ts b/server/src/services/plugin-managed-agents.ts index 9700693f6a3..df04321d740 100644 --- a/server/src/services/plugin-managed-agents.ts +++ b/server/src/services/plugin-managed-agents.ts @@ -2,6 +2,7 @@ import { and, eq, isNull, ne } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, + agentInstructionHeads, companies, pluginEntities, pluginManagedResources, @@ -344,7 +345,7 @@ export function pluginManagedAgentService( const actor = { type: "plugin" as const, pluginId: options.pluginId, pluginKey: options.pluginKey, agentKey: declaration.agentKey }; const baseline = await revisions.readForPluginReset(target, actor); const receipt = await revisions.commitPluginReset({ ...target, entryFile: declared.entryFile, - baseRevisionId: baseline?.revision.id ?? null, content: declared.files[declared.entryFile] ?? "" }, actor); + baseRevisionId: baseline.snapshot?.revision.id ?? null, configuredEntryFile: baseline.configuredEntryFile, content: declared.files[declared.entryFile] ?? "" }, actor); if (receipt.materialization === "pending") throw conflict("Instruction revision saved; retry reset to repair its disk copy", { revisionId: receipt.revision.id }); const refreshed = await agentSvc.getById(agent.id); if (!refreshed) throw notFound("Managed agent not found"); @@ -352,8 +353,10 @@ export function pluginManagedAgentService( if (file !== declared.entryFile) await instructions.writeFile(refreshed, file, content); } const bundle = await instructions.getBundle(refreshed); + const historicalEntries = new Set((await db.select({ entryFile: agentInstructionHeads.entryFile }).from(agentInstructionHeads) + .where(and(eq(agentInstructionHeads.companyId, companyId), eq(agentInstructionHeads.agentId, agent.id)))).map((row) => row.entryFile)); for (const file of bundle.files) { - if (!file.isEntryFile && !file.virtual && !(file.path in declared.files)) await instructions.deleteFile(refreshed, file.path); + if (!file.isEntryFile && !file.virtual && !historicalEntries.has(file.path) && !(file.path in declared.files)) await instructions.deleteFile(refreshed, file.path); } adapterConfig = { ...refreshed.adapterConfig }; delete adapterConfig.promptTemplate; @@ -394,8 +397,11 @@ export function pluginManagedAgentService( return { entryFile: declared.entryFile, changedFiles: [declared.entryFile] }; } + const historicalEntries = new Set((await db.select({ entryFile: agentInstructionHeads.entryFile }).from(agentInstructionHeads) + .where(and(eq(agentInstructionHeads.companyId, companyId), eq(agentInstructionHeads.agentId, agent.id)))).map((row) => row.entryFile)); const paths = new Set([...Object.keys(declared.files), ...Object.keys(exported.files)]); const changedFiles = [...paths] + .filter((filePath) => filePath in declared.files || !historicalEntries.has(filePath)) .filter((filePath) => (exported.files[filePath] ?? null) !== (declared.files[filePath] ?? null)) .sort((left, right) => left.localeCompare(right)); if (exported.entryFile !== declared.entryFile && !changedFiles.includes(declared.entryFile)) {