From 0bb1ee3caa07e5eec4a05bcd7fcbb4b02075221e Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 16:05:39 -0500 Subject: [PATCH] Recover agent instructions from disk Co-Authored-By: Paperclip --- .../agent-instructions-routes.test.ts | 38 +++++++++ .../agent-instructions-service.test.ts | 84 +++++++++++++++++++ server/src/routes/agents.ts | 8 +- server/src/services/agent-instructions.ts | 44 +++++++++- 4 files changed, 169 insertions(+), 5 deletions(-) diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index f47f8dcc..99d6061d 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -236,4 +236,42 @@ describe("agent instructions bundle routes", () => { expect.any(Object), ); }); + + it("merges same-adapter config patches so instructions metadata is not dropped", async () => { + mockAgentService.getById.mockResolvedValue({ + ...makeAgent(), + adapterType: "codex_local", + adapterConfig: { + instructionsBundleMode: "managed", + instructionsRootPath: "/tmp/agent-1", + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: "/tmp/agent-1/AGENTS.md", + model: "gpt-5.4", + }, + }); + + const res = await request(createApp()) + .patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1") + .send({ + adapterConfig: { + command: "codex --profile engineer", + }, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAgentService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + adapterConfig: expect.objectContaining({ + command: "codex --profile engineer", + model: "gpt-5.4", + instructionsBundleMode: "managed", + instructionsRootPath: "/tmp/agent-1", + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: "/tmp/agent-1/AGENTS.md", + }), + }), + expect.any(Object), + ); + }); }); diff --git a/server/src/__tests__/agent-instructions-service.test.ts b/server/src/__tests__/agent-instructions-service.test.ts index a0731ee6..cdd98aa2 100644 --- a/server/src/__tests__/agent-instructions-service.test.ts +++ b/server/src/__tests__/agent-instructions-service.test.ts @@ -192,4 +192,88 @@ describe("agent instructions service", () => { expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]); expect(exported.files).toEqual({ "AGENTS.md": "# Recovered Agent\n" }); }); + + it("prefers the managed bundle on disk when managed metadata points at a stale root", async () => { + const paperclipHome = await makeTempDir("paperclip-agent-instructions-stale-managed-"); + const staleRoot = await makeTempDir("paperclip-agent-instructions-stale-root-"); + cleanupDirs.add(paperclipHome); + cleanupDirs.add(staleRoot); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "test-instance"; + + const managedRoot = path.join( + paperclipHome, + "instances", + "test-instance", + "companies", + "company-1", + "agents", + "agent-1", + "instructions", + ); + await fs.mkdir(managedRoot, { recursive: true }); + await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8"); + + const svc = agentInstructionsService(); + const agent = makeAgent({ + instructionsBundleMode: "managed", + instructionsRootPath: staleRoot, + instructionsEntryFile: "docs/MISSING.md", + instructionsFilePath: path.join(staleRoot, "docs", "MISSING.md"), + }); + + const bundle = await svc.getBundle(agent); + const exported = await svc.exportFiles(agent); + + expect(bundle.mode).toBe("managed"); + expect(bundle.rootPath).toBe(managedRoot); + expect(bundle.entryFile).toBe("AGENTS.md"); + expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]); + expect(bundle.warnings).toEqual([ + `Recovered managed instructions from disk at ${managedRoot}; ignoring stale configured root ${staleRoot}.`, + "Recovered managed instructions entry file from disk as AGENTS.md; previous entry docs/MISSING.md was missing.", + ]); + expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" }); + }); + + it("recovers the managed bundle when stale root metadata is present but mode is missing", async () => { + const paperclipHome = await makeTempDir("paperclip-agent-instructions-partial-managed-"); + const staleRoot = await makeTempDir("paperclip-agent-instructions-partial-root-"); + cleanupDirs.add(paperclipHome); + cleanupDirs.add(staleRoot); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "test-instance"; + + const managedRoot = path.join( + paperclipHome, + "instances", + "test-instance", + "companies", + "company-1", + "agents", + "agent-1", + "instructions", + ); + await fs.mkdir(managedRoot, { recursive: true }); + await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8"); + + const svc = agentInstructionsService(); + const agent = makeAgent({ + instructionsRootPath: staleRoot, + instructionsEntryFile: "docs/MISSING.md", + }); + + const bundle = await svc.getBundle(agent); + const exported = await svc.exportFiles(agent); + + expect(bundle.mode).toBe("managed"); + expect(bundle.rootPath).toBe(managedRoot); + expect(bundle.entryFile).toBe("AGENTS.md"); + expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]); + expect(bundle.warnings).toEqual([ + `Recovered managed instructions from disk at ${managedRoot}; ignoring stale configured root ${staleRoot}.`, + "Recovered managed instructions entry file from disk as AGENTS.md; previous entry docs/MISSING.md was missing.", + ]); + expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" }); + }); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 5621d259..2769a1d1 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1727,9 +1727,13 @@ export function agentRoutes(db: Db) { const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {}; const changingAdapterType = typeof patchData.adapterType === "string" && patchData.adapterType !== existing.adapterType; - let rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") + const requestedAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") ? (asRecord(patchData.adapterConfig) ?? {}) - : existingAdapterConfig; + : null; + let rawEffectiveAdapterConfig = requestedAdapterConfig ?? existingAdapterConfig; + if (requestedAdapterConfig && !changingAdapterType) { + rawEffectiveAdapterConfig = { ...existingAdapterConfig, ...requestedAdapterConfig }; + } if (changingAdapterType) { rawEffectiveAdapterConfig = preserveInstructionsBundleConfig( existingAdapterConfig, diff --git a/server/src/services/agent-instructions.ts b/server/src/services/agent-instructions.ts index 1e1a5b32..9b4238d4 100644 --- a/server/src/services/agent-instructions.ts +++ b/server/src/services/agent-instructions.ts @@ -273,8 +273,6 @@ function deriveBundleState(agent: AgentLike): BundleState { } async function recoverManagedBundleState(agent: AgentLike, state: BundleState): Promise { - if (state.rootPath) return state; - const managedRootPath = resolveManagedInstructionsRoot(agent); const stat = await statIfExists(managedRootPath); if (!stat?.isDirectory()) return state; @@ -282,11 +280,51 @@ async function recoverManagedBundleState(agent: AgentLike, state: BundleState): const files = await listFilesRecursive(managedRootPath); if (files.length === 0) return state; + const recoveredEntryFile = files.includes(state.entryFile) + ? state.entryFile + : files.includes(ENTRY_FILE_DEFAULT) + ? ENTRY_FILE_DEFAULT + : files[0]!; + + if (!state.rootPath) { + return { + ...state, + mode: "managed", + rootPath: managedRootPath, + entryFile: recoveredEntryFile, + resolvedEntryPath: path.resolve(managedRootPath, recoveredEntryFile), + }; + } + + if (state.mode === "external") return state; + + const resolvedConfiguredRoot = path.resolve(state.rootPath); + const configuredRootMatchesManaged = resolvedConfiguredRoot === managedRootPath; + const hasEntryMismatch = recoveredEntryFile !== state.entryFile; + + if (configuredRootMatchesManaged && !hasEntryMismatch) { + return state; + } + + const warnings = [...state.warnings]; + if (!configuredRootMatchesManaged) { + warnings.push( + `Recovered managed instructions from disk at ${managedRootPath}; ignoring stale configured root ${state.rootPath}.`, + ); + } + if (hasEntryMismatch) { + warnings.push( + `Recovered managed instructions entry file from disk as ${recoveredEntryFile}; previous entry ${state.entryFile} was missing.`, + ); + } + return { ...state, mode: "managed", rootPath: managedRootPath, - resolvedEntryPath: path.resolve(managedRootPath, state.entryFile), + entryFile: recoveredEntryFile, + resolvedEntryPath: path.resolve(managedRootPath, recoveredEntryFile), + warnings, }; }