fix: recover managed agent instructions from disk

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-23 07:21:34 -05:00
parent a315838d43
commit 1adfd30b3b
2 changed files with 68 additions and 7 deletions

View file

@ -161,4 +161,35 @@ describe("agent instructions service", () => {
"docs/TOOLS.md",
]);
});
it("recovers a managed bundle from disk when bundle config metadata is missing", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-recover-");
cleanupDirs.add(paperclipHome);
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"), "# Recovered Agent\n", "utf8");
const svc = agentInstructionsService();
const agent = makeAgent({});
const bundle = await svc.getBundle(agent);
const exported = await svc.exportFiles(agent);
expect(bundle.mode).toBe("managed");
expect(bundle.rootPath).toBe(managedRoot);
expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
expect(exported.files).toEqual({ "AGENTS.md": "# Recovered Agent\n" });
});
});

View file

@ -272,6 +272,24 @@ function deriveBundleState(agent: AgentLike): BundleState {
};
}
async function recoverManagedBundleState(agent: AgentLike, state: BundleState): Promise<BundleState> {
if (state.rootPath) return state;
const managedRootPath = resolveManagedInstructionsRoot(agent);
const stat = await statIfExists(managedRootPath);
if (!stat?.isDirectory()) return state;
const files = await listFilesRecursive(managedRootPath);
if (files.length === 0) return state;
return {
...state,
mode: "managed",
rootPath: managedRootPath,
resolvedEntryPath: path.resolve(managedRootPath, state.entryFile),
};
}
function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructionsFileSummary[]): AgentInstructionsBundle {
const nextFiles = [...files];
if (state.legacyPromptTemplateActive && !nextFiles.some((file) => file.path === LEGACY_PROMPT_TEMPLATE_PATH)) {
@ -366,7 +384,7 @@ export function syncInstructionsBundleConfigFromFilePath(
export function agentInstructionsService() {
async function getBundle(agent: AgentLike): Promise<AgentInstructionsBundle> {
const state = deriveBundleState(agent);
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
if (!state.rootPath) return toBundle(agent, state, []);
const stat = await statIfExists(state.rootPath);
if (!stat?.isDirectory()) {
@ -381,7 +399,7 @@ export function agentInstructionsService() {
}
async function readFile(agent: AgentLike, relativePath: string): Promise<AgentInstructionsFileDetail> {
const state = deriveBundleState(agent);
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
const content = asString(state.config[PROMPT_KEY]);
if (content === null) throw notFound("Instructions file not found");
@ -422,9 +440,21 @@ export function agentInstructionsService() {
agent: AgentLike,
options?: { clearLegacyPromptTemplate?: boolean },
): Promise<{ adapterConfig: Record<string, unknown>; state: BundleState }> {
const current = deriveBundleState(agent);
const derived = deriveBundleState(agent);
const current = await recoverManagedBundleState(agent, derived);
if (current.rootPath && current.mode) {
return { adapterConfig: current.config, state: current };
const adapterConfig = derived.rootPath
? current.config
: applyBundleConfig(current.config, {
mode: current.mode,
rootPath: current.rootPath,
entryFile: current.entryFile,
clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate,
});
return {
adapterConfig,
state: deriveBundleState({ ...agent, adapterConfig }),
};
}
const managedRoot = resolveManagedInstructionsRoot(agent);
@ -462,7 +492,7 @@ export function agentInstructionsService() {
clearLegacyPromptTemplate?: boolean;
},
): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record<string, unknown> }> {
const state = deriveBundleState(agent);
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
const nextMode = input.mode ?? state.mode ?? "managed";
const nextEntryFile = input.entryFile ? normalizeRelativeFilePath(input.entryFile) : state.entryFile;
let nextRootPath: string;
@ -544,7 +574,7 @@ export function agentInstructionsService() {
bundle: AgentInstructionsBundle;
adapterConfig: Record<string, unknown>;
}> {
const state = deriveBundleState(agent);
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
throw unprocessable("Cannot delete the legacy promptTemplate pseudo-file");
}
@ -564,7 +594,7 @@ export function agentInstructionsService() {
entryFile: string;
warnings: string[];
}> {
const state = deriveBundleState(agent);
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
if (state.rootPath) {
const stat = await statIfExists(state.rootPath);
if (stat?.isDirectory()) {