diff --git a/packages/agent/src/harness/agent-harness.ts b/packages/agent/src/harness/agent-harness.ts index c7fb7826a..3f806a0ac 100644 --- a/packages/agent/src/harness/agent-harness.ts +++ b/packages/agent/src/harness/agent-harness.ts @@ -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 Message[] | Promise; entryProjectors?: Record; - telemetryContext?: TelemetryContext; } export interface AgentLane { diff --git a/packages/agent/src/harness/compaction/branch-summarization.ts b/packages/agent/src/harness/compaction/branch-summarization.ts index c3560e31f..e7b7618cf 100644 --- a/packages/agent/src/harness/compaction/branch-summarization.ts +++ b/packages/agent/src/harness/compaction/branch-summarization.ts @@ -65,8 +65,6 @@ export interface GenerateBranchSummaryOptions { models: Models; /** Model used for summarization. */ model: Model; - /** 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> { - 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")); diff --git a/packages/agent/src/harness/compaction/compaction.ts b/packages/agent/src/harness/compaction/compaction.ts index 094024278..d1e14640e 100644 --- a/packages/agent/src/harness/compaction/compaction.ts +++ b/packages/agent/src/harness/compaction/compaction.ts @@ -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 { export async function completeSimpleWithRetries( models: Models, model: Model, - context: Context, + aiContext: AiContext, options: SimpleStreamOptions, - retry?: RetryPolicy, - callbacks?: RetryCallbacks, + retry: RetryPolicy | undefined, + callbacks: RetryCallbacks | undefined, + context: Context, ): Promise { // 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, 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> { 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, 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> { 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, - 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> { 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, reserveTokens: number, - signal?: AbortSignal, - thinkingLevel?: ThinkingLevel, - retry?: RetryPolicy, - callbacks?: RetryCallbacks, + thinkingLevel: ThinkingLevel | undefined, + retry: RetryPolicy | undefined, + callbacks: RetryCallbacks | undefined, + context: Context, ): Promise> { 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")); diff --git a/packages/agent/src/harness/env/nodejs.ts b/packages/agent/src/harness/env/nodejs.ts index 1225efb92..732dc84db 100644 --- a/packages/agent/src/harness/env/nodejs.ts +++ b/packages/agent/src/harness/env/nodejs.ts @@ -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> { + async absolutePath(path: string, _context: Context): Promise> { return ok(resolvePath(this.cwd, path)); } - async joinPath(parts: string[]): Promise> { + async joinPath(parts: string[], _context: Context): Promise> { return ok(join(...parts)); } async exec( command: string, - options?: ShellExecOptions, + options: ShellExecOptions | undefined, + context: Context, ): Promise> { - 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> { + async readTextFile(path: string, context: Context): Promise> { const resolved = resolvePath(this.cwd, path); - const aborted = abortResult(abortSignal, resolved); + const signal = context.abortSignal; + const aborted = abortResult(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> { const resolved = resolvePath(this.cwd, path); - const aborted = abortResult(options?.abortSignal, resolved); + const signal = context.abortSignal; + const aborted = abortResult(signal, resolved); if (aborted) return aborted; if (options?.maxLines !== undefined && options.maxLines <= 0) return ok([]); let stream: ReturnType | undefined; let lineReader: ReturnType | 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(options?.abortSignal, resolved); + const loopAbort = abortResult(signal, resolved); if (loopAbort) return loopAbort; lines.push(line); if (options?.maxLines !== undefined && lines.length >= options.maxLines) break; } - const afterReadAbort = abortResult(options?.abortSignal, resolved); + const afterReadAbort = abortResult(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> { + async readBinaryFile(path: string, context: Context): Promise> { const resolved = resolvePath(this.cwd, path); - const aborted = abortResult(abortSignal, resolved); + const signal = context.abortSignal; + const aborted = abortResult(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> { + async writeFile(path: string, content: string | Uint8Array, context: Context): Promise> { const resolved = resolvePath(this.cwd, path); - const aborted = abortResult(abortSignal, resolved); + const signal = context.abortSignal; + const aborted = abortResult(signal, resolved); if (aborted) return aborted; try { await mkdir(resolve(resolved, ".."), { recursive: true }); - const afterMkdirAbort = abortResult(abortSignal, resolved); + const afterMkdirAbort = abortResult(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> { + async appendFile(path: string, content: string | Uint8Array, context: Context): Promise> { const resolved = resolvePath(this.cwd, path); + const signal = context.abortSignal; + const aborted = abortResult(signal, resolved); + if (aborted) return aborted; try { await mkdir(resolve(resolved, ".."), { recursive: true }); + const afterMkdirAbort = abortResult(signal, resolved); + if (afterMkdirAbort) return afterMkdirAbort; await appendFile(resolved, content); - return ok(undefined); + const afterAppendAbort = abortResult(signal, resolved); + return afterAppendAbort ?? ok(undefined); } catch (error) { return err(toFileError(error, resolved)); } } - async renameFile( - sourcePath: string, - destinationPath: string, - abortSignal?: AbortSignal, - ): Promise> { + async renameFile(sourcePath: string, destinationPath: string, context: Context): Promise> { const source = resolvePath(this.cwd, sourcePath); const destination = resolvePath(this.cwd, destinationPath); - const aborted = abortResult(abortSignal, destination); + const aborted = abortResult(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> { + async fileInfo(path: string, context: Context): Promise> { const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(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> { + async listDir(path: string, context: Context): Promise> { const resolved = resolvePath(this.cwd, path); - const aborted = abortResult(abortSignal, resolved); + const signal = context.abortSignal; + const aborted = abortResult(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(abortSignal, resolved); + const loopAbort = abortResult(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> { + async canonicalPath(path: string, context: Context): Promise> { const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(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> { - const result = await this.fileInfo(path); + async exists(path: string, context: Context): Promise> { + 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> { + async createDir( + path: string, + options: { recursive?: boolean } | undefined, + context: Context, + ): Promise> { const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(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> { + async remove( + path: string, + options: { recursive?: boolean; force?: boolean } | undefined, + context: Context, + ): Promise> { const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(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> { + async createTempDir(prefix: string | undefined, context: Context): Promise> { + const aborted = abortResult(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> { - const dir = await this.createTempDir("tmp-"); + async createTempFile( + options: { prefix?: string; suffix?: string } | undefined, + context: Context, + ): Promise> { + 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 { + async cleanup(_context: Context): Promise { for (const pid of this.activeChildPids) killProcessTree(pid); this.activeChildPids.clear(); } diff --git a/packages/agent/src/harness/execution/assistant.ts b/packages/agent/src/harness/execution/assistant.ts index 57576544d..3f08a235c 100644 --- a/packages/agent/src/harness/execution/assistant.ts +++ b/packages/agent/src/harness/execution/assistant.ts @@ -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; - update(message: AssistantMessage, event: AssistantMessageEvent): void | Promise; - end(message: SettledAssistantMessage): void | Promise; + start(message: AssistantMessage, context: Context): void | Promise; + update(message: AssistantMessage, event: AssistantMessageEvent, context: Context): void | Promise; + end(message: SettledAssistantMessage, context: Context): void | Promise; } /** 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; - beforePayload?: (payload: unknown, model: Model) => unknown | undefined | Promise; + toProviderMessages: (messages: AgentMessage[], context: Context) => Message[] | Promise; + beforePayload?: ( + payload: unknown, + model: Model, + context: Context, + ) => unknown | undefined | Promise; afterResponse?: ( message: SettledAssistantMessage, metadata: AssistantResponseMetadata, + context: Context, ) => Promise; request( - context: Context, + aiContext: AiContext, options: SimpleStreamOptions, + context: Context, ): AssistantMessageEventStream | Promise; 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 { 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; } diff --git a/packages/agent/src/harness/execution/tools.ts b/packages/agent/src/harness/execution/tools.ts index d48658674..e2087f7ff 100644 --- a/packages/agent/src/harness/execution/tools.ts +++ b/packages/agent/src/harness/execution/tools.ts @@ -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) => void, - _telemetryContext: TelemetryContext, + context: Context, ): Promise { 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 }; diff --git a/packages/agent/src/harness/hooks.ts b/packages/agent/src/harness/hooks.ts index 0c2336fe2..a7bed67e2 100644 --- a/packages/agent/src/harness/hooks.ts +++ b/packages/agent/src/harness/hooks.ts @@ -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 { 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, ); } diff --git a/packages/agent/src/harness/prompt-templates.ts b/packages/agent/src/harness/prompt-templates.ts index 52b5128c2..2d14b7219 100644 --- a/packages/agent/src/harness/prompt-templates.ts +++ b/packages/agent/src/harness/prompt-templates.ts @@ -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( 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; @@ -78,11 +83,11 @@ export async function loadSourcedPromptTemplates = []; const diagnostics: Array = []; 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 { 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({ diff --git a/packages/agent/src/harness/runtime2/harness.ts b/packages/agent/src/harness/runtime2/harness.ts index c092dca3c..4eb4bd24d 100644 --- a/packages/agent/src/harness/runtime2/harness.ts +++ b/packages/agent/src/harness/runtime2/harness.ts @@ -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 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) => diff --git a/packages/agent/src/harness/runtime2/types.ts b/packages/agent/src/harness/runtime2/types.ts index ea8b27464..8adaef9c8 100644 --- a/packages/agent/src/harness/runtime2/types.ts +++ b/packages/agent/src/harness/runtime2/types.ts @@ -27,7 +27,6 @@ export interface Config { readonly systemPrompt: AgentHarnessOptions["systemPrompt"]; readonly toProviderMessages: NonNullable["toProviderMessages"]>; readonly entryProjectors: Readonly["entryProjectors"]>>; - readonly telemetryContext: NonNullable["telemetryContext"]>; } /** The current durable state owned by one lane. */ diff --git a/packages/agent/src/harness/session/jsonl/repo.ts b/packages/agent/src/harness/session/jsonl/repo.ts index 70f07dc2a..ee91b87de 100644 --- a/packages/agent/src/harness/session/jsonl/repo.ts +++ b/packages/agent/src/harness/session/jsonl/repo.ts @@ -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 { + async delete(metadata: JsonlSessionMetadata, context: Context): Promise { 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 { - if (!fileValue(await this.fileSystem.exists(directory), `Failed to check sessions directory ${directory}`)) { + private async listDirectory( + directory: string, + cwd: string | undefined, + context: Context, + ): Promise { + 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 { - return fileValue(await this.fileSystem.listDir(root), `Failed to list sessions root ${root}`) + private async sessionDirectories(root: string, context: Context): Promise { + 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 { 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 { 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 { - if (!fileValue(await this.fileSystem.exists(directory), `Failed to check sessions directory ${directory}`)) + private async assertSessionIdAvailable(directory: string, id: string, context: Context): Promise { + 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 { // 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 { + private root(context: Context): Promise { return this.fileSystem - .absolutePath(this.sessionsRootInput) + .absolutePath(this.sessionsRootInput, context) .then((result) => fileValue(result, `Failed to resolve sessions root ${this.sessionsRootInput}`)); } diff --git a/packages/agent/src/harness/session/jsonl/storage.ts b/packages/agent/src/harness/session/jsonl/storage.ts index fbc933b84..b792fc7e6 100644 --- a/packages/agent/src/harness/session/jsonl/storage.ts +++ b/packages/agent/src/harness/session/jsonl/storage.ts @@ -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 { +async function publishFileAtomically( + fileSystem: FileSystem, + destinationPath: string, + content: string, + context: Context, +): Promise { 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 { 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 { + static async open(options: JsonlStorageOptions, context: Context): Promise { 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 { + async commit(writes: Write[], context: Context): Promise { 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 { + private async applyCommit(writes: Write[], context: Context): Promise { 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}`, ); diff --git a/packages/agent/src/harness/skills.ts b/packages/agent/src/harness/skills.ts index 44fd828ed..c1eb948ba 100644 --- a/packages/agent/src/harness/skills.ts +++ b/packages/agent/src/harness/skills.ts @@ -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( 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; @@ -92,9 +95,12 @@ export async function loadSourcedSkills( const skills: Array<{ skill: TSkill; source: TSource }> = []; const diagnostics: Array = []; 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 { 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({ diff --git a/packages/agent/src/harness/telemetry.ts b/packages/agent/src/harness/telemetry.ts index 409f1d1bb..1892bbb61 100644 --- a/packages/agent/src/harness/telemetry.ts +++ b/packages/agent/src/harness/telemetry.ts @@ -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 = SchemaTelemetrySpan; export function startAiSpan, Result>( - telemetryContext: TelemetryContext, name: Name, attributes: ExactTelemetryAttributes, Attributes>, - callback: (span: AiTelemetrySpan) => Result | Promise, + callback: (span: AiTelemetrySpan, context: Context) => Result | Promise, + context: Context, ): Promise { - return telemetryContext.startSpan({ name, attributes }, (span) => callback(span as AiTelemetrySpan)); + return context.telemetryContext.startSpan({ name, attributes }, (span) => + callback(span as AiTelemetrySpan, withTelemetryContext(span, context)), + ); } const HOOK_NAMES = [ @@ -623,12 +625,12 @@ export function startHarnessSpan< const Attributes extends HarnessSpanStartAttributes, Result, >( - telemetryContext: TelemetryContext, name: Name, attributes: ExactTelemetryAttributes, Attributes>, - callback: (span: HarnessTelemetrySpan) => Result | Promise, + callback: (span: HarnessTelemetrySpan, context: Context) => Result | Promise, + context: Context, ): Promise { - return telemetryContext.startSpan({ name, attributes }, (span: TelemetrySpan) => - callback(span as HarnessTelemetrySpan), + return context.telemetryContext.startSpan({ name, attributes }, (span: TelemetrySpan) => + callback(span as HarnessTelemetrySpan, withTelemetryContext(span, context)), ); } diff --git a/packages/agent/src/harness/tools/bash.ts b/packages/agent/src/harness/tools/bash.ts index c0e1f19da..b4c702c3c 100644 --- a/packages/agent/src/harness/tools/bash.ts +++ b/packages/agent/src/harness/tools/bash.ts @@ -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 = ( execution: BashExecution, - context: TContext, - signal?: AbortSignal, + toolContext: TContext, + context: Context, ) => void | Promise; export interface BashToolOptions { @@ -56,16 +57,16 @@ export function createBashTool ShellCaptureProgress) | undefined; let updateTimer: ReturnType | undefined; let updateDirty = false; @@ -107,18 +108,22 @@ export function createBashTool { - 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; diff --git a/packages/agent/src/harness/tools/edit.ts b/packages/agent/src/harness/tools/edit.ts index 5473c48b8..97fa68044 100644 --- a/packages/agent/src/harness/tools/edit.ts +++ b/packages/agent/src/harness/tools/edit.ts @@ -99,42 +99,47 @@ export function createEditTool { - 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, + ); }, }; } diff --git a/packages/agent/src/harness/tools/file-mutation-queue.ts b/packages/agent/src/harness/tools/file-mutation-queue.ts index 51bb04f83..14b64cc1e 100644 --- a/packages/agent/src/harness/tools/file-mutation-queue.ts +++ b/packages/agent/src/harness/tools/file-mutation-queue.ts @@ -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 { - const absolutePath = getOrThrow(await env.absolutePath(path)); - const canonicalPath = await env.canonicalPath(absolutePath); +async function getMutationQueueKey(env: ExecutionEnv, path: string, context: Context): Promise { + 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(env: ExecutionEnv, path: string, fn: () => Promise): Promise { +export async function withFileMutationQueue( + env: ExecutionEnv, + path: string, + fn: () => Promise, + context: Context, +): Promise { 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 = () => {}; diff --git a/packages/agent/src/harness/tools/path-utils.ts b/packages/agent/src/harness/tools/path-utils.ts index 1919db60c..c1620fec9 100644 --- a/packages/agent/src/harness/tools/path-utils.ts +++ b/packages/agent/src/harness/tools/path-utils.ts @@ -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 { - return getOrThrow(await env.absolutePath(normalizeToolPath(path), signal)); +export async function resolveToolPath(env: ExecutionEnv, path: string, context: Context): Promise { + return getOrThrow(await env.absolutePath(normalizeToolPath(path), context)); } -export async function resolveReadToolPath(env: ExecutionEnv, path: string, signal?: AbortSignal): Promise { - const resolved = await resolveToolPath(env, path, signal); +export async function resolveReadToolPath(env: ExecutionEnv, path: string, context: Context): Promise { + 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; } diff --git a/packages/agent/src/harness/tools/read.ts b/packages/agent/src/harness/tools/read.ts index 5fdbdf6c0..eb81b399f 100644 --- a/packages/agent/src/harness/tools/read.ts +++ b/packages/agent/src/harness/tools/read.ts @@ -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; export interface ReadToolOptions { @@ -50,15 +52,18 @@ export function createReadTool { - 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, + ); }, }; } diff --git a/packages/agent/src/harness/types.ts b/packages/agent/src/harness/types.ts index 8cc842c3b..3d134bfc6 100644 --- a/packages/agent/src/harness/types.ts +++ b/packages/agent/src/harness/types.ts @@ -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 = { ok: true; value: TValue } | { ok: false; error: TError }; @@ -95,17 +96,17 @@ export type AgentHarnessTool< execute( toolCallId: string, params: Static, - signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback | undefined, - context: TContext, + toolContext: TContext, invocation: AgentHarnessToolInvocation, + context: Context, ): Promise>; }; -/** 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 - | (() => TContext | Promise); + | ((context: Context) => TContext | Promise); /** 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>; + absolutePath(path: string, context: Context): Promise>; /** Join path segments in the filesystem namespace without requiring the result to exist. */ - joinPath(parts: string[], abortSignal?: AbortSignal): Promise>; + joinPath(parts: string[], context: Context): Promise>; /** Read a UTF-8 text file. */ - readTextFile(path: string, abortSignal?: AbortSignal): Promise>; + readTextFile(path: string, context: Context): Promise>; /** 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>; /** Read a binary file. */ - readBinaryFile(path: string, abortSignal?: AbortSignal): Promise>; + readBinaryFile(path: string, context: Context): Promise>; /** Create or overwrite a file, creating parent directories when supported. */ - writeFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise>; + writeFile(path: string, content: string | Uint8Array, context: Context): Promise>; /** Create or append to a file, creating parent directories when supported. */ - appendFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise>; + appendFile(path: string, content: string | Uint8Array, context: Context): Promise>; /** Atomically rename a file, replacing the destination when it exists. Does not copy across filesystems. */ - renameFile(sourcePath: string, destinationPath: string, abortSignal?: AbortSignal): Promise>; + renameFile(sourcePath: string, destinationPath: string, context: Context): Promise>; /** Return metadata for the addressed path without following symlinks. */ - fileInfo(path: string, abortSignal?: AbortSignal): Promise>; + fileInfo(path: string, context: Context): Promise>; /** List direct children of a directory without following symlinks. */ - listDir(path: string, abortSignal?: AbortSignal): Promise>; + listDir(path: string, context: Context): Promise>; /** Return the canonical path for an existing path, resolving symlinks where supported. */ - canonicalPath(path: string, abortSignal?: AbortSignal): Promise>; + canonicalPath(path: string, context: Context): Promise>; /** Return false for missing paths. Other errors, such as permission failures, return a {@link FileError}. */ - exists(path: string, abortSignal?: AbortSignal): Promise>; - /** Create a directory. Defaults: `recursive: true`, no abort signal. */ + exists(path: string, context: Context): Promise>; + /** Create a directory. Defaults to `recursive: true`. */ createDir( path: string, - options?: { recursive?: boolean; abortSignal?: AbortSignal }, + options: { recursive?: boolean } | undefined, + context: Context, ): Promise>; - /** 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>; - /** Create a temporary directory and return its absolute path. Defaults: `prefix: "tmp-"`, no abort signal. */ - createTempDir(prefix?: string, abortSignal?: AbortSignal): Promise>; - /** Create a temporary file and return its absolute path. Defaults: `prefix: ""`, `suffix: ""`, no abort signal. */ - createTempFile(options?: { - prefix?: string; - suffix?: string; - abortSignal?: AbortSignal; - }): Promise>; + /** Create a temporary directory and return its absolute path. Defaults to `prefix: "tmp-"`. */ + createTempDir(prefix: string | undefined, context: Context): Promise>; + /** Create a temporary file and return its absolute path. Defaults to `prefix: ""` and `suffix: ""`. */ + createTempFile( + options: { prefix?: string; suffix?: string } | undefined, + context: Context, + ): Promise>; /** Release filesystem resources. Must be best-effort and must not throw or reject. */ - cleanup(): Promise; + cleanup(context: Context): Promise; } /** 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>; /** Release shell resources. Must be best-effort and must not throw or reject. */ - cleanup(): Promise; + cleanup(context: Context): Promise; } /** Filesystem and process execution environment used by the harness. */ diff --git a/packages/agent/src/harness/utils/shell-output.ts b/packages/agent/src/harness/utils/shell-output.ts index 0090362d3..754d35a16 100644 --- a/packages/agent/src/harness/utils/shell-output.ts +++ b/packages/agent/src/harness/utils/shell-output.ts @@ -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 { - 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> { 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, diff --git a/packages/agent/test/harness/compaction.test.ts b/packages/agent/test/harness/compaction.test.ts index 9db0e385e..22253b17b 100644 --- a/packages/agent/test/harness/compaction.test.ts +++ b/packages/agent/test/harness/compaction.test.ts @@ -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); diff --git a/packages/agent/test/harness/execution-assistant.test.ts b/packages/agent/test/harness/execution-assistant.test.ts index af91c1dd6..f5bdbef84 100644 --- a/packages/agent/test/harness/execution-assistant.test.ts +++ b/packages/agent/test/harness/execution-assistant.test.ts @@ -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"]); diff --git a/packages/agent/test/harness/execution-tools.test.ts b/packages/agent/test/harness/execution-tools.test.ts index b50cf2dd5..7bba9c776 100644 --- a/packages/agent/test/harness/execution-tools.test.ts +++ b/packages/agent/test/harness/execution-tools.test.ts @@ -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[] = []; - 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(); }); diff --git a/packages/agent/test/harness/jsonl-session-repo.test.ts b/packages/agent/test/harness/jsonl-session-repo.test.ts index 79bdbe413..72e63e5a5 100644 --- a/packages/agent/test/harness/jsonl-session-repo.test.ts +++ b/packages/agent/test/harness/jsonl-session-repo.test.ts @@ -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", diff --git a/packages/agent/test/harness/jsonl-storage.test.ts b/packages/agent/test/harness/jsonl-storage.test.ts index 34dcff9af..a209ad247 100644 --- a/packages/agent/test/harness/jsonl-storage.test.ts +++ b/packages/agent/test/harness/jsonl-storage.test.ts @@ -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/); }); diff --git a/packages/agent/test/harness/nodejs-env.test.ts b/packages/agent/test/harness/nodejs-env.test.ts index becaa8fc3..361040495 100644 --- a/packages/agent/test/harness/nodejs-env.test.ts +++ b/packages/agent/test/harness/nodejs-env.test.ts @@ -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); }); diff --git a/packages/agent/test/harness/prompt-templates.test.ts b/packages/agent/test/harness/prompt-templates.test.ts index 8e118ad49..60df6ba86 100644 --- a/packages/agent/test/harness/prompt-templates.test.ts +++ b/packages/agent/test/harness/prompt-templates.test.ts @@ -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" }, diff --git a/packages/agent/test/harness/skills.test.ts b/packages/agent/test/harness/skills.test.ts index 88c155948..acdcba5b8 100644 --- a/packages/agent/test/harness/skills.test.ts +++ b/packages/agent/test/harness/skills.test.ts @@ -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"]); diff --git a/packages/agent/test/harness/telemetry.test.ts b/packages/agent/test/harness/telemetry.test.ts index 207db8e44..8a5c036a0 100644 --- a/packages/agent/test/harness/telemetry.test.ts +++ b/packages/agent/test/harness/telemetry.test.ts @@ -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(); 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(); }); diff --git a/packages/agent/test/harness/tools.test.ts b/packages/agent/test/harness/tools.test.ts index 3920b4d8c..5fe81ab37 100644 --- a/packages/agent/test/harness/tools.test.ts +++ b/packages/agent/test/harness/tools.test.ts @@ -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 { } class SlowReadExecutionEnv extends NodeExecutionEnv { - override async readTextFile(path: string, abortSignal?: AbortSignal): Promise> { + override async readTextFile(path: string, context: Context): Promise> { 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> { 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> { 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> { - 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> { 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({ 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"); }); diff --git a/packages/agent/test/harness/types.test.ts b/packages/agent/test/harness/types.test.ts index 4d4c029f6..c2a0c02ce 100644 --- a/packages/agent/test/harness/types.test.ts +++ b/packages/agent/test/harness/types.test.ts @@ -442,7 +442,8 @@ it("covers Part 5 results, events, hooks, snapshots, tools, and stream options", expectTypeOf().toEqualTypeOf< "prompt" | "skill" | "prompt_template" | "compaction" | "navigation" >(); - expectTypeOf["execute"]>[5]>().toEqualTypeOf(); + expectTypeOf["execute"]>[4]>().toEqualTypeOf(); + expectTypeOf["execute"]>[5]>().toEqualTypeOf(); expectTypeOf().toEqualTypeOf<"never" | "safe" | undefined>(); expectTypeOf().toEqualTypeOf< boolean | { window?: "15m" | "1h" | "24h" } | undefined diff --git a/packages/coding-agent/src/experimental/server.ts b/packages/coding-agent/src/experimental/server.ts index 44be4ec11..e31a383f2 100644 --- a/packages/coding-agent/src/experimental/server.ts +++ b/packages/coding-agent/src/experimental/server.ts @@ -351,7 +351,7 @@ async function startServerBackend( }; const socketPath = options.path; const closeCatalog = async (): Promise => { - 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"); diff --git a/packages/coding-agent/src/experimental/session-worker.ts b/packages/coding-agent/src/experimental/session-worker.ts index 598344d37..c9dd73c0c 100644 --- a/packages/coding-agent/src/experimental/session-worker.ts +++ b/packages/coding-agent/src/experimental/session-worker.ts @@ -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); } diff --git a/packages/coding-agent/test/experimental-session-support.ts b/packages/coding-agent/test/experimental-session-support.ts index 315983cb1..f71763d0a 100644 --- a/packages/coding-agent/test/experimental-session-support.ts +++ b/packages/coding-agent/test/experimental-session-support.ts @@ -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); } }