fix(agent): auto-recover from persistent 400 format errors by reloading session

When the LLM API returns a 400 "tool_call_id is not found" error, the corrupted
in-memory message state persists, blocking all subsequent messages until restart.
This adds a recovery handler that reloads sanitized messages from disk and retries,
similar to the existing context overflow recovery pattern.

Also adds `default` export condition to core package.json to fix tsx watch
resolution for subpath exports (gateway startup failure).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiang Bohan
2026-02-24 13:19:39 +08:00
parent 7387216482
commit bfab8d97b2
3 changed files with 50 additions and 8 deletions

View File

@@ -5,35 +5,43 @@
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./agent": {
"types": "./dist/agent/index.d.ts",
"import": "./dist/agent/index.js"
"import": "./dist/agent/index.js",
"default": "./dist/agent/index.js"
},
"./hub": {
"types": "./dist/hub/index.d.ts",
"import": "./dist/hub/index.js"
"import": "./dist/hub/index.js",
"default": "./dist/hub/index.js"
},
"./channels": {
"types": "./dist/channels/index.d.ts",
"import": "./dist/channels/index.js"
"import": "./dist/channels/index.js",
"default": "./dist/channels/index.js"
},
"./cron": {
"types": "./dist/cron/index.d.ts",
"import": "./dist/cron/index.js"
"import": "./dist/cron/index.js",
"default": "./dist/cron/index.js"
},
"./heartbeat": {
"types": "./dist/heartbeat/index.d.ts",
"import": "./dist/heartbeat/index.js"
"import": "./dist/heartbeat/index.js",
"default": "./dist/heartbeat/index.js"
},
"./media": {
"types": "./dist/media/index.d.ts",
"import": "./dist/media/index.js"
"import": "./dist/media/index.js",
"default": "./dist/media/index.js"
},
"./client": {
"types": "./dist/client/index.d.ts",
"import": "./dist/client/index.js"
"import": "./dist/client/index.js",
"default": "./dist/client/index.js"
}
},
"main": "./dist/index.js",

View File

@@ -20,6 +20,13 @@ describe("classifyError", () => {
expect(classifyError(new Error("Schema validation failed"))).toBe("format");
});
it("classifies tool_call_id 400 errors as format (recoverable via transcript repair)", () => {
expect(
classifyError(new Error("400 tool_call_id is not found in the list of tool calls")),
).toBe("format");
expect(classifyError(new Error("400 Bad Request: tool_call_id not found"))).toBe("format");
});
it("classifies 429/rate limit as rate_limit", () => {
expect(classifyError(new Error("429 Too Many Requests"))).toBe("rate_limit");
expect(classifyError(new Error("Rate limit exceeded"))).toBe("rate_limit");

View File

@@ -794,6 +794,8 @@ export class Agent {
const MAX_OVERFLOW_COMPACTION_ATTEMPTS = 2;
let overflowAttempts = 0;
const MAX_FORMAT_REPAIR_ATTEMPTS = 1;
let formatRepairAttempts = 0;
// Loop to exhaust all candidate profiles on rotatable errors
while (true) {
@@ -860,6 +862,31 @@ export class Agent {
reason,
rotatable: isRotatableError(reason),
});
// Format error recovery: reload sanitized messages from disk and retry.
// This handles corrupted in-memory state (e.g. orphaned tool_call_id references)
// that causes persistent 400 errors until process restart.
if (reason === "format" && formatRepairAttempts < MAX_FORMAT_REPAIR_ATTEMPTS) {
formatRepairAttempts++;
this.stderr.write(
`[format-repair] Format error detected (attempt ${formatRepairAttempts}/${MAX_FORMAT_REPAIR_ATTEMPTS}), reloading messages from disk...\n`,
);
this.runLog.log("format_repair", {
attempt: formatRepairAttempts,
error: errorMsg.slice(0, 200),
messages_before: this.agent.state.messages.length,
});
const repairedMessages = this.session.loadMessages();
if (repairedMessages.length > 0) {
this.runLog.log("format_repair_reloaded", {
messages_after: repairedMessages.length,
});
this.agent.replaceMessages(repairedMessages);
this.output.state.lastAssistantText = "";
continue; // retry with sanitized messages
}
}
if (this.currentProfileId && isRotatableError(reason)) {
markAuthProfileFailure(this.currentProfileId, reason);
}