feat(agent): align harness execution tools
This commit is contained in:
@@ -8,7 +8,11 @@
|
|||||||
|
|
||||||
### Added
|
### 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
|
## [0.81.1] - 2026-07-21
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
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
|
### 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
|
### Session
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import type {
|
|||||||
AgentHarnessResources,
|
AgentHarnessResources,
|
||||||
AgentHarnessStreamOptions,
|
AgentHarnessStreamOptions,
|
||||||
AgentHarnessStreamOptionsPatch,
|
AgentHarnessStreamOptionsPatch,
|
||||||
|
AgentHarnessSystemPrompt,
|
||||||
AgentHarnessTool,
|
AgentHarnessTool,
|
||||||
AgentHarnessToolContextSource,
|
AgentHarnessToolContextSource,
|
||||||
CompactResult,
|
CompactResult,
|
||||||
@@ -181,7 +182,7 @@ export class AgentHarness<
|
|||||||
private pendingSessionWrites: PendingSessionWrite[] = [];
|
private pendingSessionWrites: PendingSessionWrite[] = [];
|
||||||
private model: Model<any>;
|
private model: Model<any>;
|
||||||
private thinkingLevel: ThinkingLevel;
|
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 toolContext: AgentHarnessToolContextSource<TContext> | undefined;
|
||||||
private streamOptions: AgentHarnessStreamOptions;
|
private streamOptions: AgentHarnessStreamOptions;
|
||||||
private retry: RetryPolicy | undefined;
|
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 { randomUUID } from "node:crypto";
|
||||||
import { constants, createReadStream } from "node:fs";
|
import { constants, createReadStream } from "node:fs";
|
||||||
import {
|
import {
|
||||||
@@ -13,9 +13,10 @@ import {
|
|||||||
rm,
|
rm,
|
||||||
writeFile,
|
writeFile,
|
||||||
} from "node:fs/promises";
|
} from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { homedir, tmpdir } from "node:os";
|
||||||
import { isAbsolute, join, resolve } from "node:path";
|
import { isAbsolute, join, resolve } from "node:path";
|
||||||
import { createInterface } from "node:readline";
|
import { createInterface } from "node:readline";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import {
|
import {
|
||||||
type ExecutionEnv,
|
type ExecutionEnv,
|
||||||
ExecutionError,
|
ExecutionError,
|
||||||
@@ -25,11 +26,13 @@ import {
|
|||||||
type FileKind,
|
type FileKind,
|
||||||
ok,
|
ok,
|
||||||
type Result,
|
type Result,
|
||||||
|
type ShellExecOptions,
|
||||||
toError,
|
toError,
|
||||||
} from "../types.ts";
|
} from "../types.ts";
|
||||||
|
|
||||||
const MAX_TIMEOUT_MS = 2_147_483_647;
|
const MAX_TIMEOUT_MS = 2_147_483_647;
|
||||||
const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000;
|
const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000;
|
||||||
|
const EXIT_STDIO_GRACE_MS = 100;
|
||||||
|
|
||||||
function resolveTimeoutMs(timeout: number | undefined): Result<number | undefined, ExecutionError> {
|
function resolveTimeoutMs(timeout: number | undefined): Result<number | undefined, ExecutionError> {
|
||||||
if (timeout === undefined) return ok(undefined);
|
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 {
|
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: {
|
function fileKindFromStats(stats: {
|
||||||
@@ -197,7 +212,16 @@ async function getShellConfig(customShellPath?: string): Promise<Result<ShellCon
|
|||||||
if (bashOnPath) {
|
if (bashOnPath) {
|
||||||
return ok(getBashShellConfig(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")) {
|
if (await pathExists("/bin/bash")) {
|
||||||
@@ -210,7 +234,12 @@ async function getShellConfig(customShellPath?: string): Promise<Result<ShellCon
|
|||||||
return ok({ shell: "sh", args: ["-c"] });
|
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 {
|
return {
|
||||||
...process.env,
|
...process.env,
|
||||||
...baseEnv,
|
...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 {
|
export class NodeExecutionEnv implements ExecutionEnv {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
private shellPath?: string;
|
private shellPath?: string;
|
||||||
private shellEnv?: NodeJS.ProcessEnv;
|
private shellEnv?: NodeJS.ProcessEnv;
|
||||||
|
private activeChildPids = new Set<number>();
|
||||||
|
|
||||||
constructor(options: { cwd: string; shellPath?: string; shellEnv?: NodeJS.ProcessEnv }) {
|
constructor(options: { cwd: string; shellPath?: string; shellEnv?: NodeJS.ProcessEnv }) {
|
||||||
this.cwd = options.cwd;
|
this.cwd = options.cwd;
|
||||||
@@ -264,14 +363,7 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||||||
|
|
||||||
async exec(
|
async exec(
|
||||||
command: string,
|
command: string,
|
||||||
options?: {
|
options?: ShellExecOptions,
|
||||||
cwd?: string;
|
|
||||||
env?: Record<string, string>;
|
|
||||||
timeout?: number;
|
|
||||||
abortSignal?: AbortSignal;
|
|
||||||
onStdout?: (chunk: string) => void;
|
|
||||||
onStderr?: (chunk: string) => void;
|
|
||||||
},
|
|
||||||
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
|
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
|
||||||
if (options?.abortSignal?.aborted) return err(new ExecutionError("aborted", "aborted"));
|
if (options?.abortSignal?.aborted) return err(new ExecutionError("aborted", "aborted"));
|
||||||
const timeoutMsResult = resolveTimeoutMs(options?.timeout);
|
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 cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd;
|
||||||
const shellConfig = await getShellConfig(this.shellPath);
|
const shellConfig = await getShellConfig(this.shellPath);
|
||||||
if (!shellConfig.ok) return shellConfig;
|
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) => {
|
return await new Promise((resolvePromise) => {
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
@@ -300,6 +404,7 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||||||
const settle = (result: Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>) => {
|
const settle = (result: Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>) => {
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
if (options?.abortSignal) options.abortSignal.removeEventListener("abort", onAbort);
|
if (options?.abortSignal) options.abortSignal.removeEventListener("abort", onAbort);
|
||||||
|
if (child?.pid) this.activeChildPids.delete(child.pid);
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
resolvePromise(result);
|
resolvePromise(result);
|
||||||
@@ -313,11 +418,12 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||||||
{
|
{
|
||||||
cwd,
|
cwd,
|
||||||
detached: process.platform !== "win32",
|
detached: process.platform !== "win32",
|
||||||
env: getShellEnv(this.shellEnv, options?.env),
|
env: getShellEnv(this.shellEnv, options?.env, options?.inheritEnv),
|
||||||
stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"],
|
stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"],
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (child.pid) this.activeChildPids.add(child.pid);
|
||||||
if (commandFromStdin) {
|
if (commandFromStdin) {
|
||||||
child.stdin?.on("error", () => {});
|
child.stdin?.on("error", () => {});
|
||||||
child.stdin?.end(command);
|
child.stdin?.end(command);
|
||||||
@@ -369,25 +475,24 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on("error", (error) => {
|
void waitForChildProcess(child).then(
|
||||||
settle(err(new ExecutionError("spawn_error", error.message, error)));
|
(code) => {
|
||||||
});
|
if (callbackError) {
|
||||||
|
settle(err(callbackError));
|
||||||
child.on("close", (code) => {
|
return;
|
||||||
if (callbackError) {
|
}
|
||||||
settle(err(callbackError));
|
if (timedOut) {
|
||||||
return;
|
settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`)));
|
||||||
}
|
return;
|
||||||
if (timedOut) {
|
}
|
||||||
settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`)));
|
if (options?.abortSignal?.aborted) {
|
||||||
return;
|
settle(err(new ExecutionError("aborted", "aborted")));
|
||||||
}
|
return;
|
||||||
if (options?.abortSignal?.aborted) {
|
}
|
||||||
settle(err(new ExecutionError("aborted", "aborted")));
|
settle(ok({ stdout, stderr, exitCode: code ?? 0 }));
|
||||||
return;
|
},
|
||||||
}
|
(error: Error) => settle(err(new ExecutionError("spawn_error", error.message, error))),
|
||||||
settle(ok({ stdout, stderr, exitCode: code ?? 0 }));
|
);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,6 +669,7 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async cleanup(): Promise<void> {
|
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 Static, Type } from "typebox";
|
||||||
import type { AgentHarnessTool, ExecutionEnv } from "../types.ts";
|
import type { AgentHarnessTool } from "../types.ts";
|
||||||
import { getOrThrow } from "../types.ts";
|
import { getOrThrow } from "../types.ts";
|
||||||
import { executeShellWithCapture } from "../utils/shell-output.ts";
|
import { executeShellWithCapture, type ShellCaptureProgress } from "../utils/shell-output.ts";
|
||||||
import {
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult } from "../utils/truncate.ts";
|
||||||
DEFAULT_MAX_BYTES,
|
import type { ExecutionToolContext } from "./tool-context.ts";
|
||||||
DEFAULT_MAX_LINES,
|
|
||||||
formatSize,
|
|
||||||
type TruncationResult,
|
|
||||||
truncateTail,
|
|
||||||
} from "../utils/truncate.ts";
|
|
||||||
|
|
||||||
const MAX_TIMEOUT_SECONDS = 2_147_483_647 / 1000;
|
const MAX_TIMEOUT_SECONDS = 2_147_483_647 / 1000;
|
||||||
const BASH_UPDATE_THROTTLE_MS = 100;
|
const BASH_UPDATE_THROTTLE_MS = 100;
|
||||||
@@ -20,27 +15,27 @@ const bashSchema = Type.Object({
|
|||||||
|
|
||||||
export type BashToolInput = Static<typeof bashSchema>;
|
export type BashToolInput = Static<typeof bashSchema>;
|
||||||
|
|
||||||
export interface BashToolContext {
|
|
||||||
env: ExecutionEnv;
|
|
||||||
sessionId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BashToolDetails {
|
export interface BashToolDetails {
|
||||||
truncation?: TruncationResult;
|
truncation?: TruncationResult;
|
||||||
fullOutputPath?: string;
|
fullOutputPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BashSpawnContext {
|
export interface BashExecution {
|
||||||
command: string;
|
command: string;
|
||||||
cwd: 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;
|
commandPrefix?: string;
|
||||||
spawnHook?: BashSpawnHook;
|
prepare?: BashPrepare<TContext>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateTimeout(timeout: number | undefined): void {
|
function validateTimeout(timeout: number | undefined): void {
|
||||||
@@ -53,34 +48,40 @@ function validateTimeout(timeout: number | undefined): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBashTool<TContext extends BashToolContext = BashToolContext>(
|
export function createBashTool<TContext extends ExecutionToolContext = ExecutionToolContext>(
|
||||||
options?: BashToolOptions,
|
options?: BashToolOptions<TContext>,
|
||||||
): AgentHarnessTool<TContext, typeof bashSchema, BashToolDetails | undefined> {
|
): AgentHarnessTool<TContext, typeof bashSchema, BashToolDetails | undefined> {
|
||||||
return {
|
return {
|
||||||
name: "bash",
|
name: "bash",
|
||||||
label: "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.`,
|
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,
|
parameters: bashSchema,
|
||||||
async execute(_toolCallId, { command, timeout }, signal, onUpdate, { env }) {
|
async execute(_toolCallId, { command, timeout }, signal, onUpdate, context) {
|
||||||
validateTimeout(timeout);
|
validateTimeout(timeout);
|
||||||
const resolvedCommand = options?.commandPrefix ? `${options.commandPrefix}\n${command}` : command;
|
const { env } = context;
|
||||||
const spawnContext = options?.spawnHook?.({ command: resolvedCommand, cwd: env.cwd }) ?? {
|
const execution: BashExecution = {
|
||||||
command: resolvedCommand,
|
command: options?.commandPrefix ? `${options.commandPrefix}\n${command}` : command,
|
||||||
cwd: env.cwd,
|
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 updateTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let updateDirty = false;
|
let updateDirty = false;
|
||||||
let lastUpdateAt = 0;
|
let lastUpdateAt = 0;
|
||||||
|
|
||||||
const emitOutputUpdate = (): void => {
|
const emitOutputUpdate = (): void => {
|
||||||
if (!onUpdate || !updateDirty) return;
|
if (!onUpdate || !updateDirty || !getLatestProgress) return;
|
||||||
updateDirty = false;
|
updateDirty = false;
|
||||||
lastUpdateAt = Date.now();
|
lastUpdateAt = Date.now();
|
||||||
const truncation = truncateTail(partialOutput);
|
const progress = getLatestProgress();
|
||||||
onUpdate({
|
onUpdate({
|
||||||
content: [{ type: "text", text: truncation.content }],
|
content: [{ type: "text", text: progress.output }],
|
||||||
details: { truncation: truncation.truncated ? truncation : undefined },
|
details: {
|
||||||
|
truncation: progress.truncation.truncated ? progress.truncation : undefined,
|
||||||
|
fullOutputPath: progress.fullOutputPath,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const clearUpdateTimer = (): void => {
|
const clearUpdateTimer = (): void => {
|
||||||
@@ -106,22 +107,22 @@ export function createBashTool<TContext extends BashToolContext = BashToolContex
|
|||||||
onUpdate?.({ content: [], details: undefined });
|
onUpdate?.({ content: [], details: undefined });
|
||||||
try {
|
try {
|
||||||
const capture = getOrThrow(
|
const capture = getOrThrow(
|
||||||
await executeShellWithCapture(env, spawnContext.command, {
|
await executeShellWithCapture(env, execution.command, {
|
||||||
cwd: spawnContext.cwd,
|
cwd: execution.cwd,
|
||||||
env: spawnContext.env,
|
env: execution.env,
|
||||||
|
inheritEnv: execution.inheritEnv,
|
||||||
timeout,
|
timeout,
|
||||||
abortSignal: signal,
|
abortSignal: signal,
|
||||||
returnExecutionErrors: true,
|
returnExecutionErrors: true,
|
||||||
onChunk: (chunk) => {
|
onChunk: (_chunk, getProgress) => {
|
||||||
partialOutput += chunk;
|
getLatestProgress = getProgress;
|
||||||
if (partialOutput.length > DEFAULT_MAX_BYTES * 4) {
|
|
||||||
partialOutput = partialOutput.slice(-DEFAULT_MAX_BYTES * 2);
|
|
||||||
}
|
|
||||||
scheduleOutputUpdate();
|
scheduleOutputUpdate();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
clearUpdateTimer();
|
clearUpdateTimer();
|
||||||
|
getLatestProgress = () => capture;
|
||||||
|
updateDirty = true;
|
||||||
emitOutputUpdate();
|
emitOutputUpdate();
|
||||||
|
|
||||||
let outputText = capture.output;
|
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 startLine = capture.truncation.totalLines - capture.truncation.outputLines + 1;
|
||||||
const endLine = capture.truncation.totalLines;
|
const endLine = capture.truncation.totalLines;
|
||||||
if (capture.truncation.lastLinePartial) {
|
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") {
|
} else if (capture.truncation.truncatedBy === "lines") {
|
||||||
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${capture.truncation.totalLines}. Full output: ${capture.fullOutputPath}]`;
|
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${capture.truncation.totalLines}. Full output: ${capture.fullOutputPath}]`;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type Static, Type } from "typebox";
|
import { type Static, Type } from "typebox";
|
||||||
import type { AgentHarnessTool, ExecutionEnv, FileError } from "../types.ts";
|
import type { AgentHarnessTool, FileError } from "../types.ts";
|
||||||
import {
|
import {
|
||||||
applyEditsToNormalizedContent,
|
applyEditsToNormalizedContent,
|
||||||
detectLineEnding,
|
detectLineEnding,
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "./edit-diff.ts";
|
} from "./edit-diff.ts";
|
||||||
import { withFileMutationQueue } from "./file-mutation-queue.ts";
|
import { withFileMutationQueue } from "./file-mutation-queue.ts";
|
||||||
import { resolveToolPath } from "./path-utils.ts";
|
import { resolveToolPath } from "./path-utils.ts";
|
||||||
|
import type { ExecutionToolContext } from "./tool-context.ts";
|
||||||
|
|
||||||
const replaceEditSchema = Type.Object(
|
const replaceEditSchema = Type.Object(
|
||||||
{
|
{
|
||||||
@@ -38,11 +39,6 @@ const editSchema = Type.Object(
|
|||||||
export type EditToolInput = Static<typeof editSchema>;
|
export type EditToolInput = Static<typeof editSchema>;
|
||||||
type LegacyEditToolInput = EditToolInput & { oldText?: unknown; newText?: unknown };
|
type LegacyEditToolInput = EditToolInput & { oldText?: unknown; newText?: unknown };
|
||||||
|
|
||||||
export interface EditToolContext {
|
|
||||||
env: ExecutionEnv;
|
|
||||||
sessionId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EditToolDetails {
|
export interface EditToolDetails {
|
||||||
diff: string;
|
diff: string;
|
||||||
patch: 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 });
|
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,
|
TContext,
|
||||||
typeof editSchema,
|
typeof editSchema,
|
||||||
EditToolDetails | undefined
|
EditToolDetails | undefined
|
||||||
@@ -97,7 +93,9 @@ export function createEditTool<TContext extends EditToolContext = EditToolContex
|
|||||||
if (signal?.aborted) throw new Error("Operation aborted");
|
if (signal?.aborted) throw new Error("Operation aborted");
|
||||||
const info = await env.fileInfo(absolutePath, signal);
|
const info = await env.fileInfo(absolutePath, signal);
|
||||||
if (!info.ok) throw editAccessError(path, info.error);
|
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);
|
const readResult = await env.readTextFile(absolutePath, signal);
|
||||||
if (!readResult.ok) throw editAccessError(path, readResult.error);
|
if (!readResult.ok) throw editAccessError(path, readResult.error);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export {
|
export {
|
||||||
type BashSpawnContext,
|
type BashExecution,
|
||||||
type BashSpawnHook,
|
type BashPrepare,
|
||||||
type BashToolContext,
|
|
||||||
type BashToolDetails,
|
type BashToolDetails,
|
||||||
type BashToolInput,
|
type BashToolInput,
|
||||||
type BashToolOptions,
|
type BashToolOptions,
|
||||||
@@ -9,7 +8,6 @@ export {
|
|||||||
} from "./bash.ts";
|
} from "./bash.ts";
|
||||||
export {
|
export {
|
||||||
createEditTool,
|
createEditTool,
|
||||||
type EditToolContext,
|
|
||||||
type EditToolDetails,
|
type EditToolDetails,
|
||||||
type EditToolInput,
|
type EditToolInput,
|
||||||
} from "./edit.ts";
|
} from "./edit.ts";
|
||||||
@@ -17,9 +15,9 @@ export {
|
|||||||
createReadTool,
|
createReadTool,
|
||||||
type ReadImageProcessor,
|
type ReadImageProcessor,
|
||||||
type ReadImageProcessorResult,
|
type ReadImageProcessorResult,
|
||||||
type ReadToolContext,
|
|
||||||
type ReadToolDetails,
|
type ReadToolDetails,
|
||||||
type ReadToolInput,
|
type ReadToolInput,
|
||||||
type ReadToolOptions,
|
type ReadToolOptions,
|
||||||
} from "./read.ts";
|
} 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 { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
||||||
import { type Static, Type } from "typebox";
|
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 { getOrThrow } from "../types.ts";
|
||||||
import {
|
import {
|
||||||
DEFAULT_MAX_BYTES,
|
DEFAULT_MAX_BYTES,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "../utils/truncate.ts";
|
} from "../utils/truncate.ts";
|
||||||
import { detectSupportedImageMimeType, encodeBase64 } from "./image.ts";
|
import { detectSupportedImageMimeType, encodeBase64 } from "./image.ts";
|
||||||
import { resolveReadToolPath } from "./path-utils.ts";
|
import { resolveReadToolPath } from "./path-utils.ts";
|
||||||
|
import type { ExecutionToolContext } from "./tool-context.ts";
|
||||||
|
|
||||||
const readSchema = Type.Object({
|
const readSchema = Type.Object({
|
||||||
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
|
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 type ReadToolInput = Static<typeof readSchema>;
|
||||||
|
|
||||||
export interface ReadToolContext {
|
|
||||||
env: ExecutionEnv;
|
|
||||||
sessionId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReadToolDetails {
|
export interface ReadToolDetails {
|
||||||
truncation?: TruncationResult;
|
truncation?: TruncationResult;
|
||||||
}
|
}
|
||||||
@@ -46,7 +42,7 @@ export interface ReadToolOptions {
|
|||||||
imageProcessor?: ReadImageProcessor;
|
imageProcessor?: ReadImageProcessor;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createReadTool<TContext extends ReadToolContext = ReadToolContext>(
|
export function createReadTool<TContext extends ExecutionToolContext = ExecutionToolContext>(
|
||||||
options?: ReadToolOptions,
|
options?: ReadToolOptions,
|
||||||
): AgentHarnessTool<TContext, typeof readSchema, ReadToolDetails | undefined> {
|
): AgentHarnessTool<TContext, typeof readSchema, ReadToolDetails | undefined> {
|
||||||
return {
|
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 Static, Type } from "typebox";
|
||||||
import type { AgentHarnessTool, ExecutionEnv } from "../types.ts";
|
import type { AgentHarnessTool } from "../types.ts";
|
||||||
import { getOrThrow } from "../types.ts";
|
import { getOrThrow } from "../types.ts";
|
||||||
import { withFileMutationQueue } from "./file-mutation-queue.ts";
|
import { withFileMutationQueue } from "./file-mutation-queue.ts";
|
||||||
import { resolveToolPath } from "./path-utils.ts";
|
import { resolveToolPath } from "./path-utils.ts";
|
||||||
|
import type { ExecutionToolContext } from "./tool-context.ts";
|
||||||
|
|
||||||
const writeSchema = Type.Object({
|
const writeSchema = Type.Object({
|
||||||
path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
|
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 type WriteToolInput = Static<typeof writeSchema>;
|
||||||
|
|
||||||
export interface WriteToolContext {
|
export function createWriteTool<TContext extends ExecutionToolContext = ExecutionToolContext>(): AgentHarnessTool<
|
||||||
env: ExecutionEnv;
|
|
||||||
sessionId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createWriteTool<TContext extends WriteToolContext = WriteToolContext>(): AgentHarnessTool<
|
|
||||||
TContext,
|
TContext,
|
||||||
typeof writeSchema,
|
typeof writeSchema,
|
||||||
undefined
|
undefined
|
||||||
|
|||||||
@@ -344,8 +344,10 @@ export interface FileSystem {
|
|||||||
export interface ShellExecOptions {
|
export interface ShellExecOptions {
|
||||||
/** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */
|
/** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */
|
||||||
cwd?: string;
|
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>;
|
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 in seconds. Implementations should return a timeout error when the command exceeds this duration. Defaults to no timeout. */
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
/** Abort signal used to terminate the command. Defaults to no abort signal. */
|
/** Abort signal used to terminate the command. Defaults to no abort signal. */
|
||||||
@@ -891,11 +893,26 @@ export interface BranchSummaryResult {
|
|||||||
modifiedFiles: string[];
|
modifiedFiles: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentHarnessOptions<
|
export type AgentHarnessSystemPrompt<
|
||||||
TContext extends object | undefined = undefined,
|
TContext extends object | undefined = undefined,
|
||||||
TSkill extends Skill = Skill,
|
TSkill extends Skill = Skill,
|
||||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||||
TTool extends AgentHarnessTool<TContext> = AgentHarnessTool<TContext>,
|
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;
|
session: Session;
|
||||||
/**
|
/**
|
||||||
@@ -905,22 +922,12 @@ export interface AgentHarnessOptions<
|
|||||||
*/
|
*/
|
||||||
models: Models;
|
models: Models;
|
||||||
tools?: TTool[];
|
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.
|
* Concrete resources available to explicit invocation methods and system-prompt callbacks.
|
||||||
* Applications own loading/reloading resources and should call `setResources()` with new values.
|
* Applications own loading/reloading resources and should call `setResources()` with new values.
|
||||||
*/
|
*/
|
||||||
resources?: AgentHarnessResources<TSkill, TPromptTemplate>;
|
resources?: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||||
systemPrompt?:
|
systemPrompt?: AgentHarnessSystemPrompt<TContext, TSkill, TPromptTemplate, TTool>;
|
||||||
| string
|
|
||||||
| ((context: {
|
|
||||||
session: Session;
|
|
||||||
model: Model<any>;
|
|
||||||
thinkingLevel: ThinkingLevel;
|
|
||||||
activeTools: TTool[];
|
|
||||||
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
|
||||||
}) => string | Promise<string>);
|
|
||||||
/** Curated stream/provider request options. Snapshotted at turn start. */
|
/** Curated stream/provider request options. Snapshotted at turn start. */
|
||||||
streamOptions?: AgentHarnessStreamOptions;
|
streamOptions?: AgentHarnessStreamOptions;
|
||||||
/** Optional retry policy for generated compaction and branch-summary requests. */
|
/** Optional retry policy for generated compaction and branch-summary requests. */
|
||||||
@@ -932,4 +939,20 @@ export interface AgentHarnessOptions<
|
|||||||
followUpMode?: QueueMode;
|
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";
|
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 { 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";
|
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"> {
|
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. */
|
/** Return shell execution failures with captured output instead of as a failed Result. */
|
||||||
returnExecutionErrors?: boolean;
|
returnExecutionErrors?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShellCaptureResult {
|
export interface ShellCaptureResult extends ShellCaptureProgress {
|
||||||
output: string;
|
|
||||||
exitCode: number | undefined;
|
exitCode: number | undefined;
|
||||||
cancelled: boolean;
|
cancelled: boolean;
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
truncation: TruncationResult;
|
|
||||||
fullOutputPath?: string;
|
|
||||||
executionError?: ExecutionError;
|
executionError?: ExecutionError;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,21 +40,30 @@ export function sanitizeBinaryOutput(str: string): string {
|
|||||||
.join("");
|
.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(
|
export async function executeShellWithCapture(
|
||||||
env: ExecutionEnv,
|
env: ExecutionEnv,
|
||||||
command: string,
|
command: string,
|
||||||
options?: ShellCaptureOptions,
|
options?: ShellCaptureOptions,
|
||||||
): Promise<Result<ShellCaptureResult, ExecutionError>> {
|
): Promise<Result<ShellCaptureResult, ExecutionError>> {
|
||||||
const outputChunks: string[] = [];
|
let tailOutput = "";
|
||||||
let outputBytes = 0;
|
|
||||||
const maxOutputBytes = DEFAULT_MAX_BYTES * 2;
|
const maxOutputBytes = DEFAULT_MAX_BYTES * 2;
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
let totalBytes = 0;
|
let totalBytes = 0;
|
||||||
let completedLines = 0;
|
let completedLines = 0;
|
||||||
let hasOpenLine = false;
|
let hasOpenLine = false;
|
||||||
|
let currentLineBytes = 0;
|
||||||
let fullOutputPath: string | undefined;
|
let fullOutputPath: string | undefined;
|
||||||
let fullOutputRequested = false;
|
let fullOutputRequested = false;
|
||||||
|
let acceptingOutput = true;
|
||||||
let writeChain: Promise<Result<void, ExecutionError>> = Promise.resolve(ok(undefined));
|
let writeChain: Promise<Result<void, ExecutionError>> = Promise.resolve(ok(undefined));
|
||||||
let captureError: ExecutionError | 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 {
|
try {
|
||||||
const text = sanitizeBinaryOutput(chunk).replace(/\r/g, "");
|
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;
|
const newlineCount = text.split("\n").length - 1;
|
||||||
completedLines += newlineCount;
|
completedLines += newlineCount;
|
||||||
if (newlineCount > 0) hasOpenLine = !text.endsWith("\n");
|
const lastNewline = text.lastIndexOf("\n");
|
||||||
else if (text.length > 0) hasOpenLine = true;
|
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);
|
const totalLines = completedLines + (hasOpenLine ? 1 : 0);
|
||||||
if ((totalBytes > DEFAULT_MAX_BYTES || totalLines > DEFAULT_MAX_LINES) && !fullOutputRequested) {
|
if ((totalBytes > DEFAULT_MAX_BYTES || totalLines > DEFAULT_MAX_LINES) && !fullOutputRequested) {
|
||||||
ensureFullOutputFile(outputChunks.join("") + text);
|
ensureFullOutputFile(tailOutput);
|
||||||
} else if (fullOutputRequested) {
|
} else if (fullOutputRequested) {
|
||||||
appendFullOutput(text);
|
appendFullOutput(text);
|
||||||
}
|
}
|
||||||
outputChunks.push(text);
|
tailOutput = trimToLastUtf8Bytes(tailOutput, maxOutputBytes, encoder);
|
||||||
outputBytes += text.length;
|
options?.onChunk?.(text, createProgress);
|
||||||
while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
|
|
||||||
const removed = outputChunks.shift()!;
|
|
||||||
outputBytes -= removed.length;
|
|
||||||
}
|
|
||||||
options?.onChunk?.(text);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
captureError = toExecutionError(error);
|
captureError = toExecutionError(error);
|
||||||
}
|
}
|
||||||
@@ -107,48 +147,35 @@ export async function executeShellWithCapture(
|
|||||||
const result = await env.exec(command, {
|
const result = await env.exec(command, {
|
||||||
cwd: options?.cwd,
|
cwd: options?.cwd,
|
||||||
env: options?.env,
|
env: options?.env,
|
||||||
|
inheritEnv: options?.inheritEnv,
|
||||||
timeout: options?.timeout,
|
timeout: options?.timeout,
|
||||||
abortSignal: options?.abortSignal,
|
abortSignal: options?.abortSignal,
|
||||||
onStdout: onChunk,
|
onStdout: onChunk,
|
||||||
onStderr: onChunk,
|
onStderr: onChunk,
|
||||||
});
|
});
|
||||||
const tailOutput = outputChunks.join("");
|
acceptingOutput = false;
|
||||||
const tailTruncation = truncateTail(tailOutput);
|
let progress = createProgress();
|
||||||
const totalLines = completedLines + (hasOpenLine ? 1 : 0);
|
if (progress.truncation.truncated && !fullOutputRequested) ensureFullOutputFile(tailOutput);
|
||||||
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);
|
|
||||||
const writeResult = await writeChain;
|
const writeResult = await writeChain;
|
||||||
if (!writeResult.ok) return err(writeResult.error);
|
if (!writeResult.ok) return err(writeResult.error);
|
||||||
if (captureError) return err(captureError);
|
if (captureError) return err(captureError);
|
||||||
|
progress = createProgress();
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
if (result.error.code === "aborted" || options?.abortSignal?.aborted) {
|
if (result.error.code === "aborted" || options?.abortSignal?.aborted) {
|
||||||
return ok({
|
return ok({
|
||||||
output: truncationResult.truncated ? truncationResult.content : tailOutput,
|
...progress,
|
||||||
exitCode: undefined,
|
exitCode: undefined,
|
||||||
cancelled: true,
|
cancelled: true,
|
||||||
truncated: truncationResult.truncated,
|
truncated: progress.truncation.truncated,
|
||||||
truncation: truncationResult,
|
|
||||||
fullOutputPath,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options?.returnExecutionErrors) {
|
if (options?.returnExecutionErrors) {
|
||||||
return ok({
|
return ok({
|
||||||
output: truncationResult.truncated ? truncationResult.content : tailOutput,
|
...progress,
|
||||||
exitCode: undefined,
|
exitCode: undefined,
|
||||||
cancelled: false,
|
cancelled: false,
|
||||||
truncated: truncationResult.truncated,
|
truncated: progress.truncation.truncated,
|
||||||
truncation: truncationResult,
|
|
||||||
fullOutputPath,
|
|
||||||
executionError: result.error,
|
executionError: result.error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -156,14 +183,13 @@ export async function executeShellWithCapture(
|
|||||||
}
|
}
|
||||||
const cancelled = options?.abortSignal?.aborted ?? false;
|
const cancelled = options?.abortSignal?.aborted ?? false;
|
||||||
return ok({
|
return ok({
|
||||||
output: truncationResult.truncated ? truncationResult.content : tailOutput,
|
...progress,
|
||||||
exitCode: cancelled ? undefined : result.value.exitCode,
|
exitCode: cancelled ? undefined : result.value.exitCode,
|
||||||
cancelled,
|
cancelled,
|
||||||
truncated: truncationResult.truncated,
|
truncated: progress.truncation.truncated,
|
||||||
truncation: truncationResult,
|
|
||||||
fullOutputPath,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
acceptingOutput = false;
|
||||||
return err(toExecutionError(error));
|
return err(toExecutionError(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ function utf8ByteLength(content: string): number {
|
|||||||
return bytes;
|
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 {
|
function replaceUnpairedSurrogates(content: string): string {
|
||||||
let output = "";
|
let output = "";
|
||||||
for (let i = 0; i < content.length; i++) {
|
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 maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||||
|
|
||||||
const totalBytes = utf8ByteLength(content);
|
const totalBytes = utf8ByteLength(content);
|
||||||
const lines = content.split("\n");
|
const lines = splitLinesForCounting(content);
|
||||||
const totalLines = lines.length;
|
const totalLines = lines.length;
|
||||||
|
|
||||||
// Check if no truncation needed
|
// Check if no truncation needed
|
||||||
@@ -217,8 +224,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
|
|||||||
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||||
|
|
||||||
const totalBytes = utf8ByteLength(content);
|
const totalBytes = utf8ByteLength(content);
|
||||||
const lines = content.split("\n");
|
const lines = splitLinesForCounting(content);
|
||||||
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
|
||||||
const totalLines = lines.length;
|
const totalLines = lines.length;
|
||||||
|
|
||||||
// Check if no truncation needed
|
// Check if no truncation needed
|
||||||
|
|||||||
@@ -499,7 +499,7 @@ describe("AgentHarness", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||||
const toolContext = { env, sessionId: "session-1" };
|
const toolContext = { env };
|
||||||
let receivedContext: typeof toolContext | undefined;
|
let receivedContext: typeof toolContext | undefined;
|
||||||
const contextTool: AgentHarnessTool<typeof toolContext, typeof calculateTool.parameters, undefined> = {
|
const contextTool: AgentHarnessTool<typeof toolContext, typeof calculateTool.parameters, undefined> = {
|
||||||
...calculateTool,
|
...calculateTool,
|
||||||
|
|||||||
@@ -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 { access, chmod, realpath, symlink } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
import { delimiter, join } from "node:path";
|
import { delimiter, join } from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||||
import { FileError, getOrThrow } from "../../src/harness/types.ts";
|
import { FileError, getOrThrow } from "../../src/harness/types.ts";
|
||||||
@@ -8,6 +12,52 @@ import { createTempDir } from "./session-test-utils.ts";
|
|||||||
|
|
||||||
const chmodRestorePaths: string[] = [];
|
const chmodRestorePaths: string[] = [];
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, ms: number, onTimeout?: () => void): Promise<T> {
|
||||||
|
return new Promise<T>((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 () => {
|
afterEach(async () => {
|
||||||
for (const path of chmodRestorePaths.splice(0)) {
|
for (const path of chmodRestorePaths.splice(0)) {
|
||||||
try {
|
try {
|
||||||
@@ -45,6 +95,14 @@ describe("NodeExecutionEnv", () => {
|
|||||||
expect(getOrThrow(await env.exists("nested/child/file.txt"))).toBe(false);
|
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 () => {
|
it("returns fileInfo for files, directories, and symlinks without following symlinks", async () => {
|
||||||
const root = createTempDir();
|
const root = createTempDir();
|
||||||
const env = new NodeExecutionEnv({ cwd: root });
|
const env = new NodeExecutionEnv({ cwd: root });
|
||||||
@@ -201,6 +259,29 @@ describe("NodeExecutionEnv", () => {
|
|||||||
expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 });
|
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 () => {
|
it("uses stdin command transport for legacy WSL bash paths", async () => {
|
||||||
if (process.platform === "win32") return;
|
if (process.platform === "win32") return;
|
||||||
const root = createTempDir();
|
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 () => {
|
it("streams stdout and stderr chunks", async () => {
|
||||||
const root = createTempDir();
|
const root = createTempDir();
|
||||||
const env = new NodeExecutionEnv({ cwd: root });
|
const env = new NodeExecutionEnv({ cwd: root });
|
||||||
@@ -254,6 +370,17 @@ describe("NodeExecutionEnv", () => {
|
|||||||
expect(stderr).toBe("err");
|
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 () => {
|
it("returns non-zero command exit codes as successful execution results", async () => {
|
||||||
const root = createTempDir();
|
const root = createTempDir();
|
||||||
const env = new NodeExecutionEnv({ cwd: root });
|
const env = new NodeExecutionEnv({ cwd: root });
|
||||||
|
|||||||
@@ -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<Api>;
|
||||||
|
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] });
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
|
import { symlink } from "node:fs/promises";
|
||||||
import { applyPatch } from "diff";
|
import { applyPatch } from "diff";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
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 { createEditTool } from "../../src/harness/tools/edit.ts";
|
||||||
import { createReadTool } from "../../src/harness/tools/read.ts";
|
import { createReadTool } from "../../src/harness/tools/read.ts";
|
||||||
import { createWriteTool } from "../../src/harness/tools/write.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";
|
import { createTempDir } from "./session-test-utils.ts";
|
||||||
|
|
||||||
function textOutput(result: { content: Array<{ type: string; text?: string }> }): string {
|
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() {
|
function createContext() {
|
||||||
const env = new NodeExecutionEnv({ cwd: createTempDir() });
|
const env = new NodeExecutionEnv({ cwd: createTempDir() });
|
||||||
return { env, sessionId: "session-1" };
|
return { env };
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||||
|
let resolve = () => {};
|
||||||
|
const promise = new Promise<void>((resolvePromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
class SlowReadExecutionEnv extends NodeExecutionEnv {
|
||||||
|
override async readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>> {
|
||||||
|
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<Result<void, FileError>> {
|
||||||
|
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<Result<void, FileError>> {
|
||||||
|
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<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
|
||||||
|
options?.onStdout?.("before\n");
|
||||||
|
setTimeout(() => options?.onStdout?.("late\n"), 0);
|
||||||
|
return ok({ stdout: "before\n", stderr: "", exitCode: 0 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTinyBmp(): Uint8Array {
|
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 () => {
|
it("rejects offsets beyond the file", async () => {
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
getOrThrow(await context.env.writeFile("short.txt", "one\ntwo\nthree"));
|
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(textOutput(result)).toBe("Successfully wrote 5 bytes to nested/dir/file.txt");
|
||||||
expect(getOrThrow(await context.env.readTextFile("nested/dir/file.txt"))).toBe("hello");
|
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", () => {
|
describe("edit", () => {
|
||||||
@@ -226,6 +358,79 @@ describe("AgentHarness tools", () => {
|
|||||||
).rejects.toThrow(/Found 3 occurrences/);
|
).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 () => {
|
it("preserves BOM and CRLF line endings", async () => {
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
getOrThrow(await context.env.writeFile("edit.txt", "\uFEFFone\r\ntwo\r\n"));
|
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");
|
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<typeof context>({
|
||||||
|
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 () => {
|
it("supports command prefixes", async () => {
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
const result = await createBashTool({ commandPrefix: "value=hello" }).execute(
|
const result = await createBashTool({ commandPrefix: "value=hello" }).execute(
|
||||||
@@ -312,12 +575,15 @@ describe("AgentHarness tools", () => {
|
|||||||
|
|
||||||
it("coalesces updates and persists truncated full output", async () => {
|
it("coalesces updates and persists truncated full output", async () => {
|
||||||
const context = createContext();
|
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(
|
const result = await createBashTool().execute(
|
||||||
"bash-5",
|
"bash-5",
|
||||||
{ command: "i=1; while [ $i -le 3000 ]; do echo line-$i; i=$((i + 1)); done" },
|
{ command: "i=1; while [ $i -le 3000 ]; do echo line-$i; i=$((i + 1)); done" },
|
||||||
undefined,
|
undefined,
|
||||||
(update) => updates.push(textOutput(update)),
|
(update) => updates.push(update),
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -330,6 +596,12 @@ describe("AgentHarness tools", () => {
|
|||||||
});
|
});
|
||||||
expect(textOutput(result)).toContain("line-3000");
|
expect(textOutput(result)).toContain("line-3000");
|
||||||
expect(result.details?.fullOutputPath).toBeDefined();
|
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!));
|
const fullOutput = getOrThrow(await context.env.readTextFile(result.details!.fullOutputPath!));
|
||||||
expect(fullOutput).toContain("line-1\nline-2");
|
expect(fullOutput).toContain("line-1\nline-2");
|
||||||
expect(fullOutput).toContain("line-2999\nline-3000");
|
expect(fullOutput).toContain("line-2999\nline-3000");
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ describe("truncate utilities", () => {
|
|||||||
expect(result.totalBytes).toBe(9);
|
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", () => {
|
it("truncates head on UTF-8 byte limits without partial lines", () => {
|
||||||
const content = "éé\nabc";
|
const content = "éé\nabc";
|
||||||
const result = truncateHead(content, { maxBytes: 4, maxLines: 10 });
|
const result = truncateHead(content, { maxBytes: 4, maxLines: 10 });
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ const agent = new AgentHarness({
|
|||||||
model,
|
model,
|
||||||
thinkingLevel: "low",
|
thinkingLevel: "low",
|
||||||
tools: [createReadTool(), createWriteTool(), createEditTool(), createBashTool()],
|
tools: [createReadTool(), createWriteTool(), createEditTool(), createBashTool()],
|
||||||
toolContext: async () => ({ env, sessionId: (await session.getMetadata()).id }),
|
toolContext: { env },
|
||||||
systemPrompt: ({ resources }) =>
|
systemPrompt: ({ resources }) =>
|
||||||
[
|
[
|
||||||
"You are a helpful assistant.",
|
"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);
|
console.log(response);
|
||||||
|
|||||||
Reference in New Issue
Block a user