feat(agent): align harness execution tools
This commit is contained in:
@@ -8,7 +8,11 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Added context-aware `read`, `write`, `edit`, and `bash` harness tools backed by `ExecutionEnv`.
|
||||
- Added context-aware `read`, `write`, `edit`, and `bash` harness tools backed by `ExecutionEnv`, including async bash execution preparation.
|
||||
|
||||
### Changed
|
||||
|
||||
- Aligned harness tool path handling, edit serialization, shell output capture, explicit non-inherited environments, and cross-platform process cleanup with coding-agent behavior.
|
||||
|
||||
## [0.81.1] - 2026-07-21
|
||||
|
||||
|
||||
@@ -75,13 +75,13 @@ Static option values are used directly. System-prompt provider callbacks are inv
|
||||
|
||||
Resource arrays are shallow-copied when a snapshot is created. Individual skill and prompt-template objects are not deep-copied.
|
||||
|
||||
`toolContext` is application-defined. A static value is reused, while a zero-argument sync or async provider is resolved once for each turn snapshot. Harness tools receive that resolved value when they execute. Individual tools can structurally require only the context fields they use.
|
||||
`toolContext` is application-defined and required when the configured tools require a non-`undefined` context. A static value is reused, while a zero-argument sync or async provider is resolved once for each turn snapshot. Harness tools receive that resolved value when they execute. Individual tools can structurally require only the context fields they use.
|
||||
|
||||
Stream options are shallow-copied when a snapshot is created. `headers` and `metadata` maps are shallow-copied; their values are not deep-copied. Credentials from `getApiKeyAndHeaders()` are resolved per provider request so expiring tokens can refresh, but the configured stream options and derived session id come from the current turn snapshot.
|
||||
|
||||
### Built-in tools
|
||||
|
||||
The package exports `createReadTool()`, `createWriteTool()`, `createEditTool()`, and `createBashTool()`. They perform filesystem and shell operations exclusively through the `ExecutionEnv` supplied in their tool context. Each tool structurally requires a context containing `env: ExecutionEnv` and `sessionId: string`; applications may provide additional fields. `createReadTool()` accepts an optional image processor for host-provided conversion and resizing without imposing an image-processing dependency on the agent package.
|
||||
The package exports `createReadTool()`, `createWriteTool()`, `createEditTool()`, and `createBashTool()`. They perform filesystem and shell operations exclusively through the `ExecutionEnv` supplied in their tool context. Each tool structurally requires the shared `ExecutionToolContext`, containing `env: ExecutionEnv`; applications may provide additional fields. `createReadTool()` accepts an optional image processor for host-provided conversion and resizing without imposing an image-processing dependency on the agent package. `createBashTool()` accepts an async `prepare` hook that can mutate the command, working directory, environment, and environment-inheritance policy using the current tool context.
|
||||
|
||||
### Session
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+127
-21
@@ -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,11 +475,8 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
settle(err(new ExecutionError("spawn_error", error.message, error)));
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
void waitForChildProcess(child).then(
|
||||
(code) => {
|
||||
if (callbackError) {
|
||||
settle(err(callbackError));
|
||||
return;
|
||||
@@ -387,7 +490,9 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
||||
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
|
||||
|
||||
@@ -499,7 +499,7 @@ describe("AgentHarness", () => {
|
||||
}),
|
||||
]);
|
||||
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
const toolContext = { env, sessionId: "session-1" };
|
||||
const toolContext = { env };
|
||||
let receivedContext: typeof toolContext | undefined;
|
||||
const contextTool: AgentHarnessTool<typeof toolContext, typeof calculateTool.parameters, undefined> = {
|
||||
...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 { homedir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { FileError, getOrThrow } from "../../src/harness/types.ts";
|
||||
@@ -8,6 +12,52 @@ import { createTempDir } from "./session-test-utils.ts";
|
||||
|
||||
const chmodRestorePaths: string[] = [];
|
||||
|
||||
function withTimeout<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 () => {
|
||||
for (const path of chmodRestorePaths.splice(0)) {
|
||||
try {
|
||||
@@ -45,6 +95,14 @@ describe("NodeExecutionEnv", () => {
|
||||
expect(getOrThrow(await env.exists("nested/child/file.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
it("expands home-relative paths and file URLs", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
expect(getOrThrow(await env.absolutePath("~/pi-node-env-test"))).toBe(join(homedir(), "pi-node-env-test"));
|
||||
const filePath = join(root, "file with spaces.txt");
|
||||
expect(getOrThrow(await env.absolutePath(pathToFileURL(filePath).href))).toBe(filePath);
|
||||
});
|
||||
|
||||
it("returns fileInfo for files, directories, and symlinks without following symlinks", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
@@ -201,6 +259,29 @@ describe("NodeExecutionEnv", () => {
|
||||
expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 });
|
||||
});
|
||||
|
||||
it("can replace rather than inherit the default shell environment", async () => {
|
||||
const root = createTempDir();
|
||||
const inheritedKey = "PI_NODE_ENV_INHERITED_TEST";
|
||||
const configuredKey = "PI_NODE_ENV_CONFIGURED_TEST";
|
||||
const explicitKey = "PI_NODE_ENV_EXPLICIT_TEST";
|
||||
const previousInherited = process.env[inheritedKey];
|
||||
process.env[inheritedKey] = "host";
|
||||
try {
|
||||
const env = new NodeExecutionEnv({ cwd: root, shellEnv: { [configuredKey]: "configured" } });
|
||||
const result = getOrThrow(
|
||||
await env.exec(`printf '%s:%s:%s' "\${${inheritedKey}-}" "\${${configuredKey}-}" "\${${explicitKey}-}"`, {
|
||||
inheritEnv: false,
|
||||
env: { [explicitKey]: "explicit" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.stdout).toBe("::explicit");
|
||||
} finally {
|
||||
if (previousInherited === undefined) delete process.env[inheritedKey];
|
||||
else process.env[inheritedKey] = previousInherited;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses stdin command transport for legacy WSL bash paths", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const root = createTempDir();
|
||||
@@ -234,6 +315,41 @@ describe("NodeExecutionEnv", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "win32")(
|
||||
"settles after the shell exits when a detached descendant retains inherited stdio",
|
||||
async () => {
|
||||
const root = createTempDir();
|
||||
const pidFile = join(root, "grandchild.pid");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const result = getOrThrow(
|
||||
await withTimeout(
|
||||
env.exec(createInheritedStdioCommand(pidFile), { abortSignal: controller.signal }),
|
||||
3000,
|
||||
() => controller.abort(),
|
||||
),
|
||||
);
|
||||
expect(result.stdout).toContain("child-exiting");
|
||||
} finally {
|
||||
controller.abort();
|
||||
cleanupDetachedChild(pidFile);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("cleanup terminates active shell processes", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const execution = env.exec("touch started; sleep 60");
|
||||
for (let attempt = 0; attempt < 100 && !getOrThrow(await env.exists("started")); attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
expect(getOrThrow(await env.exists("started"))).toBe(true);
|
||||
await env.cleanup();
|
||||
await expect(withTimeout(execution, 3000)).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("streams stdout and stderr chunks", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
@@ -254,6 +370,17 @@ describe("NodeExecutionEnv", () => {
|
||||
expect(stderr).toBe("err");
|
||||
});
|
||||
|
||||
it("reports a missing working directory before spawning", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: join(root, "missing") });
|
||||
const result = await env.exec("printf ok");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "spawn_error", message: expect.stringContaining("Working directory does not exist") },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns non-zero command exit codes as successful execution results", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
|
||||
@@ -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 { describe, expect, it } from "vitest";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { createBashTool } from "../../src/harness/tools/bash.ts";
|
||||
import { type BashToolDetails, createBashTool } from "../../src/harness/tools/bash.ts";
|
||||
import { createEditTool } from "../../src/harness/tools/edit.ts";
|
||||
import { createReadTool } from "../../src/harness/tools/read.ts";
|
||||
import { createWriteTool } from "../../src/harness/tools/write.ts";
|
||||
import { getOrThrow } from "../../src/harness/types.ts";
|
||||
import {
|
||||
type ExecutionError,
|
||||
type FileError,
|
||||
getOrThrow,
|
||||
ok,
|
||||
type Result,
|
||||
type ShellExecOptions,
|
||||
} from "../../src/harness/types.ts";
|
||||
import { createTempDir } from "./session-test-utils.ts";
|
||||
|
||||
function textOutput(result: { content: Array<{ type: string; text?: string }> }): string {
|
||||
@@ -14,7 +22,82 @@ function textOutput(result: { content: Array<{ type: string; text?: string }> })
|
||||
|
||||
function createContext() {
|
||||
const env = new NodeExecutionEnv({ cwd: createTempDir() });
|
||||
return { env, sessionId: "session-1" };
|
||||
return { env };
|
||||
}
|
||||
|
||||
function deferred(): { promise: Promise<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 {
|
||||
@@ -80,6 +163,24 @@ describe("AgentHarness tools", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not count a trailing newline as an extra line at the truncation limit", async () => {
|
||||
const context = createContext();
|
||||
getOrThrow(
|
||||
await context.env.writeFile("exact.txt", `${Array.from({ length: 2000 }, () => "x").join("\n")}\n`),
|
||||
);
|
||||
|
||||
const result = await createReadTool().execute(
|
||||
"read-exact",
|
||||
{ path: "exact.txt" },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
);
|
||||
|
||||
expect(result.details).toBeUndefined();
|
||||
expect(textOutput(result)).not.toContain("Use offset=");
|
||||
});
|
||||
|
||||
it("rejects offsets beyond the file", async () => {
|
||||
const context = createContext();
|
||||
getOrThrow(await context.env.writeFile("short.txt", "one\ntwo\nthree"));
|
||||
@@ -150,6 +251,37 @@ describe("AgentHarness tools", () => {
|
||||
expect(textOutput(result)).toBe("Successfully wrote 5 bytes to nested/dir/file.txt");
|
||||
expect(getOrThrow(await context.env.readTextFile("nested/dir/file.txt"))).toBe("hello");
|
||||
});
|
||||
|
||||
it("keeps the mutation queue locked until an aborted write settles", async () => {
|
||||
const env = new BlockingWriteExecutionEnv({ cwd: createTempDir() });
|
||||
const tool = createWriteTool();
|
||||
const controller = new AbortController();
|
||||
const firstWrite = tool.execute(
|
||||
"write-first",
|
||||
{ path: "file.txt", content: "first\n" },
|
||||
controller.signal,
|
||||
undefined,
|
||||
{
|
||||
env,
|
||||
},
|
||||
);
|
||||
await env.firstWriteStarted.promise;
|
||||
controller.abort();
|
||||
const secondWrite = tool.execute(
|
||||
"write-second",
|
||||
{ path: "file.txt", content: "second\n" },
|
||||
undefined,
|
||||
undefined,
|
||||
{ env },
|
||||
);
|
||||
|
||||
await delay(20);
|
||||
expect(env.secondWriteStarted).toBe(false);
|
||||
env.finishFirstWrite.resolve();
|
||||
await expect(firstWrite).rejects.toThrow();
|
||||
await secondWrite;
|
||||
expect(getOrThrow(await env.readTextFile("file.txt"))).toBe("second\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit", () => {
|
||||
@@ -226,6 +358,79 @@ describe("AgentHarness tools", () => {
|
||||
).rejects.toThrow(/Found 3 occurrences/);
|
||||
});
|
||||
|
||||
it("keeps the mutation queue locked until an aborted edit write settles", async () => {
|
||||
const env = new BlockingEditExecutionEnv({ cwd: createTempDir() });
|
||||
getOrThrow(await env.writeFile("file.txt", "alpha\nbeta\n"));
|
||||
const tool = createEditTool();
|
||||
const controller = new AbortController();
|
||||
const firstEdit = tool.execute(
|
||||
"edit-first",
|
||||
{ path: "file.txt", edits: [{ oldText: "alpha", newText: "ALPHA" }] },
|
||||
controller.signal,
|
||||
undefined,
|
||||
{ env },
|
||||
);
|
||||
await env.firstEditWriteStarted.promise;
|
||||
controller.abort();
|
||||
const secondEdit = tool.execute(
|
||||
"edit-second",
|
||||
{ path: "file.txt", edits: [{ oldText: "beta", newText: "BETA" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
{ env },
|
||||
);
|
||||
|
||||
await delay(20);
|
||||
expect(env.secondEditWriteStarted).toBe(false);
|
||||
env.finishFirstEditWrite.resolve();
|
||||
await expect(firstEdit).rejects.toThrow("Operation aborted");
|
||||
await secondEdit;
|
||||
expect(env.firstEditWriteSettled).toBe(true);
|
||||
expect(getOrThrow(await env.readTextFile("file.txt"))).toBe("ALPHA\nBETA\n");
|
||||
});
|
||||
|
||||
it("serializes concurrent edits through canonical and symlink paths", async () => {
|
||||
const env = new SlowReadExecutionEnv({ cwd: createTempDir() });
|
||||
getOrThrow(await env.writeFile("target.txt", "alpha\nbeta\ngamma\n"));
|
||||
await symlink("target.txt", `${env.cwd}/link.txt`);
|
||||
const tool = createEditTool();
|
||||
|
||||
await Promise.all([
|
||||
tool.execute(
|
||||
"edit-target",
|
||||
{ path: "target.txt", edits: [{ oldText: "alpha", newText: "ALPHA" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
{ env },
|
||||
),
|
||||
tool.execute(
|
||||
"edit-link",
|
||||
{ path: "link.txt", edits: [{ oldText: "beta", newText: "BETA" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
{ env },
|
||||
),
|
||||
]);
|
||||
|
||||
expect(getOrThrow(await env.readTextFile("target.txt"))).toBe("ALPHA\nBETA\ngamma\n");
|
||||
});
|
||||
|
||||
it("edits regular files through symlinks", async () => {
|
||||
const context = createContext();
|
||||
getOrThrow(await context.env.writeFile("target.txt", "before\n"));
|
||||
await symlink("target.txt", `${context.env.cwd}/link.txt`);
|
||||
|
||||
await createEditTool().execute(
|
||||
"edit-symlink",
|
||||
{ path: "link.txt", edits: [{ oldText: "before", newText: "after" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
);
|
||||
|
||||
expect(getOrThrow(await context.env.readTextFile("target.txt"))).toBe("after\n");
|
||||
});
|
||||
|
||||
it("preserves BOM and CRLF line endings", async () => {
|
||||
const context = createContext();
|
||||
getOrThrow(await context.env.writeFile("edit.txt", "\uFEFFone\r\ntwo\r\n"));
|
||||
@@ -297,6 +502,64 @@ describe("AgentHarness tools", () => {
|
||||
expect(fullOutput).toContain("line-2999\nline-3000");
|
||||
});
|
||||
|
||||
it("ignores output callbacks after execution settles", async () => {
|
||||
const env = new LateOutputExecutionEnv({ cwd: createTempDir() });
|
||||
const updates: string[] = [];
|
||||
const result = await createBashTool().execute(
|
||||
"bash-late",
|
||||
{ command: "late" },
|
||||
undefined,
|
||||
(update) => updates.push(textOutput(update)),
|
||||
{ env },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(textOutput(result)).toBe("before\n");
|
||||
expect(updates.some((update) => update.includes("late"))).toBe(false);
|
||||
});
|
||||
|
||||
it("reports the total size of an oversized final line", async () => {
|
||||
const context = createContext();
|
||||
const result = await createBashTool().execute(
|
||||
"bash-long-line",
|
||||
{ command: "printf '%060000d' 0" },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
);
|
||||
|
||||
expect(textOutput(result)).toMatch(/Showing last 50\.0KB of line 1 \(line is 58\.6KB\)\. Full output:/);
|
||||
});
|
||||
|
||||
it("prepares command, cwd, and an explicit environment with the turn context", async () => {
|
||||
const env = new NodeExecutionEnv({
|
||||
cwd: createTempDir(),
|
||||
shellEnv: { PI_BASH_PREPARE_INHERITED: "inherited" },
|
||||
});
|
||||
getOrThrow(await env.createDir("workspace"));
|
||||
const context = { env, workspace: `${env.cwd}/workspace` };
|
||||
const controller = new AbortController();
|
||||
let receivedContext: typeof context | undefined;
|
||||
let receivedSignal: AbortSignal | undefined;
|
||||
const tool = createBashTool<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 () => {
|
||||
const context = createContext();
|
||||
const result = await createBashTool({ commandPrefix: "value=hello" }).execute(
|
||||
@@ -312,12 +575,15 @@ describe("AgentHarness tools", () => {
|
||||
|
||||
it("coalesces updates and persists truncated full output", async () => {
|
||||
const context = createContext();
|
||||
const updates: string[] = [];
|
||||
const updates: Array<{
|
||||
content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>;
|
||||
details?: BashToolDetails;
|
||||
}> = [];
|
||||
const result = await createBashTool().execute(
|
||||
"bash-5",
|
||||
{ command: "i=1; while [ $i -le 3000 ]; do echo line-$i; i=$((i + 1)); done" },
|
||||
undefined,
|
||||
(update) => updates.push(textOutput(update)),
|
||||
(update) => updates.push(update),
|
||||
context,
|
||||
);
|
||||
|
||||
@@ -330,6 +596,12 @@ describe("AgentHarness tools", () => {
|
||||
});
|
||||
expect(textOutput(result)).toContain("line-3000");
|
||||
expect(result.details?.fullOutputPath).toBeDefined();
|
||||
const finalUpdate = updates.at(-1);
|
||||
expect(finalUpdate ? textOutput(finalUpdate) : "").toContain("line-3000");
|
||||
expect(finalUpdate?.details).toMatchObject({
|
||||
truncation: { totalLines: 3000, totalBytes: expect.any(Number) },
|
||||
fullOutputPath: result.details?.fullOutputPath,
|
||||
});
|
||||
const fullOutput = getOrThrow(await context.env.readTextFile(result.details!.fullOutputPath!));
|
||||
expect(fullOutput).toContain("line-1\nline-2");
|
||||
expect(fullOutput).toContain("line-2999\nline-3000");
|
||||
|
||||
@@ -72,6 +72,15 @@ describe("truncate utilities", () => {
|
||||
expect(result.totalBytes).toBe(9);
|
||||
});
|
||||
|
||||
it("does not count a trailing newline as an extra line", () => {
|
||||
const content = `${Array.from({ length: 3 }, () => "line").join("\n")}\n`;
|
||||
const head = truncateHead(content, { maxBytes: 100, maxLines: 3 });
|
||||
const tail = truncateTail(content, { maxBytes: 100, maxLines: 3 });
|
||||
|
||||
expect(head).toMatchObject({ truncated: false, totalLines: 3, outputLines: 3 });
|
||||
expect(tail).toMatchObject({ truncated: false, totalLines: 3, outputLines: 3 });
|
||||
});
|
||||
|
||||
it("truncates head on UTF-8 byte limits without partial lines", () => {
|
||||
const content = "éé\nabc";
|
||||
const result = truncateHead(content, { maxBytes: 4, maxLines: 10 });
|
||||
|
||||
@@ -58,7 +58,7 @@ const agent = new AgentHarness({
|
||||
model,
|
||||
thinkingLevel: "low",
|
||||
tools: [createReadTool(), createWriteTool(), createEditTool(), createBashTool()],
|
||||
toolContext: async () => ({ env, sessionId: (await session.getMetadata()).id }),
|
||||
toolContext: { env },
|
||||
systemPrompt: ({ resources }) =>
|
||||
[
|
||||
"You are a helpful assistant.",
|
||||
@@ -73,5 +73,7 @@ const agent = new AgentHarness({
|
||||
},
|
||||
});
|
||||
|
||||
const response = await agent.prompt("What skills do you have? Any duplicates?");
|
||||
const response = await agent.prompt(
|
||||
"What skills do you have? Any duplicates? Also use bash to get the current date and time, then read README.md and tell me what this project is about.",
|
||||
);
|
||||
console.log(response);
|
||||
|
||||
Reference in New Issue
Block a user