Recover agent instructions from disk
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
3b2cb3a699
commit
0bb1ee3caa
4 changed files with 169 additions and 5 deletions
|
|
@ -236,4 +236,42 @@ describe("agent instructions bundle routes", () => {
|
||||||
expect.any(Object),
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -192,4 +192,88 @@ describe("agent instructions service", () => {
|
||||||
expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
|
expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
|
||||||
expect(exported.files).toEqual({ "AGENTS.md": "# Recovered Agent\n" });
|
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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1727,9 +1727,13 @@ export function agentRoutes(db: Db) {
|
||||||
const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {};
|
const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {};
|
||||||
const changingAdapterType =
|
const changingAdapterType =
|
||||||
typeof patchData.adapterType === "string" && patchData.adapterType !== existing.adapterType;
|
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) ?? {})
|
? (asRecord(patchData.adapterConfig) ?? {})
|
||||||
: existingAdapterConfig;
|
: null;
|
||||||
|
let rawEffectiveAdapterConfig = requestedAdapterConfig ?? existingAdapterConfig;
|
||||||
|
if (requestedAdapterConfig && !changingAdapterType) {
|
||||||
|
rawEffectiveAdapterConfig = { ...existingAdapterConfig, ...requestedAdapterConfig };
|
||||||
|
}
|
||||||
if (changingAdapterType) {
|
if (changingAdapterType) {
|
||||||
rawEffectiveAdapterConfig = preserveInstructionsBundleConfig(
|
rawEffectiveAdapterConfig = preserveInstructionsBundleConfig(
|
||||||
existingAdapterConfig,
|
existingAdapterConfig,
|
||||||
|
|
|
||||||
|
|
@ -273,8 +273,6 @@ function deriveBundleState(agent: AgentLike): BundleState {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recoverManagedBundleState(agent: AgentLike, state: BundleState): Promise<BundleState> {
|
async function recoverManagedBundleState(agent: AgentLike, state: BundleState): Promise<BundleState> {
|
||||||
if (state.rootPath) return state;
|
|
||||||
|
|
||||||
const managedRootPath = resolveManagedInstructionsRoot(agent);
|
const managedRootPath = resolveManagedInstructionsRoot(agent);
|
||||||
const stat = await statIfExists(managedRootPath);
|
const stat = await statIfExists(managedRootPath);
|
||||||
if (!stat?.isDirectory()) return state;
|
if (!stat?.isDirectory()) return state;
|
||||||
|
|
@ -282,11 +280,51 @@ async function recoverManagedBundleState(agent: AgentLike, state: BundleState):
|
||||||
const files = await listFilesRecursive(managedRootPath);
|
const files = await listFilesRecursive(managedRootPath);
|
||||||
if (files.length === 0) return state;
|
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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
mode: "managed",
|
mode: "managed",
|
||||||
rootPath: managedRootPath,
|
rootPath: managedRootPath,
|
||||||
resolvedEntryPath: path.resolve(managedRootPath, state.entryFile),
|
entryFile: recoveredEntryFile,
|
||||||
|
resolvedEntryPath: path.resolve(managedRootPath, recoveredEntryFile),
|
||||||
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue