From 1db58085e3ab5b6311894302b4cb2220de12334f Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 21 Jan 2026 06:40:25 +0100 Subject: [PATCH] fix(coding-agent/core): corrected async stream handling and timeout conversion - Fixed async stream handling in executors by adding await to stream.dump() calls. - Improved output sink to handle large outputs and prevent truncation. - Fixed timeout handling in web fetch tool with proper millisecond conversion. - Updated process execution in web scrapers to use ptree.cspawn instead of Bun executor. --- packages/coding-agent/CHANGELOG.md | 11 + packages/coding-agent/DEVELOPMENT.md | 27 +-- .../coding-agent/src/core/agent-session.ts | 19 +- .../coding-agent/src/core/bash-executor.ts | 71 +----- .../coding-agent/src/core/extensions/types.ts | 3 - packages/coding-agent/src/core/index.ts | 2 +- .../coding-agent/src/core/python-executor.ts | 40 ++-- .../coding-agent/src/core/ssh/ssh-executor.ts | 21 +- .../coding-agent/src/core/streaming-output.ts | 208 +++++------------- packages/coding-agent/src/core/tools/bash.ts | 54 +---- packages/coding-agent/src/core/tools/index.ts | 2 +- .../coding-agent/src/core/tools/python.ts | 21 +- packages/coding-agent/src/core/tools/ssh.ts | 20 +- .../coding-agent/src/core/tools/truncate.ts | 92 ++++++++ .../coding-agent/src/core/tools/web-fetch.ts | 10 +- .../src/core/tools/web-scrapers/utils.ts | 37 +--- .../tools/web-search/providers/anthropic.ts | 1 - packages/coding-agent/src/index.ts | 1 - packages/pi-utils/src/ptree.ts | 6 +- 19 files changed, 239 insertions(+), 407 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0a0d0751a..09333a936 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,17 @@ # Changelog ## [Unreleased] +### Changed + +- Updated output sink to properly handle large outputs +- Improved error message formatting in SSH executor +- Updated web fetch timeout bounds and conversion + +### Fixed + +- Fixed output truncation handling in streaming output +- Fixed timeout handling in web fetch tool +- Fixed async stream dumping in executors ## [6.8.3] - 2026-01-21 diff --git a/packages/coding-agent/DEVELOPMENT.md b/packages/coding-agent/DEVELOPMENT.md index 626704661..772a3e56c 100644 --- a/packages/coding-agent/DEVELOPMENT.md +++ b/packages/coding-agent/DEVELOPMENT.md @@ -324,6 +324,7 @@ const myExtension: ExtensionFactory = async (pi) => { ``` Key extension events: + - `before_agent_start`: Receives `systemPrompt` and can return full replacement (not just append) - `user_bash`: Intercept `!`/`!!` commands for custom execution (e.g., remote SSH) - `session_shutdown`: Cleanup notification before exit @@ -411,32 +412,6 @@ bun test --testNamePattern="RPC" bun test/rpc-example.ts ``` -### Pluggable Tool Operations - -Built-in tools support pluggable operations for remote execution: - -- **BashOperations**: Execute commands on remote systems -- **LsOperations**: Remote directory listing -- **GrepOperations**: Remote content search -- **FindOperations**: Remote file search - -Example: SSH extension overriding bash execution: - -```typescript -pi.on("user_bash", async (event) => { - if (shouldRunRemotely()) { - return { - operations: { - exec: async (cmd, cwd, opts) => { - // Execute via SSH - return { exitCode: 0 }; - }, - }, - }; - } -}); -``` - ### Managed Binaries Tools like `fd` and `rg` are auto-downloaded to `~/.omp/bin/` (migrated from `~/.omp/agent/tools/`). diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index e47376d4c..0efb0e651 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -22,7 +22,7 @@ import type { Rule } from "../capability/rule"; import { getAgentDbPath } from "../config"; import { theme } from "../modes/interactive/theme/theme"; import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" }; -import { type BashResult, executeBash as executeBashCommand, executeBashWithOperations } from "./bash-executor"; +import { type BashResult, executeBash as executeBashCommand } from "./bash-executor"; import { type CompactionResult, calculateContextTokens, @@ -60,7 +60,6 @@ import type { Skill, SkillWarning } from "./skills"; import { expandSlashCommand, type FileSlashCommand } from "./slash-commands"; import { closeAllConnections } from "./ssh/connection-manager"; import { unmountAll } from "./ssh/sshfs-mount"; -import type { BashOperations } from "./tools/bash"; import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "./tools/patch"; import { resolveToCwd } from "./tools/path-utils"; import type { TodoItem } from "./tools/todo-write"; @@ -2546,25 +2545,19 @@ export class AgentSession { * @param command The bash command to execute * @param onChunk Optional streaming callback for output * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix) - * @param options.operations Custom BashOperations for remote execution */ async executeBash( command: string, onChunk?: (chunk: string) => void, - options?: { excludeFromContext?: boolean; operations?: BashOperations }, + options?: { excludeFromContext?: boolean }, ): Promise { this._bashAbortController = new AbortController(); try { - const result = options?.operations - ? await executeBashWithOperations(command, process.cwd(), options.operations, { - onChunk, - signal: this._bashAbortController.signal, - }) - : await executeBashCommand(command, { - onChunk, - signal: this._bashAbortController.signal, - }); + const result = await executeBashCommand(command, { + onChunk, + signal: this._bashAbortController.signal, + }); this.recordBashResult(command, result, options); return result; diff --git a/packages/coding-agent/src/core/bash-executor.ts b/packages/coding-agent/src/core/bash-executor.ts index 2283d3e7a..f93cefc88 100644 --- a/packages/coding-agent/src/core/bash-executor.ts +++ b/packages/coding-agent/src/core/bash-executor.ts @@ -8,7 +8,6 @@ import { cspawn, Exception, ptree } from "@oh-my-pi/pi-utils"; import { getShellConfig } from "../utils/shell"; import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot"; import { OutputSink } from "./streaming-output"; -import type { BashOperations } from "./tools/bash"; export interface BashExecutorOptions { cwd?: string; @@ -34,7 +33,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions const prefixedCommand = prefix ? `${prefix} ${command}` : command; const finalCommand = `${snapshotPrefix}${prefixedCommand}`; - const stream = new OutputSink({ onChunk: options?.onChunk }); + const sink = new OutputSink({ onChunk: options?.onChunk }); const child = cspawn([shell, ...args, finalCommand], { cwd: options?.cwd, @@ -45,12 +44,9 @@ export async function executeBash(command: string, options?: BashExecutorOptions // Pump streams - errors during abort/timeout are expected // Use preventClose to avoid closing the shared sink when either stream finishes - await Promise.allSettled([ - child.stdout.pipeTo(stream.createWritable()), - child.stderr.pipeTo(stream.createWritable()), - ]) - .then(() => stream.close()) - .catch(() => {}); + await Promise.allSettled([child.stdout.pipeTo(sink.createInput()), child.stderr.pipeTo(sink.createInput())]).catch( + () => {}, + ); // Wait for process exit try { @@ -58,7 +54,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions return { exitCode: child.exitCode ?? 0, cancelled: false, - ...stream.dump(), + ...(await sink.dump()), }; } catch (err) { // Exception covers NonZeroExitError, AbortError, TimeoutError @@ -71,7 +67,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions return { exitCode: undefined, cancelled: true, - ...stream.dump(annotation), + ...(await sink.dump(annotation)), }; } @@ -79,60 +75,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions return { exitCode: err.exitCode, cancelled: false, - ...stream.dump(), - }; - } - - throw err; - } -} - -export async function executeBashWithOperations( - command: string, - cwd: string, - operations: BashOperations, - options?: BashExecutorOptions, -): Promise { - const stream = new OutputSink({ onChunk: options?.onChunk }); - const writable = stream.createWritable(); - const writer = writable.getWriter(); - - const closeStreams = async () => { - try { - await writer.close(); - } catch {} - try { - await writable.close(); - } catch {} - try { - await stream.close(); - } catch {} - }; - - try { - const result = await operations.exec(command, cwd, { - onData: (data) => writer.write(data), - signal: options?.signal, - timeout: options?.timeout, - }); - - await closeStreams(); - - const cancelled = options?.signal?.aborted ?? false; - - return { - exitCode: cancelled ? undefined : (result.exitCode ?? undefined), - cancelled, - ...stream.dump(), - }; - } catch (err) { - await closeStreams(); - - if (options?.signal?.aborted) { - return { - exitCode: undefined, - cancelled: true, - ...stream.dump(), + ...(await sink.dump()), }; } diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index d20288ed1..100ed47d9 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -29,7 +29,6 @@ import type { SessionManager, } from "../session-manager"; import type { BashToolDetails, FindToolDetails, GrepToolDetails, LsToolDetails, ReadToolDetails } from "../tools"; -import type { BashOperations } from "../tools/bash"; import type { EditToolDetails } from "../tools/patch"; export type { ExecOptions, ExecResult } from "../exec"; @@ -551,8 +550,6 @@ export interface InputEventResult { /** Result from user_bash event handler */ export interface UserBashEventResult { - /** Custom operations to use for execution */ - operations?: BashOperations; /** Full replacement: extension handled execution, use this result */ result?: BashResult; } diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 256346957..d6ad5b2f7 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -11,7 +11,7 @@ export { type PromptOptions, type SessionStats, } from "./agent-session"; -export { type BashExecutorOptions, type BashResult, executeBash, executeBashWithOperations } from "./bash-executor"; +export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor"; export type { CompactionResult } from "./compaction/index"; export { discoverAndLoadExtensions, diff --git a/packages/coding-agent/src/core/python-executor.ts b/packages/coding-agent/src/core/python-executor.ts index bf09d03aa..d1920c36a 100644 --- a/packages/coding-agent/src/core/python-executor.ts +++ b/packages/coding-agent/src/core/python-executor.ts @@ -1,4 +1,4 @@ -import { logger, sanitizeText } from "@oh-my-pi/pi-utils"; +import { logger } from "@oh-my-pi/pi-utils"; import { checkPythonKernelAvailability, type KernelDisplayOutput, @@ -210,30 +210,20 @@ async function executeWithKernel( code: string, options: PythonExecutorOptions | undefined, ): Promise { - const sink = new OutputSink({ onLine: options?.onChunk }); + const sink = new OutputSink({ onChunk: options?.onChunk }); const displayOutputs: KernelDisplayOutput[] = []; try { - const writable = sink.createStringWritable(); - const writer = writable.getWriter(); - let result: KernelExecuteResult; - try { - result = await kernel.execute(code, { - signal: options?.signal, - timeoutMs: options?.timeout, - onChunk: (text) => { - writer.write(sanitizeText(text)); - }, - onDisplay: (output) => { - displayOutputs.push(output); - }, - }); - } catch (err) { - await writer.abort(err); - throw err; - } finally { - await writer.close().catch(() => {}); - } + const result = await kernel.execute(code, { + signal: options?.signal, + timeoutMs: options?.timeout, + onChunk: (text) => { + sink.push(text); + }, + onDisplay: (output) => { + displayOutputs.push(output); + }, + }); if (result.cancelled) { const secs = options?.timeout ? Math.round(options.timeout / 1000) : undefined; @@ -244,7 +234,7 @@ async function executeWithKernel( cancelled: true, displayOutputs, stdinRequested: result.stdinRequested, - ...sink.dump(annotation), + ...(await sink.dump(annotation)), }; } @@ -254,7 +244,7 @@ async function executeWithKernel( cancelled: false, displayOutputs, stdinRequested: true, - ...sink.dump("Kernel requested stdin; interactive input is not supported."), + ...(await sink.dump("Kernel requested stdin; interactive input is not supported.")), }; } @@ -264,7 +254,7 @@ async function executeWithKernel( cancelled: false, displayOutputs, stdinRequested: false, - ...sink.dump(), + ...(await sink.dump()), }; } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); diff --git a/packages/coding-agent/src/core/ssh/ssh-executor.ts b/packages/coding-agent/src/core/ssh/ssh-executor.ts index ed67e47b2..2e01d9d53 100644 --- a/packages/coding-agent/src/core/ssh/ssh-executor.ts +++ b/packages/coding-agent/src/core/ssh/ssh-executor.ts @@ -70,16 +70,11 @@ export async function executeSSH( timeout: options?.timeout, }); - const sink = new OutputSink({ onLine: options?.onChunk }); + const sink = new OutputSink({ onChunk: options?.onChunk }); - try { - await Promise.allSettled([ - child.stdout.pipeTo(sink.createWritable()), - child.stderr.pipeTo(sink.createWritable()), - ]); - } finally { - await sink.close(); - } + await Promise.allSettled([child.stdout.pipeTo(sink.createInput()), child.stderr.pipeTo(sink.createInput())]).catch( + () => {}, + ); try { await child.exited; @@ -87,7 +82,7 @@ export async function executeSSH( return { exitCode, cancelled: false, - ...sink.dump(), + ...(await sink.dump()), }; } catch (err) { if (err instanceof ptree.Exception) { @@ -95,20 +90,20 @@ export async function executeSSH( return { exitCode: undefined, cancelled: true, - ...sink.dump(`SSH: ${err.message}`), + ...(await sink.dump(`SSH: ${err.message}`)), }; } if (err.aborted) { return { exitCode: undefined, cancelled: true, - ...sink.dump(`SSH command aborted: ${err.message}`), + ...(await sink.dump(`Command aborted: ${err.message}`)), }; } return { exitCode: err.exitCode, cancelled: false, - ...sink.dump(`Unexpected error: ${err.message}`), + ...(await sink.dump(`Unexpected error: ${err.message}`)), }; } throw err; diff --git a/packages/coding-agent/src/core/streaming-output.ts b/packages/coding-agent/src/core/streaming-output.ts index f2837bb14..611c3540c 100644 --- a/packages/coding-agent/src/core/streaming-output.ts +++ b/packages/coding-agent/src/core/streaming-output.ts @@ -2,7 +2,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { sanitizeText } from "@oh-my-pi/pi-utils"; import { nanoid } from "nanoid"; -import { DEFAULT_MAX_BYTES, DEFAULT_MAX_COLUMN } from "./tools/truncate"; +import { DEFAULT_MAX_BYTES } from "./tools/truncate"; export interface OutputResult { output: string; @@ -14,7 +14,6 @@ export interface OutputSinkOptions { allocateFilePath?: () => string; spillThreshold?: number; maxColumn?: number; - onLine?: (line: string) => void; onChunk?: (chunk: string) => void; } @@ -29,182 +28,93 @@ function defaultFilePathAllocator(): string { * When memory limit exceeded, spills ~half to file in one batch operation. */ export class OutputSink { - private buffer = ""; - private lineEnds: number[] = []; // String index after each \n + #buffer = ""; + #file?: { + path: string; + sink: Bun.FileSink; + }; + #bytesWritten: number = 0; - private fileSink?: Bun.FileSink; - private filePath?: string; - - private readonly allocateFilePath: () => string; - private readonly spillThreshold: number; - private readonly maxColumn: number; - private readonly onLine?: (line: string) => void; - private readonly onChunk?: (chunk: string) => void; + readonly #allocateFilePath: () => string; + readonly #spillThreshold: number; + readonly #onChunk?: (chunk: string) => void; constructor(options?: OutputSinkOptions) { const { allocateFilePath = defaultFilePathAllocator, spillThreshold = DEFAULT_MAX_BYTES, - maxColumn = DEFAULT_MAX_COLUMN, - onLine, onChunk, } = options ?? {}; - this.allocateFilePath = allocateFilePath; - this.spillThreshold = spillThreshold; - this.maxColumn = maxColumn; - this.onLine = onLine; - this.onChunk = onChunk; + this.#allocateFilePath = allocateFilePath; + this.#spillThreshold = spillThreshold; + this.#onChunk = onChunk; } - private pushLine(line: string, term?: string): void { - while (line.length > this.maxColumn) { - this.pushLine(line.slice(0, this.maxColumn), "--\n"); - line = line.slice(this.maxColumn); - } + async #pushSanitized(data: string): Promise { + this.#onChunk?.(data); + const dataBytes = Buffer.byteLength(data); + const overflow = dataBytes + this.#bytesWritten > this.#spillThreshold || this.#file != null; - this.buffer += line; - if (term) { - this.buffer += term; - } + const sink = overflow ? await this.#fileSink() : null; - this.lineEnds.push(this.buffer.length); - this.onLine?.(line); + this.#buffer += data; + await sink?.write(data); - if (this.buffer.length > this.spillThreshold) { - this.spillHalf(); + if (this.#buffer.length > this.#spillThreshold) { + this.#buffer = this.#buffer.slice(-this.#spillThreshold); } } - private pushChunk(line: string): void { - this.onChunk?.(line); - this.pushLine(line); + async #fileSink(): Promise { + if (!this.#file) { + const filePath = this.#allocateFilePath(); + this.#file = { + path: filePath, + sink: Bun.file(filePath).writer(), + }; + await this.#file.sink.write(this.#buffer); + } + return this.#file.sink; } - private getFileSink(): Bun.FileSink { - if (!this.fileSink) { - const filePath = this.allocateFilePath(); - this.filePath = filePath; - this.fileSink = Bun.file(filePath).writer(); - } - return this.fileSink; + async push(chunk: string): Promise { + chunk = sanitizeText(chunk); + await this.#pushSanitized(chunk); } - private spillHalf(): void { - const target = this.buffer.length >>> 1; + createInput(): WritableStream { + let decoder: TextDecoder | undefined; + let finalize = async () => {}; - // Binary search: first line ending >= target - let lo = 0; - let hi = this.lineEnds.length; - while (lo < hi) { - const mid = (lo + hi) >>> 1; - if (this.lineEnds[mid] < target) { - lo = mid + 1; - } else { - hi = mid; - } - } - - // Clamp: evict at least 1 line, keep at least 1 line - const splitIdx = Math.max(1, Math.min(lo, this.lineEnds.length - 1)); - const splitPos = this.lineEnds[splitIdx - 1]; - - // Write evicted portion to file - this.getFileSink().write(this.buffer.slice(0, splitPos)); - - // Truncate buffer, shift line positions - this.buffer = this.buffer.slice(splitPos); - const remaining = this.lineEnds.length - splitIdx; - for (let i = 0; i < remaining; i++) { - this.lineEnds[i] = this.lineEnds[i + splitIdx] - splitPos; - } - this.lineEnds.length = remaining; - } - - createWritable(): WritableStream { - const decoder = new TextDecoder("utf-8", { ignoreBOM: true }); - let buf = ""; - - const flushLines = () => { - let start = 0; - while (true) { - const nl = buf.indexOf("\n", start); - if (nl === -1) break; - this.pushChunk(buf.slice(start, nl + 1)); - start = nl + 1; - } - buf = buf.slice(start); - }; - - const finalize = () => { - buf += sanitizeText(decoder.decode()); - flushLines(); - buf = buf.trimEnd(); - if (buf) { - this.pushChunk(`${buf}\n`); - } - }; - - return new WritableStream({ - write: (chunk) => { - buf += sanitizeText(decoder.decode(chunk, { stream: true })); - flushLines(); + return new WritableStream({ + write: async (chunk) => { + if (typeof chunk === "string") { + await this.push(chunk); + } else { + if (!decoder) { + const dec = new TextDecoder("utf-8", { ignoreBOM: true }); + decoder = dec; + finalize = async () => { + await this.push(dec.decode()); + }; + } + await this.push(decoder.decode(chunk, { stream: true })); + } }, close: finalize, abort: finalize, }); } - createStringWritable(): WritableStream { - let buf = ""; + async dump(notice?: string): Promise { + const noticeLine = notice ? `[${notice}]\n` : ""; - const flushLines = () => { - let start = 0; - while (true) { - const nl = buf.indexOf("\n", start); - if (nl === -1) break; - this.pushChunk(buf.slice(start, nl + 1)); - start = nl + 1; - } - buf = buf.slice(start); - }; - - const finalize = () => { - flushLines(); - buf = buf.trimEnd(); - if (buf) { - this.pushChunk(`${buf}\n`); - } - }; - - return new WritableStream({ - write: (chunk) => { - buf += sanitizeText(chunk); - flushLines(); - }, - close: finalize, - abort: finalize, - }); - } - - async close(): Promise { - await this.fileSink?.end(); - } - - dump(annotation?: string): OutputResult { - let output = this.buffer; - if (annotation) { - output += `\n${annotation}\n`; + if (this.#file) { + await this.#file.sink.end(); + return { output: `${noticeLine}...${this.#buffer}`, truncated: true, fullOutputPath: this.#file.path }; + } else { + return { output: `${noticeLine}${this.#buffer}`, truncated: false }; } - if (!this.filePath) { - return { output, truncated: false }; - } - this.fileSink!.write(this.buffer); - this.fileSink!.flush(); - return { - output, - truncated: true, - fullOutputPath: this.filePath, - }; } } diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 4da3bf38c..cb6d9bd23 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -6,14 +6,14 @@ import { Type } from "@sinclair/typebox"; import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate"; import type { Theme } from "../../modes/interactive/theme/theme"; import bashDescription from "../../prompts/tools/bash.md" with { type: "text" }; -import { type BashExecutorOptions, executeBash, executeBashWithOperations } from "../bash-executor"; +import { type BashExecutorOptions, executeBash } from "../bash-executor"; import type { RenderResultOptions } from "../custom-tools/types"; import { renderPromptTemplate } from "../prompt-templates"; import { checkBashInterception, checkSimpleLsInterception } from "./bash-interceptor"; import type { ToolSession } from "./index"; import { resolveToCwd } from "./path-utils"; import { ToolUIKit } from "./render-utils"; -import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate"; +import { formatTailTruncationNotice, type TruncationResult, truncateTail } from "./truncate"; export const BASH_DEFAULT_PREVIEW_LINES = 10; @@ -31,32 +31,12 @@ export interface BashToolDetails { fullOutput?: string; } -/** - * Pluggable operations for bash execution. - * Override to delegate command execution to remote systems. - */ -export interface BashOperations { - exec: ( - command: string, - cwd: string, - options: { - onData: (data: Buffer) => void; - signal?: AbortSignal; - timeout?: number; - }, - ) => Promise<{ exitCode: number | null }>; -} - -export interface BashToolOptions { - /** Custom operations for command execution. Default: local shell */ - operations?: BashOperations; -} +export interface BashToolOptions {} /** * Bash tool implementation. * * Executes bash commands with optional timeout and working directory. - * Supports custom operations for remote execution. */ export class BashTool implements AgentTool { public readonly name = "bash"; @@ -65,11 +45,9 @@ export class BashTool implements AgentTool { public readonly parameters = bashSchema; private readonly session: ToolSession; - private readonly options?: BashToolOptions; - constructor(session: ToolSession, options?: BashToolOptions) { + constructor(session: ToolSession) { this.session = session; - this.options = options; this.description = renderPromptTemplate(bashDescription); } @@ -125,12 +103,8 @@ export class BashTool implements AgentTool { }, }; - // Use custom operations if provided, otherwise use default local executor - const result = this.options?.operations - ? await executeBashWithOperations(command, commandCwd, this.options.operations, executorOptions) - : await executeBash(command, executorOptions); - // Handle errors + const result = await executeBash(command, executorOptions); if (result.cancelled) { throw new Error(result.output || "Command aborted"); } @@ -147,18 +121,10 @@ export class BashTool implements AgentTool { fullOutputPath: result.fullOutputPath, fullOutput: currentOutput, }; - - const startLine = truncation.totalLines - truncation.outputLines + 1; - const endLine = truncation.totalLines; - - if (truncation.lastLinePartial) { - const lastLineSize = formatSize(Buffer.byteLength(result.output.split("\n").pop() || "", "utf-8")); - outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${result.fullOutputPath}]`; - } else if (truncation.truncatedBy === "lines") { - outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${result.fullOutputPath}]`; - } else { - outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${result.fullOutputPath}]`; - } + outputText += formatTailTruncationNotice(truncation, { + fullOutputPath: result.fullOutputPath, + originalContent: result.output, + }); } if (result.exitCode !== 0 && result.exitCode !== undefined) { @@ -263,7 +229,7 @@ export const bashToolRenderer = { warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`); } else { warnings.push( - `Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`, + `Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes)} limit)`, ); } } diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index ea0e57318..d2c1266e7 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -1,5 +1,5 @@ export { AskTool, type AskToolDetails } from "./ask"; -export { type BashOperations, BashTool, type BashToolDetails, type BashToolOptions } from "./bash"; +export { BashTool, type BashToolDetails, type BashToolOptions } from "./bash"; export { CalculatorTool, type CalculatorToolDetails } from "./calculator"; export { CompleteTool } from "./complete"; // Exa MCP tools (22 tools) diff --git a/packages/coding-agent/src/core/tools/python.ts b/packages/coding-agent/src/core/tools/python.ts index e08548889..a611c751b 100644 --- a/packages/coding-agent/src/core/tools/python.ts +++ b/packages/coding-agent/src/core/tools/python.ts @@ -14,7 +14,7 @@ import type { PreludeHelper, PythonStatusEvent } from "../python-kernel"; import type { ToolSession } from "./index"; import { resolveToCwd } from "./path-utils"; import { getTreeBranch, getTreeContinuePrefix, shortenPath, ToolUIKit, truncate } from "./render-utils"; -import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate"; +import { DEFAULT_MAX_BYTES, formatTailTruncationNotice, type TruncationResult, truncateTail } from "./truncate"; export const PYTHON_DEFAULT_PREVIEW_LINES = 10; @@ -234,7 +234,6 @@ export class PythonTool implements AgentTool { let details: PythonToolDetails | undefined; if (truncation.truncated) { - const fullOutputSuffix = result.fullOutputPath ? ` Full output: ${result.fullOutputPath}` : ""; details = { truncation, fullOutputPath: result.fullOutputPath, @@ -242,18 +241,10 @@ export class PythonTool implements AgentTool { images, statusEvents: statusEvents.length > 0 ? statusEvents : undefined, }; - - const startLine = truncation.totalLines - truncation.outputLines + 1; - const endLine = truncation.totalLines; - - if (truncation.lastLinePartial) { - const lastLineSize = formatSize(Buffer.byteLength(result.output.split("\n").pop() || "", "utf-8")); - outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize})${fullOutputSuffix}]`; - } else if (truncation.truncatedBy === "lines") { - outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}${fullOutputSuffix}]`; - } else { - outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit)${fullOutputSuffix}]`; - } + outputText += formatTailTruncationNotice(truncation, { + fullOutputPath: result.fullOutputPath, + originalContent: result.output, + }); } if (!details && (jsonOutputs.length > 0 || images.length > 0 || statusEvents.length > 0)) { @@ -685,7 +676,7 @@ export const pythonToolRenderer = { warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`); } else { warnings.push( - `Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`, + `Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes)} limit)`, ); } } diff --git a/packages/coding-agent/src/core/tools/ssh.ts b/packages/coding-agent/src/core/tools/ssh.ts index febeaef3f..40c61d171 100644 --- a/packages/coding-agent/src/core/tools/ssh.ts +++ b/packages/coding-agent/src/core/tools/ssh.ts @@ -14,7 +14,7 @@ import { ensureHostInfo, getHostInfoForHost } from "../ssh/connection-manager"; import { executeSSH } from "../ssh/ssh-executor"; import type { ToolSession } from "./index"; import { ToolUIKit } from "./render-utils"; -import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate"; +import { formatTailTruncationNotice, type TruncationResult, truncateTail } from "./truncate"; const sshSchema = Type.Object({ host: Type.String({ description: "Host name from ssh.json or .ssh.json" }), @@ -193,18 +193,10 @@ export class SshTool implements AgentTool { truncation, fullOutputPath: result.fullOutputPath, }; - - const startLine = truncation.totalLines - truncation.outputLines + 1; - const endLine = truncation.totalLines; - - if (truncation.lastLinePartial) { - const lastLineSize = formatSize(Buffer.byteLength(result.output.split("\n").pop() || "", "utf-8")); - outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${result.fullOutputPath}]`; - } else if (truncation.truncatedBy === "lines") { - outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${result.fullOutputPath}]`; - } else { - outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${result.fullOutputPath}]`; - } + outputText += formatTailTruncationNotice(truncation, { + fullOutputPath: result.fullOutputPath, + originalContent: result.output, + }); } if (result.exitCode !== 0 && result.exitCode !== undefined) { @@ -311,7 +303,7 @@ export const sshToolRenderer = { warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`); } else { warnings.push( - `Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`, + `Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes)} limit)`, ); } } diff --git a/packages/coding-agent/src/core/tools/truncate.ts b/packages/coding-agent/src/core/tools/truncate.ts index d4839bf65..950a45bed 100644 --- a/packages/coding-agent/src/core/tools/truncate.ts +++ b/packages/coding-agent/src/core/tools/truncate.ts @@ -289,3 +289,95 @@ export function truncateLine( } return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true }; } + +// ============================================================================= +// Truncation notice formatting +// ============================================================================= + +export interface TailTruncationNoticeOptions { + /** Path to full output file (e.g., from bash/python executor) */ + fullOutputPath?: string; + /** Original content for computing last line size when lastLinePartial */ + originalContent?: string; + /** Additional suffix to append inside the brackets */ + suffix?: string; +} + +/** + * Format a truncation notice for tail-truncated output (bash, python, ssh). + * Returns empty string if not truncated. + * + * Examples: + * - "[Showing last 50KB of line 1000 (line is 2.1MB). Full output: /tmp/out.txt]" + * - "[Showing lines 500-1000 of 1000. Full output: /tmp/out.txt]" + * - "[Showing lines 500-1000 of 1000 (50KB limit). Full output: /tmp/out.txt]" + */ +export function formatTailTruncationNotice( + truncation: TruncationResult, + options: TailTruncationNoticeOptions = {}, +): string { + if (!truncation.truncated) { + return ""; + } + + const { fullOutputPath, originalContent, suffix = "" } = options; + const startLine = truncation.totalLines - truncation.outputLines + 1; + const endLine = truncation.totalLines; + const fullOutputPart = fullOutputPath ? `. Full output: ${fullOutputPath}` : ""; + + let notice: string; + + if (truncation.lastLinePartial) { + let lastLineSizePart = ""; + if (originalContent) { + const lastLine = originalContent.split("\n").pop() || ""; + lastLineSizePart = ` (line is ${formatSize(Buffer.byteLength(lastLine, "utf-8"))})`; + } + notice = `[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine}${lastLineSizePart}${fullOutputPart}${suffix}]`; + } else if (truncation.truncatedBy === "lines") { + notice = `[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}${fullOutputPart}${suffix}]`; + } else { + notice = `[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} limit)${fullOutputPart}${suffix}]`; + } + + return `\n\n${notice}`; +} + +export interface HeadTruncationNoticeOptions { + /** 1-indexed start line number (default: 1) */ + startLine?: number; + /** Total lines in the original file (for "of N" display) */ + totalFileLines?: number; +} + +/** + * Format a truncation notice for head-truncated output (read tool). + * Returns empty string if not truncated. + * + * Examples: + * - "[Showing lines 1-2000 of 5000. Use offset=2001 to continue]" + * - "[Showing lines 100-2099 of 5000 (50KB limit). Use offset=2100 to continue]" + */ +export function formatHeadTruncationNotice( + truncation: TruncationResult, + options: HeadTruncationNoticeOptions = {}, +): string { + if (!truncation.truncated) { + return ""; + } + + const startLineDisplay = options.startLine ?? 1; + const totalFileLines = options.totalFileLines ?? truncation.totalLines; + const endLineDisplay = startLineDisplay + truncation.outputLines - 1; + const nextOffset = endLineDisplay + 1; + + let notice: string; + + if (truncation.truncatedBy === "lines") { + notice = `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`; + } else { + notice = `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(truncation.maxBytes)} limit). Use offset=${nextOffset} to continue]`; + } + + return `\n\n${notice}`; +} diff --git a/packages/coding-agent/src/core/tools/web-fetch.ts b/packages/coding-agent/src/core/tools/web-fetch.ts index ef4ce881b..2cbefca33 100644 --- a/packages/coding-agent/src/core/tools/web-fetch.ts +++ b/packages/coding-agent/src/core/tools/web-fetch.ts @@ -24,7 +24,9 @@ import { convertWithMarkitdown, fetchBinary } from "./web-scrapers/utils"; // Types and Constants // ============================================================================= -const DEFAULT_TIMEOUT = 20; +const MIN_TIMEOUT = 1_000; +const DEFAULT_TIMEOUT = 20_000; +const MAX_TIMEOUT = 45_000; // Convertible document types (markitdown supported) const CONVERTIBLE_MIMES = new Set([ @@ -109,7 +111,7 @@ async function exec( ): Promise<{ stdout: string; stderr: string; ok: boolean }> { const proc = ptree.cspawn([cmd, ...args], { stdin: options?.input ? "pipe" : null, - timeout: options?.timeout, + timeout: options?.timeout ? options.timeout * 1000 : undefined, }); if (options?.input) { @@ -244,7 +246,7 @@ async function tryMdSuffix(url: string, timeout: number, signal?: AbortSignal): if (signal?.aborted) { return null; } - const result = await loadPage(candidate, { timeout: Math.min(timeout, 5), signal }); + const result = await loadPage(candidate, { timeout: Math.min(timeout, MAX_TIMEOUT), signal }); if (result.ok && result.content.trim().length > 100 && !looksLikeHtml(result.content)) { return result.content; } @@ -910,7 +912,7 @@ export class WebFetchTool implements AgentTool { - void options; - const result = await $`${cmd} ${args}`.quiet().nothrow(); - const decoder = new TextDecoder(); - return { - stdout: result.stdout ? decoder.decode(result.stdout) : "", - stderr: result.stderr ? decoder.decode(result.stderr) : "", - ok: result.exitCode === 0, - exitCode: result.exitCode ?? -1, - }; -} - export interface ConvertResult { content: string; ok: boolean; @@ -72,16 +49,16 @@ export async function convertWithMarkitdown( try { await Bun.write(tmpFile, content); - const result = await exec(markitdown, [tmpFile], { timeout }); - if (!result.ok) { - const stderr = result.stderr.trim(); + const result = await ptree.cspawn([markitdown, tmpFile], { timeout }); + const [stdout, stderr, exitCode] = await Promise.all([result.stdout.text(), result.stderr.text(), result.exited]); + if (exitCode !== 0) { return { - content: result.stdout, + content: stdout, ok: false, - error: stderr.length > 0 ? stderr : `markitdown failed (exit ${result.exitCode})`, + error: stderr.length > 0 ? stderr : `markitdown failed (exit ${exitCode})`, }; } - return { content: result.stdout, ok: true }; + return { content: stdout, ok: true }; } finally { try { await rm(tmpFile, { force: true }); diff --git a/packages/coding-agent/src/core/tools/web-search/providers/anthropic.ts b/packages/coding-agent/src/core/tools/web-search/providers/anthropic.ts index bdc4c6b5b..581e3d7dd 100644 --- a/packages/coding-agent/src/core/tools/web-search/providers/anthropic.ts +++ b/packages/coding-agent/src/core/tools/web-search/providers/anthropic.ts @@ -64,7 +64,6 @@ function buildSystemBlocks( return buildAnthropicSystemBlocks(systemPrompt, { includeClaudeCodeInstruction: includeClaudeCode, - includeCacheControl: auth.isOAuth, extraInstructions, }); } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index f2376f687..03991f3c6 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -194,7 +194,6 @@ export { export { type FileSlashCommand, loadSlashCommands as discoverSlashCommands } from "./core/slash-commands"; // Tools (detail types and utilities) export { - type BashOperations, type BashToolDetails, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, diff --git a/packages/pi-utils/src/ptree.ts b/packages/pi-utils/src/ptree.ts index 033655030..7ccc507bb 100644 --- a/packages/pi-utils/src/ptree.ts +++ b/packages/pi-utils/src/ptree.ts @@ -142,7 +142,7 @@ export class ChildProcess { #stderrStream?: ReadableStream; #exitReason?: Exception; #exitReasonPending?: Exception; - #exited: Promise; + #exited: Promise; #resolveExited: (ex?: PromiseLike | Exception) => void; constructor(proc: PipedSubprocess) { @@ -221,7 +221,7 @@ export class ChildProcess { const { promise, resolve } = Promise.withResolvers(); this.#exited = promise.then((ex?: Exception) => { - if (!ex) return; // success, no exception + if (!ex) return proc.exitCode ?? -1337; // success, no exception if (proc.killed && this.#exitReasonPending) { ex = this.#exitReasonPending; // propagate reason if killed } @@ -245,7 +245,7 @@ export class ChildProcess { get pid(): number | undefined { return this.#proc.pid; } - get exited(): Promise { + get exited(): Promise { return this.#exited; } get exitCode(): number | null {