From b3bf49f826b78b02bef089252df8159eafcc3990 Mon Sep 17 00:00:00 2001 From: RSI smoke Date: Tue, 13 Oct 2026 21:13:41 -0600 Subject: [PATCH] Sanitize malformed tool-call arguments before echoing history (#11) A truncated or empty arguments string echoed back in conversation history makes Meta 400 the next request ('arguments must be valid JSON'), fail-closing the pilot. Replace unparseable arguments with {} on the wire; the journal keeps the raw response and the run continues through the designed error-nudge path. --- scripts/monitor-pilot/live-runner.ts | 15 ++++++++++++++- src/monitoring/__tests__/live-runner.test.ts | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/scripts/monitor-pilot/live-runner.ts b/scripts/monitor-pilot/live-runner.ts index 9f814eb..8a5f62b 100644 --- a/scripts/monitor-pilot/live-runner.ts +++ b/scripts/monitor-pilot/live-runner.ts @@ -282,7 +282,20 @@ function parseArgs(call: { readonly id: string; readonly function: { readonly na } function toolCallMessage(response: LlmResponse): ChatMessage { - return response.message + const toolCalls = response.message.tool_calls?.map((call) => { + if (isJsonObjectArguments(call.function.arguments)) return call + return { ...call, function: { ...call.function, arguments: "{}" } } + }) + return { ...response.message, tool_calls: toolCalls } +} + +function isJsonObjectArguments(raw: string): boolean { + try { + const parsed: unknown = JSON.parse(raw) + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + } catch { + return false + } } function stableCallBatchSignature(calls: readonly PilotToolCall[]): string { diff --git a/src/monitoring/__tests__/live-runner.test.ts b/src/monitoring/__tests__/live-runner.test.ts index 79029cb..87d3755 100644 --- a/src/monitoring/__tests__/live-runner.test.ts +++ b/src/monitoring/__tests__/live-runner.test.ts @@ -456,6 +456,25 @@ async function testReasoningHeavyNativeOverrunIsAccepted(): Promise { } finally { await fs.rm(directory, { recursive: true, force: true }) } } +async function testMalformedArgumentsAreSanitizedOnEcho(): Promise { + const directory = await scratch() + try { + const broken = { name: "apply_diff", args: "{\"content\":\"truncated" } + const done = { name: "attempt_completion", + args: JSON.stringify({ result: "done" }) } + const client = new MockBillingClient([broken, done]) + const summary = await collectLiveDevelopment({ outputDir: directory, client }) + assert.equal(summary.abortedAttempts, 0) + assert.equal(summary.completedRuns, 32) + const history = client.requests[1]?.messages ?? [] + const calls = history.filter((message) => { + return (message.tool_calls?.length ?? 0) > 0 + }) + const echoed = calls[0]?.tool_calls?.[0]?.function.arguments + assert.equal(echoed, "{}") + } finally { await fs.rm(directory, { recursive: true, force: true }) } +} + async function testHeldoutCannotStartWithoutQualifiedDevelopment(): Promise { const directory = await scratch() try { @@ -537,6 +556,7 @@ await testCopiedGlobalLedgerEpochsDoNotBlockResume() await testKnownPerRunAdmissionStopCompletesAndSettlesTrajectory() await testCompletionOverrunIsJournaledBeforeAnyToolDispatch() await testReasoningHeavyNativeOverrunIsAccepted() +await testMalformedArgumentsAreSanitizedOnEcho() await testHeldoutCannotStartWithoutQualifiedDevelopment() await testHeldoutRejectsDifferentFrozenOutputCapBeforeInference() await testStorageRootSymlinkIsRejectedBeforeInference()