Skip to content
Open
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.0] - 2026-08-28

### Changed

- **Ported the 6.8.2 coding-harness update from the Orbital extension.**
- `search_files` is now one-shot: ripgrep-first with FFF fallback, results bounded to the first 100 matches (default `max_results` 100), and cursor pagination removed from the model-facing schema and output. Capped results tell the model to refine the query instead of paginating.
- Independent read-only tool calls (`read_file`, `search_files`, `list_files`, `list_code_definition_names`, `codebase_search`, `lsp`) at the start of an assistant response now execute concurrently (max 4) with results committed in model order; mutating and interactive tools stay serialized.
- Malformed tool-call JSON now returns a corrective tool result that includes the raw arguments, so the model can re-issue the call with valid JSON instead of dead-ending.
- Native tool schemas tightened for strict mode: optional parameters are now required with nullable types (`replace_all`, `recursive`, `follow_up`, `offset`/`limit`, `cwd`/`message`/`isDangerous`, and the inactive-in-CLI tool schemas), and `execute_command` guidance asks for an explicit safety classification.
- System-prompt `search_files` guidance updated to the bounded one-shot behavior.

### Added

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@matterailab/orbcode",
"version": "6.7.9",
"version": "6.8.0",
"description": "OrbCode CLI — agentic coding in your terminal, powered by Axon models by MatterAI",
"type": "module",
"bin": {
Expand Down
55 changes: 53 additions & 2 deletions src/core/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@ import {

const MAX_STEPS_PER_TURN = 50
const RESULT_PREVIEW_LINES = 6
/** Maximum number of independent read-only tools started at once. */
const MAX_PARALLEL_READ_ONLY_TOOLS = 4

/** These tools only observe repository state, so a leading run of them in one
* assistant response can execute concurrently. Mutating, interactive, and
* external tools stay serialized. */
const PARALLEL_READ_ONLY_TOOLS = new Set([
"read_file",
"search_files",
"list_files",
"list_code_definition_names",
"codebase_search",
"lsp",
])
/** How many times to automatically re-establish a model request that fails
* before producing any output (transient/connection errors). */
const MAX_STREAM_RETRIES = 3
Expand Down Expand Up @@ -1147,13 +1161,46 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`
}

let completed = false
for (const toolCall of toolCalls) {
const runToolCall = async (toolCall: PendingToolCall): Promise<void> => {
const resultText = await this.handleToolCall(toolCall)
this.messages.push({ role: "tool", tool_call_id: toolCall.id, content: resultText })
if (toolCall.name === "attempt_completion") {
completed = true
}
}

// Independent read-only calls (the leading run of the response) execute
// concurrently, at most MAX_PARALLEL_READ_ONLY_TOOLS at a time. Results are
// committed in model order so tool_call/tool_result pairing stays intact;
// mutating and interactive calls remain on the serialized path.
let batchEnd = 0
while (batchEnd < toolCalls.length && PARALLEL_READ_ONLY_TOOLS.has(toolCalls[batchEnd].name)) {
batchEnd++
}

if (batchEnd > 1) {
const batch = toolCalls.slice(0, batchEnd)
const results = new Array<string>(batch.length)
let nextIndex = 0
await Promise.all(
Array.from({ length: Math.min(MAX_PARALLEL_READ_ONLY_TOOLS, batch.length) }, async () => {
while (nextIndex < batch.length) {
const index = nextIndex++
results[index] = await this.handleToolCall(batch[index])
}
Comment on lines +1187 to +1190

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Error Handling

Issue: In the new parallel batch, results are buffered in results[] and only pushed to this.messages after Promise.all resolves. If handleToolCall rejects for any one tool (e.g. an AbortError from user interrupt, or a throwing PreToolUse hook), Promise.all rejects immediately: the tool results that already completed are discarded and no role: "tool" messages are pushed for the batch. The assistant message with tool_calls is already in this.messages, so the next model request violates the tool_call/tool_result pairing contract and the API call will fail. The serialized path pushed each result as it completed, so this is a regression introduced by the parallel path.

Fix: Catch per-tool errors inside each worker and store the error text as that tool's result, so every tool_call_id always gets a tool response and completed results survive a sibling failure.

Impact: Keeps the OpenAI message contract intact on aborts/hook failures and prevents losing already-completed parallel tool results.

Suggested change
while (nextIndex < batch.length) {
const index = nextIndex++
results[index] = await this.handleToolCall(batch[index])
}
while (nextIndex < batch.length) {
const index = nextIndex++
try {
results[index] = await this.handleToolCall(batch[index])
} catch (error) {
results[index] = `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`
}
}

}),
)
for (const [index, toolCall] of batch.entries()) {
this.messages.push({ role: "tool", tool_call_id: toolCall.id, content: results[index] })
}
} else {
for (let index = 0; index < batchEnd; index++) {
await runToolCall(toolCalls[index])
}
}
for (let index = batchEnd; index < toolCalls.length; index++) {
await runToolCall(toolCalls[index])
}
return completed
}

Expand All @@ -1164,7 +1211,11 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`
try {
args = toolCall.arguments ? JSON.parse(toolCall.arguments) : {}
} catch (error) {
const message = `Invalid JSON arguments for ${toolCall.name}: ${(error as Error).message}`
// 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.`
onEvent({
type: "tool-end",
id: toolCall.id,
Expand Down
10 changes: 4 additions & 6 deletions src/prompts/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,19 +185,17 @@ Command validity rules: a command is never empty, never just \`:\`, never a bare

## search_files

Search file contents using a Rust-compatible regex. Results are compact, limited to three matches per file, and paginated.
Search file contents using a Rust-compatible regex. Results are compact and bounded to the first 100 matches; refine the query instead of paginating.

### Parameters

1. **path** (string, required): Directory to search recursively, relative to workspace
2. **regex** (string, required): Rust-compatible regular expression pattern
3. **file_pattern** (string or null, required): Glob pattern to filter files OR null
4. **cursor** (string or null, required): Copy the opaque cursor from the same search exactly, or pass JSON null without quotes for the first page
5. **max_results** (integer or null, required): Target 1-100 results; null defaults to 50
6. **context_lines** (integer or null, required): 0-2 surrounding lines; null defaults to 0
4. **max_results** (integer or null, required): Target 1-100 results; null defaults to 100
5. **context_lines** (integer or null, required): 0-2 surrounding lines; null defaults to 0

Use zero context for discovery, then read the relevant file region. Reuse a cursor only with the same path, regex, and file pattern; never invent or edit one.
If \`Next cursor\` is \`none\`, the search is complete: stop and never pass the word \`none\`. If a result says \`Restarted: yes\`, the FFF continuation failed and ripgrep restarted at page one, so account for repeated matches and continue only with the new cursor.
Use zero context for discovery, then read the relevant file region. If results are capped, refine the path, regex, or file pattern.

### Search Hygiene

Expand Down
24 changes: 11 additions & 13 deletions src/tools/executors/searchFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,33 +63,31 @@ export async function searchFiles(args: Record<string, unknown>, context: ToolCo
const options = parseSearchOptions(args, fingerprint)
let page: SearchPage

if (options.cursor?.engine === "ripgrep") {
page = await searchFilesWithRipgrep(context.cwd, directoryPath, regex, filePattern, options)
if (options.cursor?.engine === "fff") {
page = await searchFilesWithFff(context.cwd, directoryPath, regex, filePattern, options)
} else {
try {
page = await searchFilesWithFff(context.cwd, directoryPath, regex, filePattern, options)
// Ripgrep is the fast, deterministic default used by coding agents. FFF
// remains available as a fallback for installations where the bundled
// ripgrep binary is unavailable or cannot execute the requested pattern.
page = await searchFilesWithRipgrep(context.cwd, directoryPath, regex, filePattern, options)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const restarted = options.cursor?.engine === "fff"
page = await searchFilesWithRipgrep(context.cwd, directoryPath, regex, filePattern, {
page = await searchFilesWithFff(context.cwd, directoryPath, regex, filePattern, {
...options,
cursor: null,
})
page.warning = restarted
? `FFF continuation failed; ripgrep fallback restarted from the first page and may repeat earlier results (${message})`
: `FFF failed; used ripgrep fallback (${message})`
page.restarted = restarted
page.warning = `ripgrep failed; used FFF fallback (${message})`
}
}

const output = formatSearchPage(page)

// forked_change: append guidance when a search returns no matches,
// steering the model toward tightening/loosening the regex or scoping
// the path instead of blindly retrying with a slightly different pattern.
// Append guidance when a search returns no matches so the model changes
// the query instead of repeating the same search unchanged.
if (page.matches.length === 0) {
return {
text: output + "\n\nNo matches found. Before retrying:\n- Tighten or simplify the regex (e.g. use a shorter, more specific pattern).\n- Widen the path scope (e.g. search from the repo root instead of a subdirectory).\n- Try a different file_pattern glob.\n- If you have already searched 2+ times with no results, stop searching and reason from what you already know.",
text: output + "\n\nNo matches found. Change the regex, path, or file_pattern before retrying; do not repeat this unchanged search.",
}
}

Expand Down
9 changes: 5 additions & 4 deletions src/tools/executors/searchFiles/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function stripSearchPageMetadataForDisplay(text: string): string {
let firstVisibleLine = 0
while (
firstVisibleLine < lines.length &&
/^(?:Engine|Matches|Next cursor|Restarted|Warning):/.test(lines[firstVisibleLine])
/^(?:Engine|Matches|Next cursor|Restarted|Warning):|^Additional matches omitted/.test(lines[firstVisibleLine])
) {
firstVisibleLine++
}
Expand All @@ -35,9 +35,10 @@ export function stripSearchPageMetadataForDisplay(text: string): string {

export function formatSearchPage(page: SearchPage): string {
const cursor = serializeSearchCursor(page.nextCursor)
const nextCursor = cursor ?? "none (search complete; do not continue)"
const header = [`Engine: ${page.engine}`, `Matches: ${page.matches.length}`, `Next cursor: ${nextCursor}`]
if (page.restarted) header.push("Restarted: yes")
const header = [`Engine: ${page.engine}`, `Matches: ${page.matches.length}`]
if (cursor) {
header.push("Additional matches omitted; refine the search pattern or path instead of paginating.")
}
if (page.warning) header.push(`Warning: ${page.warning}`)
if (page.matches.length === 0) return header.join("\n")

Expand Down
5 changes: 3 additions & 2 deletions src/tools/executors/searchFiles/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { createHash } from "node:crypto"
import * as path from "node:path"

export const DEFAULT_SEARCH_RESULTS = 50
// Keep the model-facing search operation one-shot. A larger first page is
// cheaper than forcing the model through cursor continuation turns.
export const DEFAULT_SEARCH_RESULTS = 100
export const MAX_SEARCH_RESULTS = 100
export const MAX_MATCHES_PER_FILE = 3
export const MAX_SEARCH_CONTEXT_LINES = 2
Expand Down Expand Up @@ -48,7 +50,6 @@ export interface SearchPage {
matches: SearchMatch[]
nextCursor: SearchCursor | null
warning?: string
restarted?: boolean
}

export interface SearchOptions {
Expand Down
2 changes: 1 addition & 1 deletion src/tools/schemas/ask_followup_question.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export default {
maxItems: 4,
},
},
required: ["question"],
required: ["question", "follow_up"],
additionalProperties: false,
},
},
Expand Down
2 changes: 1 addition & 1 deletion src/tools/schemas/browser_action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export default {
description: "Text to type when performing the type action",
},
},
required: ["action"],
required: ["action", "url", "coordinate", "size", "text"],
additionalProperties: false,
},
},
Expand Down
4 changes: 2 additions & 2 deletions src/tools/schemas/check_past_chat_memories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ export default {
description: "Regular expression pattern to search memory contents",
},
workspace: {
type: "string",
type: ["string", "null"],
description: "Filter by workspace directory (optional, defaults to current workspace)",
},
},
required: ["regex"],
required: ["regex", "workspace"],
additionalProperties: false,
},
},
Expand Down
2 changes: 1 addition & 1 deletion src/tools/schemas/codebase_search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default {
description: "Optional subdirectory (relative to the workspace) to limit the search scope",
},
},
required: ["query"],
required: ["query", "path"],
additionalProperties: false,
},
},
Expand Down
21 changes: 10 additions & 11 deletions src/tools/schemas/execute_command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export default {
function: {
name: "execute_command",
description:
"Run a CLI command on the user's system. Tailor the command to the environment, explain what it does, and prefer relative paths or shell-appropriate chaining. Use the cwd parameter only when directed to run in a different directory.",
"Run one CLI command. Provide a short user-facing message and explicitly classify whether it may modify or delete data. Prefer commands scoped to the workspace.",
strict: true,
parameters: {
type: "object",
Expand All @@ -14,22 +14,21 @@ export default {
type: "string",
description: "Shell command to execute",
},
cwd: {
type: ["string", "null"],
description: "Optional working directory for the command, relative or absolute",
},
message: {
type: "string",
description:
"A clear, concise one-line description of what the command does, shown to the user for approval (e.g. 'Install project dependencies with npm')",
},
cwd: {
type: ["string", "null"],
description: "Working directory, or null for the workspace directory",
},
message: {
type: "string",
description: "Clear one-line description shown to the user for approval",
},
isDangerous: {
type: "boolean",
description:
"Set true when the command is potentially destructive or irreversible — e.g. deletes/overwrites files (rm, mv over existing paths), force-pushes or resets git history, drops/migrates databases, changes system/network/permission state, installs globally, or sends data to external services. Set false for safe read-only or routine commands (ls, cat, build, test, install local deps). The user's selected approval mode may auto-approve only commands marked false.",
},
},
required: ["command"],
required: ["command", "cwd", "message", "isDangerous"],
additionalProperties: false,
},
},
Expand Down
12 changes: 6 additions & 6 deletions src/tools/schemas/file_edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ export default {
description:
"Replacement text. This will be inserted in place of the matched section. Can be an empty string to delete the match.",
},
replace_all: {
type: "boolean",
description:
"Set to true to replace every occurrence of the matched text. Defaults to false (replace a single uniquely identified occurrence).",
},
replace_all: {
type: ["boolean", "null"],
description:
"Pass false (or null) unless the requested change intentionally applies to every occurrence. Never use it to bypass an ambiguity error.",
},
required: ["file_path", "old_string", "new_string"],
},
required: ["file_path", "old_string", "new_string", "replace_all"],
additionalProperties: false,
},
},
Expand Down
2 changes: 1 addition & 1 deletion src/tools/schemas/generate_image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default {
"Optional path (relative to the workspace) to an existing image to edit; supports PNG, JPG, JPEG, GIF, and WEBP",
},
},
required: ["prompt", "path"],
required: ["prompt", "path", "image"],
additionalProperties: false,
},
},
Expand Down
2 changes: 1 addition & 1 deletion src/tools/schemas/list_files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default {
description: "Set true to list contents recursively; omit or false to show only the top level",
},
},
required: ["path"],
required: ["path", "recursive"],
additionalProperties: false,
},
},
Expand Down
7 changes: 2 additions & 5 deletions src/tools/schemas/lsp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export default {
type: "function",
function: {
name: "lsp",
description: `Interact with Language Server Protocol (LSP) servers to get code intelligence features like go-to-definition, find-references, hover information, and symbol search.
description: `Use language-server code intelligence when textual search is ambiguous. Supported operations are go_to_definition, find_references, hover, document_symbol, and workspace_symbol.

Supported operations:
- go_to_definition: Find where a symbol is defined
Expand All @@ -13,10 +13,7 @@ Supported operations:
- document_symbol: Get all symbols (functions, classes, variables) in a document
- workspace_symbol: Search for symbols across the entire workspace

All operations require:
- file_path: The absolute path to the file to operate on
- line: The line number (1-based, as shown in editors)
- character: The character offset (1-based, as shown in editors)
Position-based operations require file_path, line, and character. document_symbol and workspace_symbol also accept a position to identify the document or symbol query.

Note: LSP servers must be configured for the file type. If no server is available, an error will be returned.`,
strict: true,
Expand Down
12 changes: 6 additions & 6 deletions src/tools/schemas/multi_file_edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ export default {
description:
"Replacement text. This will be inserted in place of the matched section. Can be an empty string to delete the match.",
},
replace_all: {
type: "boolean",
description:
"Set to true to replace every occurrence of the matched text. Defaults to false (replace a single uniquely identified occurrence).",
},
replace_all: {
type: ["boolean", "null"],
description:
"Pass false (or null) unless the requested change intentionally applies to every occurrence. Never use it to bypass an ambiguity error.",
},
required: ["file_path", "old_string", "new_string"],
},
required: ["file_path", "old_string", "new_string", "replace_all"],
additionalProperties: false,
},
},
Expand Down
2 changes: 1 addition & 1 deletion src/tools/schemas/new_task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default {
"Optional initial todo list written as a markdown checklist; required when the workspace mandates todos",
},
},
required: ["mode", "message"],
required: ["mode", "message", "todos"],
additionalProperties: false,
},
},
Expand Down
2 changes: 1 addition & 1 deletion src/tools/schemas/read_file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const read_file = {
"Lines to read from offset. Prefer 500-1000. Use null to read from offset up to the 1000-line cap.",
},
},
required: ["file_path"],
required: ["file_path", "offset", "limit"],
additionalProperties: false,
},
},
Expand Down
Loading