From 70c7c8fdcdfe34544520a28e4baee0be69498914 Mon Sep 17 00:00:00 2001 From: code-crusher Date: Fri, 4 Sep 2026 11:28:01 +0530 Subject: [PATCH 1/3] fix(agent): repair malformed tool-call JSON and clamp search limits Models that emit almost-JSON (unquoted strings, unquoted keys, single quotes, trailing commas, Python literals, comments, truncated output) no longer burn a round trip on a corrective error they tend to repeat. A best-effort repair pass recovers the intended arguments, the tool executes with them, and a note on the tool result tells the model what actually ran. search_files max_results/context_lines now clamp to the nearest bound instead of failing the whole search. --- CHANGELOG.md | 5 + package.json | 1 + src/api/aiSdkClient.ts | 9 +- src/core/agent.ts | 44 ++-- src/tools/executors/searchFiles/types.ts | 16 +- src/utils/jsonRepair.ts | 255 +++++++++++++++++++++++ test/json-repair.test.ts | 118 +++++++++++ test/search-files.test.ts | 33 ++- 8 files changed, 451 insertions(+), 30 deletions(-) create mode 100644 src/utils/jsonRepair.ts create mode 100644 test/json-repair.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 38ade22..740f4f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Malformed tool-call JSON is now repaired instead of rejected.** Models that emit almost-JSON — unquoted strings (`"file_pattern": *.tsx`), unquoted keys, single quotes, trailing commas, Python literals (`True`/`None`), comments, or output truncated mid-call — no longer burn a round trip on a corrective error (weaker models repeated the same mistake on retry). A best-effort repair pass (`src/utils/jsonRepair.ts`) recovers the intended arguments, the tool executes with them, and a note on the tool result tells the model what actually ran; only truly unrecoverable arguments still return the corrective error. Session replay and the AI SDK history path use the same repair so the model sees its own repaired calls. Covered by `test/json-repair.test.ts` (`npm run test:json-repair`). +- **`search_files` numeric limits clamp instead of failing.** `max_results` and `context_lines` values that are fractional, out of range, or numeric strings now clamp to the nearest bound (or fall back to the default when non-numeric) instead of failing the whole search — e.g. `context_lines: 3` runs with 2. + ## [6.8.2] - 2026-09-04 ### Fixed diff --git a/package.json b/package.json index d6cfb48..c89b191 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "test:mcp": "node --import tsx --test test/mcp-client.test.ts", "test:plugins": "node --import tsx --test test/plugins.test.ts", "test:metrics": "node --import tsx --test test/metrics.test.ts", + "test:json-repair": "node --import tsx --test test/json-repair.test.ts", "test:search": "node --import tsx --test test/search-files.test.ts", "test:ui": "bun test test/ui-viewport.test.tsx", "prepublishOnly": "npm run typecheck && npm run build" diff --git a/src/api/aiSdkClient.ts b/src/api/aiSdkClient.ts index 20ef782..b4f7857 100644 --- a/src/api/aiSdkClient.ts +++ b/src/api/aiSdkClient.ts @@ -17,6 +17,7 @@ import { } from "ai" import type OpenAI from "openai" +import { parseToolCallArguments } from "../utils/jsonRepair.js" import { REASONING_DETAILS_FIELD, type LLMClient } from "./llmClient.js" // `ReasoningPart` isn't re-exported from "ai"; derive it from the exported @@ -300,11 +301,9 @@ function contentToText(content: unknown): string { function safeParseJson(raw: string | undefined): unknown { if (!raw) return {} - try { - return JSON.parse(raw) - } catch { - return {} - } + // Repair malformed arguments so history replay shows the model the same + // arguments the agent actually executed. + return parseToolCallArguments(raw)?.args ?? {} } function asError(error: unknown): Error { diff --git a/src/core/agent.ts b/src/core/agent.ts index dcf0cec..94c7eb9 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -36,6 +36,7 @@ import { loadMemoryFiles } from "../memory/loader.js" import { loadSkills } from "../skills/loader.js" import { renderLinkedReposSection } from "../config/links.js" import { unifiedDiff } from "../utils/diff.js" +import { parseToolCallArguments } from "../utils/jsonRepair.js" import { countDiffLines, detectGitRepo, @@ -200,6 +201,14 @@ function contentToText(content: unknown): string { .join("") } +/** Note appended to a tool result whose arguments needed JSON repair, so the + * model sees what actually ran instead of repeating the same malformed call. */ +function jsonRepairNote(toolName: string, args: Record): string { + const interpreted = JSON.stringify(args) ?? "{}" + const preview = interpreted.length > 500 ? `${interpreted.slice(0, 500)}…` : interpreted + return `[OrbCode] The ${toolName} arguments were malformed JSON and were auto-repaired before execution. Interpreted arguments: ${preview}. Emit strictly valid JSON in future tool calls — every key and string value must be double-quoted.` +} + function formatResultPreview(toolName: string, text: string): string { const visibleText = toolName === "search_files" ? stripSearchPageMetadataForDisplay(text) : text if (!visibleText) return "" @@ -291,12 +300,9 @@ function legacyTranscript(messages: OpenAI.Chat.ChatCompletionMessageParam[]): S if (text.trim()) entries.push({ kind: "assistant", text }) for (const call of message.tool_calls ?? []) { if (call.type !== "function") continue - let args: Record = {} - try { - args = JSON.parse(call.function.arguments || "{}") as Record - } catch { - // Keep an empty argument object; the call name is still useful history. - } + // Repair when possible so old sessions with malformed arguments + // still produce useful summaries. + const args = parseToolCallArguments(call.function.arguments)?.args ?? {} if (call.function.name === "attempt_completion") { entries.push({ kind: "completion", text: String(args.result ?? "") }) continue @@ -1207,15 +1213,13 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` private async handleToolCall(toolCall: PendingToolCall): Promise { const { onEvent, requestApproval, requestFollowup } = this.options.callbacks - let args: Record - try { - args = toolCall.arguments ? JSON.parse(toolCall.arguments) : {} - } catch (error) { + const parsed = parseToolCallArguments(toolCall.arguments) + if (!parsed) { // Recover instead of dead-ending: the error result carries the raw // arguments so the model can re-issue the call with valid, complete JSON. const rawArgs = toolCall.arguments.trim() const preview = rawArgs.length > 500 ? `${rawArgs.slice(0, 500)}...(truncated)` : rawArgs - const message = `Malformed tool call JSON for ${toolCall.name}: ${(error as Error).message}. The raw arguments were:\n\n${preview}\n\nPlease re-issue the tool call with valid, complete JSON arguments.` + const message = `Malformed tool call JSON for ${toolCall.name}: the arguments could not be parsed or repaired. The raw arguments were:\n\n${preview}\n\nPlease re-issue the tool call with valid, complete JSON arguments.` onEvent({ type: "tool-end", id: toolCall.id, @@ -1226,10 +1230,23 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` }) return message } + let args = parsed.args + // A repair surfaces twice: a transcript event for the user, and a note on + // the tool result so the model sees the arguments that actually ran. + const repairNote = parsed.repaired ? jsonRepairNote(toolCall.name, args) : "" + if (parsed.repaired) { + onEvent({ + type: "system", + message: `Repaired malformed JSON arguments for ${toolCall.name}.`, + isError: false, + }) + } if (toolCall.name === "attempt_completion") { onEvent({ type: "completion", result: String(args.result ?? "") }) - return "The user has been shown the completion result." + return repairNote + ? `The user has been shown the completion result.\n\n${repairNote}` + : "The user has been shown the completion result." } if (toolCall.name === "ask_followup_question") { @@ -1245,7 +1262,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` } const answer = await requestFollowup(question, suggestions) this.transcript.push({ kind: "user", text: answer }) - return `\n${answer}\n` + return `\n${answer}\n${repairNote ? `\n\n${repairNote}` : ""}` } // PreToolUse runs before approval/execution. It can block the call, @@ -1342,6 +1359,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` // model. PreToolUse additionalContext is delivered here too. let resultText = result.text const extras: string[] = [] + if (repairNote) extras.push(repairNote) if (preContext) extras.push(wrapHookContext("PreToolUse", preContext)) if (this.hooks.hasHooks("PostToolUse")) { const post = await this.hooks.run("PostToolUse", { diff --git a/src/tools/executors/searchFiles/types.ts b/src/tools/executors/searchFiles/types.ts index 87d1077..3c517d8 100644 --- a/src/tools/executors/searchFiles/types.ts +++ b/src/tools/executors/searchFiles/types.ts @@ -71,16 +71,18 @@ export function normalizeSearchFilePattern(value: unknown): string | undefined { return pattern.startsWith(".") && !pattern.includes("*") ? `*${pattern}` : pattern } -function boundedInteger(value: unknown, name: string, fallback: number, min: number, max: number): number { +/** Coerce a model-supplied integer into [min, max]. Absent, empty, or + * non-numeric values fall back to `fallback`; fractional values truncate; + * out-of-range values clamp to the nearest bound. Result-shaping limits + * degrade gracefully instead of failing the whole search. */ +function boundedInteger(value: unknown, fallback: number, min: number, max: number): number { if (value == null || value === "" || (typeof value === "string" && value.trim().toLowerCase() === "null")) { return fallback } const parsed = typeof value === "number" ? value : Number(value) - if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) { - throw new Error(`${name} must be an integer from ${min} to ${max}, or null`) - } - return parsed + if (!Number.isFinite(parsed)) return fallback + return Math.min(max, Math.max(min, Math.trunc(parsed))) } export function createSearchFingerprint(directoryPath: string, regex: string, filePattern?: string): string { @@ -123,8 +125,8 @@ export function serializeSearchCursor(cursor: SearchCursor | null): string | nul export function parseSearchOptions(args: Record, fingerprint: string): SearchOptions { return { cursor: parseSearchCursor(args.cursor, fingerprint), - maxResults: boundedInteger(args.max_results, "max_results", DEFAULT_SEARCH_RESULTS, 1, MAX_SEARCH_RESULTS), - contextLines: boundedInteger(args.context_lines, "context_lines", 0, 0, MAX_SEARCH_CONTEXT_LINES), + maxResults: boundedInteger(args.max_results, DEFAULT_SEARCH_RESULTS, 1, MAX_SEARCH_RESULTS), + contextLines: boundedInteger(args.context_lines, 0, 0, MAX_SEARCH_CONTEXT_LINES), fingerprint, } } diff --git a/src/utils/jsonRepair.ts b/src/utils/jsonRepair.ts new file mode 100644 index 0000000..a03e371 --- /dev/null +++ b/src/utils/jsonRepair.ts @@ -0,0 +1,255 @@ +/** + * Best-effort recovery for malformed LLM tool-call arguments. + * + * Models occasionally emit almost-JSON: unquoted strings (`"file_pattern": *.tsx`), + * unquoted keys, single quotes, trailing commas, Python literals (`True`/`None`), + * comments, or output truncated mid-call. Failing the call outright burns a + * round trip, and weaker models repeat the same mistake on retry. This module + * repairs the common cases and reports whether it intervened so the agent can + * tell the model which arguments actually ran. + */ + +export interface ParsedToolCallArguments { + args: Record; + /** True when the source was not valid JSON and had to be repaired. */ + repaired: boolean; +} + +/** + * Parse tool-call arguments, repairing common JSON malformations. Returns null + * only when nothing resembling an argument object can be recovered. + */ +export function parseToolCallArguments( + raw: string, +): ParsedToolCallArguments | null { + const trimmed = raw.trim(); + if (!trimmed) return { args: {}, repaired: false }; + + const strict = tryParseObject(trimmed); + if (strict) return { args: strict, repaired: false }; + + // A lone object wrapped in an array is a common shape mistake. + const unwrapped = tryParseSingleObjectArray(trimmed); + if (unwrapped) return { args: unwrapped, repaired: true }; + + const repairedSource = repairJsonSource(trimmed); + if (!repairedSource) return null; + const repaired = + tryParseObject(repairedSource) ?? tryParseSingleObjectArray(repairedSource); + return repaired ? { args: repaired, repaired: true } : null; +} + +function tryParseObject(source: string): Record | null { + try { + const parsed: unknown = JSON.parse(source); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function tryParseSingleObjectArray( + source: string, +): Record | null { + try { + const parsed: unknown = JSON.parse(source); + if (!Array.isArray(parsed) || parsed.length !== 1) return null; + return isRecord(parsed[0]) ? parsed[0] : null; + } catch { + return null; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Characters that always terminate a bare (unquoted) token. */ +const BARE_TOKEN_TERMINATORS = new Set([",", "{", "}", "[", "]", '"', "'"]); + +/** Matching closer for each opening bracket. */ +const CLOSERS = new Map([ + ["{", "}"], + ["[", "]"], +]); + +/** JSON literal spellings, including the Python variants models sometimes emit. */ +const LITERAL_ALIASES: Record = { + true: "true", + false: "false", + null: "null", + none: "null", +}; + +/** Escape text for use inside a JSON string literal. */ +function quoteAsJsonString(text: string): string { + const escaped = text + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t") + // Raw control characters are invalid inside JSON strings; drop them. + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, ""); + return `"${escaped}"`; +} + +/** Index of the closing quote for the string starting at `start`, or -1 when + * the input ends first. Backslash escapes are skipped over. */ +function findStringEnd(source: string, start: number, quote: string): number { + for (let i = start + 1; i < source.length; i++) { + if (source[i] === "\\") { + i++; + continue; + } + if (source[i] === quote) return i; + } + return -1; +} + +/** Render a bare value token: boolean/null literals (including Python + * spellings) and strict JSON numbers stay unquoted; anything else becomes a + * JSON string. */ +function formatBareValue(token: string): string { + const literal = LITERAL_ALIASES[token.toLowerCase()]; + if (literal) return literal; + try { + if (typeof JSON.parse(token) === "number") return token; + } catch { + // Not a JSON number; fall through to quoting. + } + return quoteAsJsonString(token); +} + +/** Scan a bare (unquoted) token starting at `start`. In value position the + * token may contain spaces and colons (`git status`, `https://…`); in key + * position it ends at the first whitespace or colon. */ +function scanBareToken( + source: string, + start: number, + valuePosition: boolean, +): { text: string; end: number } { + let end = start; + while (end < source.length) { + const char = source[end]; + if (BARE_TOKEN_TERMINATORS.has(char)) break; + if (!valuePosition && (char === ":" || /\s/.test(char))) break; + end++; + } + return { text: source.slice(start, end).trim(), end }; +} + +/** + * Re-write malformed JSON into something `JSON.parse` accepts. Handles unquoted + * keys and values, single-quoted strings, trailing commas, `//` and `/* *\/` + * comments, Python literals, and structure left open by truncated output. + * Braceless object bodies are wrapped so `path: "src"` parses as an object. + */ +function repairJsonSource(input: string): string | null { + const source = /^[[{]/.test(input) ? input : `{${input}}`; + + let out = ""; + let index = 0; + /** Open braces/brackets, innermost last. */ + const stack: string[] = []; + /** True while the next token is a value rather than an object key. */ + let expectValue = false; + + while (index < source.length) { + const char = source[index]; + + if (char === '"') { + const end = findStringEnd(source, index, '"'); + if (end === -1) { + // Truncated mid-string: close it and stop scanning. + out += quoteAsJsonString(source.slice(index + 1)); + index = source.length; + } else { + out += source.slice(index, end + 1); + index = end + 1; + } + expectValue = false; + continue; + } + + if (char === "'") { + const end = findStringEnd(source, index, "'"); + out += + end === -1 + ? quoteAsJsonString(source.slice(index + 1)) + : quoteAsJsonString(source.slice(index + 1, end)); + index = end === -1 ? source.length : end + 1; + expectValue = false; + continue; + } + + if (char === "/" && source[index + 1] === "/") { + const newline = source.indexOf("\n", index); + index = newline === -1 ? source.length : newline; + continue; + } + if (char === "/" && source[index + 1] === "*") { + const close = source.indexOf("*/", index + 2); + index = close === -1 ? source.length : close + 2; + continue; + } + + if (char === ":") { + out += ":"; + expectValue = true; + index++; + continue; + } + if (char === ",") { + out += ","; + // Inside an object the next token is a key; inside an array, a value. + expectValue = stack[stack.length - 1] === "["; + index++; + continue; + } + if (char === "{" || char === "[") { + out += char; + stack.push(char); + // Object contents are keys; array elements are values. + expectValue = char === "["; + index++; + continue; + } + if (char === "}" || char === "]") { + // Drop a trailing comma before the closer. + out = out.replace(/,\s*$/, ""); + out += char; + stack.pop(); + expectValue = false; + index++; + continue; + } + if (/\s/.test(char)) { + out += char; + index++; + continue; + } + + // Bare token: a key when a value isn't expected, otherwise a value. + const token = scanBareToken(source, index, expectValue); + if (!token.text) { + // Unrecognized character; keep it and let JSON.parse judge. + out += char; + index++; + continue; + } + out += expectValue + ? formatBareValue(token.text) + : quoteAsJsonString(token.text); + index = token.end; + expectValue = false; + } + + // Close whatever truncation left open: drop a dangling comma or colon, + // then append the missing closers innermost-first. + out = out.replace(/,\s*$/, ""); + if (/:\s*$/.test(out)) out += " null"; + while (stack.length > 0) out += CLOSERS.get(stack.pop() ?? "") ?? ""; + + return out.trim() || null; +} diff --git a/test/json-repair.test.ts b/test/json-repair.test.ts new file mode 100644 index 0000000..40ce3bd --- /dev/null +++ b/test/json-repair.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { parseToolCallArguments } from "../src/utils/jsonRepair.js"; + +test("passes valid JSON through without repair", () => { + const parsed = parseToolCallArguments( + '{"path": "src", "regex": "needle", "file_pattern": null}', + ); + assert.deepEqual(parsed, { + args: { path: "src", regex: "needle", file_pattern: null }, + repaired: false, + }); + assert.deepEqual(parseToolCallArguments(" "), { + args: {}, + repaired: false, + }); +}); + +test("quotes unquoted string values like globs and file names", () => { + const raw = + '{"path": "/Users/x/src/ui", "regex": "setUpdateInfo|updateCheck", "file_pattern": *.tsx, "max_results": 20, "context_lines": 2}'; + const parsed = parseToolCallArguments(raw); + assert.equal(parsed?.repaired, true); + assert.equal(parsed?.args.file_pattern, "*.tsx"); + assert.equal(parsed?.args.max_results, 20); + assert.equal(parsed?.args.context_lines, 2); + + const bareName = parseToolCallArguments( + '{"path": "src/ui", "regex": "x", "file_pattern": App.tsx}', + ); + assert.equal(bareName?.repaired, true); + assert.equal(bareName?.args.file_pattern, "App.tsx"); +}); + +test("quotes unquoted keys", () => { + const parsed = parseToolCallArguments('{path: "src", regex: "needle"}'); + assert.equal(parsed?.repaired, true); + assert.deepEqual(parsed?.args, { path: "src", regex: "needle" }); +}); + +test("converts single quotes and Python literals", () => { + const parsed = parseToolCallArguments( + "{'path': 'src', 'flag': True, 'missing': None}", + ); + assert.equal(parsed?.repaired, true); + assert.deepEqual(parsed?.args, { path: "src", flag: true, missing: null }); +}); + +test("removes trailing commas and comments", () => { + const parsed = parseToolCallArguments( + '{"path": "src", // workspace directory\n"regex": "x",}', + ); + assert.equal(parsed?.repaired, true); + assert.deepEqual(parsed?.args, { path: "src", regex: "x" }); + + const blocked = parseToolCallArguments('{"path": /* inline */ "src"}'); + assert.deepEqual(blocked?.args, { path: "src" }); +}); + +test("closes JSON truncated mid-string or mid-structure", () => { + const midString = parseToolCallArguments('{"path": "src", "regex": "needle'); + assert.equal(midString?.repaired, true); + assert.deepEqual(midString?.args, { path: "src", regex: "needle" }); + + const midObject = parseToolCallArguments( + '{"path": "src", "regex": "needle", "nested": {"a": 1,', + ); + assert.deepEqual(midObject?.args, { + path: "src", + regex: "needle", + nested: { a: 1 }, + }); + + const midValue = parseToolCallArguments('{"path": "src", "regex":'); + assert.deepEqual(midValue?.args, { path: "src", regex: null }); +}); + +test("wraps braceless object bodies", () => { + const parsed = parseToolCallArguments('path: "src", regex: "needle"'); + assert.equal(parsed?.repaired, true); + assert.deepEqual(parsed?.args, { path: "src", regex: "needle" }); +}); + +test("unwraps a single-object array", () => { + const parsed = parseToolCallArguments('[{"path": "src"}]'); + assert.equal(parsed?.repaired, true); + assert.deepEqual(parsed?.args, { path: "src" }); +}); + +test("keeps numbers, URLs, and multi-word bare values intact", () => { + const parsed = parseToolCallArguments( + '{"limit": 20, "url": https://example.com/x, "command": git status}', + ); + assert.equal(parsed?.repaired, true); + assert.deepEqual(parsed?.args, { + limit: 20, + url: "https://example.com/x", + command: "git status", + }); +}); + +test("repairs nested structures with bare tokens", () => { + const parsed = parseToolCallArguments( + "{edits: [{file_path: src/a.ts, old_string: x, new_string: y}]}", + ); + assert.equal(parsed?.repaired, true); + assert.deepEqual(parsed?.args, { + edits: [{ file_path: "src/a.ts", old_string: "x", new_string: "y" }], + }); +}); + +test("returns null for unrecoverable input", () => { + assert.equal(parseToolCallArguments("just some prose"), null); + assert.equal(parseToolCallArguments('"a bare string"'), null); + assert.equal(parseToolCallArguments("42"), null); + assert.equal(parseToolCallArguments("null"), null); +}); diff --git a/test/search-files.test.ts b/test/search-files.test.ts index 7826876..1dab113 100644 --- a/test/search-files.test.ts +++ b/test/search-files.test.ts @@ -115,14 +115,37 @@ test("binds opaque cursors to the originating search", () => { assert.throws(() => parseSearchCursor("none", fingerprint), /search is complete/) }) -test("rejects fractional result and context limits", async () => { +test("clamps fractional, out-of-range, and string limits instead of failing", async () => { const cwd = await fixture() - const result = await searchFiles( + const context = { cwd, token: "", getTodos: () => "", setTodos: () => {} } + + const fractional = await searchFiles( { path: "src", regex: "needle", file_pattern: "*.ts", cursor: null, max_results: 1.5, context_lines: 0 }, - { cwd, token: "", getTodos: () => "", setTodos: () => {} }, + context, + ) + assert.equal(fractional.isError, undefined) + assert.match(fractional.text, /^Matches: 1$/m) + + const overflow = await searchFiles( + { path: "src", regex: "needle", file_pattern: "*.ts", cursor: null, max_results: 500, context_lines: 9 }, + context, + ) + assert.equal(overflow.isError, undefined) + assert.match(overflow.text, /^Matches: 2$/m) + + const numericStrings = await searchFiles( + { path: "src", regex: "needle", file_pattern: "*.ts", cursor: null, max_results: "1", context_lines: "2" }, + context, + ) + assert.equal(numericStrings.isError, undefined) + assert.match(numericStrings.text, /^Matches: 1$/m) + + const nonNumeric = await searchFiles( + { path: "src", regex: "needle", file_pattern: "*.ts", cursor: null, max_results: "many", context_lines: null }, + context, ) - assert.equal(result.isError, true) - assert.match(result.text, /max_results must be an integer/) + assert.equal(nonNumeric.isError, undefined) + assert.match(nonNumeric.text, /^Matches: 2$/m) }) test("ripgrep preserves ignores, normalized patterns, and path-relative nested globs", async () => { From 7804b263f286bbb6c88fe987a0b230f481d6fe96 Mon Sep 17 00:00:00 2001 From: code-crusher Date: Fri, 4 Sep 2026 11:28:20 +0530 Subject: [PATCH 2/3] release: v6.8.3 --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 740f4f9..a379863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [6.8.3] - 2026-09-04 ### Changed diff --git a/package.json b/package.json index c89b191..0699abc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@matterailab/orbcode", - "version": "6.8.2", + "version": "6.8.3", "description": "OrbCode CLI — agentic coding in your terminal, by MatterAI", "type": "module", "bin": { From 9f3786fa4bfa1f7a2cffd575b143d5e679c697f8 Mon Sep 17 00:00:00 2001 From: code-crusher Date: Fri, 4 Sep 2026 11:39:26 +0530 Subject: [PATCH 3/3] fix: repair read_file args with interleaved XML tags and dropped key quotes Models sometimes emit internal markup tags (, ) where JSON punctuation belongs, or drop a key's closing quote ("offset: 600). The repair pass now strips XML-style tags before scanning and splits broken key-position strings at the first colon (else whitespace) so the head becomes the key and the tail is re-scanned as the value. Tags inside valid quoted strings are legitimate content and stay untouched. read_file already defaults missing offset/limit at the executor, so repaired calls run with sane bounds. --- CHANGELOG.md | 6 +++++- src/ui/App.tsx | 11 ++++++++++ src/utils/jsonRepair.ts | 45 ++++++++++++++++++++++++++++++++++------ test/json-repair.test.ts | 34 ++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a379863..3d326e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [6.8.3] - 2026-09-04 +### Fixed + +- **Model picker now updates the header row's MODEL line.** Selecting a model in the picker updated `settings` and the agent, but the header row's `modelName` was a stale snapshot — the MODEL/WORKSPACE line only refreshed on `/new` or `/resume`. `switchModel` now patches the header row in place. + ### Changed -- **Malformed tool-call JSON is now repaired instead of rejected.** Models that emit almost-JSON — unquoted strings (`"file_pattern": *.tsx`), unquoted keys, single quotes, trailing commas, Python literals (`True`/`None`), comments, or output truncated mid-call — no longer burn a round trip on a corrective error (weaker models repeated the same mistake on retry). A best-effort repair pass (`src/utils/jsonRepair.ts`) recovers the intended arguments, the tool executes with them, and a note on the tool result tells the model what actually ran; only truly unrecoverable arguments still return the corrective error. Session replay and the AI SDK history path use the same repair so the model sees its own repaired calls. Covered by `test/json-repair.test.ts` (`npm run test:json-repair`). +- **Malformed tool-call JSON is now repaired instead of rejected.** Models that emit almost-JSON — unquoted strings (`"file_pattern": *.tsx`), unquoted keys, single quotes, trailing commas, Python literals (`True`/`None`), comments, XML-style tags interleaved where punctuation belongs (`"offset`), keys with dropped closing quotes (`"offset: 600`), or output truncated mid-call — no longer burn a round trip on a corrective error (weaker models repeated the same mistake on retry). A best-effort repair pass (`src/utils/jsonRepair.ts`) recovers the intended arguments, the tool executes with them, and a note on the tool result tells the model what actually ran; only truly unrecoverable arguments still return the corrective error. Session replay and the AI SDK history path use the same repair so the model sees its own repaired calls. Covered by `test/json-repair.test.ts` (`npm run test:json-repair`). - **`search_files` numeric limits clamp instead of failing.** `max_results` and `context_lines` values that are fractional, out of range, or numeric strings now clamp to the nearest bound (or fall back to the default when non-numeric) instead of failing the whole search — e.g. `context_lines: 3` runs with 2. ## [6.8.2] - 2026-09-04 diff --git a/src/ui/App.tsx b/src/ui/App.tsx index bf81601..02e3d60 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -882,6 +882,17 @@ export function App({ setSettings(updated); saveSettings(updated); agentRef.current?.setModel(modelId); + setRows((prev) => { + const headerIndex = prev.findIndex((row) => row.kind === "header"); + if (headerIndex === -1) return prev; + const updatedHeader = { + ...prev[headerIndex]!, + modelName: getModel(modelId).name, + }; + const next = [...prev]; + next[headerIndex] = updatedHeader; + return next; + }); pushRow({ kind: "info", text: `Model switched to ${getModel(modelId).name}`, diff --git a/src/utils/jsonRepair.ts b/src/utils/jsonRepair.ts index a03e371..bc215a0 100644 --- a/src/utils/jsonRepair.ts +++ b/src/utils/jsonRepair.ts @@ -3,10 +3,11 @@ * * Models occasionally emit almost-JSON: unquoted strings (`"file_pattern": *.tsx`), * unquoted keys, single quotes, trailing commas, Python literals (`True`/`None`), - * comments, or output truncated mid-call. Failing the call outright burns a - * round trip, and weaker models repeat the same mistake on retry. This module - * repairs the common cases and reports whether it intervened so the agent can - * tell the model which arguments actually ran. + * comments, XML-style tags interleaved where punctuation belongs, keys with + * dropped closing quotes, or output truncated mid-call. Failing the call + * outright burns a round trip, and weaker models repeat the same mistake on + * retry. This module repairs the common cases and reports whether it + * intervened so the agent can tell the model which arguments actually ran. */ export interface ParsedToolCallArguments { @@ -67,6 +68,10 @@ function isRecord(value: unknown): value is Record { /** Characters that always terminate a bare (unquoted) token. */ const BARE_TOKEN_TERMINATORS = new Set([",", "{", "}", "[", "]", '"', "'"]); +/** XML-style tags (``) some models interleave into + * arguments where JSON punctuation belongs. */ +const XML_TAG_PATTERN = /<\/?[A-Za-z_][A-Za-z0-9_.-]*>/g; + /** Matching closer for each opening bracket. */ const CLOSERS = new Map([ ["{", "}"], @@ -121,6 +126,14 @@ function formatBareValue(token: string): string { return quoteAsJsonString(token); } +/** Index at which a broken key-position string should split: the first colon, + * else the first whitespace. -1 (or 0) when the content looks like a plain key. */ +function firstKeySplit(content: string): number { + const colonAt = content.indexOf(":"); + if (colonAt > 0) return colonAt; + return content.search(/\s/); +} + /** Scan a bare (unquoted) token starting at `start`. In value position the * token may contain spaces and colons (`git status`, `https://…`); in key * position it ends at the first whitespace or colon. */ @@ -146,7 +159,11 @@ function scanBareToken( * Braceless object bodies are wrapped so `path: "src"` parses as an object. */ function repairJsonSource(input: string): string | null { - const source = /^[[{]/.test(input) ? input : `{${input}}`; + // Strip XML-style tags first: some models interleave them where JSON + // punctuation belongs. Valid JSON never reaches this path, so legitimate + // `<` usage in strings is unaffected. + const stripped = input.replace(XML_TAG_PATTERN, ""); + const source = /^[[{]/.test(stripped) ? stripped : `{${stripped}}`; let out = ""; let index = 0; @@ -160,9 +177,25 @@ function repairJsonSource(input: string): string | null { if (char === '"') { const end = findStringEnd(source, index, '"'); + const content = + end === -1 ? source.slice(index + 1) : source.slice(index + 1, end); + if (!expectValue) { + // A key-position string containing a colon or whitespace means the + // model dropped the key's closing quote (`"offset: 600`) or put an + // XML-style tag where the colon belonged. Split at the first colon + // (else whitespace): the head becomes the key, the tail is re-scanned + // as the value. + const splitAt = firstKeySplit(content); + if (splitAt > 0) { + out += `${quoteAsJsonString(content.slice(0, splitAt))}:`; + index += 1 + splitAt + 1; + expectValue = true; + continue; + } + } if (end === -1) { // Truncated mid-string: close it and stop scanning. - out += quoteAsJsonString(source.slice(index + 1)); + out += quoteAsJsonString(content); index = source.length; } else { out += source.slice(index, end + 1); diff --git a/test/json-repair.test.ts b/test/json-repair.test.ts index 40ce3bd..8d414b9 100644 --- a/test/json-repair.test.ts +++ b/test/json-repair.test.ts @@ -110,6 +110,40 @@ test("repairs nested structures with bare tokens", () => { }); }); +test("strips XML-style tags models interleave into arguments", () => { + const raw = + '{"files": [{"file_path": "/Users/x/project/src/ui/App.tsx", "offset\n 600, "limit": 40}'; + const parsed = parseToolCallArguments(raw); + assert.equal(parsed?.repaired, true); + assert.deepEqual(parsed?.args, { + files: [{ file_path: "/Users/x/project/src/ui/App.tsx", offset: 600, limit: 40 }], + }); + + // Tags inside a properly quoted string are legitimate content: the JSON is + // valid, so it passes through strict parsing untouched. + const inline = parseToolCallArguments( + '{"path": "src", "regex": "ab"}', + ); + assert.equal(inline?.repaired, false); + assert.deepEqual(inline?.args, { path: "src", regex: "ab" }); +}); + +test("recovers keys with dropped closing quotes", () => { + const colon = parseToolCallArguments( + '{"files": [{"file_path": "a.ts", "offset: 600, "limit": 40}', + ); + assert.equal(colon?.repaired, true); + assert.deepEqual(colon?.args, { + files: [{ file_path: "a.ts", offset: 600, limit: 40 }], + }); + + const dangling = parseToolCallArguments('{"path: "src"}'); + assert.deepEqual(dangling?.args, { path: "src" }); + + const truncated = parseToolCallArguments('{"offset: 600}'); + assert.deepEqual(truncated?.args, { offset: 600 }); +}); + test("returns null for unrecoverable input", () => { assert.equal(parseToolCallArguments("just some prose"), null); assert.equal(parseToolCallArguments('"a bare string"'), null);