mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(agent): thread context through execution capabilities
This commit is contained in:
parent
3fbfcf48ff
commit
c40f02cb86
36 changed files with 1114 additions and 759 deletions
|
|
@ -11,7 +11,6 @@ import type {
|
|||
ToolResultMessage,
|
||||
Usage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { TelemetryContext } from "@earendil-works/pi-telemetry";
|
||||
import type { AgentMessage, AgentToolResult, QueueMode, ThinkingLevel } from "../types.ts";
|
||||
import type { BranchPreparation, BranchSummaryResult } from "./compaction/branch-summarization.ts";
|
||||
import type { CompactionPreparation, CompactionSettings, CompactResult } from "./compaction/compaction.ts";
|
||||
|
|
@ -623,7 +622,6 @@ export interface AgentHarnessOptions<TContext extends object | undefined = objec
|
|||
drive?: "automatic" | "manual";
|
||||
toProviderMessages?: (messages: AgentMessage[], context: Context) => Message[] | Promise<Message[]>;
|
||||
entryProjectors?: Record<string, EntryProjector>;
|
||||
telemetryContext?: TelemetryContext;
|
||||
}
|
||||
|
||||
export interface AgentLane {
|
||||
|
|
|
|||
|
|
@ -65,8 +65,6 @@ export interface GenerateBranchSummaryOptions {
|
|||
models: Models;
|
||||
/** Model used for summarization. */
|
||||
model: Model<Api>;
|
||||
/** Abort signal for the summarization request. */
|
||||
signal: AbortSignal;
|
||||
/** Optional instructions appended to or replacing the default prompt. */
|
||||
customInstructions?: string;
|
||||
/** Replace the default prompt with custom instructions instead of appending them. */
|
||||
|
|
@ -214,17 +212,9 @@ Keep each section concise. Preserve exact file paths, function names, and error
|
|||
export async function generateBranchSummary(
|
||||
entries: Entry[],
|
||||
options: GenerateBranchSummaryOptions,
|
||||
context: Context,
|
||||
): Promise<Result<BranchSummaryResult, BranchSummaryError>> {
|
||||
const {
|
||||
models,
|
||||
model,
|
||||
signal,
|
||||
customInstructions,
|
||||
replaceInstructions,
|
||||
reserveTokens = 16384,
|
||||
retry,
|
||||
callbacks,
|
||||
} = options;
|
||||
const { models, model, customInstructions, replaceInstructions, reserveTokens = 16384, retry, callbacks } = options;
|
||||
const contextWindow = model.contextWindow || 128000;
|
||||
const tokenBudget = contextWindow - reserveTokens;
|
||||
|
||||
|
|
@ -256,9 +246,10 @@ export async function generateBranchSummary(
|
|||
models,
|
||||
model,
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
{ signal, maxTokens: 2048 },
|
||||
{ maxTokens: 2048 },
|
||||
retry,
|
||||
callbacks,
|
||||
context,
|
||||
);
|
||||
if (response.stopReason === "aborted") {
|
||||
return err(new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted"));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
type Context as AiContext,
|
||||
type Api,
|
||||
type AssistantMessage,
|
||||
type Context,
|
||||
contentText,
|
||||
type Model,
|
||||
type Models,
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
uuidv7,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { AgentMessage, ThinkingLevel } from "../../types.ts";
|
||||
import type { Context } from "../context.ts";
|
||||
import { convertToLlm, createBranchSummaryMessage, createCompactionSummaryMessage } from "../messages.ts";
|
||||
import { buildContextEntries, sessionEntryToContextMessages } from "../session/context.ts";
|
||||
import type { CompactionEntry, Entry } from "../session/types.ts";
|
||||
|
|
@ -110,19 +111,22 @@ export interface CompactResult<T = unknown> {
|
|||
export async function completeSimpleWithRetries(
|
||||
models: Models,
|
||||
model: Model<Api>,
|
||||
context: Context,
|
||||
aiContext: AiContext,
|
||||
options: SimpleStreamOptions,
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
retry: RetryPolicy | undefined,
|
||||
callbacks: RetryCallbacks | undefined,
|
||||
context: Context,
|
||||
): Promise<AssistantMessage> {
|
||||
// Summaries are standalone requests, so isolate routing and avoid cache writes that cannot be reused.
|
||||
const requestOptions: SimpleStreamOptions = {
|
||||
...options,
|
||||
signal: context.abortSignal,
|
||||
telemetryContext: context.telemetryContext,
|
||||
cacheRetention: "none",
|
||||
sessionId: uuidv7(),
|
||||
};
|
||||
return retryAssistantCall(
|
||||
() => models.completeSimple(model, context, requestOptions),
|
||||
() => models.completeSimple(model, aiContext, requestOptions),
|
||||
retry,
|
||||
requestOptions.signal,
|
||||
callbacks,
|
||||
|
|
@ -485,24 +489,24 @@ export async function generateSummary(
|
|||
models: Models,
|
||||
model: Model<Api>,
|
||||
reserveTokens: number,
|
||||
signal?: AbortSignal,
|
||||
customInstructions?: string,
|
||||
previousSummary?: string,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
customInstructions: string | undefined,
|
||||
previousSummary: string | undefined,
|
||||
thinkingLevel: ThinkingLevel | undefined,
|
||||
retry: RetryPolicy | undefined,
|
||||
callbacks: RetryCallbacks | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<string, CompactionError>> {
|
||||
const result = await generateSummaryWithUsage(
|
||||
currentMessages,
|
||||
models,
|
||||
model,
|
||||
reserveTokens,
|
||||
signal,
|
||||
customInstructions,
|
||||
previousSummary,
|
||||
thinkingLevel,
|
||||
retry,
|
||||
callbacks,
|
||||
context,
|
||||
);
|
||||
return result.ok ? ok(result.value.text) : err(result.error);
|
||||
}
|
||||
|
|
@ -513,12 +517,12 @@ export async function generateSummaryWithUsage(
|
|||
models: Models,
|
||||
model: Model<Api>,
|
||||
reserveTokens: number,
|
||||
signal?: AbortSignal,
|
||||
customInstructions?: string,
|
||||
previousSummary?: string,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
customInstructions: string | undefined,
|
||||
previousSummary: string | undefined,
|
||||
thinkingLevel: ThinkingLevel | undefined,
|
||||
retry: RetryPolicy | undefined,
|
||||
callbacks: RetryCallbacks | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<{ text: string; usage: Usage }, CompactionError>> {
|
||||
const maxTokens = Math.min(
|
||||
Math.floor(0.8 * reserveTokens),
|
||||
|
|
@ -546,8 +550,8 @@ export async function generateSummaryWithUsage(
|
|||
|
||||
const completionOptions =
|
||||
model.reasoning && thinkingLevel && thinkingLevel !== "off"
|
||||
? { maxTokens, signal, reasoning: thinkingLevel }
|
||||
: { maxTokens, signal };
|
||||
? { maxTokens, reasoning: thinkingLevel }
|
||||
: { maxTokens };
|
||||
|
||||
const response = await completeSimpleWithRetries(
|
||||
models,
|
||||
|
|
@ -556,6 +560,7 @@ export async function generateSummaryWithUsage(
|
|||
completionOptions,
|
||||
retry,
|
||||
callbacks,
|
||||
context,
|
||||
);
|
||||
if (response.stopReason === "aborted") {
|
||||
return err(new CompactionError("aborted", response.errorMessage || "Summarization aborted"));
|
||||
|
|
@ -692,11 +697,11 @@ export async function compact(
|
|||
preparation: CompactionPreparation,
|
||||
models: Models,
|
||||
model: Model<Api>,
|
||||
customInstructions?: string,
|
||||
signal?: AbortSignal,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
customInstructions: string | undefined,
|
||||
thinkingLevel: ThinkingLevel | undefined,
|
||||
retry: RetryPolicy | undefined,
|
||||
callbacks: RetryCallbacks | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<CompactResult, CompactionError>> {
|
||||
const {
|
||||
messagesToSummarize,
|
||||
|
|
@ -721,12 +726,12 @@ export async function compact(
|
|||
models,
|
||||
model,
|
||||
settings.reserveTokens,
|
||||
signal,
|
||||
customInstructions,
|
||||
previousSummary,
|
||||
thinkingLevel,
|
||||
retry,
|
||||
callbacks,
|
||||
context,
|
||||
);
|
||||
if (!historyResult.ok) return err(historyResult.error);
|
||||
historyText = historyResult.value.text;
|
||||
|
|
@ -737,10 +742,10 @@ export async function compact(
|
|||
models,
|
||||
model,
|
||||
settings.reserveTokens,
|
||||
signal,
|
||||
thinkingLevel,
|
||||
retry,
|
||||
callbacks,
|
||||
context,
|
||||
);
|
||||
if (!turnPrefixResult.ok) return err(turnPrefixResult.error);
|
||||
summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value.text}`;
|
||||
|
|
@ -751,12 +756,12 @@ export async function compact(
|
|||
models,
|
||||
model,
|
||||
settings.reserveTokens,
|
||||
signal,
|
||||
customInstructions,
|
||||
previousSummary,
|
||||
thinkingLevel,
|
||||
retry,
|
||||
callbacks,
|
||||
context,
|
||||
);
|
||||
if (!summaryResult.ok) return err(summaryResult.error);
|
||||
summary = summaryResult.value.text;
|
||||
|
|
@ -779,10 +784,10 @@ async function generateTurnPrefixSummary(
|
|||
models: Models,
|
||||
model: Model<Api>,
|
||||
reserveTokens: number,
|
||||
signal?: AbortSignal,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
thinkingLevel: ThinkingLevel | undefined,
|
||||
retry: RetryPolicy | undefined,
|
||||
callbacks: RetryCallbacks | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<{ text: string; usage: Usage }, CompactionError>> {
|
||||
const maxTokens = Math.min(
|
||||
Math.floor(0.5 * reserveTokens),
|
||||
|
|
@ -801,8 +806,8 @@ async function generateTurnPrefixSummary(
|
|||
|
||||
const completionOptions =
|
||||
model.reasoning && thinkingLevel && thinkingLevel !== "off"
|
||||
? { maxTokens, signal, reasoning: thinkingLevel }
|
||||
: { maxTokens, signal };
|
||||
? { maxTokens, reasoning: thinkingLevel }
|
||||
: { maxTokens };
|
||||
const response = await completeSimpleWithRetries(
|
||||
models,
|
||||
model,
|
||||
|
|
@ -810,6 +815,7 @@ async function generateTurnPrefixSummary(
|
|||
completionOptions,
|
||||
retry,
|
||||
callbacks,
|
||||
context,
|
||||
);
|
||||
if (response.stopReason === "aborted") {
|
||||
return err(new CompactionError("aborted", response.errorMessage || "Turn prefix summarization aborted"));
|
||||
|
|
|
|||
131
packages/agent/src/harness/env/nodejs.ts
vendored
131
packages/agent/src/harness/env/nodejs.ts
vendored
|
|
@ -18,6 +18,7 @@ import { homedir, tmpdir } from "node:os";
|
|||
import { basename, isAbsolute, join, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Context } from "../context.ts";
|
||||
import {
|
||||
type ExecutionEnv,
|
||||
ExecutionError,
|
||||
|
|
@ -356,19 +357,21 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||
this.shellEnv = options.shellEnv;
|
||||
}
|
||||
|
||||
async absolutePath(path: string): Promise<Result<string, FileError>> {
|
||||
async absolutePath(path: string, _context: Context): Promise<Result<string, FileError>> {
|
||||
return ok(resolvePath(this.cwd, path));
|
||||
}
|
||||
|
||||
async joinPath(parts: string[]): Promise<Result<string, FileError>> {
|
||||
async joinPath(parts: string[], _context: Context): Promise<Result<string, FileError>> {
|
||||
return ok(join(...parts));
|
||||
}
|
||||
|
||||
async exec(
|
||||
command: string,
|
||||
options?: ShellExecOptions,
|
||||
options: ShellExecOptions | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
|
||||
if (options?.abortSignal?.aborted) return err(new ExecutionError("aborted", "aborted"));
|
||||
const signal = context.abortSignal;
|
||||
if (signal?.aborted) return err(new ExecutionError("aborted", "aborted"));
|
||||
const timeoutMsResult = resolveTimeoutMs(options?.timeout);
|
||||
if (!timeoutMsResult.ok) return err(timeoutMsResult.error);
|
||||
const timeoutMs = timeoutMsResult.value;
|
||||
|
|
@ -406,7 +409,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 (signal) signal.removeEventListener("abort", onAbort);
|
||||
if (child?.pid) this.activeChildPids.delete(child.pid);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
|
|
@ -447,11 +450,11 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||
}, timeoutMs)
|
||||
: undefined;
|
||||
|
||||
if (options?.abortSignal) {
|
||||
if (options.abortSignal.aborted) {
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
options.abortSignal.addEventListener("abort", onAbort, { once: true });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -460,7 +463,7 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||
child.stdout?.on("data", (chunk: string) => {
|
||||
stdout += chunk;
|
||||
try {
|
||||
options?.onStdout?.(chunk);
|
||||
options?.onStdout?.(chunk, context);
|
||||
} catch (error) {
|
||||
const cause = toError(error);
|
||||
callbackError = new ExecutionError("callback_error", cause.message, cause);
|
||||
|
|
@ -470,7 +473,7 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||
child.stderr?.on("data", (chunk: string) => {
|
||||
stderr += chunk;
|
||||
try {
|
||||
options?.onStderr?.(chunk);
|
||||
options?.onStderr?.(chunk, context);
|
||||
} catch (error) {
|
||||
const cause = toError(error);
|
||||
callbackError = new ExecutionError("callback_error", cause.message, cause);
|
||||
|
|
@ -488,7 +491,7 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||
settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`)));
|
||||
return;
|
||||
}
|
||||
if (options?.abortSignal?.aborted) {
|
||||
if (signal?.aborted) {
|
||||
settle(err(new ExecutionError("aborted", "aborted")));
|
||||
return;
|
||||
}
|
||||
|
|
@ -499,12 +502,13 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||
});
|
||||
}
|
||||
|
||||
async readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>> {
|
||||
async readTextFile(path: string, context: Context): Promise<Result<string, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const aborted = abortResult<string>(abortSignal, resolved);
|
||||
const signal = context.abortSignal;
|
||||
const aborted = abortResult<string>(signal, resolved);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
return ok(await readFile(resolved, { encoding: "utf8", signal: abortSignal }));
|
||||
return ok(await readFile(resolved, { encoding: "utf8", signal }));
|
||||
} catch (error) {
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
|
|
@ -512,25 +516,27 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||
|
||||
async readTextLines(
|
||||
path: string,
|
||||
options?: { maxLines?: number; abortSignal?: AbortSignal },
|
||||
options: { maxLines?: number } | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<string[], FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const aborted = abortResult<string[]>(options?.abortSignal, resolved);
|
||||
const signal = context.abortSignal;
|
||||
const aborted = abortResult<string[]>(signal, resolved);
|
||||
if (aborted) return aborted;
|
||||
if (options?.maxLines !== undefined && options.maxLines <= 0) return ok([]);
|
||||
let stream: ReturnType<typeof createReadStream> | undefined;
|
||||
let lineReader: ReturnType<typeof createInterface> | undefined;
|
||||
try {
|
||||
stream = createReadStream(resolved, { encoding: "utf8", signal: options?.abortSignal });
|
||||
stream = createReadStream(resolved, { encoding: "utf8", signal });
|
||||
lineReader = createInterface({ input: stream, crlfDelay: Infinity });
|
||||
const lines: string[] = [];
|
||||
for await (const line of lineReader) {
|
||||
const loopAbort = abortResult<string[]>(options?.abortSignal, resolved);
|
||||
const loopAbort = abortResult<string[]>(signal, resolved);
|
||||
if (loopAbort) return loopAbort;
|
||||
lines.push(line);
|
||||
if (options?.maxLines !== undefined && lines.length >= options.maxLines) break;
|
||||
}
|
||||
const afterReadAbort = abortResult<string[]>(options?.abortSignal, resolved);
|
||||
const afterReadAbort = abortResult<string[]>(signal, resolved);
|
||||
if (afterReadAbort) return afterReadAbort;
|
||||
return ok(lines);
|
||||
} catch (error) {
|
||||
|
|
@ -541,55 +547,55 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||
}
|
||||
}
|
||||
|
||||
async readBinaryFile(path: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>> {
|
||||
async readBinaryFile(path: string, context: Context): Promise<Result<Uint8Array, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const aborted = abortResult<Uint8Array>(abortSignal, resolved);
|
||||
const signal = context.abortSignal;
|
||||
const aborted = abortResult<Uint8Array>(signal, resolved);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
return ok(await readFile(resolved, { signal: abortSignal }));
|
||||
return ok(await readFile(resolved, { signal }));
|
||||
} catch (error) {
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async writeFile(
|
||||
path: string,
|
||||
content: string | Uint8Array,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<Result<void, FileError>> {
|
||||
async writeFile(path: string, content: string | Uint8Array, context: Context): Promise<Result<void, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const aborted = abortResult<void>(abortSignal, resolved);
|
||||
const signal = context.abortSignal;
|
||||
const aborted = abortResult<void>(signal, resolved);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
await mkdir(resolve(resolved, ".."), { recursive: true });
|
||||
const afterMkdirAbort = abortResult<void>(abortSignal, resolved);
|
||||
const afterMkdirAbort = abortResult<void>(signal, resolved);
|
||||
if (afterMkdirAbort) return afterMkdirAbort;
|
||||
await writeFile(resolved, content, { signal: abortSignal });
|
||||
await writeFile(resolved, content, { signal });
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async appendFile(path: string, content: string | Uint8Array): Promise<Result<void, FileError>> {
|
||||
async appendFile(path: string, content: string | Uint8Array, context: Context): Promise<Result<void, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const signal = context.abortSignal;
|
||||
const aborted = abortResult<void>(signal, resolved);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
await mkdir(resolve(resolved, ".."), { recursive: true });
|
||||
const afterMkdirAbort = abortResult<void>(signal, resolved);
|
||||
if (afterMkdirAbort) return afterMkdirAbort;
|
||||
await appendFile(resolved, content);
|
||||
return ok(undefined);
|
||||
const afterAppendAbort = abortResult<void>(signal, resolved);
|
||||
return afterAppendAbort ?? ok(undefined);
|
||||
} catch (error) {
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async renameFile(
|
||||
sourcePath: string,
|
||||
destinationPath: string,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<Result<void, FileError>> {
|
||||
async renameFile(sourcePath: string, destinationPath: string, context: Context): Promise<Result<void, FileError>> {
|
||||
const source = resolvePath(this.cwd, sourcePath);
|
||||
const destination = resolvePath(this.cwd, destinationPath);
|
||||
const aborted = abortResult<void>(abortSignal, destination);
|
||||
const aborted = abortResult<void>(context.abortSignal, destination);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
await rename(source, destination);
|
||||
|
|
@ -599,8 +605,10 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||
}
|
||||
}
|
||||
|
||||
async fileInfo(path: string): Promise<Result<FileInfo, FileError>> {
|
||||
async fileInfo(path: string, context: Context): Promise<Result<FileInfo, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const aborted = abortResult<FileInfo>(context.abortSignal, resolved);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
return fileInfoFromStats(resolved, await lstat(resolved));
|
||||
} catch (error) {
|
||||
|
|
@ -608,15 +616,16 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||
}
|
||||
}
|
||||
|
||||
async listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>> {
|
||||
async listDir(path: string, context: Context): Promise<Result<FileInfo[], FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const aborted = abortResult<FileInfo[]>(abortSignal, resolved);
|
||||
const signal = context.abortSignal;
|
||||
const aborted = abortResult<FileInfo[]>(signal, resolved);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
const entries = await readdir(resolved, { withFileTypes: true });
|
||||
const infos: FileInfo[] = [];
|
||||
for (const entry of entries) {
|
||||
const loopAbort = abortResult<FileInfo[]>(abortSignal, resolved);
|
||||
const loopAbort = abortResult<FileInfo[]>(signal, resolved);
|
||||
if (loopAbort) return loopAbort;
|
||||
const entryPath = resolve(resolved, entry.name);
|
||||
try {
|
||||
|
|
@ -632,8 +641,10 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||
}
|
||||
}
|
||||
|
||||
async canonicalPath(path: string): Promise<Result<string, FileError>> {
|
||||
async canonicalPath(path: string, context: Context): Promise<Result<string, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const aborted = abortResult<string>(context.abortSignal, resolved);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
return ok(await realpath(resolved));
|
||||
} catch (error) {
|
||||
|
|
@ -641,15 +652,21 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||
}
|
||||
}
|
||||
|
||||
async exists(path: string): Promise<Result<boolean, FileError>> {
|
||||
const result = await this.fileInfo(path);
|
||||
async exists(path: string, context: Context): Promise<Result<boolean, FileError>> {
|
||||
const result = await this.fileInfo(path, context);
|
||||
if (result.ok) return ok(true);
|
||||
if (result.error.code === "not_found") return ok(false);
|
||||
return err(result.error);
|
||||
}
|
||||
|
||||
async createDir(path: string, options?: { recursive?: boolean }): Promise<Result<void, FileError>> {
|
||||
async createDir(
|
||||
path: string,
|
||||
options: { recursive?: boolean } | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<void, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const aborted = abortResult<void>(context.abortSignal, resolved);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
await mkdir(resolved, { recursive: options?.recursive ?? true });
|
||||
return ok(undefined);
|
||||
|
|
@ -658,8 +675,14 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||
}
|
||||
}
|
||||
|
||||
async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<Result<void, FileError>> {
|
||||
async remove(
|
||||
path: string,
|
||||
options: { recursive?: boolean; force?: boolean } | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<void, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const aborted = abortResult<void>(context.abortSignal, resolved);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
await rm(resolved, { recursive: options?.recursive ?? false, force: options?.force ?? false });
|
||||
return ok(undefined);
|
||||
|
|
@ -668,16 +691,22 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||
}
|
||||
}
|
||||
|
||||
async createTempDir(prefix: string = "tmp-"): Promise<Result<string, FileError>> {
|
||||
async createTempDir(prefix: string | undefined, context: Context): Promise<Result<string, FileError>> {
|
||||
const aborted = abortResult<string>(context.abortSignal);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
prefix ??= "tmp-";
|
||||
return ok(await mkdtemp(join(tmpdir(), prefix)));
|
||||
} catch (error) {
|
||||
return err(toFileError(error));
|
||||
}
|
||||
}
|
||||
|
||||
async createTempFile(options?: { prefix?: string; suffix?: string }): Promise<Result<string, FileError>> {
|
||||
const dir = await this.createTempDir("tmp-");
|
||||
async createTempFile(
|
||||
options: { prefix?: string; suffix?: string } | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<string, FileError>> {
|
||||
const dir = await this.createTempDir("tmp-", context);
|
||||
if (!dir.ok) return dir;
|
||||
const filePath = join(dir.value, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`);
|
||||
try {
|
||||
|
|
@ -688,7 +717,7 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
|||
}
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
async cleanup(_context: Context): Promise<void> {
|
||||
for (const pid of this.activeChildPids) killProcessTree(pid);
|
||||
this.activeChildPids.clear();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import type {
|
||||
Context as AiContext,
|
||||
Api,
|
||||
AssistantMessage,
|
||||
AssistantMessageEvent,
|
||||
AssistantMessageEventStream,
|
||||
Context,
|
||||
Message,
|
||||
Model,
|
||||
SimpleStreamOptions,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { TelemetryContext } from "@earendil-works/pi-telemetry";
|
||||
import type { AgentMessage, AgentTool, ThinkingLevel } from "../../types.ts";
|
||||
import type { Context } from "../context.ts";
|
||||
import type { SettledAssistantMessage } from "../session/types.ts";
|
||||
import type { AgentHarnessStreamOptions } from "../types.ts";
|
||||
import { AbortRequested } from "./effect-gate.ts";
|
||||
|
|
@ -22,9 +22,9 @@ export interface AssistantResponseMetadata {
|
|||
|
||||
/** Process-local lifecycle observer for one assistant stream. */
|
||||
export interface AssistantStreamObserver {
|
||||
start(message: AssistantMessage): void | Promise<void>;
|
||||
update(message: AssistantMessage, event: AssistantMessageEvent): void | Promise<void>;
|
||||
end(message: SettledAssistantMessage): void | Promise<void>;
|
||||
start(message: AssistantMessage, context: Context): void | Promise<void>;
|
||||
update(message: AssistantMessage, event: AssistantMessageEvent, context: Context): void | Promise<void>;
|
||||
end(message: SettledAssistantMessage, context: Context): void | Promise<void>;
|
||||
}
|
||||
|
||||
/** Executable inputs for one already-approved assistant provider request. */
|
||||
|
|
@ -35,27 +35,32 @@ export interface HarnessAssistantStreamConfig {
|
|||
thinkingLevel: ThinkingLevel;
|
||||
streamOptions: AgentHarnessStreamOptions;
|
||||
transformContext?: (
|
||||
context: { messages: AgentMessage[]; systemPrompt: string },
|
||||
signal: AbortSignal,
|
||||
requestContext: { messages: AgentMessage[]; systemPrompt: string },
|
||||
context: Context,
|
||||
) => Promise<{ messages: AgentMessage[]; systemPrompt: string }>;
|
||||
toProviderMessages: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
||||
beforePayload?: (payload: unknown, model: Model<Api>) => unknown | undefined | Promise<unknown | undefined>;
|
||||
toProviderMessages: (messages: AgentMessage[], context: Context) => Message[] | Promise<Message[]>;
|
||||
beforePayload?: (
|
||||
payload: unknown,
|
||||
model: Model<Api>,
|
||||
context: Context,
|
||||
) => unknown | undefined | Promise<unknown | undefined>;
|
||||
afterResponse?: (
|
||||
message: SettledAssistantMessage,
|
||||
metadata: AssistantResponseMetadata,
|
||||
context: Context,
|
||||
) => Promise<SettledAssistantMessage>;
|
||||
request(
|
||||
context: Context,
|
||||
aiContext: AiContext,
|
||||
options: SimpleStreamOptions,
|
||||
context: Context,
|
||||
): AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
|
||||
observer: AssistantStreamObserver;
|
||||
telemetryContext: TelemetryContext;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
function createRequestOptions(
|
||||
config: HarnessAssistantStreamConfig,
|
||||
captureMetadata: (metadata: AssistantResponseMetadata) => void,
|
||||
context: Context,
|
||||
): SimpleStreamOptions {
|
||||
const options = config.streamOptions;
|
||||
return {
|
||||
|
|
@ -68,9 +73,12 @@ function createRequestOptions(
|
|||
cacheRetention: options.cacheRetention,
|
||||
deferred: options.deferred,
|
||||
...(config.thinkingLevel === "off" ? {} : { reasoning: config.thinkingLevel }),
|
||||
signal: config.signal,
|
||||
telemetryContext: config.telemetryContext,
|
||||
onPayload: config.beforePayload,
|
||||
signal: context.abortSignal,
|
||||
telemetryContext: context.telemetryContext,
|
||||
onPayload:
|
||||
config.beforePayload === undefined
|
||||
? undefined
|
||||
: (payload, model) => config.beforePayload?.(payload, model, context),
|
||||
onResponse: (response) => {
|
||||
captureMetadata({ status: response.status, headers: response.headers });
|
||||
},
|
||||
|
|
@ -87,14 +95,15 @@ function isUpdateEvent(
|
|||
export async function streamHarnessAssistant(
|
||||
messages: AgentMessage[],
|
||||
config: HarnessAssistantStreamConfig,
|
||||
context: Context,
|
||||
): Promise<SettledAssistantMessage> {
|
||||
let requestContext = { messages: messages.slice(), systemPrompt: config.systemPrompt };
|
||||
if (config.transformContext) {
|
||||
requestContext = await config.transformContext(requestContext, config.signal);
|
||||
requestContext = await config.transformContext(requestContext, context);
|
||||
}
|
||||
|
||||
const providerMessages = await config.toProviderMessages(requestContext.messages);
|
||||
const context: Context = {
|
||||
const providerMessages = await config.toProviderMessages(requestContext.messages, context);
|
||||
const aiContext: AiContext = {
|
||||
systemPrompt: requestContext.systemPrompt,
|
||||
messages: providerMessages,
|
||||
tools: config.tools,
|
||||
|
|
@ -102,35 +111,40 @@ export async function streamHarnessAssistant(
|
|||
|
||||
let metadata: AssistantResponseMetadata = {};
|
||||
const stream = await config.request(
|
||||
aiContext,
|
||||
createRequestOptions(
|
||||
config,
|
||||
(nextMetadata) => {
|
||||
metadata = nextMetadata;
|
||||
},
|
||||
context,
|
||||
),
|
||||
context,
|
||||
createRequestOptions(config, (nextMetadata) => {
|
||||
metadata = nextMetadata;
|
||||
}),
|
||||
);
|
||||
|
||||
let started = false;
|
||||
for await (const event of stream) {
|
||||
if (event.type === "start") {
|
||||
started = true;
|
||||
await config.observer.start({ ...event.partial });
|
||||
await config.observer.start({ ...event.partial }, context);
|
||||
} else if (isUpdateEvent(event)) {
|
||||
await config.observer.update({ ...event.partial }, event);
|
||||
await config.observer.update({ ...event.partial }, event, context);
|
||||
}
|
||||
}
|
||||
|
||||
const settled = (await stream.result()) as SettledAssistantMessage;
|
||||
if (!started) {
|
||||
await config.observer.start({ ...settled });
|
||||
await config.observer.start({ ...settled }, context);
|
||||
}
|
||||
let finalMessage = settled;
|
||||
if (config.afterResponse) {
|
||||
try {
|
||||
finalMessage = await config.afterResponse(settled, metadata);
|
||||
finalMessage = await config.afterResponse(settled, metadata, context);
|
||||
} catch (error) {
|
||||
if (!(error instanceof AbortRequested)) throw error;
|
||||
await error.cancellation;
|
||||
}
|
||||
}
|
||||
await config.observer.end(finalMessage);
|
||||
await config.observer.end(finalMessage, context);
|
||||
return finalMessage;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { type ToolResultMessage, validateToolArguments } from "@earendil-works/pi-ai";
|
||||
import type { TelemetryContext } from "@earendil-works/pi-telemetry";
|
||||
import type { AgentTool, AgentToolCall, AgentToolResult } from "../../types.ts";
|
||||
import { type Context, withAbortSignal } from "../context.ts";
|
||||
import type { JsonValue } from "../session/types.ts";
|
||||
import type { EffectGate } from "./effect-gate.ts";
|
||||
|
||||
|
|
@ -122,12 +122,14 @@ export async function executeToolCall(
|
|||
call: ClearedToolCall,
|
||||
effectGate: EffectGate,
|
||||
onUpdate: (partial: AgentToolResult<unknown>) => void,
|
||||
_telemetryContext: TelemetryContext,
|
||||
context: Context,
|
||||
): Promise<ExecutedToolCall> {
|
||||
let acceptingUpdates = true;
|
||||
effectGate.assertOpen();
|
||||
const admittedContext = withAbortSignal(effectGate.signal, context);
|
||||
admittedContext.abortSignal?.throwIfAborted();
|
||||
try {
|
||||
const result = await call.tool.execute(call.toolCall.id, call.args, effectGate.signal, (partial) => {
|
||||
const result = await call.tool.execute(call.toolCall.id, call.args, admittedContext.abortSignal, (partial) => {
|
||||
if (acceptingUpdates) onUpdate(partial);
|
||||
});
|
||||
return { result, isError: false };
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { HookHandler, HookInvocation, HookMap, HookName, Hooks } from "./agent-harness.ts";
|
||||
import { type Context, withAbortSignal, withTelemetryContext } from "./context.ts";
|
||||
import { type Context, withAbortSignal } from "./context.ts";
|
||||
import type { EffectGate } from "./execution/effect-gate.ts";
|
||||
import { startHarnessSpan } from "./telemetry.ts";
|
||||
import type { AgentHarnessStreamOptions, AgentHarnessStreamOptionsPatch } from "./types.ts";
|
||||
|
|
@ -374,7 +374,6 @@ export class HookRegistry implements Hooks {
|
|||
context: Context,
|
||||
): Promise<unknown> {
|
||||
return startHarnessSpan(
|
||||
context.telemetryContext,
|
||||
"pi.harness.hook",
|
||||
{
|
||||
"pi.lane.name": event.lane,
|
||||
|
|
@ -382,9 +381,9 @@ export class HookRegistry implements Hooks {
|
|||
"pi.hook.name": name,
|
||||
...(registration.id === undefined ? {} : { "pi.hook.registration_id": registration.id }),
|
||||
},
|
||||
async (span) => {
|
||||
async (span, spanContext) => {
|
||||
try {
|
||||
const result = await registration.handler(event, withTelemetryContext(span, context));
|
||||
const result = await registration.handler(event, spanContext);
|
||||
const blocked =
|
||||
name === "before_tool" &&
|
||||
result !== null &&
|
||||
|
|
@ -399,6 +398,7 @@ export class HookRegistry implements Hooks {
|
|||
throw error;
|
||||
}
|
||||
},
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { parse } from "yaml";
|
||||
import type { Context } from "./context.ts";
|
||||
import { type ExecutionEnv, type FileInfo, type PromptTemplate, type Result, toError } from "./types.ts";
|
||||
|
||||
export type PromptTemplateDiagnosticCode = "file_info_failed" | "list_failed" | "read_failed" | "parse_failed";
|
||||
|
|
@ -30,11 +31,12 @@ interface PromptTemplateFrontmatter {
|
|||
export async function loadPromptTemplates(
|
||||
env: ExecutionEnv,
|
||||
paths: string | string[],
|
||||
context: Context,
|
||||
): Promise<{ promptTemplates: PromptTemplate[]; diagnostics: PromptTemplateDiagnostic[] }> {
|
||||
const promptTemplates: PromptTemplate[] = [];
|
||||
const diagnostics: PromptTemplateDiagnostic[] = [];
|
||||
for (const path of Array.isArray(paths) ? paths : [paths]) {
|
||||
const infoResult = await env.fileInfo(path);
|
||||
const infoResult = await env.fileInfo(path, context);
|
||||
if (!infoResult.ok) {
|
||||
if (infoResult.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
|
|
@ -47,13 +49,13 @@ export async function loadPromptTemplates(
|
|||
continue;
|
||||
}
|
||||
const info = infoResult.value;
|
||||
const kind = await resolveKind(env, info, diagnostics);
|
||||
const kind = await resolveKind(env, info, diagnostics, context);
|
||||
if (kind === "directory") {
|
||||
const result = await loadTemplatesFromDir(env, info.path);
|
||||
const result = await loadTemplatesFromDir(env, info.path, context);
|
||||
promptTemplates.push(...result.promptTemplates);
|
||||
diagnostics.push(...result.diagnostics);
|
||||
} else if (kind === "file" && info.name.endsWith(".md")) {
|
||||
const result = await loadTemplateFromFile(env, info.path, info.name);
|
||||
const result = await loadTemplateFromFile(env, info.path, info.name, context);
|
||||
if (result.promptTemplate) promptTemplates.push(result.promptTemplate);
|
||||
diagnostics.push(...result.diagnostics);
|
||||
}
|
||||
|
|
@ -70,7 +72,10 @@ export async function loadPromptTemplates(
|
|||
export async function loadSourcedPromptTemplates<TSource, TPromptTemplate extends PromptTemplate = PromptTemplate>(
|
||||
env: ExecutionEnv,
|
||||
inputs: Array<{ path: string; source: TSource }>,
|
||||
mapPromptTemplate?: (promptTemplate: PromptTemplate, source: TSource) => TPromptTemplate,
|
||||
mapPromptTemplate:
|
||||
| ((promptTemplate: PromptTemplate, source: TSource, context: Context) => TPromptTemplate)
|
||||
| undefined,
|
||||
context: Context,
|
||||
): Promise<{
|
||||
promptTemplates: Array<{ promptTemplate: TPromptTemplate; source: TSource }>;
|
||||
diagnostics: Array<PromptTemplateDiagnostic & { source: TSource }>;
|
||||
|
|
@ -78,11 +83,11 @@ export async function loadSourcedPromptTemplates<TSource, TPromptTemplate extend
|
|||
const promptTemplates: Array<{ promptTemplate: TPromptTemplate; source: TSource }> = [];
|
||||
const diagnostics: Array<PromptTemplateDiagnostic & { source: TSource }> = [];
|
||||
for (const input of inputs) {
|
||||
const result = await loadPromptTemplates(env, input.path);
|
||||
const result = await loadPromptTemplates(env, input.path, context);
|
||||
for (const promptTemplate of result.promptTemplates) {
|
||||
promptTemplates.push({
|
||||
promptTemplate: mapPromptTemplate
|
||||
? mapPromptTemplate(promptTemplate, input.source)
|
||||
? mapPromptTemplate(promptTemplate, input.source, context)
|
||||
: (promptTemplate as TPromptTemplate),
|
||||
source: input.source,
|
||||
});
|
||||
|
|
@ -95,10 +100,11 @@ export async function loadSourcedPromptTemplates<TSource, TPromptTemplate extend
|
|||
async function loadTemplatesFromDir(
|
||||
env: ExecutionEnv,
|
||||
dir: string,
|
||||
context: Context,
|
||||
): Promise<{ promptTemplates: PromptTemplate[]; diagnostics: PromptTemplateDiagnostic[] }> {
|
||||
const promptTemplates: PromptTemplate[] = [];
|
||||
const diagnostics: PromptTemplateDiagnostic[] = [];
|
||||
const entriesResult = await env.listDir(dir);
|
||||
const entriesResult = await env.listDir(dir, context);
|
||||
if (!entriesResult.ok) {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
|
|
@ -111,9 +117,9 @@ async function loadTemplatesFromDir(
|
|||
const entries = entriesResult.value;
|
||||
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const kind = await resolveKind(env, entry, diagnostics);
|
||||
const kind = await resolveKind(env, entry, diagnostics, context);
|
||||
if (kind !== "file" || !entry.name.endsWith(".md")) continue;
|
||||
const result = await loadTemplateFromFile(env, entry.path, entry.name);
|
||||
const result = await loadTemplateFromFile(env, entry.path, entry.name, context);
|
||||
if (result.promptTemplate) promptTemplates.push(result.promptTemplate);
|
||||
diagnostics.push(...result.diagnostics);
|
||||
}
|
||||
|
|
@ -124,9 +130,10 @@ async function loadTemplateFromFile(
|
|||
env: ExecutionEnv,
|
||||
filePath: string,
|
||||
fileName: string,
|
||||
context: Context,
|
||||
): Promise<{ promptTemplate: PromptTemplate | null; diagnostics: PromptTemplateDiagnostic[] }> {
|
||||
const diagnostics: PromptTemplateDiagnostic[] = [];
|
||||
const rawContent = await env.readTextFile(filePath);
|
||||
const rawContent = await env.readTextFile(filePath, context);
|
||||
if (!rawContent.ok) {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
|
|
@ -169,9 +176,10 @@ async function resolveKind(
|
|||
env: ExecutionEnv,
|
||||
info: FileInfo,
|
||||
diagnostics: PromptTemplateDiagnostic[],
|
||||
context: Context,
|
||||
): Promise<"file" | "directory" | undefined> {
|
||||
if (info.kind === "file" || info.kind === "directory") return info.kind;
|
||||
const canonicalPath = await env.canonicalPath(info.path);
|
||||
const canonicalPath = await env.canonicalPath(info.path, context);
|
||||
if (!canonicalPath.ok) {
|
||||
if (canonicalPath.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
|
|
@ -183,7 +191,7 @@ async function resolveKind(
|
|||
}
|
||||
return undefined;
|
||||
}
|
||||
const target = await env.fileInfo(canonicalPath.value);
|
||||
const target = await env.fileInfo(canonicalPath.value, context);
|
||||
if (!target.ok) {
|
||||
if (target.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { Models, RetryPolicy } from "@earendil-works/pi-ai";
|
||||
import { NOOP_TELEMETRY_CONTEXT } from "@earendil-works/pi-telemetry";
|
||||
import type { QueueMode } from "../../types.ts";
|
||||
import type {
|
||||
AgentHarness,
|
||||
|
|
@ -82,9 +81,8 @@ export class Harness<TContext extends object | undefined> extends Lane implement
|
|||
drive: options.drive ?? "automatic",
|
||||
toolContext: options.toolContext,
|
||||
systemPrompt: options.systemPrompt,
|
||||
toProviderMessages: options.toProviderMessages ?? convertToLlm,
|
||||
toProviderMessages: options.toProviderMessages ?? ((messages) => convertToLlm(messages)),
|
||||
entryProjectors: options.entryProjectors ?? {},
|
||||
telemetryContext: options.telemetryContext ?? NOOP_TELEMETRY_CONTEXT,
|
||||
};
|
||||
this.events = new HarnessEventBus();
|
||||
this.hooks = new HookRegistry((error, hook, lane, context) =>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ export interface Config<TContext extends object | undefined> {
|
|||
readonly systemPrompt: AgentHarnessOptions<TContext>["systemPrompt"];
|
||||
readonly toProviderMessages: NonNullable<AgentHarnessOptions<TContext>["toProviderMessages"]>;
|
||||
readonly entryProjectors: Readonly<NonNullable<AgentHarnessOptions<TContext>["entryProjectors"]>>;
|
||||
readonly telemetryContext: NonNullable<AgentHarnessOptions<TContext>["telemetryContext"]>;
|
||||
}
|
||||
|
||||
/** The current durable state owned by one lane. */
|
||||
|
|
|
|||
|
|
@ -103,11 +103,11 @@ export class JsonlSessionRepo
|
|||
],
|
||||
context,
|
||||
);
|
||||
const info = fileValue(await this.fileSystem.fileInfo(path), `Failed to read session ${path}`);
|
||||
const info = fileValue(await this.fileSystem.fileInfo(path, context), `Failed to read session ${path}`);
|
||||
return this.publishOpenSession(metadataFromHeader(header, path, info.mtimeMs), storage, key);
|
||||
} catch (error) {
|
||||
await storage?.close(context).catch(() => undefined);
|
||||
if (path !== undefined) await this.fileSystem.remove(path, { force: true });
|
||||
if (path !== undefined) await this.fileSystem.remove(path, { force: true }, context);
|
||||
throw error;
|
||||
} finally {
|
||||
this.pendingCreates.delete(key);
|
||||
|
|
@ -135,29 +135,34 @@ export class JsonlSessionRepo
|
|||
options.cwd === undefined
|
||||
? undefined
|
||||
: fileValue(
|
||||
await this.fileSystem.absolutePath(options.cwd),
|
||||
await this.fileSystem.absolutePath(options.cwd, context),
|
||||
`Failed to resolve session cwd ${options.cwd}`,
|
||||
);
|
||||
const root = await this.root(context);
|
||||
if (!fileValue(await this.fileSystem.exists(root), `Failed to check sessions root ${root}`)) return [];
|
||||
if (!fileValue(await this.fileSystem.exists(root, context), `Failed to check sessions root ${root}`)) return [];
|
||||
const directories =
|
||||
cwd === undefined ? await this.sessionDirectories(root) : [await this.sessionDirectory(cwd, context)];
|
||||
cwd === undefined ? await this.sessionDirectories(root, context) : [await this.sessionDirectory(cwd, context)];
|
||||
const metadata: JsonlSessionMetadata[] = [];
|
||||
for (const directory of directories) metadata.push(...(await this.listDirectory(directory, cwd)));
|
||||
for (const directory of directories) metadata.push(...(await this.listDirectory(directory, cwd, context)));
|
||||
return metadata.sort(
|
||||
(left, right) =>
|
||||
right.createdAt - left.createdAt || left.id.localeCompare(right.id) || left.cwd.localeCompare(right.cwd),
|
||||
);
|
||||
}
|
||||
|
||||
async delete(metadata: JsonlSessionMetadata, _context: Context): Promise<void> {
|
||||
async delete(metadata: JsonlSessionMetadata, context: Context): Promise<void> {
|
||||
this.assertOpen();
|
||||
const key = this.sessionKey(metadata.cwd, metadata.id);
|
||||
if (this.openSessions.has(key)) throw new Error(`Session is open: ${metadata.id}`);
|
||||
if (!fileValue(await this.fileSystem.exists(metadata.path), `Failed to check session ${metadata.path}`)) {
|
||||
if (
|
||||
!fileValue(await this.fileSystem.exists(metadata.path, context), `Failed to check session ${metadata.path}`)
|
||||
) {
|
||||
throw new Error(`Session file does not exist: ${metadata.path}`);
|
||||
}
|
||||
fileValue(await this.fileSystem.remove(metadata.path), `Failed to delete session ${metadata.path}`);
|
||||
fileValue(
|
||||
await this.fileSystem.remove(metadata.path, undefined, context),
|
||||
`Failed to delete session ${metadata.path}`,
|
||||
);
|
||||
}
|
||||
|
||||
async fork(
|
||||
|
|
@ -198,11 +203,11 @@ export class JsonlSessionRepo
|
|||
snapshot,
|
||||
context,
|
||||
);
|
||||
const info = fileValue(await this.fileSystem.fileInfo(path), `Failed to read session ${path}`);
|
||||
const info = fileValue(await this.fileSystem.fileInfo(path, context), `Failed to read session ${path}`);
|
||||
return this.publishOpenSession(metadataFromHeader(header, path, info.mtimeMs), storage, destinationKey);
|
||||
} catch (error) {
|
||||
await storage?.close(context).catch(() => undefined);
|
||||
if (path !== undefined) await this.fileSystem.remove(path, { force: true });
|
||||
if (path !== undefined) await this.fileSystem.remove(path, { force: true }, context);
|
||||
throw error;
|
||||
} finally {
|
||||
this.pendingCreates.delete(destinationKey);
|
||||
|
|
@ -221,25 +226,34 @@ export class JsonlSessionRepo
|
|||
cwdInput: string,
|
||||
id: string | undefined,
|
||||
createdAt: number,
|
||||
_context: Context,
|
||||
context: Context,
|
||||
): Promise<{ cwd: string; id: string }> {
|
||||
const destinationId = id ?? uuidv7(createdAt);
|
||||
const cwd = fileValue(await this.fileSystem.absolutePath(cwdInput), `Failed to resolve session cwd ${cwdInput}`);
|
||||
const cwd = fileValue(
|
||||
await this.fileSystem.absolutePath(cwdInput, context),
|
||||
`Failed to resolve session cwd ${cwdInput}`,
|
||||
);
|
||||
return { cwd, id: destinationId };
|
||||
}
|
||||
|
||||
private async listDirectory(directory: string, cwd?: string): Promise<JsonlSessionMetadata[]> {
|
||||
if (!fileValue(await this.fileSystem.exists(directory), `Failed to check sessions directory ${directory}`)) {
|
||||
private async listDirectory(
|
||||
directory: string,
|
||||
cwd: string | undefined,
|
||||
context: Context,
|
||||
): Promise<JsonlSessionMetadata[]> {
|
||||
if (
|
||||
!fileValue(await this.fileSystem.exists(directory, context), `Failed to check sessions directory ${directory}`)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const files = fileValue(
|
||||
await this.fileSystem.listDir(directory),
|
||||
await this.fileSystem.listDir(directory, context),
|
||||
`Failed to list sessions directory ${directory}`,
|
||||
).filter((file) => file.kind !== "directory" && file.name.endsWith(".jsonl"));
|
||||
const metadata: JsonlSessionMetadata[] = [];
|
||||
for (const file of files) {
|
||||
const lines = fileValue(
|
||||
await this.fileSystem.readTextLines(file.path, { maxLines: 1 }),
|
||||
await this.fileSystem.readTextLines(file.path, { maxLines: 1 }, context),
|
||||
`Failed to read session header ${file.path}`,
|
||||
);
|
||||
if (lines[0] === undefined) continue;
|
||||
|
|
@ -252,36 +266,41 @@ export class JsonlSessionRepo
|
|||
return metadata;
|
||||
}
|
||||
|
||||
private async sessionDirectories(root: string): Promise<string[]> {
|
||||
return fileValue(await this.fileSystem.listDir(root), `Failed to list sessions root ${root}`)
|
||||
private async sessionDirectories(root: string, context: Context): Promise<string[]> {
|
||||
return fileValue(await this.fileSystem.listDir(root, context), `Failed to list sessions root ${root}`)
|
||||
.filter((entry) => entry.kind === "directory")
|
||||
.map((entry) => entry.path);
|
||||
}
|
||||
|
||||
private async sessionDirectory(cwd: string, context: Context): Promise<string> {
|
||||
return fileValue(
|
||||
await this.fileSystem.joinPath([await this.root(context), sessionDirectoryName(cwd)]),
|
||||
await this.fileSystem.joinPath([await this.root(context), sessionDirectoryName(cwd)], context),
|
||||
`Failed to resolve sessions directory for ${cwd}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveNewSessionPath(cwd: string, createdAt: number, id: string, context: Context): Promise<string> {
|
||||
const directory = await this.sessionDirectory(cwd, context);
|
||||
await this.assertSessionIdAvailable(directory, id);
|
||||
fileValue(await this.fileSystem.createDir(directory), `Failed to create sessions directory ${directory}`);
|
||||
await this.assertSessionIdAvailable(directory, id, context);
|
||||
fileValue(
|
||||
await this.fileSystem.createDir(directory, undefined, context),
|
||||
`Failed to create sessions directory ${directory}`,
|
||||
);
|
||||
return fileValue(
|
||||
await this.fileSystem.joinPath([directory, sessionFileName(createdAt, id)]),
|
||||
await this.fileSystem.joinPath([directory, sessionFileName(createdAt, id)], context),
|
||||
`Failed to resolve path for session ${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async assertSessionIdAvailable(directory: string, id: string): Promise<void> {
|
||||
if (!fileValue(await this.fileSystem.exists(directory), `Failed to check sessions directory ${directory}`))
|
||||
private async assertSessionIdAvailable(directory: string, id: string, context: Context): Promise<void> {
|
||||
if (
|
||||
!fileValue(await this.fileSystem.exists(directory, context), `Failed to check sessions directory ${directory}`)
|
||||
)
|
||||
return;
|
||||
|
||||
const suffix = `_${encodeURIComponent(id)}.jsonl`;
|
||||
const idExists = fileValue(
|
||||
await this.fileSystem.listDir(directory),
|
||||
await this.fileSystem.listDir(directory, context),
|
||||
`Failed to list sessions directory ${directory}`,
|
||||
).some((entry) => entry.kind !== "directory" && entry.name.endsWith(suffix));
|
||||
if (idExists) throw new Error(`Session already exists: ${id}`);
|
||||
|
|
@ -320,7 +339,9 @@ export class JsonlSessionRepo
|
|||
|
||||
private async loadStorage(metadata: JsonlSessionMetadata, context: Context): Promise<JsonlStorage> {
|
||||
// TODO: normalize discovered legacy v3 sessions on open instead of rejecting them here.
|
||||
if (!fileValue(await this.fileSystem.exists(metadata.path), `Failed to check session ${metadata.path}`)) {
|
||||
if (
|
||||
!fileValue(await this.fileSystem.exists(metadata.path, context), `Failed to check session ${metadata.path}`)
|
||||
) {
|
||||
throw new Error(`Session file does not exist: ${metadata.path}`);
|
||||
}
|
||||
const storage = await JsonlStorage.open(
|
||||
|
|
@ -345,9 +366,9 @@ export class JsonlSessionRepo
|
|||
}
|
||||
}
|
||||
|
||||
private root(_context: Context): Promise<string> {
|
||||
private root(context: Context): Promise<string> {
|
||||
return this.fileSystem
|
||||
.absolutePath(this.sessionsRootInput)
|
||||
.absolutePath(this.sessionsRootInput, context)
|
||||
.then((result) => fileValue(result, `Failed to resolve sessions root ${this.sessionsRootInput}`));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -109,16 +109,24 @@ function snapshotWrites(snapshot: JsonlSnapshotContents): CommittedWrite[] {
|
|||
return writes.sort((left, right) => left.seq - right.seq);
|
||||
}
|
||||
|
||||
async function publishFileAtomically(fileSystem: FileSystem, destinationPath: string, content: string): Promise<void> {
|
||||
async function publishFileAtomically(
|
||||
fileSystem: FileSystem,
|
||||
destinationPath: string,
|
||||
content: string,
|
||||
context: Context,
|
||||
): Promise<void> {
|
||||
const tempPath = `${destinationPath}.tmp`;
|
||||
try {
|
||||
fileValue(await fileSystem.writeFile(tempPath, content), `Failed to stage JSONL storage ${destinationPath}`);
|
||||
fileValue(
|
||||
await fileSystem.renameFile(tempPath, destinationPath),
|
||||
await fileSystem.writeFile(tempPath, content, context),
|
||||
`Failed to stage JSONL storage ${destinationPath}`,
|
||||
);
|
||||
fileValue(
|
||||
await fileSystem.renameFile(tempPath, destinationPath, context),
|
||||
`Failed to publish JSONL storage ${destinationPath}`,
|
||||
);
|
||||
} catch (error) {
|
||||
await fileSystem.remove(tempPath, { force: true });
|
||||
await fileSystem.remove(tempPath, { force: true }, context);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -144,10 +152,10 @@ export class JsonlStorage implements Storage {
|
|||
static async create(
|
||||
options: JsonlStorageOptions,
|
||||
header: JsonlStorageHeader,
|
||||
_context: Context,
|
||||
context: Context,
|
||||
): Promise<JsonlStorage> {
|
||||
fileValue(
|
||||
await options.fileSystem.writeFile(options.path, `${JSON.stringify(header)}\n`),
|
||||
await options.fileSystem.writeFile(options.path, `${JSON.stringify(header)}\n`, context),
|
||||
`Failed to create JSONL storage ${options.path}`,
|
||||
);
|
||||
return new JsonlStorage(options, header);
|
||||
|
|
@ -163,13 +171,13 @@ export class JsonlStorage implements Storage {
|
|||
const writes = snapshotWrites(snapshot);
|
||||
const snapshotHeader = { ...header, nextSeq: snapshot.nextSeq };
|
||||
const content = `${[JSON.stringify(snapshotHeader), ...writes.map((write) => JSON.stringify(write))].join("\n")}\n`;
|
||||
await publishFileAtomically(options.fileSystem, options.path, content);
|
||||
await publishFileAtomically(options.fileSystem, options.path, content, context);
|
||||
return JsonlStorage.open(options, context);
|
||||
}
|
||||
|
||||
static async open(options: JsonlStorageOptions, _context: Context): Promise<JsonlStorage> {
|
||||
static async open(options: JsonlStorageOptions, context: Context): Promise<JsonlStorage> {
|
||||
const content = fileValue(
|
||||
await options.fileSystem.readTextFile(options.path),
|
||||
await options.fileSystem.readTextFile(options.path, context),
|
||||
`Failed to read JSONL storage ${options.path}`,
|
||||
);
|
||||
const { lines, torn } = splitCompleteLines(content);
|
||||
|
|
@ -189,13 +197,13 @@ export class JsonlStorage implements Storage {
|
|||
}
|
||||
}
|
||||
if (header.nextSeq !== undefined) storage.storageState.advanceNextSeq(header.nextSeq);
|
||||
if (torn) await publishFileAtomically(options.fileSystem, options.path, `${lines.join("\n")}\n`);
|
||||
if (torn) await publishFileAtomically(options.fileSystem, options.path, `${lines.join("\n")}\n`, context);
|
||||
return storage;
|
||||
}
|
||||
|
||||
async commit(writes: Write[], _context: Context): Promise<CommitResult> {
|
||||
async commit(writes: Write[], context: Context): Promise<CommitResult> {
|
||||
if (this.state !== "open") throw new Error("JsonlStorage is closed");
|
||||
const result = this.commitQueue.then(() => this.applyCommit(writes));
|
||||
const result = this.commitQueue.then(() => this.applyCommit(writes, context));
|
||||
this.commitQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
|
|
@ -203,13 +211,14 @@ export class JsonlStorage implements Storage {
|
|||
return result;
|
||||
}
|
||||
|
||||
private async applyCommit(writes: Write[]): Promise<CommitResult> {
|
||||
private async applyCommit(writes: Write[], context: Context): Promise<CommitResult> {
|
||||
const prepared = this.storageState.prepareCommit(writes, this.now());
|
||||
if (prepared.writes.length !== 0) {
|
||||
fileValue(
|
||||
await this.fileSystem.appendFile(
|
||||
this.path,
|
||||
`${JSON.stringify(prepared.writes.length === 1 ? prepared.writes[0] : prepared.writes)}\n`,
|
||||
context,
|
||||
),
|
||||
`Failed to append JSONL storage ${this.path}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import ignore from "ignore";
|
||||
import { parse } from "yaml";
|
||||
import type { Context } from "./context.ts";
|
||||
import { type ExecutionEnv, type FileInfo, type Result, type Skill, toError } from "./types.ts";
|
||||
|
||||
const MAX_NAME_LENGTH = 64;
|
||||
|
|
@ -50,11 +51,12 @@ export function formatSkillInvocation(skill: Skill, additionalInstructions?: str
|
|||
export async function loadSkills(
|
||||
env: ExecutionEnv,
|
||||
dirs: string | string[],
|
||||
context: Context,
|
||||
): Promise<{ skills: Skill[]; diagnostics: SkillDiagnostic[] }> {
|
||||
const skills: Skill[] = [];
|
||||
const diagnostics: SkillDiagnostic[] = [];
|
||||
for (const dir of Array.isArray(dirs) ? dirs : [dirs]) {
|
||||
const rootInfoResult = await env.fileInfo(dir);
|
||||
const rootInfoResult = await env.fileInfo(dir, context);
|
||||
if (!rootInfoResult.ok) {
|
||||
if (rootInfoResult.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
|
|
@ -67,8 +69,8 @@ export async function loadSkills(
|
|||
continue;
|
||||
}
|
||||
const rootInfo = rootInfoResult.value;
|
||||
if ((await resolveKind(env, rootInfo, diagnostics)) !== "directory") continue;
|
||||
const result = await loadSkillsFromDirInternal(env, rootInfo.path, true, ignore(), rootInfo.path);
|
||||
if ((await resolveKind(env, rootInfo, diagnostics, context)) !== "directory") continue;
|
||||
const result = await loadSkillsFromDirInternal(env, rootInfo.path, true, ignore(), rootInfo.path, context);
|
||||
skills.push(...result.skills);
|
||||
diagnostics.push(...result.diagnostics);
|
||||
}
|
||||
|
|
@ -84,7 +86,8 @@ export async function loadSkills(
|
|||
export async function loadSourcedSkills<TSource, TSkill extends Skill = Skill>(
|
||||
env: ExecutionEnv,
|
||||
inputs: Array<{ path: string; source: TSource }>,
|
||||
mapSkill?: (skill: Skill, source: TSource) => TSkill,
|
||||
mapSkill: ((skill: Skill, source: TSource, context: Context) => TSkill) | undefined,
|
||||
context: Context,
|
||||
): Promise<{
|
||||
skills: Array<{ skill: TSkill; source: TSource }>;
|
||||
diagnostics: Array<SkillDiagnostic & { source: TSource }>;
|
||||
|
|
@ -92,9 +95,12 @@ export async function loadSourcedSkills<TSource, TSkill extends Skill = Skill>(
|
|||
const skills: Array<{ skill: TSkill; source: TSource }> = [];
|
||||
const diagnostics: Array<SkillDiagnostic & { source: TSource }> = [];
|
||||
for (const input of inputs) {
|
||||
const result = await loadSkills(env, input.path);
|
||||
const result = await loadSkills(env, input.path, context);
|
||||
for (const skill of result.skills) {
|
||||
skills.push({ skill: mapSkill ? mapSkill(skill, input.source) : (skill as TSkill), source: input.source });
|
||||
skills.push({
|
||||
skill: mapSkill ? mapSkill(skill, input.source, context) : (skill as TSkill),
|
||||
source: input.source,
|
||||
});
|
||||
}
|
||||
for (const diagnostic of result.diagnostics) diagnostics.push({ ...diagnostic, source: input.source });
|
||||
}
|
||||
|
|
@ -107,11 +113,12 @@ async function loadSkillsFromDirInternal(
|
|||
includeRootFiles: boolean,
|
||||
ignoreMatcher: IgnoreMatcher,
|
||||
rootDir: string,
|
||||
context: Context,
|
||||
): Promise<{ skills: Skill[]; diagnostics: SkillDiagnostic[] }> {
|
||||
const skills: Skill[] = [];
|
||||
const diagnostics: SkillDiagnostic[] = [];
|
||||
|
||||
const dirInfoResult = await env.fileInfo(dir);
|
||||
const dirInfoResult = await env.fileInfo(dir, context);
|
||||
if (!dirInfoResult.ok) {
|
||||
if (dirInfoResult.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
|
|
@ -124,11 +131,11 @@ async function loadSkillsFromDirInternal(
|
|||
return { skills, diagnostics };
|
||||
}
|
||||
const dirInfo = dirInfoResult.value;
|
||||
if ((await resolveKind(env, dirInfo, diagnostics)) !== "directory") return { skills, diagnostics };
|
||||
if ((await resolveKind(env, dirInfo, diagnostics, context)) !== "directory") return { skills, diagnostics };
|
||||
|
||||
await addIgnoreRules(env, ignoreMatcher, dir, rootDir, diagnostics);
|
||||
await addIgnoreRules(env, ignoreMatcher, dir, rootDir, diagnostics, context);
|
||||
|
||||
const entriesResult = await env.listDir(dir);
|
||||
const entriesResult = await env.listDir(dir, context);
|
||||
if (!entriesResult.ok) {
|
||||
diagnostics.push({ type: "warning", code: "list_failed", message: entriesResult.error.message, path: dir });
|
||||
return { skills, diagnostics };
|
||||
|
|
@ -138,12 +145,12 @@ async function loadSkillsFromDirInternal(
|
|||
for (const entry of entries) {
|
||||
if (entry.name !== "SKILL.md") continue;
|
||||
const fullPath = entry.path;
|
||||
const kind = await resolveKind(env, entry, diagnostics);
|
||||
const kind = await resolveKind(env, entry, diagnostics, context);
|
||||
if (kind !== "file") continue;
|
||||
const relPath = relativeEnvPath(rootDir, fullPath);
|
||||
if (ignoreMatcher.ignores(relPath)) continue;
|
||||
|
||||
const result = await loadSkillFromFile(env, fullPath, dirInfo.name);
|
||||
const result = await loadSkillFromFile(env, fullPath, dirInfo.name, context);
|
||||
if (result.skill) skills.push(result.skill);
|
||||
diagnostics.push(...result.diagnostics);
|
||||
return { skills, diagnostics };
|
||||
|
|
@ -152,7 +159,7 @@ async function loadSkillsFromDirInternal(
|
|||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
||||
const fullPath = entry.path;
|
||||
const kind = await resolveKind(env, entry, diagnostics);
|
||||
const kind = await resolveKind(env, entry, diagnostics, context);
|
||||
if (!kind) continue;
|
||||
|
||||
const relPath = relativeEnvPath(rootDir, fullPath);
|
||||
|
|
@ -160,14 +167,14 @@ async function loadSkillsFromDirInternal(
|
|||
if (ignoreMatcher.ignores(ignorePath)) continue;
|
||||
|
||||
if (kind === "directory") {
|
||||
const result = await loadSkillsFromDirInternal(env, fullPath, false, ignoreMatcher, rootDir);
|
||||
const result = await loadSkillsFromDirInternal(env, fullPath, false, ignoreMatcher, rootDir, context);
|
||||
skills.push(...result.skills);
|
||||
diagnostics.push(...result.diagnostics);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kind !== "file" || !includeRootFiles || !entry.name.endsWith(".md")) continue;
|
||||
const result = await loadSkillFromFile(env, fullPath, dirInfo.name);
|
||||
const result = await loadSkillFromFile(env, fullPath, dirInfo.name, context);
|
||||
if (result.skill) skills.push(result.skill);
|
||||
diagnostics.push(...result.diagnostics);
|
||||
}
|
||||
|
|
@ -181,12 +188,13 @@ async function addIgnoreRules(
|
|||
dir: string,
|
||||
rootDir: string,
|
||||
diagnostics: SkillDiagnostic[],
|
||||
context: Context,
|
||||
): Promise<void> {
|
||||
const relativeDir = relativeEnvPath(rootDir, dir);
|
||||
const prefix = relativeDir ? `${relativeDir}/` : "";
|
||||
|
||||
for (const filename of IGNORE_FILE_NAMES) {
|
||||
const ignorePathResult = await env.joinPath([dir, filename]);
|
||||
const ignorePathResult = await env.joinPath([dir, filename], context);
|
||||
if (!ignorePathResult.ok) {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
|
|
@ -197,7 +205,7 @@ async function addIgnoreRules(
|
|||
continue;
|
||||
}
|
||||
const ignorePath = ignorePathResult.value;
|
||||
const info = await env.fileInfo(ignorePath);
|
||||
const info = await env.fileInfo(ignorePath, context);
|
||||
if (!info.ok) {
|
||||
if (info.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
|
|
@ -210,7 +218,7 @@ async function addIgnoreRules(
|
|||
continue;
|
||||
}
|
||||
if (info.value.kind !== "file") continue;
|
||||
const content = await env.readTextFile(ignorePath);
|
||||
const content = await env.readTextFile(ignorePath, context);
|
||||
if (!content.ok) {
|
||||
diagnostics.push({ type: "warning", code: "read_failed", message: content.error.message, path: ignorePath });
|
||||
continue;
|
||||
|
|
@ -245,6 +253,7 @@ async function loadSkillFromFile(
|
|||
env: ExecutionEnv,
|
||||
filePath: string,
|
||||
parentDirName: string,
|
||||
context: Context,
|
||||
): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> {
|
||||
const diagnostics: SkillDiagnostic[] = [];
|
||||
const isDeclaredSkill =
|
||||
|
|
@ -252,7 +261,7 @@ async function loadSkillFromFile(
|
|||
.replace(/[\\/]+$/, "")
|
||||
.split(/[\\/]/)
|
||||
.pop() === "SKILL.md";
|
||||
const rawContent = await env.readTextFile(filePath);
|
||||
const rawContent = await env.readTextFile(filePath, context);
|
||||
if (!rawContent.ok) {
|
||||
diagnostics.push({ type: "warning", code: "read_failed", message: rawContent.error.message, path: filePath });
|
||||
return { skill: null, diagnostics };
|
||||
|
|
@ -340,9 +349,10 @@ async function resolveKind(
|
|||
env: ExecutionEnv,
|
||||
info: FileInfo,
|
||||
diagnostics: SkillDiagnostic[],
|
||||
context: Context,
|
||||
): Promise<"file" | "directory" | undefined> {
|
||||
if (info.kind === "file" || info.kind === "directory") return info.kind;
|
||||
const canonicalPath = await env.canonicalPath(info.path);
|
||||
const canonicalPath = await env.canonicalPath(info.path, context);
|
||||
if (!canonicalPath.ok) {
|
||||
if (canonicalPath.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
|
|
@ -354,7 +364,7 @@ async function resolveKind(
|
|||
}
|
||||
return undefined;
|
||||
}
|
||||
const target = await env.fileInfo(canonicalPath.value);
|
||||
const target = await env.fileInfo(canonicalPath.value, context);
|
||||
if (!target.ok) {
|
||||
if (target.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type {
|
||||
ExactTelemetryAttributes,
|
||||
SchemaTelemetrySpan,
|
||||
TelemetryContext,
|
||||
TelemetrySchemaDefinition,
|
||||
TelemetrySchemaSpanEndAttributes,
|
||||
TelemetrySchemaSpanEventAttributes,
|
||||
|
|
@ -11,6 +10,7 @@ import type {
|
|||
TelemetrySchemaSpanUnion,
|
||||
TelemetrySpan,
|
||||
} from "@earendil-works/pi-telemetry";
|
||||
import { type Context, withTelemetryContext } from "./context.ts";
|
||||
|
||||
export type {
|
||||
AttributeValue,
|
||||
|
|
@ -136,12 +136,14 @@ export type AiTelemetrySpan<Name extends AiSpanName> = SchemaTelemetrySpan<typeo
|
|||
export type AiSpan = TelemetrySchemaSpanUnion<typeof AI_TELEMETRY_SCHEMA>;
|
||||
|
||||
export function startAiSpan<Name extends AiSpanName, const Attributes extends AiSpanStartAttributes<Name>, Result>(
|
||||
telemetryContext: TelemetryContext,
|
||||
name: Name,
|
||||
attributes: ExactTelemetryAttributes<AiSpanStartAttributes<Name>, Attributes>,
|
||||
callback: (span: AiTelemetrySpan<Name>) => Result | Promise<Result>,
|
||||
callback: (span: AiTelemetrySpan<Name>, context: Context) => Result | Promise<Result>,
|
||||
context: Context,
|
||||
): Promise<Result> {
|
||||
return telemetryContext.startSpan({ name, attributes }, (span) => callback(span as AiTelemetrySpan<Name>));
|
||||
return context.telemetryContext.startSpan({ name, attributes }, (span) =>
|
||||
callback(span as AiTelemetrySpan<Name>, withTelemetryContext(span, context)),
|
||||
);
|
||||
}
|
||||
|
||||
const HOOK_NAMES = [
|
||||
|
|
@ -623,12 +625,12 @@ export function startHarnessSpan<
|
|||
const Attributes extends HarnessSpanStartAttributes<Name>,
|
||||
Result,
|
||||
>(
|
||||
telemetryContext: TelemetryContext,
|
||||
name: Name,
|
||||
attributes: ExactTelemetryAttributes<HarnessSpanStartAttributes<Name>, Attributes>,
|
||||
callback: (span: HarnessTelemetrySpan<Name>) => Result | Promise<Result>,
|
||||
callback: (span: HarnessTelemetrySpan<Name>, context: Context) => Result | Promise<Result>,
|
||||
context: Context,
|
||||
): Promise<Result> {
|
||||
return telemetryContext.startSpan({ name, attributes }, (span: TelemetrySpan) =>
|
||||
callback(span as HarnessTelemetrySpan<Name>),
|
||||
return context.telemetryContext.startSpan({ name, attributes }, (span: TelemetrySpan) =>
|
||||
callback(span as HarnessTelemetrySpan<Name>, withTelemetryContext(span, context)),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { type Static, Type } from "typebox";
|
||||
import type { Context } from "../context.ts";
|
||||
import type { AgentHarnessTool } from "../types.ts";
|
||||
import { getOrThrow } from "../types.ts";
|
||||
import { executeShellWithCapture, type ShellCaptureProgress } from "../utils/shell-output.ts";
|
||||
|
|
@ -29,8 +30,8 @@ export interface BashExecution {
|
|||
|
||||
export type BashPrepare<TContext extends ExecutionToolContext = ExecutionToolContext> = (
|
||||
execution: BashExecution,
|
||||
context: TContext,
|
||||
signal?: AbortSignal,
|
||||
toolContext: TContext,
|
||||
context: Context,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export interface BashToolOptions<TContext extends ExecutionToolContext = ExecutionToolContext> {
|
||||
|
|
@ -56,16 +57,16 @@ export function createBashTool<TContext extends ExecutionToolContext = Execution
|
|||
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, context) {
|
||||
async execute(_toolCallId, { command, timeout }, onUpdate, toolContext, _invocation, context) {
|
||||
validateTimeout(timeout);
|
||||
const { env } = context;
|
||||
const { env } = toolContext;
|
||||
const execution: BashExecution = {
|
||||
command: options?.commandPrefix ? `${options.commandPrefix}\n${command}` : command,
|
||||
cwd: env.cwd,
|
||||
env: {},
|
||||
inheritEnv: true,
|
||||
};
|
||||
await options?.prepare?.(execution, context, signal);
|
||||
await options?.prepare?.(execution, toolContext, context);
|
||||
let getLatestProgress: (() => ShellCaptureProgress) | undefined;
|
||||
let updateTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let updateDirty = false;
|
||||
|
|
@ -107,18 +108,22 @@ export function createBashTool<TContext extends ExecutionToolContext = Execution
|
|||
onUpdate?.({ content: [], details: undefined });
|
||||
try {
|
||||
const capture = getOrThrow(
|
||||
await executeShellWithCapture(env, execution.command, {
|
||||
cwd: execution.cwd,
|
||||
env: execution.env,
|
||||
inheritEnv: execution.inheritEnv,
|
||||
timeout,
|
||||
abortSignal: signal,
|
||||
returnExecutionErrors: true,
|
||||
onChunk: (_chunk, getProgress) => {
|
||||
getLatestProgress = getProgress;
|
||||
scheduleOutputUpdate();
|
||||
await executeShellWithCapture(
|
||||
env,
|
||||
execution.command,
|
||||
{
|
||||
cwd: execution.cwd,
|
||||
env: execution.env,
|
||||
inheritEnv: execution.inheritEnv,
|
||||
timeout,
|
||||
returnExecutionErrors: true,
|
||||
onChunk: (_chunk, getProgress) => {
|
||||
getLatestProgress = getProgress;
|
||||
scheduleOutputUpdate();
|
||||
},
|
||||
},
|
||||
}),
|
||||
context,
|
||||
),
|
||||
);
|
||||
clearUpdateTimer();
|
||||
getLatestProgress = () => capture;
|
||||
|
|
|
|||
|
|
@ -99,42 +99,47 @@ export function createEditTool<TContext extends ExecutionToolContext = Execution
|
|||
"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.",
|
||||
parameters: editSchema,
|
||||
prepareArguments: prepareEditArguments,
|
||||
async execute(_toolCallId, input, signal, _onUpdate, { env }) {
|
||||
async execute(_toolCallId, input, _onUpdate, { env }, _invocation, context) {
|
||||
const { path, edits } = validateEditInput(input);
|
||||
const absolutePath = await resolveToolPath(env, path, signal);
|
||||
return withFileMutationQueue(env, absolutePath, async () => {
|
||||
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" && info.value.kind !== "symlink") {
|
||||
throw new Error(`Could not edit file: ${path}. Path is not a file.`);
|
||||
}
|
||||
const absolutePath = await resolveToolPath(env, path, context);
|
||||
return withFileMutationQueue(
|
||||
env,
|
||||
absolutePath,
|
||||
async () => {
|
||||
if (context.abortSignal?.aborted) throw new Error("Operation aborted");
|
||||
const info = await env.fileInfo(absolutePath, context);
|
||||
if (!info.ok) throw editAccessError(path, info.error);
|
||||
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);
|
||||
if (signal?.aborted) throw new Error("Operation aborted");
|
||||
const readResult = await env.readTextFile(absolutePath, context);
|
||||
if (!readResult.ok) throw editAccessError(path, readResult.error);
|
||||
if (context.abortSignal?.aborted) throw new Error("Operation aborted");
|
||||
|
||||
const { bom, text: content } = stripBom(readResult.value);
|
||||
const originalEnding = detectLineEnding(content);
|
||||
const normalizedContent = normalizeToLF(content);
|
||||
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
|
||||
if (signal?.aborted) throw new Error("Operation aborted");
|
||||
const { bom, text: content } = stripBom(readResult.value);
|
||||
const originalEnding = detectLineEnding(content);
|
||||
const normalizedContent = normalizeToLF(content);
|
||||
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
|
||||
if (context.abortSignal?.aborted) throw new Error("Operation aborted");
|
||||
|
||||
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
||||
const writeResult = await env.writeFile(absolutePath, finalContent, signal);
|
||||
if (!writeResult.ok) throw editAccessError(path, writeResult.error);
|
||||
if (signal?.aborted) throw new Error("Operation aborted");
|
||||
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
||||
const writeResult = await env.writeFile(absolutePath, finalContent, context);
|
||||
if (!writeResult.ok) throw editAccessError(path, writeResult.error);
|
||||
if (context.abortSignal?.aborted) throw new Error("Operation aborted");
|
||||
|
||||
const diffResult = generateDiffString(baseContent, newContent);
|
||||
return {
|
||||
content: [{ type: "text", text: `Successfully replaced ${edits.length} block(s) in ${path}.` }],
|
||||
details: {
|
||||
diff: diffResult.diff,
|
||||
patch: generateUnifiedPatch(path, baseContent, newContent),
|
||||
firstChangedLine: diffResult.firstChangedLine,
|
||||
},
|
||||
};
|
||||
});
|
||||
const diffResult = generateDiffString(baseContent, newContent);
|
||||
return {
|
||||
content: [{ type: "text", text: `Successfully replaced ${edits.length} block(s) in ${path}.` }],
|
||||
details: {
|
||||
diff: diffResult.diff,
|
||||
patch: generateUnifiedPatch(path, baseContent, newContent),
|
||||
firstChangedLine: diffResult.firstChangedLine,
|
||||
},
|
||||
};
|
||||
},
|
||||
context,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { Context } from "../context.ts";
|
||||
import type { ExecutionEnv } from "../types.ts";
|
||||
import { getOrThrow } from "../types.ts";
|
||||
|
||||
|
|
@ -17,19 +18,24 @@ function getState(env: ExecutionEnv): MutationQueueState {
|
|||
return state;
|
||||
}
|
||||
|
||||
async function getMutationQueueKey(env: ExecutionEnv, path: string): Promise<string> {
|
||||
const absolutePath = getOrThrow(await env.absolutePath(path));
|
||||
const canonicalPath = await env.canonicalPath(absolutePath);
|
||||
async function getMutationQueueKey(env: ExecutionEnv, path: string, context: Context): Promise<string> {
|
||||
const absolutePath = getOrThrow(await env.absolutePath(path, context));
|
||||
const canonicalPath = await env.canonicalPath(absolutePath, context);
|
||||
if (canonicalPath.ok) return canonicalPath.value;
|
||||
if (canonicalPath.error.code === "not_found" || canonicalPath.error.code === "not_supported") return absolutePath;
|
||||
throw canonicalPath.error;
|
||||
}
|
||||
|
||||
/** Serialize file mutations targeting the same environment and canonical path. */
|
||||
export async function withFileMutationQueue<T>(env: ExecutionEnv, path: string, fn: () => Promise<T>): Promise<T> {
|
||||
export async function withFileMutationQueue<T>(
|
||||
env: ExecutionEnv,
|
||||
path: string,
|
||||
fn: () => Promise<T>,
|
||||
context: Context,
|
||||
): Promise<T> {
|
||||
const state = getState(env);
|
||||
const registration = state.registration.then(async () => {
|
||||
const key = await getMutationQueueKey(env, path);
|
||||
const key = await getMutationQueueKey(env, path, context);
|
||||
const currentQueue = state.queues.get(key) ?? Promise.resolve();
|
||||
|
||||
let releaseNext = () => {};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { Context } from "../context.ts";
|
||||
import type { ExecutionEnv } from "../types.ts";
|
||||
import { getOrThrow } from "../types.ts";
|
||||
|
||||
|
|
@ -9,12 +10,12 @@ function normalizeToolPath(path: string): string {
|
|||
return normalized.startsWith("@") ? normalized.slice(1) : normalized;
|
||||
}
|
||||
|
||||
export async function resolveToolPath(env: ExecutionEnv, path: string, signal?: AbortSignal): Promise<string> {
|
||||
return getOrThrow(await env.absolutePath(normalizeToolPath(path), signal));
|
||||
export async function resolveToolPath(env: ExecutionEnv, path: string, context: Context): Promise<string> {
|
||||
return getOrThrow(await env.absolutePath(normalizeToolPath(path), context));
|
||||
}
|
||||
|
||||
export async function resolveReadToolPath(env: ExecutionEnv, path: string, signal?: AbortSignal): Promise<string> {
|
||||
const resolved = await resolveToolPath(env, path, signal);
|
||||
export async function resolveReadToolPath(env: ExecutionEnv, path: string, context: Context): Promise<string> {
|
||||
const resolved = await resolveToolPath(env, path, context);
|
||||
const variants = [
|
||||
resolved,
|
||||
resolved.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`),
|
||||
|
|
@ -24,7 +25,7 @@ export async function resolveReadToolPath(env: ExecutionEnv, path: string, signa
|
|||
];
|
||||
|
||||
for (const variant of new Set(variants)) {
|
||||
if (getOrThrow(await env.exists(variant, signal))) return variant;
|
||||
if (getOrThrow(await env.exists(variant, context))) return variant;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
||||
import { type Static, Type } from "typebox";
|
||||
import type { Context } from "../context.ts";
|
||||
import type { AgentHarnessTool } from "../types.ts";
|
||||
import { getOrThrow } from "../types.ts";
|
||||
import {
|
||||
|
|
@ -33,6 +34,7 @@ export type ReadImageProcessor = (
|
|||
bytes: Uint8Array,
|
||||
mimeType: string,
|
||||
options: { autoResizeImages: boolean },
|
||||
context: Context,
|
||||
) => Promise<ReadImageProcessorResult>;
|
||||
|
||||
export interface ReadToolOptions {
|
||||
|
|
@ -50,15 +52,18 @@ export function createReadTool<TContext extends ExecutionToolContext = Execution
|
|||
label: "read",
|
||||
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
|
||||
parameters: readSchema,
|
||||
async execute(_toolCallId, { path, offset, limit }, signal, _onUpdate, { env }) {
|
||||
const absolutePath = await resolveReadToolPath(env, path, signal);
|
||||
const bytes = getOrThrow(await env.readBinaryFile(absolutePath, signal));
|
||||
async execute(_toolCallId, { path, offset, limit }, _onUpdate, { env }, _invocation, context) {
|
||||
const absolutePath = await resolveReadToolPath(env, path, context);
|
||||
const bytes = getOrThrow(await env.readBinaryFile(absolutePath, context));
|
||||
const mimeType = detectSupportedImageMimeType(bytes);
|
||||
if (mimeType) {
|
||||
if (options?.imageProcessor) {
|
||||
const processed = await options.imageProcessor(bytes, mimeType, {
|
||||
autoResizeImages: options.autoResizeImages ?? true,
|
||||
});
|
||||
const processed = await options.imageProcessor(
|
||||
bytes,
|
||||
mimeType,
|
||||
{ autoResizeImages: options.autoResizeImages ?? true },
|
||||
context,
|
||||
);
|
||||
if (!processed.ok) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Read image file [${mimeType}]\n${processed.message}` }],
|
||||
|
|
|
|||
|
|
@ -23,17 +23,22 @@ export function createWriteTool<TContext extends ExecutionToolContext = Executio
|
|||
description:
|
||||
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
|
||||
parameters: writeSchema,
|
||||
async execute(_toolCallId, { path, content }, signal, _onUpdate, { env }) {
|
||||
const absolutePath = await resolveToolPath(env, path, signal);
|
||||
return withFileMutationQueue(env, absolutePath, async () => {
|
||||
if (signal?.aborted) throw new Error("Operation aborted");
|
||||
getOrThrow(await env.writeFile(absolutePath, content, signal));
|
||||
if (signal?.aborted) throw new Error("Operation aborted");
|
||||
return {
|
||||
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
|
||||
details: undefined,
|
||||
};
|
||||
});
|
||||
async execute(_toolCallId, { path, content }, _onUpdate, { env }, _invocation, context) {
|
||||
const absolutePath = await resolveToolPath(env, path, context);
|
||||
return withFileMutationQueue(
|
||||
env,
|
||||
absolutePath,
|
||||
async () => {
|
||||
if (context.abortSignal?.aborted) throw new Error("Operation aborted");
|
||||
getOrThrow(await env.writeFile(absolutePath, content, context));
|
||||
if (context.abortSignal?.aborted) throw new Error("Operation aborted");
|
||||
return {
|
||||
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
context,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { SimpleStreamOptions, Transport } from "@earendil-works/pi-ai";
|
||||
import type { Static, TSchema } from "typebox";
|
||||
import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "../types.ts";
|
||||
import type { Context } from "./context.ts";
|
||||
|
||||
/** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */
|
||||
export type Result<TValue, TError> = { ok: true; value: TValue } | { ok: false; error: TError };
|
||||
|
|
@ -95,17 +96,17 @@ export type AgentHarnessTool<
|
|||
execute(
|
||||
toolCallId: string,
|
||||
params: Static<TParameters>,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
|
||||
context: TContext,
|
||||
toolContext: TContext,
|
||||
invocation: AgentHarnessToolInvocation,
|
||||
context: Context,
|
||||
): Promise<AgentToolResult<TDetails>>;
|
||||
};
|
||||
|
||||
/** Static tool context or zero-argument provider resolved for each turn snapshot. */
|
||||
/** Static tool context or provider resolved for each turn snapshot. */
|
||||
export type AgentHarnessToolContextSource<TContext extends object | undefined> =
|
||||
| TContext
|
||||
| (() => TContext | Promise<TContext>);
|
||||
| ((context: Context) => TContext | Promise<TContext>);
|
||||
|
||||
/** Curated provider request options owned by the harness and snapshotted per turn. */
|
||||
export interface AgentHarnessStreamOptions {
|
||||
|
|
@ -244,53 +245,55 @@ export interface FileSystem {
|
|||
cwd: string;
|
||||
|
||||
/** Return an absolute addressed path without requiring it to exist and without resolving symlinks. */
|
||||
absolutePath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
||||
absolutePath(path: string, context: Context): Promise<Result<string, FileError>>;
|
||||
/** Join path segments in the filesystem namespace without requiring the result to exist. */
|
||||
joinPath(parts: string[], abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
||||
joinPath(parts: string[], context: Context): Promise<Result<string, FileError>>;
|
||||
/** Read a UTF-8 text file. */
|
||||
readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
||||
readTextFile(path: string, context: Context): Promise<Result<string, FileError>>;
|
||||
/** Read UTF-8 text lines. Implementations should stop once `maxLines` lines have been read. */
|
||||
readTextLines(
|
||||
path: string,
|
||||
options?: { maxLines?: number; abortSignal?: AbortSignal },
|
||||
options: { maxLines?: number } | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<string[], FileError>>;
|
||||
/** Read a binary file. */
|
||||
readBinaryFile(path: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>>;
|
||||
readBinaryFile(path: string, context: Context): Promise<Result<Uint8Array, FileError>>;
|
||||
/** Create or overwrite a file, creating parent directories when supported. */
|
||||
writeFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
|
||||
writeFile(path: string, content: string | Uint8Array, context: Context): Promise<Result<void, FileError>>;
|
||||
/** Create or append to a file, creating parent directories when supported. */
|
||||
appendFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
|
||||
appendFile(path: string, content: string | Uint8Array, context: Context): Promise<Result<void, FileError>>;
|
||||
/** Atomically rename a file, replacing the destination when it exists. Does not copy across filesystems. */
|
||||
renameFile(sourcePath: string, destinationPath: string, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
|
||||
renameFile(sourcePath: string, destinationPath: string, context: Context): Promise<Result<void, FileError>>;
|
||||
/** Return metadata for the addressed path without following symlinks. */
|
||||
fileInfo(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>>;
|
||||
fileInfo(path: string, context: Context): Promise<Result<FileInfo, FileError>>;
|
||||
/** List direct children of a directory without following symlinks. */
|
||||
listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
|
||||
listDir(path: string, context: Context): Promise<Result<FileInfo[], FileError>>;
|
||||
/** Return the canonical path for an existing path, resolving symlinks where supported. */
|
||||
canonicalPath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
||||
canonicalPath(path: string, context: Context): Promise<Result<string, FileError>>;
|
||||
/** Return false for missing paths. Other errors, such as permission failures, return a {@link FileError}. */
|
||||
exists(path: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
|
||||
/** Create a directory. Defaults: `recursive: true`, no abort signal. */
|
||||
exists(path: string, context: Context): Promise<Result<boolean, FileError>>;
|
||||
/** Create a directory. Defaults to `recursive: true`. */
|
||||
createDir(
|
||||
path: string,
|
||||
options?: { recursive?: boolean; abortSignal?: AbortSignal },
|
||||
options: { recursive?: boolean } | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<void, FileError>>;
|
||||
/** Remove a file or directory. Defaults: `recursive: false`, `force: false`, no abort signal. */
|
||||
/** Remove a file or directory. Defaults to `recursive: false` and `force: false`. */
|
||||
remove(
|
||||
path: string,
|
||||
options?: { recursive?: boolean; force?: boolean; abortSignal?: AbortSignal },
|
||||
options: { recursive?: boolean; force?: boolean } | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<void, FileError>>;
|
||||
/** Create a temporary directory and return its absolute path. Defaults: `prefix: "tmp-"`, no abort signal. */
|
||||
createTempDir(prefix?: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
||||
/** Create a temporary file and return its absolute path. Defaults: `prefix: ""`, `suffix: ""`, no abort signal. */
|
||||
createTempFile(options?: {
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<Result<string, FileError>>;
|
||||
/** Create a temporary directory and return its absolute path. Defaults to `prefix: "tmp-"`. */
|
||||
createTempDir(prefix: string | undefined, context: Context): Promise<Result<string, FileError>>;
|
||||
/** Create a temporary file and return its absolute path. Defaults to `prefix: ""` and `suffix: ""`. */
|
||||
createTempFile(
|
||||
options: { prefix?: string; suffix?: string } | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<string, FileError>>;
|
||||
|
||||
/** Release filesystem resources. Must be best-effort and must not throw or reject. */
|
||||
cleanup(): Promise<void>;
|
||||
cleanup(context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
/** Options for {@link Shell.exec}. */
|
||||
|
|
@ -303,12 +306,10 @@ export interface ShellExecOptions {
|
|||
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. */
|
||||
abortSignal?: AbortSignal;
|
||||
/** Called with stdout chunks as they are produced. */
|
||||
onStdout?: (chunk: string) => void;
|
||||
onStdout?: (chunk: string, context: Context) => void;
|
||||
/** Called with stderr chunks as they are produced. */
|
||||
onStderr?: (chunk: string) => void;
|
||||
onStderr?: (chunk: string, context: Context) => void;
|
||||
}
|
||||
|
||||
/** Shell execution capability used by the harness. */
|
||||
|
|
@ -316,10 +317,11 @@ export interface Shell {
|
|||
/** Execute a shell command in {@link FileSystem.cwd} unless `options.cwd` is provided. */
|
||||
exec(
|
||||
command: string,
|
||||
options?: ShellExecOptions,
|
||||
options: ShellExecOptions | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>>;
|
||||
/** Release shell resources. Must be best-effort and must not throw or reject. */
|
||||
cleanup(): Promise<void>;
|
||||
cleanup(context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
/** Filesystem and process execution environment used by the harness. */
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { Context } from "../context.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";
|
||||
|
||||
|
|
@ -9,7 +10,7 @@ export interface ShellCaptureProgress {
|
|||
}
|
||||
|
||||
export interface ShellCaptureOptions extends Omit<ShellExecOptions, "onStdout" | "onStderr"> {
|
||||
onChunk?: (chunk: string, getProgress: () => ShellCaptureProgress) => void;
|
||||
onChunk?: (chunk: string, getProgress: () => ShellCaptureProgress, context: Context) => void;
|
||||
/** Return shell execution failures with captured output instead of as a failed Result. */
|
||||
returnExecutionErrors?: boolean;
|
||||
}
|
||||
|
|
@ -51,7 +52,8 @@ function trimToLastUtf8Bytes(text: string, maxBytes: number, encoder: { encode(i
|
|||
export async function executeShellWithCapture(
|
||||
env: ExecutionEnv,
|
||||
command: string,
|
||||
options?: ShellCaptureOptions,
|
||||
options: ShellCaptureOptions | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<ShellCaptureResult, ExecutionError>> {
|
||||
let tailOutput = "";
|
||||
const maxOutputBytes = DEFAULT_MAX_BYTES * 2;
|
||||
|
|
@ -72,7 +74,7 @@ export async function executeShellWithCapture(
|
|||
writeChain = writeChain.then(async (previous) => {
|
||||
if (!previous.ok) return previous;
|
||||
if (!fullOutputPath) return err(new ExecutionError("unknown", "Full output path was not created"));
|
||||
const appendResult = await env.appendFile(fullOutputPath, text);
|
||||
const appendResult = await env.appendFile(fullOutputPath, text, context);
|
||||
return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error));
|
||||
});
|
||||
};
|
||||
|
|
@ -82,10 +84,10 @@ export async function executeShellWithCapture(
|
|||
fullOutputRequested = true;
|
||||
writeChain = writeChain.then(async (previous) => {
|
||||
if (!previous.ok) return previous;
|
||||
const tempFile = await env.createTempFile({ prefix: "bash-", suffix: ".log" });
|
||||
const tempFile = await env.createTempFile({ prefix: "bash-", suffix: ".log" }, context);
|
||||
if (!tempFile.ok) return err(toExecutionError(tempFile.error));
|
||||
fullOutputPath = tempFile.value;
|
||||
const appendResult = await env.appendFile(tempFile.value, initialContent);
|
||||
const appendResult = await env.appendFile(tempFile.value, initialContent, context);
|
||||
return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error));
|
||||
});
|
||||
};
|
||||
|
|
@ -137,22 +139,25 @@ export async function executeShellWithCapture(
|
|||
appendFullOutput(text);
|
||||
}
|
||||
tailOutput = trimToLastUtf8Bytes(tailOutput, maxOutputBytes, encoder);
|
||||
options?.onChunk?.(text, createProgress);
|
||||
options?.onChunk?.(text, createProgress, context);
|
||||
} catch (error) {
|
||||
captureError = toExecutionError(error);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
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 result = await env.exec(
|
||||
command,
|
||||
{
|
||||
cwd: options?.cwd,
|
||||
env: options?.env,
|
||||
inheritEnv: options?.inheritEnv,
|
||||
timeout: options?.timeout,
|
||||
onStdout: onChunk,
|
||||
onStderr: onChunk,
|
||||
},
|
||||
context,
|
||||
);
|
||||
acceptingOutput = false;
|
||||
let progress = createProgress();
|
||||
if (progress.truncation.truncated && !fullOutputRequested) ensureFullOutputFile(tailOutput);
|
||||
|
|
@ -162,7 +167,7 @@ export async function executeShellWithCapture(
|
|||
progress = createProgress();
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.error.code === "aborted" || options?.abortSignal?.aborted) {
|
||||
if (result.error.code === "aborted" || context.abortSignal?.aborted) {
|
||||
return ok({
|
||||
...progress,
|
||||
exitCode: undefined,
|
||||
|
|
@ -181,7 +186,7 @@ export async function executeShellWithCapture(
|
|||
}
|
||||
return err(result.error);
|
||||
}
|
||||
const cancelled = options?.abortSignal?.aborted ?? false;
|
||||
const cancelled = context.abortSignal?.aborted ?? false;
|
||||
return ok({
|
||||
...progress,
|
||||
exitCode: cancelled ? undefined : result.value.exitCode,
|
||||
|
|
|
|||
|
|
@ -448,7 +448,18 @@ describe("harness compaction", () => {
|
|||
},
|
||||
]);
|
||||
getOrThrow(
|
||||
await generateSummary(messages, models, reasoningModel, 2000, undefined, undefined, undefined, "medium"),
|
||||
await generateSummary(
|
||||
messages,
|
||||
models,
|
||||
reasoningModel,
|
||||
2000,
|
||||
undefined,
|
||||
undefined,
|
||||
"medium",
|
||||
undefined,
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
expect(seenOptions[0]).toMatchObject({ reasoning: "medium" });
|
||||
|
||||
|
|
@ -459,7 +470,20 @@ describe("harness compaction", () => {
|
|||
return fauxAssistantMessage("## Goal\nTest summary");
|
||||
},
|
||||
]);
|
||||
getOrThrow(await generateSummary(messages, models, offModel, 2000, undefined, undefined, undefined, "off"));
|
||||
getOrThrow(
|
||||
await generateSummary(
|
||||
messages,
|
||||
models,
|
||||
offModel,
|
||||
2000,
|
||||
undefined,
|
||||
undefined,
|
||||
"off",
|
||||
undefined,
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
expect(seenOptions[1]).not.toHaveProperty("reasoning");
|
||||
|
||||
const { faux: fauxNonReasoning, model: nonReasoningModel } = createFauxModel(false);
|
||||
|
|
@ -470,7 +494,18 @@ describe("harness compaction", () => {
|
|||
},
|
||||
]);
|
||||
getOrThrow(
|
||||
await generateSummary(messages, models, nonReasoningModel, 2000, undefined, undefined, undefined, "medium"),
|
||||
await generateSummary(
|
||||
messages,
|
||||
models,
|
||||
nonReasoningModel,
|
||||
2000,
|
||||
undefined,
|
||||
undefined,
|
||||
"medium",
|
||||
undefined,
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
expect(seenOptions[2]).not.toHaveProperty("reasoning");
|
||||
});
|
||||
|
|
@ -489,7 +524,18 @@ describe("harness compaction", () => {
|
|||
]);
|
||||
|
||||
const summary = getOrThrow(
|
||||
await generateSummaryWithUsage(messages, models, model, 2000, undefined, "focus", "old summary"),
|
||||
await generateSummaryWithUsage(
|
||||
messages,
|
||||
models,
|
||||
model,
|
||||
2000,
|
||||
"focus",
|
||||
"old summary",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
|
||||
expect(summary.text).toContain("Test summary");
|
||||
|
|
@ -507,14 +553,40 @@ describe("harness compaction", () => {
|
|||
const { faux, model } = createFauxModel(false);
|
||||
faux.setResponses([fauxAssistantMessage("## Goal\nTest summary")]);
|
||||
|
||||
expect(getOrThrow(await generateSummary(messages, models, model, 2000))).toBe("## Goal\nTest summary");
|
||||
expect(
|
||||
getOrThrow(
|
||||
await generateSummary(
|
||||
messages,
|
||||
models,
|
||||
model,
|
||||
2000,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
),
|
||||
).toBe("## Goal\nTest summary");
|
||||
});
|
||||
|
||||
it("returns error results for failed or aborted summary generations", async () => {
|
||||
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
|
||||
const { faux: errorFaux, model: errorModel } = createFauxModel(false);
|
||||
errorFaux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "boom" })]);
|
||||
const errorResult = await generateSummary(messages, models, errorModel, 2000);
|
||||
const errorResult = await generateSummary(
|
||||
messages,
|
||||
models,
|
||||
errorModel,
|
||||
2000,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
expect(errorResult).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "summarization_failed", message: "Summarization failed: boom" },
|
||||
|
|
@ -522,7 +594,18 @@ describe("harness compaction", () => {
|
|||
|
||||
const { faux: abortedFaux, model: abortedModel } = createFauxModel(false);
|
||||
abortedFaux.setResponses([fauxAssistantMessage("", { stopReason: "aborted", errorMessage: "stopped" })]);
|
||||
const abortedResult = await generateSummary(messages, models, abortedModel, 2000);
|
||||
const abortedResult = await generateSummary(
|
||||
messages,
|
||||
models,
|
||||
abortedModel,
|
||||
2000,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
expect(abortedResult).toMatchObject({ ok: false, error: { code: "aborted", message: "stopped" } });
|
||||
});
|
||||
|
||||
|
|
@ -550,7 +633,9 @@ describe("harness compaction", () => {
|
|||
settings: { enabled: true, reserveTokens: 500000, keepRecentTokens: 20000 },
|
||||
};
|
||||
|
||||
getOrThrow(await compact(preparation, models, model));
|
||||
getOrThrow(
|
||||
await compact(preparation, models, model, undefined, undefined, undefined, undefined, BACKGROUND_CONTEXT),
|
||||
);
|
||||
|
||||
expect(seenOptions.map((options) => options?.maxTokens)).toEqual([128000, 128000]);
|
||||
expect(seenOptions.map((options) => options?.cacheRetention)).toEqual(["none", "none"]);
|
||||
|
|
@ -571,7 +656,18 @@ describe("harness compaction", () => {
|
|||
};
|
||||
const { faux: historyFaux, model: historyModel } = createFauxModel(false);
|
||||
historyFaux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "history failed" })]);
|
||||
expect(await compact(preparation, models, historyModel)).toMatchObject({
|
||||
expect(
|
||||
await compact(
|
||||
preparation,
|
||||
models,
|
||||
historyModel,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "summarization_failed", message: "Summarization failed: history failed" },
|
||||
});
|
||||
|
|
@ -596,7 +692,9 @@ describe("harness compaction", () => {
|
|||
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
|
||||
};
|
||||
|
||||
const result = getOrThrow(await compact(preparation, usageModels, model));
|
||||
const result = getOrThrow(
|
||||
await compact(preparation, usageModels, model, undefined, undefined, undefined, undefined, BACKGROUND_CONTEXT),
|
||||
);
|
||||
|
||||
expect(result.usage).toEqual(createMockUsage(6, 8, 10, 12));
|
||||
});
|
||||
|
|
@ -621,7 +719,9 @@ describe("harness compaction", () => {
|
|||
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
|
||||
};
|
||||
|
||||
getOrThrow(await compact(preparation, models, model, undefined, undefined, "high"));
|
||||
getOrThrow(
|
||||
await compact(preparation, models, model, undefined, "high", undefined, undefined, BACKGROUND_CONTEXT),
|
||||
);
|
||||
|
||||
expect(seenOptions[0]).toMatchObject({ reasoning: "high" });
|
||||
});
|
||||
|
|
@ -640,14 +740,27 @@ describe("harness compaction", () => {
|
|||
const { faux, model } = createFauxModel(false);
|
||||
faux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "prefix failed" })]);
|
||||
|
||||
expect(await compact(preparation, models, model)).toMatchObject({
|
||||
expect(
|
||||
await compact(preparation, models, model, undefined, undefined, undefined, undefined, BACKGROUND_CONTEXT),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "summarization_failed", message: "Turn prefix summarization failed: prefix failed" },
|
||||
});
|
||||
|
||||
const { faux: abortedFaux, model: abortedModel } = createFauxModel(false);
|
||||
abortedFaux.setResponses([fauxAssistantMessage("", { stopReason: "aborted", errorMessage: "prefix stopped" })]);
|
||||
expect(await compact(preparation, models, abortedModel)).toMatchObject({
|
||||
expect(
|
||||
await compact(
|
||||
preparation,
|
||||
models,
|
||||
abortedModel,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "aborted", message: "prefix stopped" },
|
||||
});
|
||||
|
|
@ -666,7 +779,9 @@ describe("harness compaction", () => {
|
|||
expect(preparation).toBeDefined();
|
||||
const { faux, model } = createFauxModel(false);
|
||||
faux.setResponses([fauxAssistantMessage("## Goal\nTest summary")]);
|
||||
const result = getOrThrow(await compact(preparation!, models, model));
|
||||
const result = getOrThrow(
|
||||
await compact(preparation!, models, model, undefined, undefined, undefined, undefined, BACKGROUND_CONTEXT),
|
||||
);
|
||||
expect(result.summary.length).toBeGreaterThan(0);
|
||||
expect(result.usage?.totalTokens).toBeGreaterThan(0);
|
||||
expect(result.retainedTail?.length).toBeGreaterThan(0);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from "@earendil-works/pi-ai";
|
||||
import { NOOP_TELEMETRY_CONTEXT } from "@earendil-works/pi-telemetry";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT, withAbortSignal } from "../../src/harness/context.ts";
|
||||
import { type AssistantResponseMetadata, streamHarnessAssistant } from "../../src/harness/execution/assistant.ts";
|
||||
import { AbortRequested } from "../../src/harness/execution/effect-gate.ts";
|
||||
import type { AgentMessage } from "../../src/types.ts";
|
||||
|
|
@ -86,77 +87,79 @@ describe("streamHarnessAssistant", () => {
|
|||
let receivedOptions: SimpleStreamOptions | undefined;
|
||||
let responseMetadata: AssistantResponseMetadata | undefined;
|
||||
|
||||
const result = await streamHarnessAssistant(input, {
|
||||
model: requestModel,
|
||||
systemPrompt: "system",
|
||||
thinkingLevel: "high",
|
||||
streamOptions: {
|
||||
transport: "websocket",
|
||||
timeoutMs: 123,
|
||||
maxRetries: 2,
|
||||
maxRetryDelayMs: 456,
|
||||
headers: { authorization: "test" },
|
||||
metadata: { tenant: "one" },
|
||||
cacheRetention: "long",
|
||||
deferred: { window: "1h" },
|
||||
},
|
||||
transformContext: async (context) => {
|
||||
order.push("transform_context");
|
||||
transformedInputWasCopy = context.messages !== originalArray;
|
||||
context.messages.push(user("injected"));
|
||||
return { messages: context.messages, systemPrompt: "transformed system" };
|
||||
},
|
||||
toProviderMessages: (messages) => {
|
||||
order.push("to_provider_messages");
|
||||
convertedMessages = messages;
|
||||
return toProviderMessages(messages);
|
||||
},
|
||||
beforePayload: (payload, seenModel) => {
|
||||
order.push("before_payload");
|
||||
expect(seenModel.id).toBe("resolved");
|
||||
requestContext = payload;
|
||||
return { replaced: true };
|
||||
},
|
||||
afterResponse: async (message, metadata) => {
|
||||
order.push("after_response");
|
||||
responseMetadata = metadata;
|
||||
return { ...message, content: [{ type: "text", text: "transformed" }] };
|
||||
},
|
||||
request: async (context, options) => {
|
||||
order.push("request");
|
||||
receivedContext = context;
|
||||
receivedOptions = options;
|
||||
expect(await options.onPayload?.({ original: true }, { ...requestModel, id: "resolved" })).toEqual({
|
||||
replaced: true,
|
||||
});
|
||||
await options.onResponse?.({ status: 201, headers: { "request-id": "r1" } }, requestModel);
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
const initial = { ...assistant(""), stopReason: "pending" as const };
|
||||
const partial = { ...initial, content: [{ type: "text" as const, text: "raw" }] };
|
||||
stream.push({ type: "start", partial: initial });
|
||||
stream.push({ type: "text_delta", contentIndex: 0, delta: "raw", partial });
|
||||
stream.push({ type: "done", reason: "stop", message: assistant("raw") });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
observer: {
|
||||
start(message) {
|
||||
order.push("observer_start");
|
||||
starts.push(message);
|
||||
const result = await streamHarnessAssistant(
|
||||
input,
|
||||
{
|
||||
model: requestModel,
|
||||
systemPrompt: "system",
|
||||
thinkingLevel: "high",
|
||||
streamOptions: {
|
||||
transport: "websocket",
|
||||
timeoutMs: 123,
|
||||
maxRetries: 2,
|
||||
maxRetryDelayMs: 456,
|
||||
headers: { authorization: "test" },
|
||||
metadata: { tenant: "one" },
|
||||
cacheRetention: "long",
|
||||
deferred: { window: "1h" },
|
||||
},
|
||||
update(message) {
|
||||
order.push("observer_update");
|
||||
updates.push(message);
|
||||
transformContext: async (context) => {
|
||||
order.push("transform_context");
|
||||
transformedInputWasCopy = context.messages !== originalArray;
|
||||
context.messages.push(user("injected"));
|
||||
return { messages: context.messages, systemPrompt: "transformed system" };
|
||||
},
|
||||
end(message) {
|
||||
order.push("observer_end");
|
||||
ends.push(message);
|
||||
toProviderMessages: (messages) => {
|
||||
order.push("to_provider_messages");
|
||||
convertedMessages = messages;
|
||||
return toProviderMessages(messages);
|
||||
},
|
||||
beforePayload: (payload, seenModel) => {
|
||||
order.push("before_payload");
|
||||
expect(seenModel.id).toBe("resolved");
|
||||
requestContext = payload;
|
||||
return { replaced: true };
|
||||
},
|
||||
afterResponse: async (message, metadata) => {
|
||||
order.push("after_response");
|
||||
responseMetadata = metadata;
|
||||
return { ...message, content: [{ type: "text", text: "transformed" }] };
|
||||
},
|
||||
request: async (context, options) => {
|
||||
order.push("request");
|
||||
receivedContext = context;
|
||||
receivedOptions = options;
|
||||
expect(await options.onPayload?.({ original: true }, { ...requestModel, id: "resolved" })).toEqual({
|
||||
replaced: true,
|
||||
});
|
||||
await options.onResponse?.({ status: 201, headers: { "request-id": "r1" } }, requestModel);
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
const initial = { ...assistant(""), stopReason: "pending" as const };
|
||||
const partial = { ...initial, content: [{ type: "text" as const, text: "raw" }] };
|
||||
stream.push({ type: "start", partial: initial });
|
||||
stream.push({ type: "text_delta", contentIndex: 0, delta: "raw", partial });
|
||||
stream.push({ type: "done", reason: "stop", message: assistant("raw") });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
observer: {
|
||||
start(message) {
|
||||
order.push("observer_start");
|
||||
starts.push(message);
|
||||
},
|
||||
update(message) {
|
||||
order.push("observer_update");
|
||||
updates.push(message);
|
||||
},
|
||||
end(message) {
|
||||
order.push("observer_end");
|
||||
ends.push(message);
|
||||
},
|
||||
},
|
||||
},
|
||||
telemetryContext: NOOP_TELEMETRY_CONTEXT,
|
||||
signal: controller.signal,
|
||||
});
|
||||
withAbortSignal(controller.signal, BACKGROUND_CONTEXT),
|
||||
);
|
||||
|
||||
expect(input).toEqual([user("original")]);
|
||||
expect(transformedInputWasCopy).toBe(true);
|
||||
|
|
@ -203,30 +206,32 @@ describe("streamHarnessAssistant", () => {
|
|||
const lifecycle: string[] = [];
|
||||
let seenContext: Message[] = [];
|
||||
|
||||
const result = await streamHarnessAssistant([user("prompt")], {
|
||||
model: faux.getModel(),
|
||||
systemPrompt: "system",
|
||||
thinkingLevel: "off",
|
||||
streamOptions: {},
|
||||
toProviderMessages,
|
||||
request: (context, options) => {
|
||||
seenContext = context.messages;
|
||||
return faux.provider.streamSimple(faux.getModel(), context, options);
|
||||
},
|
||||
observer: {
|
||||
start() {
|
||||
lifecycle.push("start");
|
||||
const result = await streamHarnessAssistant(
|
||||
[user("prompt")],
|
||||
{
|
||||
model: faux.getModel(),
|
||||
systemPrompt: "system",
|
||||
thinkingLevel: "off",
|
||||
streamOptions: {},
|
||||
toProviderMessages,
|
||||
request: (context, options) => {
|
||||
seenContext = context.messages;
|
||||
return faux.provider.streamSimple(faux.getModel(), context, options);
|
||||
},
|
||||
update() {
|
||||
lifecycle.push("update");
|
||||
},
|
||||
end() {
|
||||
lifecycle.push("end");
|
||||
observer: {
|
||||
start() {
|
||||
lifecycle.push("start");
|
||||
},
|
||||
update() {
|
||||
lifecycle.push("update");
|
||||
},
|
||||
end() {
|
||||
lifecycle.push("end");
|
||||
},
|
||||
},
|
||||
},
|
||||
telemetryContext: NOOP_TELEMETRY_CONTEXT,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(seenContext).toEqual([user("prompt")]);
|
||||
expect(result.content).toEqual([{ type: "text", text: "hello" }]);
|
||||
|
|
@ -239,32 +244,34 @@ describe("streamHarnessAssistant", () => {
|
|||
const events: string[] = [];
|
||||
const final = assistant("complete");
|
||||
let options: SimpleStreamOptions | undefined;
|
||||
const result = await streamHarnessAssistant([user("prompt")], {
|
||||
model: model(),
|
||||
systemPrompt: "system",
|
||||
thinkingLevel: "off",
|
||||
streamOptions: {},
|
||||
toProviderMessages,
|
||||
request: (_context, requestOptions) => {
|
||||
options = requestOptions;
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => stream.push({ type: "done", reason: "stop", message: final }));
|
||||
return stream;
|
||||
},
|
||||
observer: {
|
||||
start(message) {
|
||||
events.push(`start:${message.stopReason}`);
|
||||
const result = await streamHarnessAssistant(
|
||||
[user("prompt")],
|
||||
{
|
||||
model: model(),
|
||||
systemPrompt: "system",
|
||||
thinkingLevel: "off",
|
||||
streamOptions: {},
|
||||
toProviderMessages,
|
||||
request: (_context, requestOptions) => {
|
||||
options = requestOptions;
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => stream.push({ type: "done", reason: "stop", message: final }));
|
||||
return stream;
|
||||
},
|
||||
update() {
|
||||
events.push("update");
|
||||
},
|
||||
end(message) {
|
||||
events.push(`end:${message.stopReason}`);
|
||||
observer: {
|
||||
start(message) {
|
||||
events.push(`start:${message.stopReason}`);
|
||||
},
|
||||
update() {
|
||||
events.push("update");
|
||||
},
|
||||
end(message) {
|
||||
events.push(`end:${message.stopReason}`);
|
||||
},
|
||||
},
|
||||
},
|
||||
telemetryContext: NOOP_TELEMETRY_CONTEXT,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(result).toBe(final);
|
||||
expect(events).toEqual(["start:stop", "end:stop"]);
|
||||
|
|
@ -274,30 +281,32 @@ describe("streamHarnessAssistant", () => {
|
|||
it("keeps the raw settlement when cancellation interrupts after_response", async () => {
|
||||
const final = assistant("raw");
|
||||
const ended: AssistantMessage[] = [];
|
||||
const result = await streamHarnessAssistant([user("prompt")], {
|
||||
model: model(),
|
||||
systemPrompt: "system",
|
||||
thinkingLevel: "off",
|
||||
streamOptions: {},
|
||||
toProviderMessages,
|
||||
request: () => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => stream.push({ type: "done", reason: "stop", message: final }));
|
||||
return stream;
|
||||
},
|
||||
afterResponse: async () => {
|
||||
throw new AbortRequested(Promise.resolve());
|
||||
},
|
||||
observer: {
|
||||
start() {},
|
||||
update() {},
|
||||
end(message) {
|
||||
ended.push(message);
|
||||
const result = await streamHarnessAssistant(
|
||||
[user("prompt")],
|
||||
{
|
||||
model: model(),
|
||||
systemPrompt: "system",
|
||||
thinkingLevel: "off",
|
||||
streamOptions: {},
|
||||
toProviderMessages,
|
||||
request: () => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => stream.push({ type: "done", reason: "stop", message: final }));
|
||||
return stream;
|
||||
},
|
||||
afterResponse: async () => {
|
||||
throw new AbortRequested(Promise.resolve());
|
||||
},
|
||||
observer: {
|
||||
start() {},
|
||||
update() {},
|
||||
end(message) {
|
||||
ended.push(message);
|
||||
},
|
||||
},
|
||||
},
|
||||
telemetryContext: NOOP_TELEMETRY_CONTEXT,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(result).toBe(final);
|
||||
expect(ended).toEqual([final]);
|
||||
|
|
@ -306,31 +315,33 @@ describe("streamHarnessAssistant", () => {
|
|||
it("returns provider error settlements through the same lifecycle", async () => {
|
||||
const final = assistant("provider failed", "error");
|
||||
const events: string[] = [];
|
||||
const result = await streamHarnessAssistant([user("prompt")], {
|
||||
model: model(),
|
||||
systemPrompt: "system",
|
||||
thinkingLevel: "off",
|
||||
streamOptions: {},
|
||||
toProviderMessages,
|
||||
request: () => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => stream.push({ type: "error", reason: "error", error: final }));
|
||||
return stream;
|
||||
},
|
||||
observer: {
|
||||
start(message) {
|
||||
events.push(`start:${message.stopReason}`);
|
||||
const result = await streamHarnessAssistant(
|
||||
[user("prompt")],
|
||||
{
|
||||
model: model(),
|
||||
systemPrompt: "system",
|
||||
thinkingLevel: "off",
|
||||
streamOptions: {},
|
||||
toProviderMessages,
|
||||
request: () => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => stream.push({ type: "error", reason: "error", error: final }));
|
||||
return stream;
|
||||
},
|
||||
update() {
|
||||
events.push("update");
|
||||
},
|
||||
end(message) {
|
||||
events.push(`end:${message.stopReason}`);
|
||||
observer: {
|
||||
start(message) {
|
||||
events.push(`start:${message.stopReason}`);
|
||||
},
|
||||
update() {
|
||||
events.push("update");
|
||||
},
|
||||
end(message) {
|
||||
events.push(`end:${message.stopReason}`);
|
||||
},
|
||||
},
|
||||
},
|
||||
telemetryContext: NOOP_TELEMETRY_CONTEXT,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(result).toBe(final);
|
||||
expect(events).toEqual(["start:error", "end:error"]);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { NOOP_TELEMETRY_CONTEXT } from "@earendil-works/pi-telemetry";
|
||||
import { Type } from "typebox";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../../src/harness/context.ts";
|
||||
import { AbortRequested, OperationEffectGate } from "../../src/harness/execution/effect-gate.ts";
|
||||
import {
|
||||
applyBeforeToolDecision,
|
||||
|
|
@ -120,7 +120,7 @@ describe("tool execution primitives", () => {
|
|||
const cleared = clearPrepared(prepareToolCall(call(), [tool({ execute })]));
|
||||
const updates: AgentToolResult<unknown>[] = [];
|
||||
|
||||
const result = await executeToolCall(cleared, gate, (update) => updates.push(update), NOOP_TELEMETRY_CONTEXT);
|
||||
const result = await executeToolCall(cleared, gate, (update) => updates.push(update), BACKGROUND_CONTEXT);
|
||||
lateUpdate?.({ content: [{ type: "text", text: "late" }], details: { value: "late" } });
|
||||
|
||||
expect(vi.mocked(execute)).toHaveBeenCalledOnce();
|
||||
|
|
@ -146,7 +146,7 @@ describe("tool execution primitives", () => {
|
|||
])("converts %s tool throws to error output", async (_kind, execute) => {
|
||||
const cleared = clearPrepared(prepareToolCall(call(), [tool({ execute })]));
|
||||
|
||||
const result = await executeToolCall(cleared, effectGate(), () => {}, NOOP_TELEMETRY_CONTEXT);
|
||||
const result = await executeToolCall(cleared, effectGate(), () => {}, BACKGROUND_CONTEXT);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(text(result.result)).toBe("tool failed");
|
||||
|
|
@ -158,9 +158,7 @@ describe("tool execution primitives", () => {
|
|||
const gate = effectGate();
|
||||
gate.beginAbort(Promise.resolve());
|
||||
|
||||
await expect(executeToolCall(cleared, gate, () => {}, NOOP_TELEMETRY_CONTEXT)).rejects.toBeInstanceOf(
|
||||
AbortRequested,
|
||||
);
|
||||
await expect(executeToolCall(cleared, gate, () => {}, BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(AbortRequested);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ describe("JsonlSessionRepo cwd-scoped lifecycle", () => {
|
|||
|
||||
expect(await repo.list({ cwd: "/other" }, BACKGROUND_CONTEXT)).toEqual([]);
|
||||
expect(await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT)).toEqual([metadata]);
|
||||
const firstLine = getOrThrow(await fileSystem.readTextLines(metadata.path, { maxLines: 1 }))[0];
|
||||
const firstLine = getOrThrow(
|
||||
await fileSystem.readTextLines(metadata.path, { maxLines: 1 }, BACKGROUND_CONTEXT),
|
||||
)[0];
|
||||
expect(JSON.parse(firstLine!)).toEqual({
|
||||
v: 4,
|
||||
kind: "header",
|
||||
|
|
@ -47,10 +49,13 @@ describe("JsonlSessionRepo cwd-scoped lifecycle", () => {
|
|||
it("discovers legacy v3 session files without rewriting them", async () => {
|
||||
const fileSystem = new NodeExecutionEnv({ cwd: createTempDir() });
|
||||
const repo = new JsonlSessionRepo({ fileSystem, sessionsRoot: "sessions", now: () => NOW });
|
||||
const directory = getOrThrow(await fileSystem.joinPath(["sessions", "--workspace--"]));
|
||||
getOrThrow(await fileSystem.createDir(directory));
|
||||
const directory = getOrThrow(await fileSystem.joinPath(["sessions", "--workspace--"], BACKGROUND_CONTEXT));
|
||||
getOrThrow(await fileSystem.createDir(directory, undefined, BACKGROUND_CONTEXT));
|
||||
const path = getOrThrow(
|
||||
await fileSystem.absolutePath(getOrThrow(await fileSystem.joinPath([directory, "legacy.jsonl"]))),
|
||||
await fileSystem.absolutePath(
|
||||
getOrThrow(await fileSystem.joinPath([directory, "legacy.jsonl"], BACKGROUND_CONTEXT)),
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
const content = `${JSON.stringify({
|
||||
type: "session",
|
||||
|
|
@ -66,11 +71,11 @@ describe("JsonlSessionRepo cwd-scoped lifecycle", () => {
|
|||
timestamp: new Date(NOW + 1).toISOString(),
|
||||
message: { role: "user", content: [{ type: "text", text: "hello" }] },
|
||||
})}\n`;
|
||||
getOrThrow(await fileSystem.writeFile(path, content));
|
||||
getOrThrow(await fileSystem.writeFile(path, content, BACKGROUND_CONTEXT));
|
||||
|
||||
const before = getOrThrow(await fileSystem.readTextFile(path));
|
||||
const before = getOrThrow(await fileSystem.readTextFile(path, BACKGROUND_CONTEXT));
|
||||
const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT);
|
||||
const after = getOrThrow(await fileSystem.readTextFile(path));
|
||||
const after = getOrThrow(await fileSystem.readTextFile(path, BACKGROUND_CONTEXT));
|
||||
|
||||
expect(metadata).toMatchObject({
|
||||
id: "legacy",
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ describe("JsonlStorage snapshot creation", () => {
|
|||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
const lines = getOrThrow(await fileSystem.readTextFile("fork.jsonl"))
|
||||
const lines = getOrThrow(await fileSystem.readTextFile("fork.jsonl", BACKGROUND_CONTEXT))
|
||||
.trimEnd()
|
||||
.split("\n");
|
||||
expect(lines.slice(1).every((line) => !Array.isArray(JSON.parse(line)))).toBe(true);
|
||||
|
|
@ -100,7 +100,7 @@ describe("JsonlStorage snapshot creation", () => {
|
|||
(await storage.commit([storedValues.setValue(storedValues.sessionName, "updated")], BACKGROUND_CONTEXT))
|
||||
.firstSeq,
|
||||
).toBe(9);
|
||||
expect(getOrThrow(await fileSystem.exists("fork.jsonl.tmp"))).toBe(false);
|
||||
expect(getOrThrow(await fileSystem.exists("fork.jsonl.tmp", BACKGROUND_CONTEXT))).toBe(false);
|
||||
await storage.close(BACKGROUND_CONTEXT);
|
||||
|
||||
const reopened = await JsonlStorage.open(options, BACKGROUND_CONTEXT);
|
||||
|
|
@ -112,7 +112,7 @@ describe("JsonlStorage snapshot creation", () => {
|
|||
|
||||
it("removes the temporary file when publication fails", async () => {
|
||||
const fileSystem = new NodeExecutionEnv({ cwd: createTempDir() });
|
||||
getOrThrow(await fileSystem.createDir("blocked.jsonl"));
|
||||
getOrThrow(await fileSystem.createDir("blocked.jsonl", undefined, BACKGROUND_CONTEXT));
|
||||
|
||||
await expect(
|
||||
JsonlStorage.createFromSnapshot(
|
||||
|
|
@ -122,8 +122,10 @@ describe("JsonlStorage snapshot creation", () => {
|
|||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).rejects.toThrow("Failed to publish JSONL storage");
|
||||
expect(getOrThrow(await fileSystem.exists("blocked.jsonl.tmp"))).toBe(false);
|
||||
expect(getOrThrow(await fileSystem.fileInfo("blocked.jsonl"))).toMatchObject({ kind: "directory" });
|
||||
expect(getOrThrow(await fileSystem.exists("blocked.jsonl.tmp", BACKGROUND_CONTEXT))).toBe(false);
|
||||
expect(getOrThrow(await fileSystem.fileInfo("blocked.jsonl", BACKGROUND_CONTEXT))).toMatchObject({
|
||||
kind: "directory",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -179,7 +181,7 @@ describe("JsonlStorage persistence", () => {
|
|||
);
|
||||
await storage.commit([storedValues.setValue(storedValues.sessionName, "name")], BACKGROUND_CONTEXT);
|
||||
|
||||
const lines = getOrThrow(await fileSystem.readTextFile("session.jsonl"))
|
||||
const lines = getOrThrow(await fileSystem.readTextFile("session.jsonl", BACKGROUND_CONTEXT))
|
||||
.trimEnd()
|
||||
.split("\n");
|
||||
expect(JSON.parse(lines[0]!)).toEqual(header("round-trip"));
|
||||
|
|
@ -275,7 +277,7 @@ describe("JsonlStorage torn tail", () => {
|
|||
const storage = await JsonlStorage.create(options, header("torn"), BACKGROUND_CONTEXT);
|
||||
await storage.commit([entryWrite("kept")], BACKGROUND_CONTEXT);
|
||||
await storage.close(BACKGROUND_CONTEXT);
|
||||
const prefix = getOrThrow(await fileSystem.readTextFile("session.jsonl"));
|
||||
const prefix = getOrThrow(await fileSystem.readTextFile("session.jsonl", BACKGROUND_CONTEXT));
|
||||
return { fileSystem, options, prefix };
|
||||
}
|
||||
|
||||
|
|
@ -292,13 +294,14 @@ describe("JsonlStorage torn tail", () => {
|
|||
seq: 2,
|
||||
timestamp: NOW,
|
||||
}),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
const reopened = await JsonlStorage.open(options, BACKGROUND_CONTEXT);
|
||||
expect((await reopened.getEntries(["kept", "torn"], BACKGROUND_CONTEXT)).has("torn")).toBe(false);
|
||||
expect((await reopened.getEntries(["kept"], BACKGROUND_CONTEXT)).get("kept")?.id).toBe("kept");
|
||||
expect(getOrThrow(await fileSystem.readTextFile("session.jsonl"))).toBe(prefix);
|
||||
expect(getOrThrow(await fileSystem.exists("session.jsonl.tmp"))).toBe(false);
|
||||
expect(getOrThrow(await fileSystem.readTextFile("session.jsonl", BACKGROUND_CONTEXT))).toBe(prefix);
|
||||
expect(getOrThrow(await fileSystem.exists("session.jsonl.tmp", BACKGROUND_CONTEXT))).toBe(false);
|
||||
|
||||
const next = await reopened.commit([entryWrite("after")], BACKGROUND_CONTEXT);
|
||||
expect(next.firstSeq).toBe(2);
|
||||
|
|
@ -324,24 +327,25 @@ describe("JsonlStorage torn tail", () => {
|
|||
storedValues.setValue(storedValues.sessionName, "lost"),
|
||||
storedValues.appendList(events, "lost"),
|
||||
]),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
const reopened = await JsonlStorage.open(options, BACKGROUND_CONTEXT);
|
||||
expect((await reopened.getEntries(["torn-a"], BACKGROUND_CONTEXT)).has("torn-a")).toBe(false);
|
||||
expect(await reopened.getValue(storedValues.sessionName, BACKGROUND_CONTEXT)).toBeUndefined();
|
||||
expect(await reopened.readList(events, undefined, BACKGROUND_CONTEXT)).toEqual([]);
|
||||
expect(getOrThrow(await fileSystem.readTextFile("session.jsonl"))).toBe(prefix);
|
||||
expect(getOrThrow(await fileSystem.readTextFile("session.jsonl", BACKGROUND_CONTEXT))).toBe(prefix);
|
||||
await reopened.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
it("rejects a malformed interior line without rewriting", async () => {
|
||||
const { fileSystem, options, prefix } = await seed();
|
||||
const corrupted = `${prefix}not-json\n${JSON.stringify(storedValues.setValue(storedValues.sessionName, "after"))}\n`;
|
||||
await fileSystem.writeFile("session.jsonl", corrupted);
|
||||
await fileSystem.writeFile("session.jsonl", corrupted, BACKGROUND_CONTEXT);
|
||||
|
||||
await expect(JsonlStorage.open(options, BACKGROUND_CONTEXT)).rejects.toThrow(/line 3/);
|
||||
expect(getOrThrow(await fileSystem.readTextFile("session.jsonl"))).toBe(corrupted);
|
||||
expect(getOrThrow(await fileSystem.exists("session.jsonl.tmp"))).toBe(false);
|
||||
expect(getOrThrow(await fileSystem.readTextFile("session.jsonl", BACKGROUND_CONTEXT))).toBe(corrupted);
|
||||
expect(getOrThrow(await fileSystem.exists("session.jsonl.tmp", BACKGROUND_CONTEXT))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects the unsupported pre-WP01 scalar record spelling", async () => {
|
||||
|
|
@ -355,34 +359,34 @@ describe("JsonlStorage torn tail", () => {
|
|||
key: "state",
|
||||
value: true,
|
||||
})}\n`;
|
||||
await fileSystem.writeFile("session.jsonl", corrupted);
|
||||
await fileSystem.writeFile("session.jsonl", corrupted, BACKGROUND_CONTEXT);
|
||||
|
||||
await expect(JsonlStorage.open(options, BACKGROUND_CONTEXT)).rejects.toThrow(/line 3/);
|
||||
expect(getOrThrow(await fileSystem.readTextFile("session.jsonl"))).toBe(corrupted);
|
||||
expect(getOrThrow(await fileSystem.readTextFile("session.jsonl", BACKGROUND_CONTEXT))).toBe(corrupted);
|
||||
});
|
||||
|
||||
it("rejects a complete malformed final line without rewriting", async () => {
|
||||
const { fileSystem, options, prefix } = await seed();
|
||||
const corrupted = `${prefix}not-json\n`;
|
||||
await fileSystem.writeFile("session.jsonl", corrupted);
|
||||
await fileSystem.writeFile("session.jsonl", corrupted, BACKGROUND_CONTEXT);
|
||||
|
||||
await expect(JsonlStorage.open(options, BACKGROUND_CONTEXT)).rejects.toThrow(/line 3/);
|
||||
expect(getOrThrow(await fileSystem.readTextFile("session.jsonl"))).toBe(corrupted);
|
||||
expect(getOrThrow(await fileSystem.readTextFile("session.jsonl", BACKGROUND_CONTEXT))).toBe(corrupted);
|
||||
});
|
||||
|
||||
it("rejects a complete final line with invalid transaction framing", async () => {
|
||||
const { fileSystem, options, prefix } = await seed();
|
||||
const corrupted = `${prefix}${JSON.stringify({ kind: "nope", seq: 2 })}\n`;
|
||||
await fileSystem.writeFile("session.jsonl", corrupted);
|
||||
await fileSystem.writeFile("session.jsonl", corrupted, BACKGROUND_CONTEXT);
|
||||
|
||||
await expect(JsonlStorage.open(options, BACKGROUND_CONTEXT)).rejects.toThrow(/line 3/);
|
||||
expect(getOrThrow(await fileSystem.readTextFile("session.jsonl"))).toBe(corrupted);
|
||||
expect(getOrThrow(await fileSystem.readTextFile("session.jsonl", BACKGROUND_CONTEXT))).toBe(corrupted);
|
||||
});
|
||||
|
||||
it("rejects an unterminated header", async () => {
|
||||
const fileSystem = new NodeExecutionEnv({ cwd: createTempDir() });
|
||||
const options = { fileSystem, path: "session.jsonl", now: () => NOW };
|
||||
await fileSystem.writeFile("session.jsonl", JSON.stringify(header("torn")).slice(0, -4));
|
||||
await fileSystem.writeFile("session.jsonl", JSON.stringify(header("torn")).slice(0, -4), BACKGROUND_CONTEXT);
|
||||
|
||||
await expect(JsonlStorage.open(options, BACKGROUND_CONTEXT)).rejects.toThrow(/missing header/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { homedir } from "node:os";
|
|||
import { delimiter, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT, withAbortSignal } from "../../src/harness/context.ts";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { FileError, getOrThrow } from "../../src/harness/types.ts";
|
||||
import { executeShellWithCapture } from "../../src/harness/utils/shell-output.ts";
|
||||
|
|
@ -71,16 +72,24 @@ describe("NodeExecutionEnv", () => {
|
|||
it("reads, writes, lists, and removes files and directories", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
expect(getOrThrow(await env.absolutePath("nested/child"))).toBe(join(root, "nested/child"));
|
||||
expect(getOrThrow(await env.joinPath([root, "nested", "child"]))).toBe(join(root, "nested", "child"));
|
||||
getOrThrow(await env.createDir("nested/child"));
|
||||
getOrThrow(await env.writeFile("nested/child/file.txt", "hel"));
|
||||
getOrThrow(await env.appendFile("nested/child/file.txt", "lo"));
|
||||
expect(getOrThrow(await env.readTextFile("nested/child/file.txt"))).toBe("hello");
|
||||
expect(getOrThrow(await env.readTextLines("nested/child/file.txt", { maxLines: 1 }))).toEqual(["hello"]);
|
||||
expect(Buffer.from(getOrThrow(await env.readBinaryFile("nested/child/file.txt"))).toString("utf8")).toBe("hello");
|
||||
expect(getOrThrow(await env.absolutePath("nested/child", BACKGROUND_CONTEXT))).toBe(join(root, "nested/child"));
|
||||
expect(getOrThrow(await env.joinPath([root, "nested", "child"], BACKGROUND_CONTEXT))).toBe(
|
||||
join(root, "nested", "child"),
|
||||
);
|
||||
getOrThrow(await env.createDir("nested/child", undefined, BACKGROUND_CONTEXT));
|
||||
getOrThrow(await env.writeFile("nested/child/file.txt", "hel", BACKGROUND_CONTEXT));
|
||||
getOrThrow(await env.appendFile("nested/child/file.txt", "lo", BACKGROUND_CONTEXT));
|
||||
expect(getOrThrow(await env.readTextFile("nested/child/file.txt", BACKGROUND_CONTEXT))).toBe("hello");
|
||||
expect(getOrThrow(await env.readTextLines("nested/child/file.txt", { maxLines: 1 }, BACKGROUND_CONTEXT))).toEqual(
|
||||
["hello"],
|
||||
);
|
||||
expect(
|
||||
Buffer.from(getOrThrow(await env.readBinaryFile("nested/child/file.txt", BACKGROUND_CONTEXT))).toString(
|
||||
"utf8",
|
||||
),
|
||||
).toBe("hello");
|
||||
|
||||
const entries = getOrThrow(await env.listDir("nested/child"));
|
||||
const entries = getOrThrow(await env.listDir("nested/child", BACKGROUND_CONTEXT));
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toMatchObject({
|
||||
name: "file.txt",
|
||||
|
|
@ -90,58 +99,62 @@ describe("NodeExecutionEnv", () => {
|
|||
});
|
||||
expect(typeof entries[0]!.mtimeMs).toBe("number");
|
||||
|
||||
expect(getOrThrow(await env.exists("nested/child/file.txt"))).toBe(true);
|
||||
getOrThrow(await env.remove("nested/child/file.txt"));
|
||||
expect(getOrThrow(await env.exists("nested/child/file.txt"))).toBe(false);
|
||||
expect(getOrThrow(await env.exists("nested/child/file.txt", BACKGROUND_CONTEXT))).toBe(true);
|
||||
getOrThrow(await env.remove("nested/child/file.txt", undefined, BACKGROUND_CONTEXT));
|
||||
expect(getOrThrow(await env.exists("nested/child/file.txt", BACKGROUND_CONTEXT))).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"));
|
||||
expect(getOrThrow(await env.absolutePath("~/pi-node-env-test", BACKGROUND_CONTEXT))).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);
|
||||
expect(getOrThrow(await env.absolutePath(pathToFileURL(filePath).href, BACKGROUND_CONTEXT))).toBe(filePath);
|
||||
});
|
||||
|
||||
it("returns fileInfo for files, directories, and symlinks without following symlinks", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
getOrThrow(await env.createDir("dir", { recursive: true }));
|
||||
getOrThrow(await env.writeFile("dir/file.txt", "hello"));
|
||||
getOrThrow(await env.createDir("dir", { recursive: true }, BACKGROUND_CONTEXT));
|
||||
getOrThrow(await env.writeFile("dir/file.txt", "hello", BACKGROUND_CONTEXT));
|
||||
await symlink(join(root, "dir/file.txt"), join(root, "file-link"));
|
||||
await symlink(join(root, "dir"), join(root, "dir-link"));
|
||||
|
||||
expect(getOrThrow(await env.fileInfo("dir"))).toMatchObject({
|
||||
expect(getOrThrow(await env.fileInfo("dir", BACKGROUND_CONTEXT))).toMatchObject({
|
||||
name: "dir",
|
||||
path: join(root, "dir"),
|
||||
kind: "directory",
|
||||
});
|
||||
expect(getOrThrow(await env.fileInfo("dir/file.txt"))).toMatchObject({
|
||||
expect(getOrThrow(await env.fileInfo("dir/file.txt", BACKGROUND_CONTEXT))).toMatchObject({
|
||||
name: "file.txt",
|
||||
path: join(root, "dir/file.txt"),
|
||||
kind: "file",
|
||||
size: 5,
|
||||
});
|
||||
expect(getOrThrow(await env.fileInfo("file-link"))).toMatchObject({
|
||||
expect(getOrThrow(await env.fileInfo("file-link", BACKGROUND_CONTEXT))).toMatchObject({
|
||||
name: "file-link",
|
||||
path: join(root, "file-link"),
|
||||
kind: "symlink",
|
||||
});
|
||||
expect(getOrThrow(await env.fileInfo("dir-link"))).toMatchObject({
|
||||
expect(getOrThrow(await env.fileInfo("dir-link", BACKGROUND_CONTEXT))).toMatchObject({
|
||||
name: "dir-link",
|
||||
path: join(root, "dir-link"),
|
||||
kind: "symlink",
|
||||
});
|
||||
expect(getOrThrow(await env.canonicalPath("file-link"))).toBe(await realpath(join(root, "dir/file.txt")));
|
||||
expect(getOrThrow(await env.canonicalPath("file-link", BACKGROUND_CONTEXT))).toBe(
|
||||
await realpath(join(root, "dir/file.txt")),
|
||||
);
|
||||
});
|
||||
|
||||
it("lists symlinks as symlinks", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
getOrThrow(await env.writeFile("target.txt", "hello"));
|
||||
getOrThrow(await env.writeFile("target.txt", "hello", BACKGROUND_CONTEXT));
|
||||
await symlink(join(root, "target.txt"), join(root, "link.txt"));
|
||||
|
||||
const entries = getOrThrow(await env.listDir("."));
|
||||
const entries = getOrThrow(await env.listDir(".", BACKGROUND_CONTEXT));
|
||||
expect(
|
||||
entries.map((entry) => ({ name: entry.name, kind: entry.kind })).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
).toEqual([
|
||||
|
|
@ -153,14 +166,14 @@ describe("NodeExecutionEnv", () => {
|
|||
it("stops reading text lines at the requested limit", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
getOrThrow(await env.writeFile("file.txt", "one\ntwo\nthree"));
|
||||
expect(getOrThrow(await env.readTextLines("file.txt", { maxLines: 1 }))).toEqual(["one"]);
|
||||
getOrThrow(await env.writeFile("file.txt", "one\ntwo\nthree", BACKGROUND_CONTEXT));
|
||||
expect(getOrThrow(await env.readTextLines("file.txt", { maxLines: 1 }, BACKGROUND_CONTEXT))).toEqual(["one"]);
|
||||
});
|
||||
|
||||
it("returns FileError for missing paths and keeps exists false for missing paths", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const info = await env.fileInfo("missing.txt");
|
||||
const info = await env.fileInfo("missing.txt", BACKGROUND_CONTEXT);
|
||||
expect(info.ok).toBe(false);
|
||||
if (!info.ok) {
|
||||
expect(info.error).toBeInstanceOf(FileError);
|
||||
|
|
@ -170,14 +183,14 @@ describe("NodeExecutionEnv", () => {
|
|||
path: join(root, "missing.txt"),
|
||||
});
|
||||
}
|
||||
expect(getOrThrow(await env.exists("missing.txt"))).toBe(false);
|
||||
expect(getOrThrow(await env.exists("missing.txt", BACKGROUND_CONTEXT))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns FileError for listing non-directories", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
getOrThrow(await env.writeFile("file.txt", "hello"));
|
||||
const result = await env.listDir("file.txt");
|
||||
getOrThrow(await env.writeFile("file.txt", "hello", BACKGROUND_CONTEXT));
|
||||
const result = await env.listDir("file.txt", BACKGROUND_CONTEXT);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBeInstanceOf(FileError);
|
||||
|
|
@ -188,29 +201,29 @@ describe("NodeExecutionEnv", () => {
|
|||
it("appends to new files and creates parent directories", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
getOrThrow(await env.appendFile("new/nested/file.txt", "a"));
|
||||
getOrThrow(await env.appendFile("new/nested/file.txt", "b"));
|
||||
expect(getOrThrow(await env.readTextFile("new/nested/file.txt"))).toBe("ab");
|
||||
getOrThrow(await env.appendFile("new/nested/file.txt", "a", BACKGROUND_CONTEXT));
|
||||
getOrThrow(await env.appendFile("new/nested/file.txt", "b", BACKGROUND_CONTEXT));
|
||||
expect(getOrThrow(await env.readTextFile("new/nested/file.txt", BACKGROUND_CONTEXT))).toBe("ab");
|
||||
});
|
||||
|
||||
it("atomically renames a file and replaces the destination", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
getOrThrow(await env.writeFile("source.txt", "new"));
|
||||
getOrThrow(await env.writeFile("destination.txt", "old"));
|
||||
getOrThrow(await env.writeFile("source.txt", "new", BACKGROUND_CONTEXT));
|
||||
getOrThrow(await env.writeFile("destination.txt", "old", BACKGROUND_CONTEXT));
|
||||
|
||||
getOrThrow(await env.renameFile("source.txt", "destination.txt"));
|
||||
getOrThrow(await env.renameFile("source.txt", "destination.txt", BACKGROUND_CONTEXT));
|
||||
|
||||
expect(getOrThrow(await env.exists("source.txt"))).toBe(false);
|
||||
expect(getOrThrow(await env.readTextFile("destination.txt"))).toBe("new");
|
||||
expect(getOrThrow(await env.exists("source.txt", BACKGROUND_CONTEXT))).toBe(false);
|
||||
expect(getOrThrow(await env.readTextFile("destination.txt", BACKGROUND_CONTEXT))).toBe("new");
|
||||
});
|
||||
|
||||
it("reports the source path when rename fails because the source is missing", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
getOrThrow(await env.writeFile("destination.txt", "unchanged"));
|
||||
getOrThrow(await env.writeFile("destination.txt", "unchanged", BACKGROUND_CONTEXT));
|
||||
|
||||
const result = await env.renameFile("missing-source.txt", "destination.txt");
|
||||
const result = await env.renameFile("missing-source.txt", "destination.txt", BACKGROUND_CONTEXT);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
|
|
@ -219,15 +232,15 @@ describe("NodeExecutionEnv", () => {
|
|||
path: join(root, "missing-source.txt"),
|
||||
},
|
||||
});
|
||||
expect(getOrThrow(await env.readTextFile("destination.txt"))).toBe("unchanged");
|
||||
expect(getOrThrow(await env.readTextFile("destination.txt", BACKGROUND_CONTEXT))).toBe("unchanged");
|
||||
});
|
||||
|
||||
it("creates temporary directories and files", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const tempDir = getOrThrow(await env.createTempDir("node-env-test-"));
|
||||
const tempDir = getOrThrow(await env.createTempDir("node-env-test-", BACKGROUND_CONTEXT));
|
||||
await expect(access(tempDir)).resolves.toBeUndefined();
|
||||
const tempFile = getOrThrow(await env.createTempFile({ prefix: "prefix-", suffix: ".txt" }));
|
||||
const tempFile = getOrThrow(await env.createTempFile({ prefix: "prefix-", suffix: ".txt" }, BACKGROUND_CONTEXT));
|
||||
await expect(access(tempFile)).resolves.toBeUndefined();
|
||||
expect(tempFile.endsWith(".txt")).toBe(true);
|
||||
});
|
||||
|
|
@ -235,36 +248,36 @@ describe("NodeExecutionEnv", () => {
|
|||
it("honors createDir recursive false and remove recursive/force options", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const createResult = await env.createDir("missing/child", { recursive: false });
|
||||
const createResult = await env.createDir("missing/child", { recursive: false }, BACKGROUND_CONTEXT);
|
||||
expect(createResult.ok).toBe(false);
|
||||
if (!createResult.ok) expect(createResult.error).toMatchObject({ code: "not_found" });
|
||||
|
||||
getOrThrow(await env.writeFile("dir/child/file.txt", "hello"));
|
||||
const removeDirectory = await env.remove("dir", { recursive: false });
|
||||
getOrThrow(await env.writeFile("dir/child/file.txt", "hello", BACKGROUND_CONTEXT));
|
||||
const removeDirectory = await env.remove("dir", { recursive: false }, BACKGROUND_CONTEXT);
|
||||
expect(removeDirectory.ok).toBe(false);
|
||||
getOrThrow(await env.remove("dir", { recursive: true }));
|
||||
expect(getOrThrow(await env.exists("dir"))).toBe(false);
|
||||
getOrThrow(await env.remove("dir", { recursive: true }, BACKGROUND_CONTEXT));
|
||||
expect(getOrThrow(await env.exists("dir", BACKGROUND_CONTEXT))).toBe(false);
|
||||
|
||||
const removeMissing = await env.remove("missing", { force: false });
|
||||
const removeMissing = await env.remove("missing", { force: false }, BACKGROUND_CONTEXT);
|
||||
expect(removeMissing.ok).toBe(false);
|
||||
getOrThrow(await env.remove("missing", { force: true }));
|
||||
getOrThrow(await env.remove("missing", { force: true }, BACKGROUND_CONTEXT));
|
||||
});
|
||||
|
||||
it("returns aborted results for pre-aborted cancellable file operations", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
getOrThrow(await env.writeFile("file.txt", "hello"));
|
||||
getOrThrow(await env.writeFile("file.txt", "hello", BACKGROUND_CONTEXT));
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const signal = controller.signal;
|
||||
const context = withAbortSignal(controller.signal, BACKGROUND_CONTEXT);
|
||||
|
||||
const results = await Promise.all([
|
||||
env.readTextFile("file.txt", signal),
|
||||
env.readTextLines("file.txt", { abortSignal: signal }),
|
||||
env.readBinaryFile("file.txt", signal),
|
||||
env.writeFile("other.txt", "hello", signal),
|
||||
env.renameFile("file.txt", "renamed.txt", signal),
|
||||
env.listDir(".", signal),
|
||||
env.readTextFile("file.txt", context),
|
||||
env.readTextLines("file.txt", undefined, context),
|
||||
env.readBinaryFile("file.txt", context),
|
||||
env.writeFile("other.txt", "hello", context),
|
||||
env.renameFile("file.txt", "renamed.txt", context),
|
||||
env.listDir(".", context),
|
||||
]);
|
||||
for (const result of results) {
|
||||
expect(result.ok).toBe(false);
|
||||
|
|
@ -275,16 +288,20 @@ describe("NodeExecutionEnv", () => {
|
|||
it("cleanup is best-effort", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await expect(env.cleanup()).resolves.toBeUndefined();
|
||||
await expect(env.cleanup(BACKGROUND_CONTEXT)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("executes commands in cwd with env overrides", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const result = getOrThrow(
|
||||
await env.exec('printf \'%s:%s\' "$PWD" "$NODE_ENV_TEST"', {
|
||||
env: { NODE_ENV_TEST: "ok" },
|
||||
}),
|
||||
await env.exec(
|
||||
'printf \'%s:%s\' "$PWD" "$NODE_ENV_TEST"',
|
||||
{
|
||||
env: { NODE_ENV_TEST: "ok" },
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 });
|
||||
});
|
||||
|
|
@ -313,6 +330,7 @@ describe("NodeExecutionEnv", () => {
|
|||
await env.exec(
|
||||
`printf '%s:%s|%s|%s' "\${PI_SESSION_FILE+x}" "\${PI_SESSION_FILE-}" "$PI_CODING_AGENT" "$PI_NODE_ENV_PRESERVED_TEST"`,
|
||||
{ env: overrides },
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -330,10 +348,14 @@ describe("NodeExecutionEnv", () => {
|
|||
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" },
|
||||
}),
|
||||
await env.exec(
|
||||
`printf '%s:%s:%s' "\${${inheritedKey}-}" "\${${configuredKey}-}" "\${${explicitKey}-}"`,
|
||||
{
|
||||
inheritEnv: false,
|
||||
env: { [explicitKey]: "explicit" },
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.stdout).toBe("::explicit");
|
||||
|
|
@ -348,7 +370,13 @@ describe("NodeExecutionEnv", () => {
|
|||
const root = createTempDir();
|
||||
const shellPath = "C:\\Windows\\System32\\bash.exe";
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
getOrThrow(await env.writeFile(shellPath, '#!/bin/sh\nprintf \'args:%s\\n\' "$*" >&2\nexec /bin/bash "$@"\n'));
|
||||
getOrThrow(
|
||||
await env.writeFile(
|
||||
shellPath,
|
||||
'#!/bin/sh\nprintf \'args:%s\\n\' "$*" >&2\nexec /bin/bash "$@"\n',
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
await chmod(join(root, shellPath), 0o755);
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
|
|
@ -364,7 +392,9 @@ describe("NodeExecutionEnv", () => {
|
|||
|
||||
const wslEnv = new NodeExecutionEnv({ cwd: root, shellPath });
|
||||
const nameExpansion = "$" + "{name}";
|
||||
const result = getOrThrow(await wslEnv.exec(`name='World'; echo "Hello, ${nameExpansion}!"`));
|
||||
const result = getOrThrow(
|
||||
await wslEnv.exec(`name='World'; echo "Hello, ${nameExpansion}!"`, undefined, BACKGROUND_CONTEXT),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ stdout: "Hello, World!\n", stderr: "args:-s\n", exitCode: 0 });
|
||||
} finally {
|
||||
|
|
@ -386,7 +416,11 @@ describe("NodeExecutionEnv", () => {
|
|||
try {
|
||||
const result = getOrThrow(
|
||||
await withTimeout(
|
||||
env.exec(createInheritedStdioCommand(pidFile), { abortSignal: controller.signal }),
|
||||
env.exec(
|
||||
createInheritedStdioCommand(pidFile),
|
||||
undefined,
|
||||
withAbortSignal(controller.signal, BACKGROUND_CONTEXT),
|
||||
),
|
||||
3000,
|
||||
() => controller.abort(),
|
||||
),
|
||||
|
|
@ -402,12 +436,12 @@ describe("NodeExecutionEnv", () => {
|
|||
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++) {
|
||||
const execution = env.exec("touch started; sleep 60", undefined, BACKGROUND_CONTEXT);
|
||||
for (let attempt = 0; attempt < 100 && !getOrThrow(await env.exists("started", BACKGROUND_CONTEXT)); attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
expect(getOrThrow(await env.exists("started"))).toBe(true);
|
||||
await env.cleanup();
|
||||
expect(getOrThrow(await env.exists("started", BACKGROUND_CONTEXT))).toBe(true);
|
||||
await env.cleanup(BACKGROUND_CONTEXT);
|
||||
await expect(withTimeout(execution, 3000)).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
|
|
@ -417,14 +451,18 @@ describe("NodeExecutionEnv", () => {
|
|||
let stdout = "";
|
||||
let stderr = "";
|
||||
const result = getOrThrow(
|
||||
await env.exec("printf out; printf err >&2", {
|
||||
onStdout: (chunk) => {
|
||||
stdout += chunk;
|
||||
await env.exec(
|
||||
"printf out; printf err >&2",
|
||||
{
|
||||
onStdout: (chunk) => {
|
||||
stdout += chunk;
|
||||
},
|
||||
onStderr: (chunk) => {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
onStderr: (chunk) => {
|
||||
stderr += chunk;
|
||||
},
|
||||
}),
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
expect(result).toEqual({ stdout: "out", stderr: "err", exitCode: 0 });
|
||||
expect(stdout).toBe("out");
|
||||
|
|
@ -434,7 +472,7 @@ describe("NodeExecutionEnv", () => {
|
|||
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");
|
||||
const result = await env.exec("printf ok", undefined, BACKGROUND_CONTEXT);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
|
|
@ -445,14 +483,14 @@ describe("NodeExecutionEnv", () => {
|
|||
it("returns non-zero command exit codes as successful execution results", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const result = getOrThrow(await env.exec("exit 7"));
|
||||
const result = getOrThrow(await env.exec("exit 7", undefined, BACKGROUND_CONTEXT));
|
||||
expect(result).toEqual({ stdout: "", stderr: "", exitCode: 7 });
|
||||
});
|
||||
|
||||
it("returns timeout errors for commands exceeding the timeout", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const result = await env.exec("sleep 5", { timeout: 0.01 });
|
||||
const result = await env.exec("sleep 5", { timeout: 0.01 }, BACKGROUND_CONTEXT);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toMatchObject({ code: "timeout" });
|
||||
});
|
||||
|
|
@ -460,11 +498,15 @@ describe("NodeExecutionEnv", () => {
|
|||
it("returns callback errors from exec stream handlers", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const result = await env.exec("printf out", {
|
||||
onStdout: () => {
|
||||
throw new Error("callback failed");
|
||||
const result = await env.exec(
|
||||
"printf out",
|
||||
{
|
||||
onStdout: () => {
|
||||
throw new Error("callback failed");
|
||||
},
|
||||
},
|
||||
});
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toMatchObject({ code: "callback_error", message: "callback failed" });
|
||||
});
|
||||
|
|
@ -472,15 +514,15 @@ describe("NodeExecutionEnv", () => {
|
|||
it("returns shell unavailable and spawn errors", async () => {
|
||||
const root = createTempDir();
|
||||
const missingShellEnv = new NodeExecutionEnv({ cwd: root, shellPath: join(root, "missing-shell") });
|
||||
const missingShell = await missingShellEnv.exec("printf ok");
|
||||
const missingShell = await missingShellEnv.exec("printf ok", undefined, BACKGROUND_CONTEXT);
|
||||
expect(missingShell.ok).toBe(false);
|
||||
if (!missingShell.ok) expect(missingShell.error).toMatchObject({ code: "shell_unavailable" });
|
||||
|
||||
const shellPath = join(root, "not-executable-shell");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
getOrThrow(await env.writeFile(shellPath, "not executable"));
|
||||
getOrThrow(await env.writeFile(shellPath, "not executable", BACKGROUND_CONTEXT));
|
||||
const spawnErrorEnv = new NodeExecutionEnv({ cwd: root, shellPath });
|
||||
const spawnError = await spawnErrorEnv.exec("printf ok");
|
||||
const spawnError = await spawnErrorEnv.exec("printf ok", undefined, BACKGROUND_CONTEXT);
|
||||
expect(spawnError.ok).toBe(false);
|
||||
if (!spawnError.ok) expect(spawnError.error).toMatchObject({ code: "spawn_error" });
|
||||
});
|
||||
|
|
@ -489,7 +531,7 @@ describe("NodeExecutionEnv", () => {
|
|||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const controller = new AbortController();
|
||||
const promise = env.exec("sleep 5", { abortSignal: controller.signal });
|
||||
const promise = env.exec("sleep 5", undefined, withAbortSignal(controller.signal, BACKGROUND_CONTEXT));
|
||||
controller.abort();
|
||||
const result = await promise;
|
||||
expect(result.ok).toBe(false);
|
||||
|
|
@ -499,10 +541,12 @@ describe("NodeExecutionEnv", () => {
|
|||
it("captures large shell output to a full output file through the execution env", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const result = getOrThrow(await executeShellWithCapture(env, "yes line | head -n 15000"));
|
||||
const result = getOrThrow(
|
||||
await executeShellWithCapture(env, "yes line | head -n 15000", undefined, BACKGROUND_CONTEXT),
|
||||
);
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.fullOutputPath).toBeDefined();
|
||||
const fullOutput = getOrThrow(await env.readTextFile(result.fullOutputPath!));
|
||||
const fullOutput = getOrThrow(await env.readTextFile(result.fullOutputPath!, BACKGROUND_CONTEXT));
|
||||
expect(fullOutput.split("\n").length).toBeGreaterThan(10000);
|
||||
expect(result.output.length).toBeLessThan(fullOutput.length);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../../src/harness/context.ts";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import {
|
||||
formatPromptTemplateInvocation,
|
||||
|
|
@ -13,13 +14,13 @@ describe("loadPromptTemplates", () => {
|
|||
it("loads markdown templates non-recursively from one or more dirs", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.createDir("a/nested", { recursive: true });
|
||||
await env.createDir("b", { recursive: true });
|
||||
await env.writeFile("a/one.md", "---\ndescription: One template\n---\nHello $1");
|
||||
await env.writeFile("a/nested/ignored.md", "Ignored");
|
||||
await env.writeFile("b/two.md", "First line description\nBody");
|
||||
await env.createDir("a/nested", { recursive: true }, BACKGROUND_CONTEXT);
|
||||
await env.createDir("b", { recursive: true }, BACKGROUND_CONTEXT);
|
||||
await env.writeFile("a/one.md", "---\ndescription: One template\n---\nHello $1", BACKGROUND_CONTEXT);
|
||||
await env.writeFile("a/nested/ignored.md", "Ignored", BACKGROUND_CONTEXT);
|
||||
await env.writeFile("b/two.md", "First line description\nBody", BACKGROUND_CONTEXT);
|
||||
|
||||
const { promptTemplates, diagnostics } = await loadPromptTemplates(env, ["a", "b"]);
|
||||
const { promptTemplates, diagnostics } = await loadPromptTemplates(env, ["a", "b"], BACKGROUND_CONTEXT);
|
||||
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(promptTemplates).toEqual([
|
||||
|
|
@ -31,12 +32,15 @@ describe("loadPromptTemplates", () => {
|
|||
it("preserves source info for sourced prompt templates", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.createDir("prompts", { recursive: true });
|
||||
await env.writeFile("prompts/example.md", "---\ndescription: Example\n---\nExample body");
|
||||
await env.createDir("prompts", { recursive: true }, BACKGROUND_CONTEXT);
|
||||
await env.writeFile("prompts/example.md", "---\ndescription: Example\n---\nExample body", BACKGROUND_CONTEXT);
|
||||
|
||||
const { promptTemplates, diagnostics } = await loadSourcedPromptTemplates(env, [
|
||||
{ path: "prompts", source: { type: "project" as const } },
|
||||
]);
|
||||
const { promptTemplates, diagnostics } = await loadSourcedPromptTemplates(
|
||||
env,
|
||||
[{ path: "prompts", source: { type: "project" as const } }],
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(promptTemplates).toEqual([
|
||||
|
|
@ -50,11 +54,14 @@ describe("loadPromptTemplates", () => {
|
|||
it("attaches source info to diagnostics", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.writeFile("broken.md", "---\ndescription: [unterminated\n---\nBody");
|
||||
await env.writeFile("broken.md", "---\ndescription: [unterminated\n---\nBody", BACKGROUND_CONTEXT);
|
||||
|
||||
const { promptTemplates, diagnostics } = await loadSourcedPromptTemplates(env, [
|
||||
{ path: "broken.md", source: { type: "user" as const } },
|
||||
]);
|
||||
const { promptTemplates, diagnostics } = await loadSourcedPromptTemplates(
|
||||
env,
|
||||
[{ path: "broken.md", source: { type: "user" as const } }],
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(promptTemplates).toEqual([]);
|
||||
expect(diagnostics).toHaveLength(1);
|
||||
|
|
@ -68,10 +75,10 @@ describe("loadPromptTemplates", () => {
|
|||
it("loads explicit markdown files and symlinked files", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.writeFile("target.md", "---\ndescription: Target\n---\nTarget body");
|
||||
await env.writeFile("target.md", "---\ndescription: Target\n---\nTarget body", BACKGROUND_CONTEXT);
|
||||
await symlink(join(root, "target.md"), join(root, "link.md"));
|
||||
|
||||
const { promptTemplates } = await loadPromptTemplates(env, ["target.md", "link.md"]);
|
||||
const { promptTemplates } = await loadPromptTemplates(env, ["target.md", "link.md"], BACKGROUND_CONTEXT);
|
||||
|
||||
expect(promptTemplates).toEqual([
|
||||
{ name: "target", description: "Target", content: "Target body" },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../../src/harness/context.ts";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { loadSkills, loadSourcedSkills } from "../../src/harness/skills.ts";
|
||||
import { createTempDir } from "./session-test-utils.ts";
|
||||
|
|
@ -9,7 +10,7 @@ describe("loadSkills", () => {
|
|||
it("loads SKILL.md files through the execution environment", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.createDir(".agents/skills/example", { recursive: true });
|
||||
await env.createDir(".agents/skills/example", { recursive: true }, BACKGROUND_CONTEXT);
|
||||
await env.writeFile(
|
||||
".agents/skills/example/SKILL.md",
|
||||
`---
|
||||
|
|
@ -19,9 +20,10 @@ disable-model-invocation: true
|
|||
---
|
||||
Use this skill.
|
||||
`,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
const { skills, diagnostics } = await loadSkills(env, ".agents/skills");
|
||||
const { skills, diagnostics } = await loadSkills(env, ".agents/skills", BACKGROUND_CONTEXT);
|
||||
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(skills).toEqual([
|
||||
|
|
@ -38,14 +40,15 @@ Use this skill.
|
|||
it("loads skills through symlinked directories", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.createDir("actual/example", { recursive: true });
|
||||
await env.createDir("actual/example", { recursive: true }, BACKGROUND_CONTEXT);
|
||||
await env.writeFile(
|
||||
"actual/example/SKILL.md",
|
||||
"---\nname: example\ndescription: Example skill\n---\nUse this skill.",
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await symlink(join(root, "actual"), join(root, "skills-link"));
|
||||
|
||||
const { skills } = await loadSkills(env, "skills-link");
|
||||
const { skills } = await loadSkills(env, "skills-link", BACKGROUND_CONTEXT);
|
||||
|
||||
expect(skills.map((skill) => skill.name)).toEqual(["example"]);
|
||||
expect(skills[0]?.filePath).toBe(join(root, "skills-link/example/SKILL.md"));
|
||||
|
|
@ -54,15 +57,19 @@ Use this skill.
|
|||
it("preserves source info for sourced skills", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.createDir("user/example", { recursive: true });
|
||||
await env.createDir("user/example", { recursive: true }, BACKGROUND_CONTEXT);
|
||||
await env.writeFile(
|
||||
"user/example/SKILL.md",
|
||||
"---\nname: example\ndescription: Example skill\n---\nUse this skill.",
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
const { skills, diagnostics } = await loadSourcedSkills(env, [
|
||||
{ path: "user", source: { type: "user" as const } },
|
||||
]);
|
||||
const { skills, diagnostics } = await loadSourcedSkills(
|
||||
env,
|
||||
[{ path: "user", source: { type: "user" as const } }],
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(skills).toEqual([
|
||||
|
|
@ -82,12 +89,15 @@ Use this skill.
|
|||
it("attaches source info to diagnostics", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.createDir("user/broken", { recursive: true });
|
||||
await env.writeFile("user/broken/SKILL.md", "---\nname: broken\n---\nMissing description.");
|
||||
await env.createDir("user/broken", { recursive: true }, BACKGROUND_CONTEXT);
|
||||
await env.writeFile("user/broken/SKILL.md", "---\nname: broken\n---\nMissing description.", BACKGROUND_CONTEXT);
|
||||
|
||||
const { skills, diagnostics } = await loadSourcedSkills(env, [
|
||||
{ path: "user", source: { type: "user" as const } },
|
||||
]);
|
||||
const { skills, diagnostics } = await loadSourcedSkills(
|
||||
env,
|
||||
[{ path: "user", source: { type: "user" as const } }],
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(skills).toEqual([]);
|
||||
expect(diagnostics).toEqual([
|
||||
|
|
@ -104,11 +114,15 @@ Use this skill.
|
|||
it("loads direct markdown children only from the root directory", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.createDir("skills/nested", { recursive: true });
|
||||
await env.writeFile("skills/root.md", "---\ndescription: Root skill\n---\nRoot content");
|
||||
await env.writeFile("skills/nested/ignored.md", "---\ndescription: Ignored\n---\nIgnored content");
|
||||
await env.createDir("skills/nested", { recursive: true }, BACKGROUND_CONTEXT);
|
||||
await env.writeFile("skills/root.md", "---\ndescription: Root skill\n---\nRoot content", BACKGROUND_CONTEXT);
|
||||
await env.writeFile(
|
||||
"skills/nested/ignored.md",
|
||||
"---\ndescription: Ignored\n---\nIgnored content",
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
const { skills } = await loadSkills(env, "skills");
|
||||
const { skills } = await loadSkills(env, "skills", BACKGROUND_CONTEXT);
|
||||
|
||||
expect(skills.map((skill) => skill.name)).toEqual(["skills"]);
|
||||
expect(skills[0]?.content).toBe("Root content");
|
||||
|
|
@ -117,17 +131,18 @@ Use this skill.
|
|||
it("ignores root markdown docs that do not declare skills", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.createDir("skills/nested-skill", { recursive: true });
|
||||
await env.writeFile("skills/README.md", "# Shared skills\n\nDocumentation.");
|
||||
await env.writeFile("skills/AGENTS.md", "# Agent notes\n\nDocumentation.");
|
||||
await env.writeFile("skills/CLAUDE.md", "---\ndescription: [invalid\n---\n\nDocumentation.");
|
||||
await env.writeFile("skills/root.md", "---\ndescription: Root skill\n---\nRoot content");
|
||||
await env.createDir("skills/nested-skill", { recursive: true }, BACKGROUND_CONTEXT);
|
||||
await env.writeFile("skills/README.md", "# Shared skills\n\nDocumentation.", BACKGROUND_CONTEXT);
|
||||
await env.writeFile("skills/AGENTS.md", "# Agent notes\n\nDocumentation.", BACKGROUND_CONTEXT);
|
||||
await env.writeFile("skills/CLAUDE.md", "---\ndescription: [invalid\n---\n\nDocumentation.", BACKGROUND_CONTEXT);
|
||||
await env.writeFile("skills/root.md", "---\ndescription: Root skill\n---\nRoot content", BACKGROUND_CONTEXT);
|
||||
await env.writeFile(
|
||||
"skills/nested-skill/SKILL.md",
|
||||
"---\nname: nested-skill\ndescription: Nested skill\n---\nNested content",
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
const { skills, diagnostics } = await loadSkills(env, "skills");
|
||||
const { skills, diagnostics } = await loadSkills(env, "skills", BACKGROUND_CONTEXT);
|
||||
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(skills.map((skill) => skill.name).sort()).toEqual(["nested-skill", "skills"]);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { resolve } from "node:path";
|
|||
import { createTypedSpanStarter, NOOP_TELEMETRY_CONTEXT, type TelemetryContext } from "@earendil-works/pi-telemetry";
|
||||
import { describe, expect, expectTypeOf, it } from "vitest";
|
||||
import { renderAgentTelemetrySchemaMarkdown } from "../../scripts/generate-telemetry-docs.ts";
|
||||
import { BACKGROUND_CONTEXT, withTelemetryContext } from "../../src/harness/context.ts";
|
||||
import {
|
||||
AGENT_TELEMETRY_SCHEMAS,
|
||||
AI_TELEMETRY_SCHEMA,
|
||||
|
|
@ -82,8 +83,8 @@ describe("agent telemetry schemas", () => {
|
|||
>();
|
||||
|
||||
const telemetryContext: TelemetryContext = NOOP_TELEMETRY_CONTEXT;
|
||||
const context = withTelemetryContext(telemetryContext, BACKGROUND_CONTEXT);
|
||||
await startAiSpan(
|
||||
telemetryContext,
|
||||
"pi.ai.request",
|
||||
{
|
||||
"pi.ai.operation": "stream",
|
||||
|
|
@ -97,6 +98,7 @@ describe("agent telemetry schemas", () => {
|
|||
// @ts-expect-error pi.ai.request declares no span events
|
||||
span.addEvent("chunk");
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
const compileTimeFailures = () => {
|
||||
|
|
@ -109,9 +111,9 @@ describe("agent telemetry schemas", () => {
|
|||
"pi.ai.unknown": true,
|
||||
} as const;
|
||||
// @ts-expect-error variables with unknown attributes are rejected
|
||||
void startAiSpan(telemetryContext, "pi.ai.request", extraAttributes, () => {});
|
||||
void startAiSpan("pi.ai.request", extraAttributes, () => {}, context);
|
||||
// @ts-expect-error missing required start attributes
|
||||
void startAiSpan(telemetryContext, "pi.ai.request", { "pi.ai.operation": "stream" }, () => {});
|
||||
void startAiSpan("pi.ai.request", { "pi.ai.operation": "stream" }, () => {}, context);
|
||||
};
|
||||
expectTypeOf(compileTimeFailures).toBeFunction();
|
||||
});
|
||||
|
|
@ -138,8 +140,8 @@ describe("agent telemetry schemas", () => {
|
|||
expectTypeOf(writeEnd["pi.session.last_seq"]).toEqualTypeOf<number>();
|
||||
|
||||
const telemetryContext: TelemetryContext = NOOP_TELEMETRY_CONTEXT;
|
||||
const context = withTelemetryContext(telemetryContext, BACKGROUND_CONTEXT);
|
||||
await startHarnessSpan(
|
||||
telemetryContext,
|
||||
"pi.harness.run",
|
||||
{
|
||||
"pi.session.id": "session",
|
||||
|
|
@ -154,6 +156,7 @@ describe("agent telemetry schemas", () => {
|
|||
// @ts-expect-error the harness schema declares no span events
|
||||
span.addEvent("result");
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
const compileTimeFailures = () => {
|
||||
|
|
@ -166,9 +169,8 @@ describe("agent telemetry schemas", () => {
|
|||
"pi.unknown": true,
|
||||
} as const;
|
||||
// @ts-expect-error variables with unknown attributes are rejected
|
||||
void startHarnessSpan(telemetryContext, "pi.harness.run", extraRunAttributes, () => {});
|
||||
void startHarnessSpan("pi.harness.run", extraRunAttributes, () => {}, context);
|
||||
void startHarnessSpan(
|
||||
telemetryContext,
|
||||
"pi.harness.checkpoint",
|
||||
{
|
||||
"pi.lane.name": "main",
|
||||
|
|
@ -179,9 +181,9 @@ describe("agent telemetry schemas", () => {
|
|||
// @ts-expect-error empty end schemas reject every attribute
|
||||
span.setAttributes({ "pi.unknown": true });
|
||||
},
|
||||
context,
|
||||
);
|
||||
void startHarnessSpan(
|
||||
telemetryContext,
|
||||
"pi.harness.run",
|
||||
{
|
||||
"pi.session.id": "session",
|
||||
|
|
@ -192,9 +194,10 @@ describe("agent telemetry schemas", () => {
|
|||
"pi.operation.recovery": false,
|
||||
},
|
||||
() => {},
|
||||
context,
|
||||
);
|
||||
// @ts-expect-error missing required run start attributes
|
||||
void startHarnessSpan(telemetryContext, "pi.harness.run", {}, () => {});
|
||||
void startHarnessSpan("pi.harness.run", {}, () => {}, context);
|
||||
};
|
||||
expectTypeOf(compileTimeFailures).toBeFunction();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { symlink } from "node:fs/promises";
|
||||
import { applyPatch } from "diff";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT, type Context, withAbortSignal } from "../../src/harness/context.ts";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { type BashToolDetails, createBashTool } from "../../src/harness/tools/bash.ts";
|
||||
import { createEditTool } from "../../src/harness/tools/edit.ts";
|
||||
|
|
@ -40,9 +41,9 @@ function delay(ms: number): Promise<void> {
|
|||
}
|
||||
|
||||
class SlowReadExecutionEnv extends NodeExecutionEnv {
|
||||
override async readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>> {
|
||||
override async readTextFile(path: string, context: Context): Promise<Result<string, FileError>> {
|
||||
await delay(20);
|
||||
return super.readTextFile(path, abortSignal);
|
||||
return super.readTextFile(path, context);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,7 +55,7 @@ class BlockingWriteExecutionEnv extends NodeExecutionEnv {
|
|||
override async writeFile(
|
||||
path: string,
|
||||
content: string | Uint8Array,
|
||||
abortSignal?: AbortSignal,
|
||||
context: Context,
|
||||
): Promise<Result<void, FileError>> {
|
||||
if (content === "first\n") {
|
||||
this.firstWriteStarted.resolve();
|
||||
|
|
@ -62,7 +63,7 @@ class BlockingWriteExecutionEnv extends NodeExecutionEnv {
|
|||
} else if (content === "second\n") {
|
||||
this.secondWriteStarted = true;
|
||||
}
|
||||
return super.writeFile(path, content, abortSignal);
|
||||
return super.writeFile(path, content, context);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -75,29 +76,30 @@ class BlockingEditExecutionEnv extends NodeExecutionEnv {
|
|||
override async writeFile(
|
||||
path: string,
|
||||
content: string | Uint8Array,
|
||||
abortSignal?: AbortSignal,
|
||||
context: Context,
|
||||
): Promise<Result<void, FileError>> {
|
||||
if (content === "ALPHA\nbeta\n") {
|
||||
this.firstEditWriteStarted.resolve();
|
||||
await this.finishFirstEditWrite.promise;
|
||||
const result = await super.writeFile(path, content);
|
||||
const result = await super.writeFile(path, content, BACKGROUND_CONTEXT);
|
||||
this.firstEditWriteSettled = true;
|
||||
return result;
|
||||
}
|
||||
if (content === "ALPHA\nBETA\n" || content === "alpha\nBETA\n") {
|
||||
this.secondEditWriteStarted = true;
|
||||
}
|
||||
return super.writeFile(path, content, abortSignal);
|
||||
return super.writeFile(path, content, context);
|
||||
}
|
||||
}
|
||||
|
||||
class LateOutputExecutionEnv extends NodeExecutionEnv {
|
||||
override async exec(
|
||||
_command: string,
|
||||
options?: ShellExecOptions,
|
||||
options: ShellExecOptions | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
|
||||
options?.onStdout?.("before\n");
|
||||
setTimeout(() => options?.onStdout?.("late\n"), 0);
|
||||
options?.onStdout?.("before\n", context);
|
||||
setTimeout(() => options?.onStdout?.("late\n", context), 0);
|
||||
return ok({ stdout: "before\n", stderr: "", exitCode: 0 });
|
||||
}
|
||||
}
|
||||
|
|
@ -108,10 +110,11 @@ const invocation = { invocationId: "test-result", operationId: "test-operation",
|
|||
class TimeoutOutputExecutionEnv extends NodeExecutionEnv {
|
||||
override async exec(
|
||||
_command: string,
|
||||
options?: ShellExecOptions,
|
||||
options: ShellExecOptions | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
|
||||
const output = `${Array.from({ length: TRUNCATED_OUTPUT_LINES }, (_, index) => `line-${index + 1}`).join("\n")}\n`;
|
||||
options?.onStdout?.(output);
|
||||
options?.onStdout?.(output, context);
|
||||
return err(new ExecutionError("timeout", `timeout:${options?.timeout}`));
|
||||
}
|
||||
}
|
||||
|
|
@ -140,6 +143,7 @@ describe("AgentHarness tools", () => {
|
|||
await context.env.writeFile(
|
||||
"test.txt",
|
||||
Array.from({ length: 100 }, (_, index) => `Line ${index + 1}`).join("\n"),
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -147,9 +151,9 @@ describe("AgentHarness tools", () => {
|
|||
"read-1",
|
||||
{ path: "test.txt", offset: 41, limit: 20 },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const output = textOutput(result);
|
||||
|
||||
|
|
@ -166,6 +170,7 @@ describe("AgentHarness tools", () => {
|
|||
await context.env.writeFile(
|
||||
"large.txt",
|
||||
Array.from({ length: 2500 }, (_, index) => `Line ${index + 1}`).join("\n"),
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -173,9 +178,9 @@ describe("AgentHarness tools", () => {
|
|||
"read-2",
|
||||
{ path: "large.txt" },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(textOutput(result)).toContain("[Showing lines 1-2000 of 2500. Use offset=2001 to continue.]");
|
||||
|
|
@ -190,16 +195,20 @@ 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`),
|
||||
await context.env.writeFile(
|
||||
"exact.txt",
|
||||
`${Array.from({ length: 2000 }, () => "x").join("\n")}\n`,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
|
||||
const result = await createReadTool().execute(
|
||||
"read-exact",
|
||||
{ path: "exact.txt" },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(result.details).toBeUndefined();
|
||||
|
|
@ -208,16 +217,16 @@ describe("AgentHarness tools", () => {
|
|||
|
||||
it("rejects offsets beyond the file", async () => {
|
||||
const context = createContext();
|
||||
getOrThrow(await context.env.writeFile("short.txt", "one\ntwo\nthree"));
|
||||
getOrThrow(await context.env.writeFile("short.txt", "one\ntwo\nthree", BACKGROUND_CONTEXT));
|
||||
|
||||
await expect(
|
||||
createReadTool().execute(
|
||||
"read-3",
|
||||
{ path: "short.txt", offset: 100 },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).rejects.toThrow("Offset 100 is beyond end of file (3 lines total)");
|
||||
});
|
||||
|
|
@ -230,15 +239,15 @@ describe("AgentHarness tools", () => {
|
|||
"base64",
|
||||
),
|
||||
);
|
||||
getOrThrow(await context.env.writeFile("image.txt", png));
|
||||
getOrThrow(await context.env.writeFile("image.txt", png, BACKGROUND_CONTEXT));
|
||||
|
||||
const result = await createReadTool().execute(
|
||||
"read-4",
|
||||
{ path: "image.txt" },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(textOutput(result)).toContain("Read image file [image/png]");
|
||||
|
|
@ -252,7 +261,7 @@ describe("AgentHarness tools", () => {
|
|||
it("delegates image conversion and resizing to an injected processor", async () => {
|
||||
const context = createContext();
|
||||
const bmp = createTinyBmp();
|
||||
getOrThrow(await context.env.writeFile("image.bmp", bmp));
|
||||
getOrThrow(await context.env.writeFile("image.bmp", bmp, BACKGROUND_CONTEXT));
|
||||
let received: { bytes: Uint8Array; mimeType: string; autoResizeImages: boolean } | undefined;
|
||||
const tool = createReadTool({
|
||||
autoResizeImages: false,
|
||||
|
|
@ -271,9 +280,9 @@ describe("AgentHarness tools", () => {
|
|||
"read-bmp",
|
||||
{ path: "image.bmp" },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(received).toMatchObject({ mimeType: "image/bmp", autoResizeImages: false });
|
||||
|
|
@ -290,13 +299,13 @@ describe("AgentHarness tools", () => {
|
|||
"write-1",
|
||||
{ path: "nested/dir/file.txt", content: "hello" },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
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", BACKGROUND_CONTEXT))).toBe("hello");
|
||||
});
|
||||
|
||||
it("keeps the mutation queue locked until an aborted write settles", async () => {
|
||||
|
|
@ -306,12 +315,12 @@ describe("AgentHarness tools", () => {
|
|||
const firstWrite = tool.execute(
|
||||
"write-first",
|
||||
{ path: "file.txt", content: "first\n" },
|
||||
controller.signal,
|
||||
undefined,
|
||||
{
|
||||
env,
|
||||
},
|
||||
invocation,
|
||||
withAbortSignal(controller.signal, BACKGROUND_CONTEXT),
|
||||
);
|
||||
await env.firstWriteStarted.promise;
|
||||
controller.abort();
|
||||
|
|
@ -319,9 +328,9 @@ describe("AgentHarness tools", () => {
|
|||
"write-second",
|
||||
{ path: "file.txt", content: "second\n" },
|
||||
undefined,
|
||||
undefined,
|
||||
{ env },
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
await delay(20);
|
||||
|
|
@ -329,7 +338,7 @@ describe("AgentHarness tools", () => {
|
|||
env.finishFirstWrite.resolve();
|
||||
await expect(firstWrite).rejects.toThrow();
|
||||
await secondWrite;
|
||||
expect(getOrThrow(await env.readTextFile("file.txt"))).toBe("second\n");
|
||||
expect(getOrThrow(await env.readTextFile("file.txt", BACKGROUND_CONTEXT))).toBe("second\n");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -337,7 +346,7 @@ describe("AgentHarness tools", () => {
|
|||
it("applies disjoint edits and returns both diff formats", async () => {
|
||||
const context = createContext();
|
||||
const original = "alpha\nbeta\ngamma\ndelta\n";
|
||||
getOrThrow(await context.env.writeFile("edit.txt", original));
|
||||
getOrThrow(await context.env.writeFile("edit.txt", original, BACKGROUND_CONTEXT));
|
||||
|
||||
const result = await createEditTool().execute(
|
||||
"edit-1",
|
||||
|
|
@ -349,21 +358,23 @@ describe("AgentHarness tools", () => {
|
|||
],
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(textOutput(result)).toBe("Successfully replaced 2 block(s) in edit.txt.");
|
||||
expect(result.details?.diff).toContain("ALPHA");
|
||||
expect(result.details?.diff).toContain("GAMMA");
|
||||
expect(applyPatch(original, result.details?.patch ?? "")).toBe("ALPHA\nbeta\nGAMMA\ndelta\n");
|
||||
expect(getOrThrow(await context.env.readTextFile("edit.txt"))).toBe("ALPHA\nbeta\nGAMMA\ndelta\n");
|
||||
expect(getOrThrow(await context.env.readTextFile("edit.txt", BACKGROUND_CONTEXT))).toBe(
|
||||
"ALPHA\nbeta\nGAMMA\ndelta\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("matches all edits against the original and rejects overlaps", async () => {
|
||||
const context = createContext();
|
||||
getOrThrow(await context.env.writeFile("edit.txt", "one\ntwo\nthree\n"));
|
||||
getOrThrow(await context.env.writeFile("edit.txt", "one\ntwo\nthree\n", BACKGROUND_CONTEXT));
|
||||
|
||||
await expect(
|
||||
createEditTool().execute(
|
||||
|
|
@ -376,17 +387,17 @@ describe("AgentHarness tools", () => {
|
|||
],
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).rejects.toThrow(/overlap/);
|
||||
expect(getOrThrow(await context.env.readTextFile("edit.txt"))).toBe("one\ntwo\nthree\n");
|
||||
expect(getOrThrow(await context.env.readTextFile("edit.txt", BACKGROUND_CONTEXT))).toBe("one\ntwo\nthree\n");
|
||||
});
|
||||
|
||||
it("rejects missing and duplicate target text", async () => {
|
||||
const context = createContext();
|
||||
getOrThrow(await context.env.writeFile("edit.txt", "foo foo foo"));
|
||||
getOrThrow(await context.env.writeFile("edit.txt", "foo foo foo", BACKGROUND_CONTEXT));
|
||||
const tool = createEditTool();
|
||||
|
||||
await expect(
|
||||
|
|
@ -394,9 +405,9 @@ describe("AgentHarness tools", () => {
|
|||
"edit-3",
|
||||
{ path: "edit.txt", edits: [{ oldText: "bar", newText: "baz" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).rejects.toThrow(/Could not find the exact text/);
|
||||
await expect(
|
||||
|
|
@ -404,25 +415,25 @@ describe("AgentHarness tools", () => {
|
|||
"edit-4",
|
||||
{ path: "edit.txt", edits: [{ oldText: "foo", newText: "bar" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).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"));
|
||||
getOrThrow(await env.writeFile("file.txt", "alpha\nbeta\n", BACKGROUND_CONTEXT));
|
||||
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 },
|
||||
invocation,
|
||||
withAbortSignal(controller.signal, BACKGROUND_CONTEXT),
|
||||
);
|
||||
await env.firstEditWriteStarted.promise;
|
||||
controller.abort();
|
||||
|
|
@ -430,9 +441,9 @@ describe("AgentHarness tools", () => {
|
|||
"edit-second",
|
||||
{ path: "file.txt", edits: [{ oldText: "beta", newText: "BETA" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
{ env },
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
await delay(20);
|
||||
|
|
@ -441,12 +452,12 @@ describe("AgentHarness tools", () => {
|
|||
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");
|
||||
expect(getOrThrow(await env.readTextFile("file.txt", BACKGROUND_CONTEXT))).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"));
|
||||
getOrThrow(await env.writeFile("target.txt", "alpha\nbeta\ngamma\n", BACKGROUND_CONTEXT));
|
||||
await symlink("target.txt", `${env.cwd}/link.txt`);
|
||||
const tool = createEditTool();
|
||||
|
||||
|
|
@ -455,54 +466,56 @@ describe("AgentHarness tools", () => {
|
|||
"edit-target",
|
||||
{ path: "target.txt", edits: [{ oldText: "alpha", newText: "ALPHA" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
{ env },
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
tool.execute(
|
||||
"edit-link",
|
||||
{ path: "link.txt", edits: [{ oldText: "beta", newText: "BETA" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
{ env },
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(getOrThrow(await env.readTextFile("target.txt"))).toBe("ALPHA\nBETA\ngamma\n");
|
||||
expect(getOrThrow(await env.readTextFile("target.txt", BACKGROUND_CONTEXT))).toBe("ALPHA\nBETA\ngamma\n");
|
||||
});
|
||||
|
||||
it("edits regular files through symlinks", async () => {
|
||||
const context = createContext();
|
||||
getOrThrow(await context.env.writeFile("target.txt", "before\n"));
|
||||
getOrThrow(await context.env.writeFile("target.txt", "before\n", BACKGROUND_CONTEXT));
|
||||
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,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(getOrThrow(await context.env.readTextFile("target.txt"))).toBe("after\n");
|
||||
expect(getOrThrow(await context.env.readTextFile("target.txt", BACKGROUND_CONTEXT))).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"));
|
||||
getOrThrow(await context.env.writeFile("edit.txt", "\uFEFFone\r\ntwo\r\n", BACKGROUND_CONTEXT));
|
||||
|
||||
await createEditTool().execute(
|
||||
"edit-5",
|
||||
{ path: "edit.txt", edits: [{ oldText: "two", newText: "TWO" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(getOrThrow(await context.env.readTextFile("edit.txt"))).toBe("\uFEFFone\r\nTWO\r\n");
|
||||
expect(getOrThrow(await context.env.readTextFile("edit.txt", BACKGROUND_CONTEXT))).toBe(
|
||||
"\uFEFFone\r\nTWO\r\n",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -513,9 +526,9 @@ describe("AgentHarness tools", () => {
|
|||
"bash-1",
|
||||
{ command: "printf out; printf err >&2" },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(textOutput(result)).toContain("out");
|
||||
|
|
@ -527,10 +540,24 @@ describe("AgentHarness tools", () => {
|
|||
const tool = createBashTool();
|
||||
|
||||
await expect(
|
||||
tool.execute("bash-2", { command: "printf failed; exit 7" }, undefined, undefined, context, invocation),
|
||||
tool.execute(
|
||||
"bash-2",
|
||||
{ command: "printf failed; exit 7" },
|
||||
undefined,
|
||||
context,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).rejects.toThrow(/failed[\s\S]*Command exited with code 7/);
|
||||
await expect(
|
||||
tool.execute("bash-3", { command: "sleep 2", timeout: 0.01 }, undefined, undefined, context, invocation),
|
||||
tool.execute(
|
||||
"bash-3",
|
||||
{ command: "sleep 2", timeout: 0.01 },
|
||||
undefined,
|
||||
context,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).rejects.toThrow(/Command timed out after 0.01 seconds/);
|
||||
});
|
||||
|
||||
|
|
@ -542,9 +569,9 @@ describe("AgentHarness tools", () => {
|
|||
"bash-timeout-output",
|
||||
{ command: "emit-output-then-time-out", timeout: 0.05 },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
} catch (cause) {
|
||||
error = cause;
|
||||
|
|
@ -555,7 +582,7 @@ describe("AgentHarness tools", () => {
|
|||
expect(message).toContain("Command timed out after 0.05 seconds");
|
||||
const fullOutputPath = message.match(/Full output: ([^\]\n]+)/)?.[1];
|
||||
expect(fullOutputPath).toBeDefined();
|
||||
const fullOutput = getOrThrow(await context.env.readTextFile(fullOutputPath!));
|
||||
const fullOutput = getOrThrow(await context.env.readTextFile(fullOutputPath!, BACKGROUND_CONTEXT));
|
||||
expect(fullOutput).toContain("line-1\nline-2");
|
||||
expect(fullOutput).toContain(`line-${DEFAULT_MAX_LINES}\nline-${TRUNCATED_OUTPUT_LINES}`);
|
||||
});
|
||||
|
|
@ -566,10 +593,10 @@ describe("AgentHarness tools", () => {
|
|||
const result = await createBashTool().execute(
|
||||
"bash-late",
|
||||
{ command: "late" },
|
||||
undefined,
|
||||
(update) => updates.push(textOutput(update)),
|
||||
{ env },
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
|
|
@ -583,9 +610,9 @@ describe("AgentHarness tools", () => {
|
|||
"bash-long-line",
|
||||
{ command: "printf '%060000d' 0" },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(textOutput(result)).toMatch(/Showing last 50\.0KB of line 1 \(line is 58\.6KB\)\. Full output:/);
|
||||
|
|
@ -596,16 +623,16 @@ describe("AgentHarness tools", () => {
|
|||
cwd: createTempDir(),
|
||||
shellEnv: { PI_BASH_PREPARE_INHERITED: "inherited" },
|
||||
});
|
||||
getOrThrow(await env.createDir("workspace"));
|
||||
getOrThrow(await env.createDir("workspace", undefined, BACKGROUND_CONTEXT));
|
||||
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) => {
|
||||
prepare: async (execution, turnContext, callContext) => {
|
||||
receivedContext = turnContext;
|
||||
receivedSignal = signal;
|
||||
receivedSignal = callContext.abortSignal;
|
||||
execution.cwd = turnContext.workspace;
|
||||
execution.env = { PI_BASH_PREPARE_EXPLICIT: "explicit" };
|
||||
execution.inheritEnv = false;
|
||||
|
|
@ -616,15 +643,17 @@ describe("AgentHarness tools", () => {
|
|||
const result = await tool.execute(
|
||||
"bash-prepare",
|
||||
{ command: ":" },
|
||||
controller.signal,
|
||||
undefined,
|
||||
context,
|
||||
invocation,
|
||||
withAbortSignal(controller.signal, BACKGROUND_CONTEXT),
|
||||
);
|
||||
|
||||
expect(receivedContext).toBe(context);
|
||||
expect(receivedSignal).toBe(controller.signal);
|
||||
expect(textOutput(result)).toBe(`ready::explicit:${getOrThrow(await env.canonicalPath(context.workspace))}`);
|
||||
expect(textOutput(result)).toBe(
|
||||
`ready::explicit:${getOrThrow(await env.canonicalPath(context.workspace, BACKGROUND_CONTEXT))}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("supports command prefixes", async () => {
|
||||
|
|
@ -633,9 +662,9 @@ describe("AgentHarness tools", () => {
|
|||
"bash-4",
|
||||
{ command: "printf $value" },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(textOutput(result)).toBe("hello");
|
||||
|
|
@ -650,10 +679,10 @@ describe("AgentHarness tools", () => {
|
|||
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(update),
|
||||
context,
|
||||
invocation,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
expect(updates.length).toBeLessThan(25);
|
||||
|
|
@ -671,7 +700,9 @@ describe("AgentHarness tools", () => {
|
|||
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!, BACKGROUND_CONTEXT),
|
||||
);
|
||||
expect(fullOutput).toContain("line-1\nline-2");
|
||||
expect(fullOutput).toContain("line-2999\nline-3000");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -442,7 +442,8 @@ it("covers Part 5 results, events, hooks, snapshots, tools, and stream options",
|
|||
expectTypeOf<OperationRequest["kind"]>().toEqualTypeOf<
|
||||
"prompt" | "skill" | "prompt_template" | "compaction" | "navigation"
|
||||
>();
|
||||
expectTypeOf<Parameters<AgentHarnessTool<object>["execute"]>[5]>().toEqualTypeOf<AgentHarnessToolInvocation>();
|
||||
expectTypeOf<Parameters<AgentHarnessTool<object>["execute"]>[4]>().toEqualTypeOf<AgentHarnessToolInvocation>();
|
||||
expectTypeOf<Parameters<AgentHarnessTool<object>["execute"]>[5]>().toEqualTypeOf<Context>();
|
||||
expectTypeOf<AgentTool["replay"]>().toEqualTypeOf<"never" | "safe" | undefined>();
|
||||
expectTypeOf<AgentHarnessStreamOptions["deferred"]>().toEqualTypeOf<
|
||||
boolean | { window?: "15m" | "1h" | "24h" } | undefined
|
||||
|
|
|
|||
|
|
@ -351,7 +351,7 @@ async function startServerBackend(
|
|||
};
|
||||
const socketPath = options.path;
|
||||
const closeCatalog = async (): Promise<void> => {
|
||||
const cleanup = await Promise.allSettled([repo.close(TODO_CONTEXT), executionEnv.cleanup()]);
|
||||
const cleanup = await Promise.allSettled([repo.close(TODO_CONTEXT), executionEnv.cleanup(TODO_CONTEXT)]);
|
||||
const errors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Experimental session catalog cleanup failed");
|
||||
|
|
|
|||
|
|
@ -481,7 +481,7 @@ async function closeResources(resources: {
|
|||
errors.push(error);
|
||||
}
|
||||
try {
|
||||
await resources.executionEnv.cleanup();
|
||||
await resources.executionEnv.cleanup(TODO_CONTEXT);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export async function createExperimentalSessions(
|
|||
return metadata;
|
||||
} finally {
|
||||
await repo.close(BACKGROUND_CONTEXT);
|
||||
await fileSystem.cleanup();
|
||||
await fileSystem.cleanup(BACKGROUND_CONTEXT);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -60,6 +60,6 @@ export async function readExperimentalSessionState(
|
|||
} finally {
|
||||
await session?.close(BACKGROUND_CONTEXT);
|
||||
await repo.close(BACKGROUND_CONTEXT);
|
||||
await fileSystem.cleanup();
|
||||
await fileSystem.cleanup(BACKGROUND_CONTEXT);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue