diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index e70ecaf1..48b24293 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -8,7 +8,11 @@ ### Added -- Added context-aware `read`, `write`, `edit`, and `bash` harness tools backed by `ExecutionEnv`. +- Added context-aware `read`, `write`, `edit`, and `bash` harness tools backed by `ExecutionEnv`, including async bash execution preparation. + +### Changed + +- Aligned harness tool path handling, edit serialization, shell output capture, explicit non-inherited environments, and cross-platform process cleanup with coding-agent behavior. ## [0.81.1] - 2026-07-21 diff --git a/packages/agent/docs/agent-harness.md b/packages/agent/docs/agent-harness.md index 7c8d49c3..fdcffe24 100644 --- a/packages/agent/docs/agent-harness.md +++ b/packages/agent/docs/agent-harness.md @@ -75,13 +75,13 @@ Static option values are used directly. System-prompt provider callbacks are inv Resource arrays are shallow-copied when a snapshot is created. Individual skill and prompt-template objects are not deep-copied. -`toolContext` is application-defined. A static value is reused, while a zero-argument sync or async provider is resolved once for each turn snapshot. Harness tools receive that resolved value when they execute. Individual tools can structurally require only the context fields they use. +`toolContext` is application-defined and required when the configured tools require a non-`undefined` context. A static value is reused, while a zero-argument sync or async provider is resolved once for each turn snapshot. Harness tools receive that resolved value when they execute. Individual tools can structurally require only the context fields they use. Stream options are shallow-copied when a snapshot is created. `headers` and `metadata` maps are shallow-copied; their values are not deep-copied. Credentials from `getApiKeyAndHeaders()` are resolved per provider request so expiring tokens can refresh, but the configured stream options and derived session id come from the current turn snapshot. ### Built-in tools -The package exports `createReadTool()`, `createWriteTool()`, `createEditTool()`, and `createBashTool()`. They perform filesystem and shell operations exclusively through the `ExecutionEnv` supplied in their tool context. Each tool structurally requires a context containing `env: ExecutionEnv` and `sessionId: string`; applications may provide additional fields. `createReadTool()` accepts an optional image processor for host-provided conversion and resizing without imposing an image-processing dependency on the agent package. +The package exports `createReadTool()`, `createWriteTool()`, `createEditTool()`, and `createBashTool()`. They perform filesystem and shell operations exclusively through the `ExecutionEnv` supplied in their tool context. Each tool structurally requires the shared `ExecutionToolContext`, containing `env: ExecutionEnv`; applications may provide additional fields. `createReadTool()` accepts an optional image processor for host-provided conversion and resizing without imposing an image-processing dependency on the agent package. `createBashTool()` accepts an async `prepare` hook that can mutate the command, working directory, environment, and environment-inheritance policy using the current tool context. ### Session diff --git a/packages/agent/src/harness/agent-harness.ts b/packages/agent/src/harness/agent-harness.ts index 85405a5d..cbc2d0a4 100644 --- a/packages/agent/src/harness/agent-harness.ts +++ b/packages/agent/src/harness/agent-harness.ts @@ -34,6 +34,7 @@ import type { AgentHarnessResources, AgentHarnessStreamOptions, AgentHarnessStreamOptionsPatch, + AgentHarnessSystemPrompt, AgentHarnessTool, AgentHarnessToolContextSource, CompactResult, @@ -181,7 +182,7 @@ export class AgentHarness< private pendingSessionWrites: PendingSessionWrite[] = []; private model: Model; private thinkingLevel: ThinkingLevel; - private systemPrompt: AgentHarnessOptions["systemPrompt"]; + private systemPrompt: AgentHarnessSystemPrompt | undefined; private toolContext: AgentHarnessToolContextSource | undefined; private streamOptions: AgentHarnessStreamOptions; private retry: RetryPolicy | undefined; diff --git a/packages/agent/src/harness/env/nodejs.ts b/packages/agent/src/harness/env/nodejs.ts index 6c6808a7..f9f94f8f 100644 --- a/packages/agent/src/harness/env/nodejs.ts +++ b/packages/agent/src/harness/env/nodejs.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { type ChildProcess, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { constants, createReadStream } from "node:fs"; import { @@ -13,9 +13,10 @@ import { rm, writeFile, } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; import { type ExecutionEnv, ExecutionError, @@ -25,11 +26,13 @@ import { type FileKind, ok, type Result, + type ShellExecOptions, toError, } from "../types.ts"; const MAX_TIMEOUT_MS = 2_147_483_647; const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000; +const EXIT_STDIO_GRACE_MS = 100; function resolveTimeoutMs(timeout: number | undefined): Result { if (timeout === undefined) return ok(undefined); @@ -45,7 +48,19 @@ function resolveTimeoutMs(timeout: number | undefined): Result ` ${path}`).join("\n")}`, + ), + ); } if (await pathExists("/bin/bash")) { @@ -210,7 +234,12 @@ async function getShellConfig(customShellPath?: string): Promise): NodeJS.ProcessEnv { +function getShellEnv( + baseEnv?: NodeJS.ProcessEnv, + extraEnv?: Record, + inheritEnv = true, +): NodeJS.ProcessEnv { + if (!inheritEnv) return { ...extraEnv }; return { ...process.env, ...baseEnv, @@ -243,10 +272,80 @@ function killProcessTree(pid: number): void { } } +function waitForChildProcess(child: ChildProcess): Promise { + return new Promise((resolvePromise, reject) => { + let settled = false; + let exited = false; + let exitCode: number | null = null; + let postExitTimer: ReturnType | undefined; + let stdoutEnded = child.stdout === null; + let stderrEnded = child.stderr === null; + + const cleanup = (): void => { + if (postExitTimer) clearTimeout(postExitTimer); + child.removeListener("error", onError); + child.removeListener("exit", onExit); + child.removeListener("close", onClose); + child.stdout?.removeListener("end", onStdoutEnd); + child.stderr?.removeListener("end", onStderrEnd); + child.stdout?.removeListener("data", onData); + child.stderr?.removeListener("data", onData); + }; + const finalize = (code: number | null): void => { + if (settled) return; + settled = true; + cleanup(); + child.stdout?.destroy(); + child.stderr?.destroy(); + resolvePromise(code); + }; + const maybeFinalizeAfterExit = (): void => { + if (exited && stdoutEnded && stderrEnded) finalize(exitCode); + }; + const armIdleTimer = (): void => { + if (postExitTimer) clearTimeout(postExitTimer); + postExitTimer = setTimeout(() => finalize(exitCode), EXIT_STDIO_GRACE_MS); + }; + const onData = (): void => { + if (exited && !settled) armIdleTimer(); + }; + const onStdoutEnd = (): void => { + stdoutEnded = true; + maybeFinalizeAfterExit(); + }; + const onStderrEnd = (): void => { + stderrEnded = true; + maybeFinalizeAfterExit(); + }; + const onError = (error: Error): void => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + const onExit = (code: number | null): void => { + exited = true; + exitCode = code; + maybeFinalizeAfterExit(); + if (!settled) armIdleTimer(); + }; + const onClose = (code: number | null): void => finalize(code); + + child.stdout?.once("end", onStdoutEnd); + child.stderr?.once("end", onStderrEnd); + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + child.once("error", onError); + child.once("exit", onExit); + child.once("close", onClose); + }); +} + export class NodeExecutionEnv implements ExecutionEnv { cwd: string; private shellPath?: string; private shellEnv?: NodeJS.ProcessEnv; + private activeChildPids = new Set(); constructor(options: { cwd: string; shellPath?: string; shellEnv?: NodeJS.ProcessEnv }) { this.cwd = options.cwd; @@ -264,14 +363,7 @@ export class NodeExecutionEnv implements ExecutionEnv { async exec( command: string, - options?: { - cwd?: string; - env?: Record; - timeout?: number; - abortSignal?: AbortSignal; - onStdout?: (chunk: string) => void; - onStderr?: (chunk: string) => void; - }, + options?: ShellExecOptions, ): Promise> { if (options?.abortSignal?.aborted) return err(new ExecutionError("aborted", "aborted")); const timeoutMsResult = resolveTimeoutMs(options?.timeout); @@ -281,6 +373,18 @@ export class NodeExecutionEnv implements ExecutionEnv { const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd; const shellConfig = await getShellConfig(this.shellPath); if (!shellConfig.ok) return shellConfig; + try { + await access(cwd, constants.F_OK); + } catch (error) { + const cause = toError(error); + return err( + new ExecutionError( + "spawn_error", + `Working directory does not exist: ${cwd}\nCannot execute bash commands.`, + cause, + ), + ); + } return await new Promise((resolvePromise) => { let stdout = ""; @@ -300,6 +404,7 @@ export class NodeExecutionEnv implements ExecutionEnv { const settle = (result: Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>) => { if (timeoutId) clearTimeout(timeoutId); if (options?.abortSignal) options.abortSignal.removeEventListener("abort", onAbort); + if (child?.pid) this.activeChildPids.delete(child.pid); if (settled) return; settled = true; resolvePromise(result); @@ -313,11 +418,12 @@ export class NodeExecutionEnv implements ExecutionEnv { { cwd, detached: process.platform !== "win32", - env: getShellEnv(this.shellEnv, options?.env), + env: getShellEnv(this.shellEnv, options?.env, options?.inheritEnv), stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"], windowsHide: true, }, ); + if (child.pid) this.activeChildPids.add(child.pid); if (commandFromStdin) { child.stdin?.on("error", () => {}); child.stdin?.end(command); @@ -369,25 +475,24 @@ export class NodeExecutionEnv implements ExecutionEnv { } }); - child.on("error", (error) => { - settle(err(new ExecutionError("spawn_error", error.message, error))); - }); - - child.on("close", (code) => { - if (callbackError) { - settle(err(callbackError)); - return; - } - if (timedOut) { - settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`))); - return; - } - if (options?.abortSignal?.aborted) { - settle(err(new ExecutionError("aborted", "aborted"))); - return; - } - settle(ok({ stdout, stderr, exitCode: code ?? 0 })); - }); + void waitForChildProcess(child).then( + (code) => { + if (callbackError) { + settle(err(callbackError)); + return; + } + if (timedOut) { + settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`))); + return; + } + if (options?.abortSignal?.aborted) { + settle(err(new ExecutionError("aborted", "aborted"))); + return; + } + settle(ok({ stdout, stderr, exitCode: code ?? 0 })); + }, + (error: Error) => settle(err(new ExecutionError("spawn_error", error.message, error))), + ); }); } @@ -564,6 +669,7 @@ export class NodeExecutionEnv implements ExecutionEnv { } async cleanup(): Promise { - // nothing to clean up for the local node implementation + for (const pid of this.activeChildPids) killProcessTree(pid); + this.activeChildPids.clear(); } } diff --git a/packages/agent/src/harness/tools/bash.ts b/packages/agent/src/harness/tools/bash.ts index 77c75fc7..c0e1f19d 100644 --- a/packages/agent/src/harness/tools/bash.ts +++ b/packages/agent/src/harness/tools/bash.ts @@ -1,14 +1,9 @@ import { type Static, Type } from "typebox"; -import type { AgentHarnessTool, ExecutionEnv } from "../types.ts"; +import type { AgentHarnessTool } from "../types.ts"; import { getOrThrow } from "../types.ts"; -import { executeShellWithCapture } from "../utils/shell-output.ts"; -import { - DEFAULT_MAX_BYTES, - DEFAULT_MAX_LINES, - formatSize, - type TruncationResult, - truncateTail, -} from "../utils/truncate.ts"; +import { executeShellWithCapture, type ShellCaptureProgress } from "../utils/shell-output.ts"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult } from "../utils/truncate.ts"; +import type { ExecutionToolContext } from "./tool-context.ts"; const MAX_TIMEOUT_SECONDS = 2_147_483_647 / 1000; const BASH_UPDATE_THROTTLE_MS = 100; @@ -20,27 +15,27 @@ const bashSchema = Type.Object({ export type BashToolInput = Static; -export interface BashToolContext { - env: ExecutionEnv; - sessionId: string; -} - export interface BashToolDetails { truncation?: TruncationResult; fullOutputPath?: string; } -export interface BashSpawnContext { +export interface BashExecution { command: string; cwd: string; - env?: Record; + env: Record; + inheritEnv: boolean; } -export type BashSpawnHook = (context: BashSpawnContext) => BashSpawnContext; +export type BashPrepare = ( + execution: BashExecution, + context: TContext, + signal?: AbortSignal, +) => void | Promise; -export interface BashToolOptions { +export interface BashToolOptions { commandPrefix?: string; - spawnHook?: BashSpawnHook; + prepare?: BashPrepare; } function validateTimeout(timeout: number | undefined): void { @@ -53,34 +48,40 @@ function validateTimeout(timeout: number | undefined): void { } } -export function createBashTool( - options?: BashToolOptions, +export function createBashTool( + options?: BashToolOptions, ): AgentHarnessTool { return { name: "bash", label: "bash", description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`, parameters: bashSchema, - async execute(_toolCallId, { command, timeout }, signal, onUpdate, { env }) { + async execute(_toolCallId, { command, timeout }, signal, onUpdate, context) { validateTimeout(timeout); - const resolvedCommand = options?.commandPrefix ? `${options.commandPrefix}\n${command}` : command; - const spawnContext = options?.spawnHook?.({ command: resolvedCommand, cwd: env.cwd }) ?? { - command: resolvedCommand, + const { env } = context; + const execution: BashExecution = { + command: options?.commandPrefix ? `${options.commandPrefix}\n${command}` : command, cwd: env.cwd, + env: {}, + inheritEnv: true, }; - let partialOutput = ""; + await options?.prepare?.(execution, context, signal); + let getLatestProgress: (() => ShellCaptureProgress) | undefined; let updateTimer: ReturnType | undefined; let updateDirty = false; let lastUpdateAt = 0; const emitOutputUpdate = (): void => { - if (!onUpdate || !updateDirty) return; + if (!onUpdate || !updateDirty || !getLatestProgress) return; updateDirty = false; lastUpdateAt = Date.now(); - const truncation = truncateTail(partialOutput); + const progress = getLatestProgress(); onUpdate({ - content: [{ type: "text", text: truncation.content }], - details: { truncation: truncation.truncated ? truncation : undefined }, + content: [{ type: "text", text: progress.output }], + details: { + truncation: progress.truncation.truncated ? progress.truncation : undefined, + fullOutputPath: progress.fullOutputPath, + }, }); }; const clearUpdateTimer = (): void => { @@ -106,22 +107,22 @@ export function createBashTool { - partialOutput += chunk; - if (partialOutput.length > DEFAULT_MAX_BYTES * 4) { - partialOutput = partialOutput.slice(-DEFAULT_MAX_BYTES * 2); - } + onChunk: (_chunk, getProgress) => { + getLatestProgress = getProgress; scheduleOutputUpdate(); }, }), ); clearUpdateTimer(); + getLatestProgress = () => capture; + updateDirty = true; emitOutputUpdate(); let outputText = capture.output; @@ -131,7 +132,8 @@ export function createBashTool; type LegacyEditToolInput = EditToolInput & { oldText?: unknown; newText?: unknown }; -export interface EditToolContext { - env: ExecutionEnv; - sessionId: string; -} - export interface EditToolDetails { diff: string; patch: string; @@ -78,7 +74,7 @@ function editAccessError(path: string, error: FileError): Error { return new Error(`Could not edit file: ${path}. Error code: ${error.code}.`, { cause: error }); } -export function createEditTool(): AgentHarnessTool< +export function createEditTool(): AgentHarnessTool< TContext, typeof editSchema, EditToolDetails | undefined @@ -97,7 +93,9 @@ export function createEditTool; -export interface ReadToolContext { - env: ExecutionEnv; - sessionId: string; -} - export interface ReadToolDetails { truncation?: TruncationResult; } @@ -46,7 +42,7 @@ export interface ReadToolOptions { imageProcessor?: ReadImageProcessor; } -export function createReadTool( +export function createReadTool( options?: ReadToolOptions, ): AgentHarnessTool { return { diff --git a/packages/agent/src/harness/tools/tool-context.ts b/packages/agent/src/harness/tools/tool-context.ts new file mode 100644 index 00000000..758827ff --- /dev/null +++ b/packages/agent/src/harness/tools/tool-context.ts @@ -0,0 +1,6 @@ +import type { ExecutionEnv } from "../types.ts"; + +/** Filesystem and shell context required by the built-in execution tools. */ +export interface ExecutionToolContext { + env: ExecutionEnv; +} diff --git a/packages/agent/src/harness/tools/write.ts b/packages/agent/src/harness/tools/write.ts index d90f4b7f..f7175284 100644 --- a/packages/agent/src/harness/tools/write.ts +++ b/packages/agent/src/harness/tools/write.ts @@ -1,8 +1,9 @@ import { type Static, Type } from "typebox"; -import type { AgentHarnessTool, ExecutionEnv } from "../types.ts"; +import type { AgentHarnessTool } from "../types.ts"; import { getOrThrow } from "../types.ts"; import { withFileMutationQueue } from "./file-mutation-queue.ts"; import { resolveToolPath } from "./path-utils.ts"; +import type { ExecutionToolContext } from "./tool-context.ts"; const writeSchema = Type.Object({ path: Type.String({ description: "Path to the file to write (relative or absolute)" }), @@ -11,12 +12,7 @@ const writeSchema = Type.Object({ export type WriteToolInput = Static; -export interface WriteToolContext { - env: ExecutionEnv; - sessionId: string; -} - -export function createWriteTool(): AgentHarnessTool< +export function createWriteTool(): AgentHarnessTool< TContext, typeof writeSchema, undefined diff --git a/packages/agent/src/harness/types.ts b/packages/agent/src/harness/types.ts index ae19d379..7a217ae3 100644 --- a/packages/agent/src/harness/types.ts +++ b/packages/agent/src/harness/types.ts @@ -344,8 +344,10 @@ export interface FileSystem { export interface ShellExecOptions { /** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */ cwd?: string; - /** Additional environment variables for the command. Values override the environment defaults. Defaults to no overrides. */ + /** Environment variables for the command. Values override inherited defaults when `inheritEnv` is true. */ env?: Record; + /** Whether to inherit the execution environment's default variables. Defaults to true. */ + inheritEnv?: boolean; /** Timeout in seconds. Implementations should return a timeout error when the command exceeds this duration. Defaults to no timeout. */ timeout?: number; /** Abort signal used to terminate the command. Defaults to no abort signal. */ @@ -891,11 +893,26 @@ export interface BranchSummaryResult { modifiedFiles: string[]; } -export interface AgentHarnessOptions< +export type AgentHarnessSystemPrompt< TContext extends object | undefined = undefined, TSkill extends Skill = Skill, TPromptTemplate extends PromptTemplate = PromptTemplate, TTool extends AgentHarnessTool = AgentHarnessTool, +> = + | string + | ((context: { + session: Session; + model: Model; + thinkingLevel: ThinkingLevel; + activeTools: TTool[]; + resources: AgentHarnessResources; + }) => string | Promise); + +interface AgentHarnessOptionsBase< + TContext extends object | undefined, + TSkill extends Skill, + TPromptTemplate extends PromptTemplate, + TTool extends AgentHarnessTool, > { session: Session; /** @@ -905,22 +922,12 @@ export interface AgentHarnessOptions< */ models: Models; tools?: TTool[]; - /** Static context or zero-argument context provider resolved for each turn snapshot. */ - toolContext?: AgentHarnessToolContextSource; /** * Concrete resources available to explicit invocation methods and system-prompt callbacks. * Applications own loading/reloading resources and should call `setResources()` with new values. */ resources?: AgentHarnessResources; - systemPrompt?: - | string - | ((context: { - session: Session; - model: Model; - thinkingLevel: ThinkingLevel; - activeTools: TTool[]; - resources: AgentHarnessResources; - }) => string | Promise); + systemPrompt?: AgentHarnessSystemPrompt; /** Curated stream/provider request options. Snapshotted at turn start. */ streamOptions?: AgentHarnessStreamOptions; /** Optional retry policy for generated compaction and branch-summary requests. */ @@ -932,4 +939,20 @@ export interface AgentHarnessOptions< followUpMode?: QueueMode; } +export type AgentHarnessOptions< + TContext extends object | undefined = undefined, + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, + TTool extends AgentHarnessTool = AgentHarnessTool, +> = AgentHarnessOptionsBase & + ([TContext] extends [undefined] + ? { + /** Context-free harnesses do not need a tool context. */ + toolContext?: undefined; + } + : { + /** Static context or zero-argument context provider resolved for each turn snapshot. */ + toolContext: AgentHarnessToolContextSource; + }); + export type { AgentHarness } from "./agent-harness.ts"; diff --git a/packages/agent/src/harness/utils/shell-output.ts b/packages/agent/src/harness/utils/shell-output.ts index d255e66b..0090362d 100644 --- a/packages/agent/src/harness/utils/shell-output.ts +++ b/packages/agent/src/harness/utils/shell-output.ts @@ -1,19 +1,23 @@ import { type ExecutionEnv, ExecutionError, err, ok, type Result, type ShellExecOptions, toError } from "../types.ts"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.ts"; +export interface ShellCaptureProgress { + output: string; + truncation: TruncationResult; + fullOutputPath?: string; + lastLineBytes: number; +} + export interface ShellCaptureOptions extends Omit { - onChunk?: (chunk: string) => void; + onChunk?: (chunk: string, getProgress: () => ShellCaptureProgress) => void; /** Return shell execution failures with captured output instead of as a failed Result. */ returnExecutionErrors?: boolean; } -export interface ShellCaptureResult { - output: string; +export interface ShellCaptureResult extends ShellCaptureProgress { exitCode: number | undefined; cancelled: boolean; truncated: boolean; - truncation: TruncationResult; - fullOutputPath?: string; executionError?: ExecutionError; } @@ -36,21 +40,30 @@ export function sanitizeBinaryOutput(str: string): string { .join(""); } +function trimToLastUtf8Bytes(text: string, maxBytes: number, encoder: { encode(input?: string): Uint8Array }): string { + const bytes = encoder.encode(text); + if (bytes.byteLength <= maxBytes) return text; + let start = bytes.byteLength - maxBytes; + while (start < bytes.byteLength && ((bytes[start] ?? 0) & 0xc0) === 0x80) start++; + return new TextDecoder().decode(bytes.subarray(start)); +} + export async function executeShellWithCapture( env: ExecutionEnv, command: string, options?: ShellCaptureOptions, ): Promise> { - const outputChunks: string[] = []; - let outputBytes = 0; + let tailOutput = ""; const maxOutputBytes = DEFAULT_MAX_BYTES * 2; const encoder = new TextEncoder(); let totalBytes = 0; let completedLines = 0; let hasOpenLine = false; + let currentLineBytes = 0; let fullOutputPath: string | undefined; let fullOutputRequested = false; + let acceptingOutput = true; let writeChain: Promise> = Promise.resolve(ok(undefined)); let captureError: ExecutionError | undefined; @@ -77,27 +90,54 @@ export async function executeShellWithCapture( }); }; - const onChunk = (chunk: string) => { + const createProgress = (): ShellCaptureProgress => { + const tailTruncation = truncateTail(tailOutput); + const totalLines = completedLines + (hasOpenLine ? 1 : 0); + const truncated = totalLines > DEFAULT_MAX_LINES || totalBytes > DEFAULT_MAX_BYTES; + const truncation: TruncationResult = { + ...tailTruncation, + truncated, + truncatedBy: truncated + ? (tailTruncation.truncatedBy ?? (totalBytes > DEFAULT_MAX_BYTES ? "bytes" : "lines")) + : null, + totalLines, + totalBytes, + }; + return { + output: truncated ? truncation.content : tailOutput, + truncation, + fullOutputPath, + lastLineBytes: currentLineBytes, + }; + }; + + const onChunk = (chunk: string): void => { + if (!acceptingOutput) return; try { const text = sanitizeBinaryOutput(chunk).replace(/\r/g, ""); - totalBytes += encoder.encode(text).byteLength; + const textBytes = encoder.encode(text).byteLength; + totalBytes += textBytes; const newlineCount = text.split("\n").length - 1; completedLines += newlineCount; - if (newlineCount > 0) hasOpenLine = !text.endsWith("\n"); - else if (text.length > 0) hasOpenLine = true; + const lastNewline = text.lastIndexOf("\n"); + if (lastNewline >= 0) { + const trailingText = text.slice(lastNewline + 1); + currentLineBytes = encoder.encode(trailingText).byteLength; + hasOpenLine = trailingText.length > 0; + } else if (text.length > 0) { + currentLineBytes += textBytes; + hasOpenLine = true; + } + + tailOutput += text; const totalLines = completedLines + (hasOpenLine ? 1 : 0); if ((totalBytes > DEFAULT_MAX_BYTES || totalLines > DEFAULT_MAX_LINES) && !fullOutputRequested) { - ensureFullOutputFile(outputChunks.join("") + text); + ensureFullOutputFile(tailOutput); } else if (fullOutputRequested) { appendFullOutput(text); } - outputChunks.push(text); - outputBytes += text.length; - while (outputBytes > maxOutputBytes && outputChunks.length > 1) { - const removed = outputChunks.shift()!; - outputBytes -= removed.length; - } - options?.onChunk?.(text); + tailOutput = trimToLastUtf8Bytes(tailOutput, maxOutputBytes, encoder); + options?.onChunk?.(text, createProgress); } catch (error) { captureError = toExecutionError(error); } @@ -107,48 +147,35 @@ export async function executeShellWithCapture( const result = await env.exec(command, { cwd: options?.cwd, env: options?.env, + inheritEnv: options?.inheritEnv, timeout: options?.timeout, abortSignal: options?.abortSignal, onStdout: onChunk, onStderr: onChunk, }); - const tailOutput = outputChunks.join(""); - const tailTruncation = truncateTail(tailOutput); - const totalLines = completedLines + (hasOpenLine ? 1 : 0); - const truncated = totalLines > DEFAULT_MAX_LINES || totalBytes > DEFAULT_MAX_BYTES; - const truncationResult: TruncationResult = { - ...tailTruncation, - truncated, - truncatedBy: truncated - ? (tailTruncation.truncatedBy ?? (totalBytes > DEFAULT_MAX_BYTES ? "bytes" : "lines")) - : null, - totalLines, - totalBytes, - }; - if (truncated && !fullOutputRequested) ensureFullOutputFile(tailOutput); + acceptingOutput = false; + let progress = createProgress(); + if (progress.truncation.truncated && !fullOutputRequested) ensureFullOutputFile(tailOutput); const writeResult = await writeChain; if (!writeResult.ok) return err(writeResult.error); if (captureError) return err(captureError); + progress = createProgress(); if (!result.ok) { if (result.error.code === "aborted" || options?.abortSignal?.aborted) { return ok({ - output: truncationResult.truncated ? truncationResult.content : tailOutput, + ...progress, exitCode: undefined, cancelled: true, - truncated: truncationResult.truncated, - truncation: truncationResult, - fullOutputPath, + truncated: progress.truncation.truncated, }); } if (options?.returnExecutionErrors) { return ok({ - output: truncationResult.truncated ? truncationResult.content : tailOutput, + ...progress, exitCode: undefined, cancelled: false, - truncated: truncationResult.truncated, - truncation: truncationResult, - fullOutputPath, + truncated: progress.truncation.truncated, executionError: result.error, }); } @@ -156,14 +183,13 @@ export async function executeShellWithCapture( } const cancelled = options?.abortSignal?.aborted ?? false; return ok({ - output: truncationResult.truncated ? truncationResult.content : tailOutput, + ...progress, exitCode: cancelled ? undefined : result.value.exitCode, cancelled, - truncated: truncationResult.truncated, - truncation: truncationResult, - fullOutputPath, + truncated: progress.truncation.truncated, }); } catch (error) { + acceptingOutput = false; return err(toExecutionError(error)); } } diff --git a/packages/agent/src/harness/utils/truncate.ts b/packages/agent/src/harness/utils/truncate.ts index 2372a941..8150458c 100644 --- a/packages/agent/src/harness/utils/truncate.ts +++ b/packages/agent/src/harness/utils/truncate.ts @@ -79,6 +79,13 @@ function utf8ByteLength(content: string): number { return bytes; } +function splitLinesForCounting(content: string): string[] { + if (content.length === 0) return []; + const lines = content.split("\n"); + if (content.endsWith("\n")) lines.pop(); + return lines; +} + function replaceUnpairedSurrogates(content: string): string { let output = ""; for (let i = 0; i < content.length; i++) { @@ -127,7 +134,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}): const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; const totalBytes = utf8ByteLength(content); - const lines = content.split("\n"); + const lines = splitLinesForCounting(content); const totalLines = lines.length; // Check if no truncation needed @@ -217,8 +224,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}): const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; const totalBytes = utf8ByteLength(content); - const lines = content.split("\n"); - if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); + const lines = splitLinesForCounting(content); const totalLines = lines.length; // Check if no truncation needed diff --git a/packages/agent/test/harness/agent-harness.test.ts b/packages/agent/test/harness/agent-harness.test.ts index 239ffd9a..53e8958f 100644 --- a/packages/agent/test/harness/agent-harness.test.ts +++ b/packages/agent/test/harness/agent-harness.test.ts @@ -499,7 +499,7 @@ describe("AgentHarness", () => { }), ]); const env = new NodeExecutionEnv({ cwd: process.cwd() }); - const toolContext = { env, sessionId: "session-1" }; + const toolContext = { env }; let receivedContext: typeof toolContext | undefined; const contextTool: AgentHarnessTool = { ...calculateTool, diff --git a/packages/agent/test/harness/nodejs-env.test.ts b/packages/agent/test/harness/nodejs-env.test.ts index d2d33a6f..6ff80b20 100644 --- a/packages/agent/test/harness/nodejs-env.test.ts +++ b/packages/agent/test/harness/nodejs-env.test.ts @@ -1,5 +1,9 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; import { access, chmod, realpath, symlink } from "node:fs/promises"; +import { homedir } from "node:os"; import { delimiter, join } from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; import { FileError, getOrThrow } from "../../src/harness/types.ts"; @@ -8,6 +12,52 @@ import { createTempDir } from "./session-test-utils.ts"; const chmodRestorePaths: string[] = []; +function withTimeout(promise: Promise, ms: number, onTimeout?: () => void): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + onTimeout?.(); + reject(new Error(`Timed out after ${ms}ms`)); + }, ms); + promise.then( + (value) => { + clearTimeout(timeoutId); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timeoutId); + reject(error); + }, + ); + }); +} + +function toBashSingleQuotedArg(value: string): string { + return `'${value.replace(/\\/g, "/").replace(/'/g, `'"'"'`)}'`; +} + +function createInheritedStdioCommand(pidFile: string): string { + return ( + 'node -e "' + + "const fs=require('fs');" + + "const {spawn}=require('child_process');" + + "const child=spawn(process.execPath,['-e','setTimeout(()=>{},60000)'],{stdio:'inherit',detached:true});" + + "fs.writeFileSync(process.argv[1], String(child.pid));" + + "child.unref();" + + "console.log('child-exiting');" + + '" ' + + toBashSingleQuotedArg(pidFile) + ); +} + +function cleanupDetachedChild(pidFile: string): void { + if (!existsSync(pidFile)) return; + const pid = Number.parseInt(readFileSync(pidFile, "utf8").trim(), 10); + if (!Number.isFinite(pid) || pid <= 0) return; + try { + execFileSync("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore" }); + } catch {} +} + afterEach(async () => { for (const path of chmodRestorePaths.splice(0)) { try { @@ -45,6 +95,14 @@ describe("NodeExecutionEnv", () => { expect(getOrThrow(await env.exists("nested/child/file.txt"))).toBe(false); }); + it("expands home-relative paths and file URLs", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + expect(getOrThrow(await env.absolutePath("~/pi-node-env-test"))).toBe(join(homedir(), "pi-node-env-test")); + const filePath = join(root, "file with spaces.txt"); + expect(getOrThrow(await env.absolutePath(pathToFileURL(filePath).href))).toBe(filePath); + }); + it("returns fileInfo for files, directories, and symlinks without following symlinks", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); @@ -201,6 +259,29 @@ describe("NodeExecutionEnv", () => { expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 }); }); + it("can replace rather than inherit the default shell environment", async () => { + const root = createTempDir(); + const inheritedKey = "PI_NODE_ENV_INHERITED_TEST"; + const configuredKey = "PI_NODE_ENV_CONFIGURED_TEST"; + const explicitKey = "PI_NODE_ENV_EXPLICIT_TEST"; + const previousInherited = process.env[inheritedKey]; + process.env[inheritedKey] = "host"; + try { + const env = new NodeExecutionEnv({ cwd: root, shellEnv: { [configuredKey]: "configured" } }); + const result = getOrThrow( + await env.exec(`printf '%s:%s:%s' "\${${inheritedKey}-}" "\${${configuredKey}-}" "\${${explicitKey}-}"`, { + inheritEnv: false, + env: { [explicitKey]: "explicit" }, + }), + ); + + expect(result.stdout).toBe("::explicit"); + } finally { + if (previousInherited === undefined) delete process.env[inheritedKey]; + else process.env[inheritedKey] = previousInherited; + } + }); + it("uses stdin command transport for legacy WSL bash paths", async () => { if (process.platform === "win32") return; const root = createTempDir(); @@ -234,6 +315,41 @@ describe("NodeExecutionEnv", () => { } }); + it.skipIf(process.platform !== "win32")( + "settles after the shell exits when a detached descendant retains inherited stdio", + async () => { + const root = createTempDir(); + const pidFile = join(root, "grandchild.pid"); + const env = new NodeExecutionEnv({ cwd: root }); + const controller = new AbortController(); + try { + const result = getOrThrow( + await withTimeout( + env.exec(createInheritedStdioCommand(pidFile), { abortSignal: controller.signal }), + 3000, + () => controller.abort(), + ), + ); + expect(result.stdout).toContain("child-exiting"); + } finally { + controller.abort(); + cleanupDetachedChild(pidFile); + } + }, + ); + + it("cleanup terminates active shell processes", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const execution = env.exec("touch started; sleep 60"); + for (let attempt = 0; attempt < 100 && !getOrThrow(await env.exists("started")); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(getOrThrow(await env.exists("started"))).toBe(true); + await env.cleanup(); + await expect(withTimeout(execution, 3000)).resolves.toMatchObject({ ok: true }); + }); + it("streams stdout and stderr chunks", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); @@ -254,6 +370,17 @@ describe("NodeExecutionEnv", () => { expect(stderr).toBe("err"); }); + it("reports a missing working directory before spawning", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: join(root, "missing") }); + const result = await env.exec("printf ok"); + + expect(result).toMatchObject({ + ok: false, + error: { code: "spawn_error", message: expect.stringContaining("Working directory does not exist") }, + }); + }); + it("returns non-zero command exit codes as successful execution results", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); diff --git a/packages/agent/test/harness/tool-context.types.ts b/packages/agent/test/harness/tool-context.types.ts new file mode 100644 index 00000000..4a27c2e4 --- /dev/null +++ b/packages/agent/test/harness/tool-context.types.ts @@ -0,0 +1,17 @@ +import type { Api, Model, Models } from "@earendil-works/pi-ai"; +import { AgentHarness } from "../../src/harness/agent-harness.ts"; +import { createReadTool } from "../../src/harness/tools/read.ts"; +import type { ExecutionToolContext } from "../../src/harness/tools/tool-context.ts"; +import type { Session } from "../../src/harness/types.ts"; + +declare const models: Models; +declare const model: Model; +declare const session: Session; +declare const toolContext: ExecutionToolContext; + +const readTool = createReadTool(); + +new AgentHarness({ models, model, session, tools: [readTool], toolContext }); + +// @ts-expect-error Context-requiring tools must be paired with toolContext. +new AgentHarness({ models, model, session, tools: [readTool] }); diff --git a/packages/agent/test/harness/tools.test.ts b/packages/agent/test/harness/tools.test.ts index 5e69cb09..404fb899 100644 --- a/packages/agent/test/harness/tools.test.ts +++ b/packages/agent/test/harness/tools.test.ts @@ -1,11 +1,19 @@ +import { symlink } from "node:fs/promises"; import { applyPatch } from "diff"; import { describe, expect, it } from "vitest"; import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; -import { createBashTool } from "../../src/harness/tools/bash.ts"; +import { type BashToolDetails, createBashTool } from "../../src/harness/tools/bash.ts"; import { createEditTool } from "../../src/harness/tools/edit.ts"; import { createReadTool } from "../../src/harness/tools/read.ts"; import { createWriteTool } from "../../src/harness/tools/write.ts"; -import { getOrThrow } from "../../src/harness/types.ts"; +import { + type ExecutionError, + type FileError, + getOrThrow, + ok, + type Result, + type ShellExecOptions, +} from "../../src/harness/types.ts"; import { createTempDir } from "./session-test-utils.ts"; function textOutput(result: { content: Array<{ type: string; text?: string }> }): string { @@ -14,7 +22,82 @@ function textOutput(result: { content: Array<{ type: string; text?: string }> }) function createContext() { const env = new NodeExecutionEnv({ cwd: createTempDir() }); - return { env, sessionId: "session-1" }; + return { env }; +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = () => {}; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +class SlowReadExecutionEnv extends NodeExecutionEnv { + override async readTextFile(path: string, abortSignal?: AbortSignal): Promise> { + await delay(20); + return super.readTextFile(path, abortSignal); + } +} + +class BlockingWriteExecutionEnv extends NodeExecutionEnv { + readonly firstWriteStarted = deferred(); + readonly finishFirstWrite = deferred(); + secondWriteStarted = false; + + override async writeFile( + path: string, + content: string | Uint8Array, + abortSignal?: AbortSignal, + ): Promise> { + if (content === "first\n") { + this.firstWriteStarted.resolve(); + await this.finishFirstWrite.promise; + } else if (content === "second\n") { + this.secondWriteStarted = true; + } + return super.writeFile(path, content, abortSignal); + } +} + +class BlockingEditExecutionEnv extends NodeExecutionEnv { + readonly firstEditWriteStarted = deferred(); + readonly finishFirstEditWrite = deferred(); + firstEditWriteSettled = false; + secondEditWriteStarted = false; + + override async writeFile( + path: string, + content: string | Uint8Array, + abortSignal?: AbortSignal, + ): Promise> { + if (content === "ALPHA\nbeta\n") { + this.firstEditWriteStarted.resolve(); + await this.finishFirstEditWrite.promise; + const result = await super.writeFile(path, content); + this.firstEditWriteSettled = true; + return result; + } + if (content === "ALPHA\nBETA\n" || content === "alpha\nBETA\n") { + this.secondEditWriteStarted = true; + } + return super.writeFile(path, content, abortSignal); + } +} + +class LateOutputExecutionEnv extends NodeExecutionEnv { + override async exec( + _command: string, + options?: ShellExecOptions, + ): Promise> { + options?.onStdout?.("before\n"); + setTimeout(() => options?.onStdout?.("late\n"), 0); + return ok({ stdout: "before\n", stderr: "", exitCode: 0 }); + } } function createTinyBmp(): Uint8Array { @@ -80,6 +163,24 @@ describe("AgentHarness tools", () => { }); }); + it("does not count a trailing newline as an extra line at the truncation limit", async () => { + const context = createContext(); + getOrThrow( + await context.env.writeFile("exact.txt", `${Array.from({ length: 2000 }, () => "x").join("\n")}\n`), + ); + + const result = await createReadTool().execute( + "read-exact", + { path: "exact.txt" }, + undefined, + undefined, + context, + ); + + expect(result.details).toBeUndefined(); + expect(textOutput(result)).not.toContain("Use offset="); + }); + it("rejects offsets beyond the file", async () => { const context = createContext(); getOrThrow(await context.env.writeFile("short.txt", "one\ntwo\nthree")); @@ -150,6 +251,37 @@ describe("AgentHarness tools", () => { expect(textOutput(result)).toBe("Successfully wrote 5 bytes to nested/dir/file.txt"); expect(getOrThrow(await context.env.readTextFile("nested/dir/file.txt"))).toBe("hello"); }); + + it("keeps the mutation queue locked until an aborted write settles", async () => { + const env = new BlockingWriteExecutionEnv({ cwd: createTempDir() }); + const tool = createWriteTool(); + const controller = new AbortController(); + const firstWrite = tool.execute( + "write-first", + { path: "file.txt", content: "first\n" }, + controller.signal, + undefined, + { + env, + }, + ); + await env.firstWriteStarted.promise; + controller.abort(); + const secondWrite = tool.execute( + "write-second", + { path: "file.txt", content: "second\n" }, + undefined, + undefined, + { env }, + ); + + await delay(20); + expect(env.secondWriteStarted).toBe(false); + env.finishFirstWrite.resolve(); + await expect(firstWrite).rejects.toThrow(); + await secondWrite; + expect(getOrThrow(await env.readTextFile("file.txt"))).toBe("second\n"); + }); }); describe("edit", () => { @@ -226,6 +358,79 @@ describe("AgentHarness tools", () => { ).rejects.toThrow(/Found 3 occurrences/); }); + it("keeps the mutation queue locked until an aborted edit write settles", async () => { + const env = new BlockingEditExecutionEnv({ cwd: createTempDir() }); + getOrThrow(await env.writeFile("file.txt", "alpha\nbeta\n")); + const tool = createEditTool(); + const controller = new AbortController(); + const firstEdit = tool.execute( + "edit-first", + { path: "file.txt", edits: [{ oldText: "alpha", newText: "ALPHA" }] }, + controller.signal, + undefined, + { env }, + ); + await env.firstEditWriteStarted.promise; + controller.abort(); + const secondEdit = tool.execute( + "edit-second", + { path: "file.txt", edits: [{ oldText: "beta", newText: "BETA" }] }, + undefined, + undefined, + { env }, + ); + + await delay(20); + expect(env.secondEditWriteStarted).toBe(false); + env.finishFirstEditWrite.resolve(); + await expect(firstEdit).rejects.toThrow("Operation aborted"); + await secondEdit; + expect(env.firstEditWriteSettled).toBe(true); + expect(getOrThrow(await env.readTextFile("file.txt"))).toBe("ALPHA\nBETA\n"); + }); + + it("serializes concurrent edits through canonical and symlink paths", async () => { + const env = new SlowReadExecutionEnv({ cwd: createTempDir() }); + getOrThrow(await env.writeFile("target.txt", "alpha\nbeta\ngamma\n")); + await symlink("target.txt", `${env.cwd}/link.txt`); + const tool = createEditTool(); + + await Promise.all([ + tool.execute( + "edit-target", + { path: "target.txt", edits: [{ oldText: "alpha", newText: "ALPHA" }] }, + undefined, + undefined, + { env }, + ), + tool.execute( + "edit-link", + { path: "link.txt", edits: [{ oldText: "beta", newText: "BETA" }] }, + undefined, + undefined, + { env }, + ), + ]); + + expect(getOrThrow(await env.readTextFile("target.txt"))).toBe("ALPHA\nBETA\ngamma\n"); + }); + + it("edits regular files through symlinks", async () => { + const context = createContext(); + getOrThrow(await context.env.writeFile("target.txt", "before\n")); + await symlink("target.txt", `${context.env.cwd}/link.txt`); + + await createEditTool().execute( + "edit-symlink", + { path: "link.txt", edits: [{ oldText: "before", newText: "after" }] }, + undefined, + undefined, + context, + ); + + expect(getOrThrow(await context.env.readTextFile("target.txt"))).toBe("after\n"); + }); + it("preserves BOM and CRLF line endings", async () => { const context = createContext(); getOrThrow(await context.env.writeFile("edit.txt", "\uFEFFone\r\ntwo\r\n")); @@ -297,6 +502,64 @@ describe("AgentHarness tools", () => { expect(fullOutput).toContain("line-2999\nline-3000"); }); + it("ignores output callbacks after execution settles", async () => { + const env = new LateOutputExecutionEnv({ cwd: createTempDir() }); + const updates: string[] = []; + const result = await createBashTool().execute( + "bash-late", + { command: "late" }, + undefined, + (update) => updates.push(textOutput(update)), + { env }, + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(textOutput(result)).toBe("before\n"); + expect(updates.some((update) => update.includes("late"))).toBe(false); + }); + + it("reports the total size of an oversized final line", async () => { + const context = createContext(); + const result = await createBashTool().execute( + "bash-long-line", + { command: "printf '%060000d' 0" }, + undefined, + undefined, + context, + ); + + expect(textOutput(result)).toMatch(/Showing last 50\.0KB of line 1 \(line is 58\.6KB\)\. Full output:/); + }); + + it("prepares command, cwd, and an explicit environment with the turn context", async () => { + const env = new NodeExecutionEnv({ + cwd: createTempDir(), + shellEnv: { PI_BASH_PREPARE_INHERITED: "inherited" }, + }); + getOrThrow(await env.createDir("workspace")); + const context = { env, workspace: `${env.cwd}/workspace` }; + const controller = new AbortController(); + let receivedContext: typeof context | undefined; + let receivedSignal: AbortSignal | undefined; + const tool = createBashTool({ + commandPrefix: "prefix=ready", + prepare: async (execution, turnContext, signal) => { + receivedContext = turnContext; + receivedSignal = signal; + execution.cwd = turnContext.workspace; + execution.env = { PI_BASH_PREPARE_EXPLICIT: "explicit" }; + execution.inheritEnv = false; + execution.command += `\nprintf '%s:%s:%s:%s' "$prefix" "\${PI_BASH_PREPARE_INHERITED-}" "$PI_BASH_PREPARE_EXPLICIT" "$PWD"`; + }, + }); + + const result = await tool.execute("bash-prepare", { command: ":" }, controller.signal, undefined, context); + + expect(receivedContext).toBe(context); + expect(receivedSignal).toBe(controller.signal); + expect(textOutput(result)).toBe(`ready::explicit:${getOrThrow(await env.canonicalPath(context.workspace))}`); + }); + it("supports command prefixes", async () => { const context = createContext(); const result = await createBashTool({ commandPrefix: "value=hello" }).execute( @@ -312,12 +575,15 @@ describe("AgentHarness tools", () => { it("coalesces updates and persists truncated full output", async () => { const context = createContext(); - const updates: string[] = []; + const updates: Array<{ + content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>; + details?: BashToolDetails; + }> = []; const result = await createBashTool().execute( "bash-5", { command: "i=1; while [ $i -le 3000 ]; do echo line-$i; i=$((i + 1)); done" }, undefined, - (update) => updates.push(textOutput(update)), + (update) => updates.push(update), context, ); @@ -330,6 +596,12 @@ describe("AgentHarness tools", () => { }); expect(textOutput(result)).toContain("line-3000"); expect(result.details?.fullOutputPath).toBeDefined(); + const finalUpdate = updates.at(-1); + expect(finalUpdate ? textOutput(finalUpdate) : "").toContain("line-3000"); + expect(finalUpdate?.details).toMatchObject({ + truncation: { totalLines: 3000, totalBytes: expect.any(Number) }, + fullOutputPath: result.details?.fullOutputPath, + }); const fullOutput = getOrThrow(await context.env.readTextFile(result.details!.fullOutputPath!)); expect(fullOutput).toContain("line-1\nline-2"); expect(fullOutput).toContain("line-2999\nline-3000"); diff --git a/packages/agent/test/harness/truncate.test.ts b/packages/agent/test/harness/truncate.test.ts index 3784a52c..42da9d1f 100644 --- a/packages/agent/test/harness/truncate.test.ts +++ b/packages/agent/test/harness/truncate.test.ts @@ -72,6 +72,15 @@ describe("truncate utilities", () => { expect(result.totalBytes).toBe(9); }); + it("does not count a trailing newline as an extra line", () => { + const content = `${Array.from({ length: 3 }, () => "line").join("\n")}\n`; + const head = truncateHead(content, { maxBytes: 100, maxLines: 3 }); + const tail = truncateTail(content, { maxBytes: 100, maxLines: 3 }); + + expect(head).toMatchObject({ truncated: false, totalLines: 3, outputLines: 3 }); + expect(tail).toMatchObject({ truncated: false, totalLines: 3, outputLines: 3 }); + }); + it("truncates head on UTF-8 byte limits without partial lines", () => { const content = "éé\nabc"; const result = truncateHead(content, { maxBytes: 4, maxLines: 10 }); diff --git a/packages/agent/test/scratch/simple.ts b/packages/agent/test/scratch/simple.ts index 54978324..87af9c79 100644 --- a/packages/agent/test/scratch/simple.ts +++ b/packages/agent/test/scratch/simple.ts @@ -58,7 +58,7 @@ const agent = new AgentHarness({ model, thinkingLevel: "low", tools: [createReadTool(), createWriteTool(), createEditTool(), createBashTool()], - toolContext: async () => ({ env, sessionId: (await session.getMetadata()).id }), + toolContext: { env }, systemPrompt: ({ resources }) => [ "You are a helpful assistant.", @@ -73,5 +73,7 @@ const agent = new AgentHarness({ }, }); -const response = await agent.prompt("What skills do you have? Any duplicates?"); +const response = await agent.prompt( + "What skills do you have? Any duplicates? Also use bash to get the current date and time, then read README.md and tell me what this project is about.", +); console.log(response);