feat(agent): align harness execution tools
This commit is contained in:
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user