Skip to content

Commit 0da6de6

Browse files
justinhuangaishakkernerd
authored andcommitted
Agent: repair malformed tool calls and session files
1 parent 0eae9f4 commit 0da6de6

13 files changed

Lines changed: 383 additions & 13 deletions

docs/reference/transcript-hygiene.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@ title: "Transcript Hygiene"
1111

1212
This document describes **provider-specific fixes** applied to transcripts before a run
1313
(building model context). These are **in-memory** adjustments used to satisfy strict
14-
provider requirements. They do **not** rewrite the stored JSONL transcript on disk.
14+
provider requirements. These hygiene steps do **not** rewrite the stored JSONL transcript
15+
on disk; however, a separate session-file repair pass may rewrite malformed JSONL files
16+
by dropping invalid lines before the session is loaded. When a repair occurs, the original
17+
file is backed up alongside the session file.
1518

1619
Scope includes:
1720

1821
- Tool call id sanitization
22+
- Tool call input validation (drop malformed tool_use/tool_call blocks missing input or arguments)
1923
- Tool result pairing repair
2024
- Turn validation / ordering
2125
- Thought signature cleanup
@@ -36,6 +40,11 @@ All transcript hygiene is centralized in the embedded runner:
3640

3741
The policy uses `provider`, `modelApi`, and `modelId` to decide what to apply.
3842

43+
Separate from transcript hygiene, session files are repaired (if needed) before load:
44+
45+
- `repairSessionFileIfNeeded` in `src/agents/session-file-repair.ts`
46+
- Called from `run/attempt.ts` and `compact.ts` (embedded runner)
47+
3948
---
4049

4150
## Global rule: image sanitization
@@ -50,6 +59,19 @@ Implementation:
5059

5160
---
5261

62+
## Global rule: malformed tool calls
63+
64+
Assistant tool-call blocks that are missing both `input` and `arguments` are dropped
65+
before model context is built. This prevents provider rejections from partially
66+
persisted tool calls (for example, after a rate limit failure).
67+
68+
Implementation:
69+
70+
- `sanitizeToolCallInputs` in `src/agents/session-transcript-repair.ts`
71+
- Applied in `sanitizeSessionHistory` in `src/agents/pi-embedded-runner/google.ts`
72+
73+
---
74+
5375
## Provider matrix (current behavior)
5476

5577
**OpenAI / OpenAI Codex**

src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ describe("formatAssistantErrorText", () => {
3535
"The AI service is temporarily overloaded. Please try again in a moment.",
3636
);
3737
});
38+
it("returns a recovery hint when tool call input is missing", () => {
39+
const msg = makeAssistantError("tool_use.input: Field required");
40+
const result = formatAssistantErrorText(msg);
41+
expect(result).toContain("Session history looks corrupted");
42+
expect(result).toContain("/new");
43+
});
3844
it("handles JSON-wrapped role errors", () => {
3945
const msg = makeAssistantError('{"error":{"message":"400 Incorrect role information"}}');
4046
const result = formatAssistantErrorText(msg);

src/agents/pi-embedded-helpers/errors.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,14 @@ export function formatAssistantErrorText(
351351
);
352352
}
353353

354+
if (isMissingToolCallInputError(raw)) {
355+
return (
356+
"Session history looks corrupted (tool call input missing). " +
357+
"Use /new to start a fresh session. " +
358+
"If this keeps happening, reset the session or delete the corrupted session transcript."
359+
);
360+
}
361+
354362
const invalidRequest = raw.match(/"type":"invalid_request_error".*?"message":"([^"]+)"/);
355363
if (invalidRequest?.[1]) {
356364
return `LLM request rejected: ${invalidRequest[1]}`;
@@ -465,6 +473,11 @@ const ERROR_PATTERNS = {
465473
],
466474
} as const;
467475

476+
const TOOL_CALL_INPUT_MISSING_RE =
477+
/tool_(?:use|call)\.(?:input|arguments).*?(?:field required|required)/i;
478+
const TOOL_CALL_INPUT_PATH_RE =
479+
/messages\.\d+\.content\.\d+\.tool_(?:use|call)\.(?:input|arguments)/i;
480+
468481
const IMAGE_DIMENSION_ERROR_RE =
469482
/image dimensions exceed max allowed size for many-image requests:\s*(\d+)\s*pixels/i;
470483
const IMAGE_DIMENSION_PATH_RE = /messages\.(\d+)\.content\.(\d+)\.image/i;
@@ -505,6 +518,13 @@ export function isBillingErrorMessage(raw: string): boolean {
505518
);
506519
}
507520

521+
export function isMissingToolCallInputError(raw: string): boolean {
522+
if (!raw) {
523+
return false;
524+
}
525+
return TOOL_CALL_INPUT_MISSING_RE.test(raw) || TOOL_CALL_INPUT_PATH_RE.test(raw);
526+
}
527+
508528
export function isBillingAssistantError(msg: AssistantMessage | undefined): boolean {
509529
if (!msg || msg.stopReason !== "error") {
510530
return false;

src/agents/pi-embedded-runner.sanitize-session-history.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,26 @@ describe("sanitizeSessionHistory", () => {
162162
expect(result[0]?.role).toBe("assistant");
163163
});
164164

165+
it("drops malformed tool calls missing input or arguments", async () => {
166+
const messages: AgentMessage[] = [
167+
{
168+
role: "assistant",
169+
content: [{ type: "toolCall", id: "call_1", name: "read" }],
170+
},
171+
{ role: "user", content: "hello" },
172+
];
173+
174+
const result = await sanitizeSessionHistory({
175+
messages,
176+
modelApi: "openai-responses",
177+
provider: "openai",
178+
sessionManager: mockSessionManager,
179+
sessionId: "test-session",
180+
});
181+
182+
expect(result.map((msg) => msg.role)).toEqual(["user"]);
183+
});
184+
165185
it("does not downgrade openai reasoning when the model has not changed", async () => {
166186
const sessionEntries: Array<{ type: string; customType: string; data: unknown }> = [
167187
{

src/agents/pi-embedded-runner/compact.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
} from "../pi-settings.js";
4343
import { createOpenClawCodingTools } from "../pi-tools.js";
4444
import { resolveSandboxContext } from "../sandbox.js";
45+
import { repairSessionFileIfNeeded } from "../session-file-repair.js";
4546
import { guardSessionManager } from "../session-tool-result-guard-wrapper.js";
4647
import { acquireSessionWriteLock } from "../session-write-lock.js";
4748
import {
@@ -357,6 +358,10 @@ export async function compactEmbeddedPiSessionDirect(
357358
sessionFile: params.sessionFile,
358359
});
359360
try {
361+
await repairSessionFileIfNeeded({
362+
sessionFile: params.sessionFile,
363+
warn: (message) => log.warn(message),
364+
});
360365
await prewarmSessionFile(params.sessionFile);
361366
const transcriptPolicy = resolveTranscriptPolicy({
362367
modelApi: model.api,

src/agents/pi-embedded-runner/google.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import {
1212
sanitizeSessionMessagesImages,
1313
} from "../pi-embedded-helpers.js";
1414
import { cleanToolSchemaForGemini } from "../pi-tools.schema.js";
15-
import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js";
15+
import {
16+
sanitizeToolCallInputs,
17+
sanitizeToolUseResultPairing,
18+
} from "../session-transcript-repair.js";
1619
import { resolveTranscriptPolicy } from "../transcript-policy.js";
1720
import { log } from "./logger.js";
1821
import { describeUnknownError } from "./utils.js";
@@ -346,9 +349,10 @@ export async function sanitizeSessionHistory(params: {
346349
const sanitizedThinking = policy.normalizeAntigravityThinkingBlocks
347350
? sanitizeAntigravityThinkingBlocks(sanitizedImages)
348351
: sanitizedImages;
352+
const sanitizedToolCalls = sanitizeToolCallInputs(sanitizedThinking);
349353
const repairedTools = policy.repairToolUseResultPairing
350-
? sanitizeToolUseResultPairing(sanitizedThinking)
351-
: sanitizedThinking;
354+
? sanitizeToolUseResultPairing(sanitizedToolCalls)
355+
: sanitizedToolCalls;
352356

353357
const isOpenAIResponsesApi =
354358
params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses";

src/agents/pi-embedded-runner/run/attempt.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { toClientToolDefinitions } from "../../pi-tool-definition-adapter.js";
4646
import { createOpenClawCodingTools } from "../../pi-tools.js";
4747
import { resolveSandboxContext } from "../../sandbox.js";
4848
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
49+
import { repairSessionFileIfNeeded } from "../../session-file-repair.js";
4950
import { guardSessionManager } from "../../session-tool-result-guard-wrapper.js";
5051
import { acquireSessionWriteLock } from "../../session-write-lock.js";
5152
import {
@@ -399,6 +400,10 @@ export async function runEmbeddedAttempt(
399400
let sessionManager: ReturnType<typeof guardSessionManager> | undefined;
400401
let session: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
401402
try {
403+
await repairSessionFileIfNeeded({
404+
sessionFile: params.sessionFile,
405+
warn: (message) => log.warn(message),
406+
});
402407
const hadSessionFile = await fs
403408
.stat(params.sessionFile)
404409
.then(() => true)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { describe, expect, it } from "vitest";
5+
import { repairSessionFileIfNeeded } from "./session-file-repair.js";
6+
7+
describe("repairSessionFileIfNeeded", () => {
8+
it("rewrites session files that contain malformed lines", async () => {
9+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-repair-"));
10+
const file = path.join(dir, "session.jsonl");
11+
const header = {
12+
type: "session",
13+
version: 7,
14+
id: "session-1",
15+
timestamp: new Date().toISOString(),
16+
cwd: "/tmp",
17+
};
18+
const message = {
19+
type: "message",
20+
id: "msg-1",
21+
parentId: null,
22+
timestamp: new Date().toISOString(),
23+
message: { role: "user", content: "hello" },
24+
};
25+
26+
const content = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n{"type":"message"`;
27+
await fs.writeFile(file, content, "utf-8");
28+
29+
const result = await repairSessionFileIfNeeded({ sessionFile: file });
30+
expect(result.repaired).toBe(true);
31+
expect(result.droppedLines).toBe(1);
32+
expect(result.backupPath).toBeTruthy();
33+
34+
const repaired = await fs.readFile(file, "utf-8");
35+
expect(repaired.trim().split("\n")).toHaveLength(2);
36+
37+
if (result.backupPath) {
38+
const backup = await fs.readFile(result.backupPath, "utf-8");
39+
expect(backup).toBe(content);
40+
}
41+
});
42+
});

src/agents/session-file-repair.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import fs from "node:fs/promises";
2+
import path from "node:path";
3+
4+
type RepairReport = {
5+
repaired: boolean;
6+
droppedLines: number;
7+
backupPath?: string;
8+
reason?: string;
9+
};
10+
11+
function isSessionHeader(entry: unknown): entry is { type: string; id: string } {
12+
if (!entry || typeof entry !== "object") {
13+
return false;
14+
}
15+
const record = entry as { type?: unknown; id?: unknown };
16+
return record.type === "session" && typeof record.id === "string" && record.id.length > 0;
17+
}
18+
19+
export async function repairSessionFileIfNeeded(params: {
20+
sessionFile: string;
21+
warn?: (message: string) => void;
22+
}): Promise<RepairReport> {
23+
const sessionFile = params.sessionFile.trim();
24+
if (!sessionFile) {
25+
return { repaired: false, droppedLines: 0, reason: "missing session file" };
26+
}
27+
28+
let content: string;
29+
try {
30+
content = await fs.readFile(sessionFile, "utf-8");
31+
} catch {
32+
return { repaired: false, droppedLines: 0, reason: "missing session file" };
33+
}
34+
35+
const lines = content.split("\n");
36+
const entries: unknown[] = [];
37+
let droppedLines = 0;
38+
39+
for (const line of lines) {
40+
if (!line.trim()) {
41+
continue;
42+
}
43+
try {
44+
const entry = JSON.parse(line);
45+
entries.push(entry);
46+
} catch {
47+
droppedLines += 1;
48+
}
49+
}
50+
51+
if (entries.length === 0) {
52+
return { repaired: false, droppedLines, reason: "empty session file" };
53+
}
54+
55+
if (!isSessionHeader(entries[0])) {
56+
return { repaired: false, droppedLines, reason: "invalid session header" };
57+
}
58+
59+
if (droppedLines === 0) {
60+
return { repaired: false, droppedLines: 0 };
61+
}
62+
63+
const cleaned = `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
64+
const backupPath = `${sessionFile}.bak-${process.pid}-${Date.now()}`;
65+
const tmpPath = `${sessionFile}.repair-${process.pid}-${Date.now()}.tmp`;
66+
try {
67+
const stat = await fs.stat(sessionFile).catch(() => null);
68+
await fs.writeFile(backupPath, content, "utf-8");
69+
if (stat) {
70+
await fs.chmod(backupPath, stat.mode);
71+
}
72+
await fs.writeFile(tmpPath, cleaned, "utf-8");
73+
if (stat) {
74+
await fs.chmod(tmpPath, stat.mode);
75+
}
76+
await fs.rename(tmpPath, sessionFile);
77+
} catch (err) {
78+
try {
79+
await fs.unlink(tmpPath);
80+
} catch {
81+
// ignore cleanup failures
82+
}
83+
return {
84+
repaired: false,
85+
droppedLines,
86+
reason: `repair failed: ${err instanceof Error ? err.message : "unknown error"}`,
87+
};
88+
}
89+
90+
params.warn?.(
91+
`session file repaired: dropped ${droppedLines} malformed line(s) (${path.basename(
92+
sessionFile,
93+
)})`,
94+
);
95+
return { repaired: true, droppedLines, backupPath };
96+
}

src/agents/session-tool-result-guard.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,4 +141,21 @@ describe("installSessionToolResultGuard", () => {
141141
.map((e) => (e as { message: AgentMessage }).message);
142142
expect(messages.map((m) => m.role)).toEqual(["assistant", "toolResult"]);
143143
});
144+
145+
it("drops malformed tool calls missing input before persistence", () => {
146+
const sm = SessionManager.inMemory();
147+
installSessionToolResultGuard(sm);
148+
149+
sm.appendMessage({
150+
role: "assistant",
151+
content: [{ type: "toolCall", id: "call_1", name: "read" }],
152+
} as AgentMessage);
153+
154+
const messages = sm
155+
.getEntries()
156+
.filter((e) => e.type === "message")
157+
.map((e) => (e as { message: AgentMessage }).message);
158+
159+
expect(messages).toHaveLength(0);
160+
});
144161
});

0 commit comments

Comments
 (0)