feat(agent): align harness execution tools

This commit is contained in:
Mario Zechner
2026-07-22 15:36:06 +02:00
parent 2d0b2294cd
commit e32c1491b5
19 changed files with 763 additions and 174 deletions
+2 -1
View File
@@ -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<any>;
private thinkingLevel: ThinkingLevel;
private systemPrompt: AgentHarnessOptions<TContext, TSkill, TPromptTemplate, TTool>["systemPrompt"];
private systemPrompt: AgentHarnessSystemPrompt<TContext, TSkill, TPromptTemplate, TTool> | undefined;
private toolContext: AgentHarnessToolContextSource<TContext> | undefined;
private streamOptions: AgentHarnessStreamOptions;
private retry: RetryPolicy | undefined;
+140 -34
View File
@@ -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<number | undefined, ExecutionError> {
if (timeout === undefined) return ok(undefined);
@@ -45,7 +48,19 @@ function resolveTimeoutMs(timeout: number | undefined): Result<number | undefine
}
function resolvePath(cwd: string, path: string): string {
return isAbsolute(path) ? path : resolve(cwd, path);
let normalized = path;
if (normalized === "~") {
normalized = homedir();
} else if (normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))) {
normalized = join(homedir(), normalized.slice(2));
} else if (normalized.startsWith("file://")) {
try {
normalized = fileURLToPath(normalized);
} catch {
// Keep malformed URLs as ordinary paths so filesystem methods preserve their non-throwing contract.
}
}
return isAbsolute(normalized) ? resolve(normalized) : resolve(cwd, normalized);
}
function fileKindFromStats(stats: {
@@ -197,7 +212,16 @@ async function getShellConfig(customShellPath?: string): Promise<Result<ShellCon
if (bashOnPath) {
return ok(getBashShellConfig(bashOnPath));
}
return err(new ExecutionError("shell_unavailable", "No bash shell found"));
return err(
new ExecutionError(
"shell_unavailable",
`No bash shell found. Options:\n` +
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
" 3. Configure an explicit shellPath\n\n" +
`Searched Git Bash in:\n${candidates.map((path) => ` ${path}`).join("\n")}`,
),
);
}
if (await pathExists("/bin/bash")) {
@@ -210,7 +234,12 @@ async function getShellConfig(customShellPath?: string): Promise<Result<ShellCon
return ok({ shell: "sh", args: ["-c"] });
}
function getShellEnv(baseEnv?: NodeJS.ProcessEnv, extraEnv?: Record<string, string>): NodeJS.ProcessEnv {
function getShellEnv(
baseEnv?: NodeJS.ProcessEnv,
extraEnv?: Record<string, string>,
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<number | null> {
return new Promise((resolvePromise, reject) => {
let settled = false;
let exited = false;
let exitCode: number | null = null;
let postExitTimer: ReturnType<typeof setTimeout> | 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<number>();
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<string, string>;
timeout?: number;
abortSignal?: AbortSignal;
onStdout?: (chunk: string) => void;
onStderr?: (chunk: string) => void;
},
options?: ShellExecOptions,
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
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<void> {
// nothing to clean up for the local node implementation
for (const pid of this.activeChildPids) killProcessTree(pid);
this.activeChildPids.clear();
}
}
+41 -39
View File
@@ -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<typeof bashSchema>;
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<string, string>;
env: Record<string, string>;
inheritEnv: boolean;
}
export type BashSpawnHook = (context: BashSpawnContext) => BashSpawnContext;
export type BashPrepare<TContext extends ExecutionToolContext = ExecutionToolContext> = (
execution: BashExecution,
context: TContext,
signal?: AbortSignal,
) => void | Promise<void>;
export interface BashToolOptions {
export interface BashToolOptions<TContext extends ExecutionToolContext = ExecutionToolContext> {
commandPrefix?: string;
spawnHook?: BashSpawnHook;
prepare?: BashPrepare<TContext>;
}
function validateTimeout(timeout: number | undefined): void {
@@ -53,34 +48,40 @@ function validateTimeout(timeout: number | undefined): void {
}
}
export function createBashTool<TContext extends BashToolContext = BashToolContext>(
options?: BashToolOptions,
export function createBashTool<TContext extends ExecutionToolContext = ExecutionToolContext>(
options?: BashToolOptions<TContext>,
): AgentHarnessTool<TContext, typeof bashSchema, BashToolDetails | undefined> {
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<typeof setTimeout> | 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<TContext extends BashToolContext = BashToolContex
onUpdate?.({ content: [], details: undefined });
try {
const capture = getOrThrow(
await executeShellWithCapture(env, spawnContext.command, {
cwd: spawnContext.cwd,
env: spawnContext.env,
await executeShellWithCapture(env, execution.command, {
cwd: execution.cwd,
env: execution.env,
inheritEnv: execution.inheritEnv,
timeout,
abortSignal: signal,
returnExecutionErrors: true,
onChunk: (chunk) => {
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<TContext extends BashToolContext = BashToolContex
const startLine = capture.truncation.totalLines - capture.truncation.outputLines + 1;
const endLine = capture.truncation.totalLines;
if (capture.truncation.lastLinePartial) {
outputText += `\n\n[Showing last ${formatSize(capture.truncation.outputBytes)} of line ${endLine}. Full output: ${capture.fullOutputPath}]`;
const lastLineSize = formatSize(capture.lastLineBytes);
outputText += `\n\n[Showing last ${formatSize(capture.truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${capture.fullOutputPath}]`;
} else if (capture.truncation.truncatedBy === "lines") {
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${capture.truncation.totalLines}. Full output: ${capture.fullOutputPath}]`;
} else {
+6 -8
View File
@@ -1,5 +1,5 @@
import { type Static, Type } from "typebox";
import type { AgentHarnessTool, ExecutionEnv, FileError } from "../types.ts";
import type { AgentHarnessTool, FileError } from "../types.ts";
import {
applyEditsToNormalizedContent,
detectLineEnding,
@@ -12,6 +12,7 @@ import {
} from "./edit-diff.ts";
import { withFileMutationQueue } from "./file-mutation-queue.ts";
import { resolveToolPath } from "./path-utils.ts";
import type { ExecutionToolContext } from "./tool-context.ts";
const replaceEditSchema = Type.Object(
{
@@ -38,11 +39,6 @@ const editSchema = Type.Object(
export type EditToolInput = Static<typeof editSchema>;
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<TContext extends EditToolContext = EditToolContext>(): AgentHarnessTool<
export function createEditTool<TContext extends ExecutionToolContext = ExecutionToolContext>(): AgentHarnessTool<
TContext,
typeof editSchema,
EditToolDetails | undefined
@@ -97,7 +93,9 @@ export function createEditTool<TContext extends EditToolContext = EditToolContex
if (signal?.aborted) throw new Error("Operation aborted");
const info = await env.fileInfo(absolutePath, signal);
if (!info.ok) throw editAccessError(path, info.error);
if (info.value.kind !== "file") throw new Error(`Could not edit file: ${path}. Path is not a file.`);
if (info.value.kind !== "file" && info.value.kind !== "symlink") {
throw new Error(`Could not edit file: ${path}. Path is not a file.`);
}
const readResult = await env.readTextFile(absolutePath, signal);
if (!readResult.ok) throw editAccessError(path, readResult.error);
+4 -6
View File
@@ -1,7 +1,6 @@
export {
type BashSpawnContext,
type BashSpawnHook,
type BashToolContext,
type BashExecution,
type BashPrepare,
type BashToolDetails,
type BashToolInput,
type BashToolOptions,
@@ -9,7 +8,6 @@ export {
} from "./bash.ts";
export {
createEditTool,
type EditToolContext,
type EditToolDetails,
type EditToolInput,
} from "./edit.ts";
@@ -17,9 +15,9 @@ export {
createReadTool,
type ReadImageProcessor,
type ReadImageProcessorResult,
type ReadToolContext,
type ReadToolDetails,
type ReadToolInput,
type ReadToolOptions,
} from "./read.ts";
export { createWriteTool, type WriteToolContext, type WriteToolInput } from "./write.ts";
export type { ExecutionToolContext } from "./tool-context.ts";
export { createWriteTool, type WriteToolInput } from "./write.ts";
+3 -7
View File
@@ -1,6 +1,6 @@
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
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 {
DEFAULT_MAX_BYTES,
@@ -11,6 +11,7 @@ import {
} from "../utils/truncate.ts";
import { detectSupportedImageMimeType, encodeBase64 } from "./image.ts";
import { resolveReadToolPath } from "./path-utils.ts";
import type { ExecutionToolContext } from "./tool-context.ts";
const readSchema = Type.Object({
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
@@ -20,11 +21,6 @@ const readSchema = Type.Object({
export type ReadToolInput = Static<typeof readSchema>;
export interface ReadToolContext {
env: ExecutionEnv;
sessionId: string;
}
export interface ReadToolDetails {
truncation?: TruncationResult;
}
@@ -46,7 +42,7 @@ export interface ReadToolOptions {
imageProcessor?: ReadImageProcessor;
}
export function createReadTool<TContext extends ReadToolContext = ReadToolContext>(
export function createReadTool<TContext extends ExecutionToolContext = ExecutionToolContext>(
options?: ReadToolOptions,
): AgentHarnessTool<TContext, typeof readSchema, ReadToolDetails | undefined> {
return {
@@ -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;
}
+3 -7
View File
@@ -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<typeof writeSchema>;
export interface WriteToolContext {
env: ExecutionEnv;
sessionId: string;
}
export function createWriteTool<TContext extends WriteToolContext = WriteToolContext>(): AgentHarnessTool<
export function createWriteTool<TContext extends ExecutionToolContext = ExecutionToolContext>(): AgentHarnessTool<
TContext,
typeof writeSchema,
undefined
+36 -13
View File
@@ -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<string, string>;
/** 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<TContext> = AgentHarnessTool<TContext>,
> =
| string
| ((context: {
session: Session;
model: Model<any>;
thinkingLevel: ThinkingLevel;
activeTools: TTool[];
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
}) => string | Promise<string>);
interface AgentHarnessOptionsBase<
TContext extends object | undefined,
TSkill extends Skill,
TPromptTemplate extends PromptTemplate,
TTool extends AgentHarnessTool<TContext>,
> {
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<TContext>;
/**
* 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<TSkill, TPromptTemplate>;
systemPrompt?:
| string
| ((context: {
session: Session;
model: Model<any>;
thinkingLevel: ThinkingLevel;
activeTools: TTool[];
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
}) => string | Promise<string>);
systemPrompt?: AgentHarnessSystemPrompt<TContext, TSkill, TPromptTemplate, TTool>;
/** 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<TContext> = AgentHarnessTool<TContext>,
> = AgentHarnessOptionsBase<TContext, TSkill, TPromptTemplate, TTool> &
([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<TContext>;
});
export type { AgentHarness } from "./agent-harness.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<ShellExecOptions, "onStdout" | "onStderr"> {
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<Result<ShellCaptureResult, ExecutionError>> {
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<Result<void, ExecutionError>> = 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));
}
}
+9 -3
View File
@@ -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