Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@ 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

### 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, XML-style tags interleaved where punctuation belongs (`"offset</longcat_arg_key>`), 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

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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"
Expand Down
9 changes: 4 additions & 5 deletions src/api/aiSdkClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
44 changes: 31 additions & 13 deletions src/core/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, unknown>): 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 ""
Expand Down Expand Up @@ -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<string, unknown> = {}
try {
args = JSON.parse(call.function.arguments || "{}") as Record<string, unknown>
} 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
Expand Down Expand Up @@ -1207,15 +1213,13 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`
private async handleToolCall(toolCall: PendingToolCall): Promise<string> {
const { onEvent, requestApproval, requestFollowup } = this.options.callbacks

let args: Record<string, unknown>
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,
Expand All @@ -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") {
Expand All @@ -1245,7 +1262,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`
}
const answer = await requestFollowup(question, suggestions)
this.transcript.push({ kind: "user", text: answer })
return `<answer>\n${answer}\n</answer>`
return `<answer>\n${answer}\n</answer>${repairNote ? `\n\n${repairNote}` : ""}`
}

// PreToolUse runs before approval/execution. It can block the call,
Expand Down Expand Up @@ -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", {
Expand Down
16 changes: 9 additions & 7 deletions src/tools/executors/searchFiles/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -123,8 +125,8 @@ export function serializeSearchCursor(cursor: SearchCursor | null): string | nul
export function parseSearchOptions(args: Record<string, unknown>, 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,
}
}
11 changes: 11 additions & 0 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
Loading