From 617d8b317d7abf3a9fb05311e90cca3498fe7836 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 6 May 2026 19:41:17 +0200 Subject: [PATCH] refactor(agent): tighten harness environment and resources --- package-lock.json | 4 +- packages/agent/package.json | 4 +- packages/agent/src/agent.ts | 5 +- packages/agent/src/harness/agent-harness.ts | 126 +++--- packages/agent/src/harness/env/nodejs.ts | 370 ++++++++++++++++++ packages/agent/src/harness/execution-env.ts | 276 +------------ .../agent/src/harness/prompt-templates.ts | 98 ++++- packages/agent/src/harness/skills.ts | 265 +++++++++++++ packages/agent/src/harness/system-prompt.ts | 34 ++ packages/agent/src/harness/types.ts | 143 +++++-- packages/agent/src/index.ts | 2 + packages/agent/test/agent.test.ts | 31 ++ packages/agent/test/harness/factory.test.ts | 5 +- .../agent/test/harness/nodejs-env.test.ts | 149 +++++++ .../test/harness/prompt-templates.test.ts | 46 +++ .../{session-repo.test.ts => repo.test.ts} | 0 .../{session-tree.test.ts => session.test.ts} | 0 packages/agent/test/harness/skills.test.ts | 65 +++ ...ession-storage.test.ts => storage.test.ts} | 0 .../agent/test/harness/system-prompt.test.ts | 45 +++ packages/agent/test/scratch/simple.ts | 26 +- 21 files changed, 1305 insertions(+), 389 deletions(-) create mode 100644 packages/agent/src/harness/env/nodejs.ts create mode 100644 packages/agent/src/harness/skills.ts create mode 100644 packages/agent/src/harness/system-prompt.ts create mode 100644 packages/agent/test/harness/nodejs-env.test.ts create mode 100644 packages/agent/test/harness/prompt-templates.test.ts rename packages/agent/test/harness/{session-repo.test.ts => repo.test.ts} (100%) rename packages/agent/test/harness/{session-tree.test.ts => session.test.ts} (100%) create mode 100644 packages/agent/test/harness/skills.test.ts rename packages/agent/test/harness/{session-storage.test.ts => storage.test.ts} (100%) create mode 100644 packages/agent/test/harness/system-prompt.test.ts diff --git a/package-lock.json b/package-lock.json index 8fa4f1d2..4ff3d711 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8179,7 +8179,9 @@ "license": "MIT", "dependencies": { "@mariozechner/pi-ai": "^0.72.1", - "typebox": "^1.1.24" + "ignore": "^7.0.5", + "typebox": "^1.1.24", + "yaml": "^2.8.2" }, "devDependencies": { "@types/node": "^24.3.0", diff --git a/packages/agent/package.json b/packages/agent/package.json index fb8e6bf5..e788a0cd 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -18,7 +18,9 @@ }, "dependencies": { "@mariozechner/pi-ai": "^0.72.1", - "typebox": "^1.1.24" + "ignore": "^7.0.5", + "typebox": "^1.1.24", + "yaml": "^2.8.2" }, "keywords": [ "ai", diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 53cb57f7..bffc88c7 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -472,8 +472,9 @@ export class Agent { errorMessage: error instanceof Error ? error.message : String(error), timestamp: Date.now(), } satisfies AgentMessage; - this._state.messages.push(failureMessage); - this._state.errorMessage = failureMessage.errorMessage; + await this.processEvents({ type: "message_start", message: failureMessage }); + await this.processEvents({ type: "message_end", message: failureMessage }); + await this.processEvents({ type: "turn_end", message: failureMessage, toolResults: [] }); await this.processEvents({ type: "agent_end", messages: [failureMessage] }); } diff --git a/packages/agent/src/harness/agent-harness.ts b/packages/agent/src/harness/agent-harness.ts index 8d36d8c0..f5ca4e84 100644 --- a/packages/agent/src/harness/agent-harness.ts +++ b/packages/agent/src/harness/agent-harness.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import { readFileSync } from "node:fs"; import type { AssistantMessage, ImageContent, Model } from "@mariozechner/pi-ai"; import { Agent } from "../agent.js"; import type { AgentEvent, AgentMessage, AgentTool, ThinkingLevel } from "../types.js"; @@ -15,34 +14,13 @@ import type { AgentHarnessOperationState, AgentHarnessOptions, AgentHarnessOwnEvent, + AgentHarnessResources, ExecutionEnv, NavigateTreeResult, - PromptTemplate, Session, Skill, - SystemPromptInputs, } from "./types.js"; -function buildSystemPrompt(inputs: SystemPromptInputs, cwd: string): string { - const parts: string[] = []; - if (inputs.basePrompt) parts.push(inputs.basePrompt); - if (inputs.appendPrompt) parts.push(inputs.appendPrompt); - if (inputs.contextFiles && inputs.contextFiles.length > 0) { - parts.push("# Project Context\n"); - for (const file of inputs.contextFiles) { - parts.push(`## ${file.path}\n\n${file.content}`); - } - } - if (inputs.skills && inputs.skills.length > 0) { - parts.push("# Available Skills\n"); - for (const skill of inputs.skills.filter((s) => !s.disableModelInvocation)) { - parts.push(`- ${skill.name}: ${skill.description}`); - } - } - parts.push(`Current working directory: ${cwd}`); - return parts.filter(Boolean).join("\n\n"); -} - function createUserMessage(text: string, images?: ImageContent[]): AgentMessage { const content: Array<{ type: "text"; text: string } | ImageContent> = [{ type: "text", text }]; if (images) content.push(...images); @@ -56,8 +34,8 @@ export class AgentHarness { readonly operation: AgentHarnessOperationState; private session: Session; - private promptTemplates: PromptTemplate[]; - private skills: Skill[]; + private resourcesInput?: AgentHarnessOptions["resources"]; + private systemPrompt: AgentHarnessOptions["systemPrompt"]; private requestAuth?: AgentHarnessOptions["requestAuth"]; private toolRegistry = new Map(); private listeners = new Set<(event: AgentHarnessEvent, signal?: AbortSignal) => Promise | void>(); @@ -67,23 +45,35 @@ export class AgentHarness { >(); constructor(options: AgentHarnessOptions) { - this.agent = new Agent({}); + this.agent = new Agent({ + initialState: { + model: options.model, + thinkingLevel: options.thinkingLevel, + tools: options.tools ?? [], + }, + }); this.env = options.env; this.session = options.session; - this.promptTemplates = options.promptTemplates ?? []; - this.skills = options.skills ?? []; + this.resourcesInput = options.resources; + this.systemPrompt = options.systemPrompt; this.requestAuth = options.requestAuth; for (const tool of this.agent.state.tools) { this.toolRegistry.set(tool.name, tool); } this.conversation = { session: options.session, - model: options.initialModel ?? this.agent.state.model, - thinkingLevel: options.initialThinkingLevel ?? this.agent.state.thinkingLevel, - activeToolNames: options.initialActiveToolNames ?? this.agent.state.tools.map((tool) => tool.name), - systemPromptInputs: options.initialSystemPromptInputs ?? { skills: this.skills }, + model: options.model, + thinkingLevel: options.thinkingLevel ?? this.agent.state.thinkingLevel, + activeToolNames: options.activeToolNames ?? this.agent.state.tools.map((tool) => tool.name), nextTurnQueue: [], }; + this.agent.state.model = this.conversation.model; + this.agent.state.thinkingLevel = this.conversation.thinkingLevel; + this.agent.getApiKey = async (provider) => { + const model = this.conversation.model; + if (!this.requestAuth || model.provider !== provider) return undefined; + return (await this.requestAuth(model))?.apiKey; + }; this.operation = { idle: true, abortRequested: false, @@ -151,6 +141,31 @@ export class AgentHarness { }; } + private async resolveResources(signal?: AbortSignal): Promise { + return typeof this.resourcesInput === "function" + ? await this.resourcesInput(this.createContext(signal)) + : (this.resourcesInput ?? {}); + } + + private getActiveTools(): AgentTool[] { + return this.conversation.activeToolNames + .map((name) => this.toolRegistry.get(name)) + .filter((tool): tool is AgentTool => tool !== undefined); + } + + private async resolveSystemPrompt(resources: AgentHarnessResources): Promise { + if (!this.systemPrompt) return "You are a helpful assistant."; + if (typeof this.systemPrompt === "string") return this.systemPrompt; + return await this.systemPrompt({ + env: this.env, + session: this.session, + model: this.conversation.model, + thinkingLevel: this.conversation.thinkingLevel, + activeTools: this.getActiveTools(), + resources, + }); + } + private async emitOwn(event: AgentHarnessOwnEvent, signal?: AbortSignal): Promise { for (const listener of this.listeners) { await listener(event, signal); @@ -195,7 +210,7 @@ export class AgentHarness { if (context.model && this.conversation.model) { // leave active model untouched; harness-level model is source of truth } - this.agent.state.systemPrompt = buildSystemPrompt(this.conversation.systemPromptInputs, this.env.cwd); + this.agent.state.systemPrompt = await this.resolveSystemPrompt(await this.resolveResources()); } private async applyPendingMutations(): Promise { @@ -232,12 +247,6 @@ export class AgentHarness { this.operation.pendingMutations.activeToolNames = undefined; } - if (this.operation.pendingMutations.systemPromptInputs) { - this.conversation.systemPromptInputs = this.operation.pendingMutations.systemPromptInputs; - this.agent.state.systemPrompt = buildSystemPrompt(this.conversation.systemPromptInputs, this.env.cwd); - this.operation.pendingMutations.systemPromptInputs = undefined; - } - await this.syncFromTree(); } @@ -264,8 +273,7 @@ export class AgentHarness { this.operation.pendingMutations.appendMessages.length > 0 || this.operation.pendingMutations.model !== undefined || this.operation.pendingMutations.thinkingLevel !== undefined || - this.operation.pendingMutations.activeToolNames !== undefined || - this.operation.pendingMutations.systemPromptInputs !== undefined; + this.operation.pendingMutations.activeToolNames !== undefined; await this.emitOwn( { type: "save_point", liveOperationId: this.operation.liveOperationId ?? "unknown", hadPendingMutations }, signal, @@ -288,14 +296,18 @@ export class AgentHarness { const beforeLength = this.agent.state.messages.length; this.operation.idle = false; this.operation.liveOperationId = randomUUID(); - const expanded = this.expandSkillCommand(expandPromptTemplate(text, this.promptTemplates)); + const resources = await this.resolveResources(this.agent.signal); + const expanded = this.expandSkillCommand( + expandPromptTemplate(text, resources.promptTemplates ?? []), + resources.skills ?? [], + ); let messages: AgentMessage[] = [createUserMessage(expanded, options?.images)]; if (this.conversation.nextTurnQueue.length > 0) { messages = [messages[0]!, ...this.conversation.nextTurnQueue]; this.conversation.nextTurnQueue = []; await this.emitQueueUpdate(); } - this.agent.state.systemPrompt = buildSystemPrompt(this.conversation.systemPromptInputs, this.env.cwd); + this.agent.state.systemPrompt = await this.resolveSystemPrompt(resources); const beforeResult = await this.emitHook( "before_agent_start", { @@ -303,7 +315,7 @@ export class AgentHarness { prompt: expanded, images: options?.images, systemPrompt: this.agent.state.systemPrompt, - systemPromptInputs: this.conversation.systemPromptInputs, + resources, }, this.agent.signal, ); @@ -324,11 +336,10 @@ export class AgentHarness { } async skill(name: string, args?: string): Promise { - const skill = this.skills.find((candidate) => candidate.name === name); + const resources = await this.resolveResources(); + const skill = (resources.skills ?? []).find((candidate) => candidate.name === name); if (!skill) throw new Error(`Unknown skill: ${name}`); - let content = readFileSync(skill.filePath, "utf8"); - content = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "").trim(); - const prompt = args ? `${content}\n\n${args}` : content; + const prompt = args ? `${skill.content}\n\n${args}` : skill.content; return await this.prompt(prompt); } @@ -546,21 +557,12 @@ export class AgentHarness { async setActiveTools(toolNames: string[]): Promise { if (this.operation.idle) { this.conversation.activeToolNames = [...toolNames]; - this.agent.state.tools = toolNames.map((name) => this.toolRegistry.get(name)).filter(Boolean) as any; + this.agent.state.tools = this.getActiveTools(); } else { this.operation.pendingMutations.activeToolNames = [...toolNames]; } } - async setSystemPromptInputs(inputs: SystemPromptInputs): Promise { - if (this.operation.idle) { - this.conversation.systemPromptInputs = inputs; - this.agent.state.systemPrompt = buildSystemPrompt(this.conversation.systemPromptInputs, this.env.cwd); - } else { - this.operation.pendingMutations.systemPromptInputs = inputs; - } - } - async abort(): Promise { this.operation.abortRequested = true; const clearedSteer = [...this.operation.steerQueue]; @@ -600,15 +602,13 @@ export class AgentHarness { return () => handlers!.delete(handler as any); } - private expandSkillCommand(text: string): string { + private expandSkillCommand(text: string, skills: Skill[]): string { if (!text.startsWith("/skill:")) return text; const spaceIndex = text.indexOf(" "); const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex); const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim(); - const skill = this.skills.find((candidate) => candidate.name === skillName); + const skill = skills.find((candidate) => candidate.name === skillName); if (!skill) return text; - let content = readFileSync(skill.filePath, "utf8"); - content = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "").trim(); - return args ? `${content}\n\n${args}` : content; + return args ? `${skill.content}\n\n${args}` : skill.content; } } diff --git a/packages/agent/src/harness/env/nodejs.ts b/packages/agent/src/harness/env/nodejs.ts new file mode 100644 index 00000000..d4b6c0a9 --- /dev/null +++ b/packages/agent/src/harness/env/nodejs.ts @@ -0,0 +1,370 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { access, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; +import { type ExecutionEnv, FileError, type FileInfo, type FileKind } from "../types.js"; + +function resolvePath(cwd: string, path: string): string { + return isAbsolute(path) ? path : resolve(cwd, path); +} + +function fileKindFromStats(stats: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }): FileKind { + if (stats.isFile()) return "file"; + if (stats.isDirectory()) return "directory"; + if (stats.isSymbolicLink()) return "symlink"; + throw new FileError("invalid", "Unsupported file type"); +} + +function fileInfoFromStats( + path: string, + stats: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean; size: number; mtimeMs: number }, +): FileInfo { + return { + name: path.replace(/\/+$/, "").split("/").pop() ?? path, + path, + kind: fileKindFromStats(stats), + size: stats.size, + mtimeMs: stats.mtimeMs, + }; +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +function toFileError(error: unknown, path?: string): FileError { + if (error instanceof FileError) return error; + if (isNodeError(error)) { + const message = error.message; + switch (error.code) { + case "ENOENT": + return new FileError("not_found", message, path, { cause: error }); + case "EACCES": + case "EPERM": + return new FileError("permission_denied", message, path, { cause: error }); + case "ENOTDIR": + return new FileError("not_directory", message, path, { cause: error }); + case "EISDIR": + return new FileError("is_directory", message, path, { cause: error }); + case "EINVAL": + return new FileError("invalid", message, path, { cause: error }); + } + } + return new FileError("unknown", error instanceof Error ? error.message : String(error), path, { cause: error }); +} + +async function pathExists(path: string): Promise { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +async function runCommand( + command: string, + args: string[], + timeoutMs: number, +): Promise<{ stdout: string; status: number | null }> { + return await new Promise((resolve) => { + let stdout = ""; + const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"] }); + const timeout = setTimeout(() => { + if (child.pid) killProcessTree(child.pid); + }, timeoutMs); + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdout += chunk; + }); + child.on("error", () => { + clearTimeout(timeout); + resolve({ stdout: "", status: null }); + }); + child.on("close", (status) => { + clearTimeout(timeout); + resolve({ stdout, status }); + }); + }); +} + +async function findBashOnPath(): Promise { + const result = + process.platform === "win32" + ? await runCommand("where", ["bash.exe"], 5000) + : await runCommand("which", ["bash"], 5000); + if (result.status !== 0 || !result.stdout) return null; + const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; + return firstMatch && (await pathExists(firstMatch)) ? firstMatch : null; +} + +async function getShellConfig(customShellPath?: string): Promise<{ shell: string; args: string[] }> { + if (customShellPath) { + if (await pathExists(customShellPath)) { + return { shell: customShellPath, args: ["-c"] }; + } + throw new Error(`Custom shell path not found: ${customShellPath}`); + } + if (process.platform === "win32") { + const candidates: string[] = []; + const programFiles = process.env.ProgramFiles; + if (programFiles) candidates.push(`${programFiles}\\Git\\bin\\bash.exe`); + const programFilesX86 = process.env["ProgramFiles(x86)"]; + if (programFilesX86) candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`); + for (const candidate of candidates) { + if (await pathExists(candidate)) { + return { shell: candidate, args: ["-c"] }; + } + } + const bashOnPath = await findBashOnPath(); + if (bashOnPath) { + return { shell: bashOnPath, args: ["-c"] }; + } + throw new Error("No bash shell found"); + } + + if (await pathExists("/bin/bash")) { + return { shell: "/bin/bash", args: ["-c"] }; + } + const bashOnPath = await findBashOnPath(); + if (bashOnPath) { + return { shell: bashOnPath, args: ["-c"] }; + } + return { shell: "sh", args: ["-c"] }; +} + +function getShellEnv(baseEnv?: NodeJS.ProcessEnv, extraEnv?: Record): NodeJS.ProcessEnv { + return { + ...process.env, + ...baseEnv, + ...extraEnv, + }; +} + +function killProcessTree(pid: number): void { + if (process.platform === "win32") { + try { + spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { + stdio: "ignore", + detached: true, + }); + } catch { + // Ignore errors. + } + return; + } + + try { + process.kill(-pid, "SIGKILL"); + } catch { + try { + process.kill(pid, "SIGKILL"); + } catch { + // Process already dead. + } + } +} + +export class NodeExecutionEnv implements ExecutionEnv { + cwd: string; + private shellPath?: string; + private shellEnv?: NodeJS.ProcessEnv; + + constructor(options: { cwd: string; shellPath?: string; shellEnv?: NodeJS.ProcessEnv }) { + this.cwd = options.cwd; + this.shellPath = options.shellPath; + this.shellEnv = options.shellEnv; + } + + async exec( + command: string, + options?: { + cwd?: string; + env?: Record; + timeout?: number; + signal?: AbortSignal; + onStdout?: (chunk: string) => void; + onStderr?: (chunk: string) => void; + }, + ): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd; + const { shell, args } = await getShellConfig(this.shellPath); + + return await new Promise((resolvePromise, reject) => { + let stdout = ""; + let stderr = ""; + let settled = false; + let timedOut = false; + const child = spawn(shell, [...args, command], { + cwd, + detached: process.platform !== "win32", + env: getShellEnv(this.shellEnv, options?.env), + stdio: ["ignore", "pipe", "pipe"], + }); + + const timeoutId = + typeof options?.timeout === "number" + ? setTimeout(() => { + timedOut = true; + if (child.pid) { + killProcessTree(child.pid); + } + }, options.timeout * 1000) + : undefined; + + const onAbort = () => { + if (child.pid) { + killProcessTree(child.pid); + } + }; + if (options?.signal) { + if (options.signal.aborted) { + onAbort(); + } else { + options.signal.addEventListener("abort", onAbort, { once: true }); + } + } + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdout += chunk; + options?.onStdout?.(chunk); + }); + child.stderr?.on("data", (chunk: string) => { + stderr += chunk; + options?.onStderr?.(chunk); + }); + + child.on("error", (error) => { + if (timeoutId) clearTimeout(timeoutId); + if (options?.signal) options.signal.removeEventListener("abort", onAbort); + if (settled) return; + settled = true; + reject(error); + }); + + child.on("close", (code) => { + if (timeoutId) clearTimeout(timeoutId); + if (options?.signal) options.signal.removeEventListener("abort", onAbort); + if (settled) return; + settled = true; + if (options?.signal?.aborted) { + reject(new Error("aborted")); + return; + } + if (timedOut) { + reject(new Error(`timeout:${options?.timeout}`)); + return; + } + resolvePromise({ stdout, stderr, exitCode: code ?? 0 }); + }); + }); + } + + async readTextFile(path: string): Promise { + const resolved = resolvePath(this.cwd, path); + try { + return await readFile(resolved, "utf8"); + } catch (error) { + throw toFileError(error, resolved); + } + } + + async readBinaryFile(path: string): Promise { + const resolved = resolvePath(this.cwd, path); + try { + return await readFile(resolved); + } catch (error) { + throw toFileError(error, resolved); + } + } + + async writeFile(path: string, content: string | Uint8Array): Promise { + const resolved = resolvePath(this.cwd, path); + try { + await mkdir(resolve(resolved, ".."), { recursive: true }); + await writeFile(resolved, content); + } catch (error) { + throw toFileError(error, resolved); + } + } + + async fileInfo(path: string): Promise { + const resolved = resolvePath(this.cwd, path); + try { + return fileInfoFromStats(resolved, await lstat(resolved)); + } catch (error) { + throw toFileError(error, resolved); + } + } + + async listDir(path: string): Promise { + const resolved = resolvePath(this.cwd, path); + try { + const entries = await readdir(resolved, { withFileTypes: true }); + const infos: FileInfo[] = []; + for (const entry of entries) { + const entryPath = resolve(resolved, entry.name); + try { + infos.push(fileInfoFromStats(entryPath, await lstat(entryPath))); + } catch (error) { + if (error instanceof FileError && error.code === "invalid") continue; + throw error; + } + } + return infos; + } catch (error) { + throw toFileError(error, resolved); + } + } + + async realPath(path: string): Promise { + const resolved = resolvePath(this.cwd, path); + try { + return await realpath(resolved); + } catch (error) { + throw toFileError(error, resolved); + } + } + + async exists(path: string): Promise { + try { + await this.fileInfo(path); + return true; + } catch (error) { + if (error instanceof FileError && error.code === "not_found") return false; + throw error; + } + } + + async createDir(path: string, options?: { recursive?: boolean }): Promise { + await mkdir(resolvePath(this.cwd, path), { recursive: options?.recursive }); + } + + async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise { + const resolved = resolvePath(this.cwd, path); + try { + await rm(resolved, { recursive: options?.recursive ?? false, force: options?.force ?? false }); + } catch (error) { + throw toFileError(error, resolved); + } + } + + async createTempDir(prefix: string = "tmp-"): Promise { + return await mkdtemp(join(tmpdir(), prefix)); + } + + async createTempFile(options?: { prefix?: string; suffix?: string }): Promise { + const dir = await this.createTempDir("tmp-"); + const filePath = join(dir, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`); + await writeFile(filePath, ""); + return filePath; + } + + async cleanup(): Promise { + // nothing to clean up for the local node implementation + } +} diff --git a/packages/agent/src/harness/execution-env.ts b/packages/agent/src/harness/execution-env.ts index 0419ea1d..786586e5 100644 --- a/packages/agent/src/harness/execution-env.ts +++ b/packages/agent/src/harness/execution-env.ts @@ -1,273 +1,3 @@ -import { spawn, spawnSync } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { isAbsolute, join, resolve } from "node:path"; -import type { ExecutionEnv } from "./types.js"; - -function resolvePath(cwd: string, path: string): string { - return isAbsolute(path) ? path : resolve(cwd, path); -} - -function findBashOnPath(): string | null { - if (process.platform === "win32") { - try { - const result = spawnSync("where", ["bash.exe"], { encoding: "utf-8", timeout: 5000 }); - if (result.status === 0 && result.stdout) { - const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; - if (firstMatch && existsSync(firstMatch)) { - return firstMatch; - } - } - } catch { - // Ignore errors. - } - return null; - } - - try { - const result = spawnSync("which", ["bash"], { encoding: "utf-8", timeout: 5000 }); - if (result.status === 0 && result.stdout) { - const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; - if (firstMatch) { - return firstMatch; - } - } - } catch { - // Ignore errors. - } - return null; -} - -function getShellConfig(customShellPath?: string): { shell: string; args: string[] } { - if (customShellPath) { - if (existsSync(customShellPath)) { - return { shell: customShellPath, args: ["-c"] }; - } - throw new Error(`Custom shell path not found: ${customShellPath}`); - } - if (process.platform === "win32") { - const candidates: string[] = []; - const programFiles = process.env.ProgramFiles; - if (programFiles) candidates.push(`${programFiles}\\Git\\bin\\bash.exe`); - const programFilesX86 = process.env["ProgramFiles(x86)"]; - if (programFilesX86) candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`); - for (const candidate of candidates) { - if (existsSync(candidate)) { - return { shell: candidate, args: ["-c"] }; - } - } - const bashOnPath = findBashOnPath(); - if (bashOnPath) { - return { shell: bashOnPath, args: ["-c"] }; - } - throw new Error("No bash shell found"); - } - - if (existsSync("/bin/bash")) { - return { shell: "/bin/bash", args: ["-c"] }; - } - const bashOnPath = findBashOnPath(); - if (bashOnPath) { - return { shell: bashOnPath, args: ["-c"] }; - } - return { shell: "sh", args: ["-c"] }; -} - -function getShellEnv(baseEnv?: NodeJS.ProcessEnv, extraEnv?: Record): NodeJS.ProcessEnv { - return { - ...process.env, - ...baseEnv, - ...extraEnv, - }; -} - -function killProcessTree(pid: number): void { - if (process.platform === "win32") { - try { - spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { - stdio: "ignore", - detached: true, - }); - } catch { - // Ignore errors. - } - return; - } - - try { - process.kill(-pid, "SIGKILL"); - } catch { - try { - process.kill(pid, "SIGKILL"); - } catch { - // Process already dead. - } - } -} - -export class NodeExecutionEnv implements ExecutionEnv { - cwd: string; - private shellPath?: string; - private shellEnv?: NodeJS.ProcessEnv; - - constructor(options: { cwd: string; shellPath?: string; shellEnv?: NodeJS.ProcessEnv }) { - this.cwd = options.cwd; - this.shellPath = options.shellPath; - this.shellEnv = options.shellEnv; - } - - async exec( - command: string, - options?: { - cwd?: string; - env?: Record; - timeout?: number; - signal?: AbortSignal; - onStdout?: (chunk: string) => void; - onStderr?: (chunk: string) => void; - }, - ): Promise<{ stdout: string; stderr: string; exitCode: number }> { - const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd; - const { shell, args } = getShellConfig(this.shellPath); - - return await new Promise((resolvePromise, reject) => { - let stdout = ""; - let stderr = ""; - let settled = false; - let timedOut = false; - const child = spawn(shell, [...args, command], { - cwd, - detached: process.platform !== "win32", - env: getShellEnv(this.shellEnv, options?.env), - stdio: ["ignore", "pipe", "pipe"], - }); - - const timeoutId = - typeof options?.timeout === "number" - ? setTimeout(() => { - timedOut = true; - if (child.pid) { - killProcessTree(child.pid); - } - }, options.timeout * 1000) - : undefined; - - const onAbort = () => { - if (child.pid) { - killProcessTree(child.pid); - } - }; - if (options?.signal) { - if (options.signal.aborted) { - onAbort(); - } else { - options.signal.addEventListener("abort", onAbort, { once: true }); - } - } - - child.stdout?.setEncoding("utf8"); - child.stderr?.setEncoding("utf8"); - child.stdout?.on("data", (chunk: string) => { - stdout += chunk; - options?.onStdout?.(chunk); - }); - child.stderr?.on("data", (chunk: string) => { - stderr += chunk; - options?.onStderr?.(chunk); - }); - - child.on("error", (error) => { - if (timeoutId) clearTimeout(timeoutId); - if (options?.signal) options.signal.removeEventListener("abort", onAbort); - if (settled) return; - settled = true; - reject(error); - }); - - child.on("close", (code) => { - if (timeoutId) clearTimeout(timeoutId); - if (options?.signal) options.signal.removeEventListener("abort", onAbort); - if (settled) return; - settled = true; - if (options?.signal?.aborted) { - reject(new Error("aborted")); - return; - } - if (timedOut) { - reject(new Error(`timeout:${options?.timeout}`)); - return; - } - resolvePromise({ stdout, stderr, exitCode: code ?? 0 }); - }); - }); - } - - async readTextFile(path: string): Promise { - return await readFile(resolvePath(this.cwd, path), "utf8"); - } - - async readBinaryFile(path: string): Promise { - return await readFile(resolvePath(this.cwd, path)); - } - - async writeFile(path: string, content: string | Uint8Array): Promise { - const resolved = resolvePath(this.cwd, path); - await mkdir(resolve(resolved, ".."), { recursive: true }); - await writeFile(resolved, content); - } - - async stat( - path: string, - ): Promise<{ isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean; size: number; mtime: Date }> { - const s = await stat(resolvePath(this.cwd, path)); - return { - isFile: s.isFile(), - isDirectory: s.isDirectory(), - isSymbolicLink: s.isSymbolicLink(), - size: s.size, - mtime: s.mtime, - }; - } - - async listDir(path: string): Promise { - return await readdir(resolvePath(this.cwd, path)); - } - - async pathExists(path: string): Promise { - try { - await stat(resolvePath(this.cwd, path)); - return true; - } catch { - return false; - } - } - - async createDir(path: string, options?: { recursive?: boolean }): Promise { - await mkdir(resolvePath(this.cwd, path), { recursive: options?.recursive }); - } - - async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise { - await rm(resolvePath(this.cwd, path), { recursive: options?.recursive, force: options?.force }); - } - - async createTempDir(prefix: string = "tmp-"): Promise { - return await mkdtemp(join(tmpdir(), prefix)); - } - - async createTempFile(options?: { prefix?: string; suffix?: string }): Promise { - const dir = await this.createTempDir("tmp-"); - const filePath = join(dir, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`); - await writeFile(filePath, ""); - return filePath; - } - - resolvePath(path: string): string { - return resolvePath(this.cwd, path); - } - - async cleanup(): Promise { - // nothing to clean up for the local node implementation - } -} +export { NodeExecutionEnv } from "./env/nodejs.js"; +export type { ExecutionEnv, ExecutionEnvExecOptions, FileErrorCode, FileInfo, FileKind } from "./types.js"; +export { FileError } from "./types.js"; diff --git a/packages/agent/src/harness/prompt-templates.ts b/packages/agent/src/harness/prompt-templates.ts index a05336d3..19aca1d0 100644 --- a/packages/agent/src/harness/prompt-templates.ts +++ b/packages/agent/src/harness/prompt-templates.ts @@ -1,4 +1,100 @@ -import type { PromptTemplate } from "./types.js"; +import { parse } from "yaml"; +import type { ExecutionEnv, FileInfo, PromptTemplate } from "./types.js"; + +interface PromptTemplateFrontmatter { + description?: string; + "argument-hint"?: string; + [key: string]: unknown; +} + +export async function loadPromptTemplates(env: ExecutionEnv, paths: string | string[]): Promise { + const templates: PromptTemplate[] = []; + for (const path of Array.isArray(paths) ? paths : [paths]) { + const info = await safeFileInfo(env, path); + if (!info) continue; + const kind = await resolveKind(env, info); + if (kind === "directory") { + templates.push(...(await loadTemplatesFromDir(env, info.path))); + } else if (kind === "file" && info.name.endsWith(".md")) { + const template = await loadTemplateFromFile(env, info.path); + if (template) templates.push(template); + } + } + return templates; +} + +async function loadTemplatesFromDir(env: ExecutionEnv, dir: string): Promise { + const templates: PromptTemplate[] = []; + let entries: FileInfo[]; + try { + entries = await env.listDir(dir); + } catch { + return templates; + } + + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const kind = await resolveKind(env, entry); + if (kind !== "file" || !entry.name.endsWith(".md")) continue; + const template = await loadTemplateFromFile(env, entry.path); + if (template) templates.push(template); + } + return templates; +} + +async function loadTemplateFromFile(env: ExecutionEnv, filePath: string): Promise { + try { + const rawContent = await env.readTextFile(filePath); + const { frontmatter, body } = parseFrontmatter(rawContent); + const firstLine = body.split("\n").find((line) => line.trim()); + let description = typeof frontmatter.description === "string" ? frontmatter.description : ""; + if (!description && firstLine) { + description = firstLine.slice(0, 60); + if (firstLine.length > 60) description += "..."; + } + return { + name: basenameEnvPath(filePath).replace(/\.md$/i, ""), + description, + content: body, + }; + } catch { + return null; + } +} + +async function safeFileInfo(env: ExecutionEnv, path: string): Promise { + try { + return await env.fileInfo(path); + } catch { + return undefined; + } +} + +async function resolveKind(env: ExecutionEnv, info: FileInfo): Promise<"file" | "directory" | undefined> { + if (info.kind === "file" || info.kind === "directory") return info.kind; + try { + const realPath = await env.realPath(info.path); + const target = await env.fileInfo(realPath); + return target.kind === "file" || target.kind === "directory" ? target.kind : undefined; + } catch { + return undefined; + } +} + +function parseFrontmatter>(content: string): { frontmatter: T; body: string } { + const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!normalized.startsWith("---")) return { frontmatter: {} as T, body: normalized }; + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) return { frontmatter: {} as T, body: normalized }; + const yamlString = normalized.slice(4, endIndex); + const body = normalized.slice(endIndex + 4).trim(); + return { frontmatter: (parse(yamlString) ?? {}) as T, body }; +} + +function basenameEnvPath(path: string): string { + const normalized = path.replace(/\/+$/, ""); + const slashIndex = normalized.lastIndexOf("/"); + return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1); +} export function parseCommandArgs(argsString: string): string[] { const args: string[] = []; diff --git a/packages/agent/src/harness/skills.ts b/packages/agent/src/harness/skills.ts new file mode 100644 index 00000000..b65850ee --- /dev/null +++ b/packages/agent/src/harness/skills.ts @@ -0,0 +1,265 @@ +import ignore from "ignore"; +import { parse } from "yaml"; +import type { ExecutionEnv, Skill } from "./types.js"; + +const MAX_NAME_LENGTH = 64; +const MAX_DESCRIPTION_LENGTH = 1024; +const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"]; + +type IgnoreMatcher = ReturnType; + +export interface SkillDiagnostic { + type: "warning"; + message: string; + path: string; +} + +interface SkillFrontmatter { + name?: string; + description?: string; + "disable-model-invocation"?: boolean; + [key: string]: unknown; +} + +export async function loadSkills(env: ExecutionEnv, dirs: string | string[]): Promise { + return (await loadSkillsWithDiagnostics(env, dirs)).skills; +} + +export async function loadSkillsWithDiagnostics( + env: ExecutionEnv, + dirs: string | string[], +): Promise<{ skills: Skill[]; diagnostics: SkillDiagnostic[] }> { + const skills: Skill[] = []; + const diagnostics: SkillDiagnostic[] = []; + for (const dir of Array.isArray(dirs) ? dirs : [dirs]) { + const rootInfo = await safeFileInfo(env, dir); + if (!rootInfo || (await resolveKind(env, rootInfo)) !== "directory") continue; + const result = await loadSkillsFromDirInternal(env, rootInfo.path, true, ignore(), rootInfo.path); + skills.push(...result.skills); + diagnostics.push(...result.diagnostics); + } + return { skills, diagnostics }; +} + +async function loadSkillsFromDirInternal( + env: ExecutionEnv, + dir: string, + includeRootFiles: boolean, + ignoreMatcher: IgnoreMatcher, + rootDir: string, +): Promise<{ skills: Skill[]; diagnostics: SkillDiagnostic[] }> { + const skills: Skill[] = []; + const diagnostics: SkillDiagnostic[] = []; + + if (!(await env.exists(dir))) return { skills, diagnostics }; + const dirInfo = await safeFileInfo(env, dir); + if (!dirInfo || (await resolveKind(env, dirInfo)) !== "directory") return { skills, diagnostics }; + + await addIgnoreRules(env, ignoreMatcher, dir, rootDir); + + let entries: Awaited>; + try { + entries = await env.listDir(dir); + } catch { + return { skills, diagnostics }; + } + + for (const entry of entries) { + if (entry.name !== "SKILL.md") continue; + const fullPath = entry.path; + const kind = await resolveKind(env, entry); + if (kind !== "file") continue; + const relPath = relativeEnvPath(rootDir, fullPath); + if (ignoreMatcher.ignores(relPath)) continue; + + const result = await loadSkillFromFile(env, fullPath); + if (result.skill) skills.push(result.skill); + diagnostics.push(...result.diagnostics); + return { skills, diagnostics }; + } + + 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); + if (!kind) continue; + + const relPath = relativeEnvPath(rootDir, fullPath); + const ignorePath = kind === "directory" ? `${relPath}/` : relPath; + if (ignoreMatcher.ignores(ignorePath)) continue; + + if (kind === "directory") { + const result = await loadSkillsFromDirInternal(env, fullPath, false, ignoreMatcher, rootDir); + skills.push(...result.skills); + diagnostics.push(...result.diagnostics); + continue; + } + + if (kind !== "file" || !includeRootFiles || !entry.name.endsWith(".md")) continue; + const result = await loadSkillFromFile(env, fullPath); + if (result.skill) skills.push(result.skill); + diagnostics.push(...result.diagnostics); + } + + return { skills, diagnostics }; +} + +async function addIgnoreRules(env: ExecutionEnv, ig: IgnoreMatcher, dir: string, rootDir: string): Promise { + const relativeDir = relativeEnvPath(rootDir, dir); + const prefix = relativeDir ? `${relativeDir}/` : ""; + + for (const filename of IGNORE_FILE_NAMES) { + const ignorePath = joinEnvPath(dir, filename); + const info = await safeFileInfo(env, ignorePath); + if (info?.kind !== "file") continue; + try { + const content = await env.readTextFile(ignorePath); + const patterns = content + .split(/\r?\n/) + .map((line) => prefixIgnorePattern(line, prefix)) + .filter((line): line is string => Boolean(line)); + if (patterns.length > 0) ig.add(patterns); + } catch {} + } +} + +function prefixIgnorePattern(line: string, prefix: string): string | null { + const trimmed = line.trim(); + if (!trimmed) return null; + if (trimmed.startsWith("#") && !trimmed.startsWith("\\#")) return null; + + let pattern = line; + let negated = false; + if (pattern.startsWith("!")) { + negated = true; + pattern = pattern.slice(1); + } else if (pattern.startsWith("\\!")) { + pattern = pattern.slice(1); + } + if (pattern.startsWith("/")) pattern = pattern.slice(1); + const prefixed = prefix ? `${prefix}${pattern}` : pattern; + return negated ? `!${prefixed}` : prefixed; +} + +async function loadSkillFromFile( + env: ExecutionEnv, + filePath: string, +): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> { + const diagnostics: SkillDiagnostic[] = []; + try { + const rawContent = await env.readTextFile(filePath); + const { frontmatter, body } = parseFrontmatter(rawContent); + const skillDir = dirnameEnvPath(filePath); + const parentDirName = basenameEnvPath(skillDir); + + for (const error of validateDescription(frontmatter.description)) { + diagnostics.push({ type: "warning", message: error, path: filePath }); + } + + const name = frontmatter.name || parentDirName; + for (const error of validateName(name, parentDirName)) { + diagnostics.push({ type: "warning", message: error, path: filePath }); + } + + if (!frontmatter.description || frontmatter.description.trim() === "") { + return { skill: null, diagnostics }; + } + + return { + skill: { + name, + description: frontmatter.description, + content: body, + filePath, + disableModelInvocation: frontmatter["disable-model-invocation"] === true, + }, + diagnostics, + }; + } catch (error) { + const message = error instanceof Error ? error.message : "failed to parse skill file"; + diagnostics.push({ type: "warning", message, path: filePath }); + return { skill: null, diagnostics }; + } +} + +function validateName(name: string, parentDirName: string): string[] { + const errors: string[] = []; + if (name !== parentDirName) errors.push(`name "${name}" does not match parent directory "${parentDirName}"`); + if (name.length > MAX_NAME_LENGTH) errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`); + if (!/^[a-z0-9-]+$/.test(name)) { + errors.push("name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)"); + } + if (name.startsWith("-") || name.endsWith("-")) errors.push("name must not start or end with a hyphen"); + if (name.includes("--")) errors.push("name must not contain consecutive hyphens"); + return errors; +} + +function validateDescription(description: string | undefined): string[] { + const errors: string[] = []; + if (!description || description.trim() === "") { + errors.push("description is required"); + } else if (description.length > MAX_DESCRIPTION_LENGTH) { + errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`); + } + return errors; +} + +function parseFrontmatter>(content: string): { frontmatter: T; body: string } { + const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!normalized.startsWith("---")) return { frontmatter: {} as T, body: normalized }; + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) return { frontmatter: {} as T, body: normalized }; + const yamlString = normalized.slice(4, endIndex); + const body = normalized.slice(endIndex + 4).trim(); + return { frontmatter: (parse(yamlString) ?? {}) as T, body }; +} + +async function safeFileInfo( + env: ExecutionEnv, + path: string, +): Promise> | undefined> { + try { + return await env.fileInfo(path); + } catch { + return undefined; + } +} + +async function resolveKind( + env: ExecutionEnv, + info: Awaited>, +): Promise<"file" | "directory" | undefined> { + if (info.kind === "file" || info.kind === "directory") return info.kind; + try { + const realPath = await env.realPath(info.path); + const target = await env.fileInfo(realPath); + return target.kind === "file" || target.kind === "directory" ? target.kind : undefined; + } catch { + return undefined; + } +} + +function joinEnvPath(base: string, child: string): string { + return `${base.replace(/\/+$/, "")}/${child.replace(/^\/+/, "")}`; +} + +function dirnameEnvPath(path: string): string { + const normalized = path.replace(/\/+$/, ""); + const slashIndex = normalized.lastIndexOf("/"); + return slashIndex <= 0 ? "/" : normalized.slice(0, slashIndex); +} + +function basenameEnvPath(path: string): string { + const normalized = path.replace(/\/+$/, ""); + const slashIndex = normalized.lastIndexOf("/"); + return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1); +} + +function relativeEnvPath(root: string, path: string): string { + const normalizedRoot = root.replace(/\/+$/, ""); + const normalizedPath = path.replace(/\/+$/, ""); + if (normalizedPath === normalizedRoot) return ""; + return normalizedPath.startsWith(`${normalizedRoot}/`) + ? normalizedPath.slice(normalizedRoot.length + 1) + : normalizedPath.replace(/^\/+/, ""); +} diff --git a/packages/agent/src/harness/system-prompt.ts b/packages/agent/src/harness/system-prompt.ts new file mode 100644 index 00000000..7a1d1542 --- /dev/null +++ b/packages/agent/src/harness/system-prompt.ts @@ -0,0 +1,34 @@ +import type { Skill } from "./types.js"; + +export function formatSkillsForSystemPrompt(skills: Skill[]): string { + const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation); + if (visibleSkills.length === 0) return ""; + + const lines = [ + "The following skills provide specialized instructions for specific tasks.", + "Use the read tool to load a skill's file when the task matches its description.", + "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.", + "", + "", + ]; + + for (const skill of visibleSkills) { + lines.push(" "); + lines.push(` ${escapeXml(skill.name)}`); + lines.push(` ${escapeXml(skill.description)}`); + lines.push(` ${escapeXml(skill.filePath)}`); + lines.push(" "); + } + + lines.push(""); + return lines.join("\n"); +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/packages/agent/src/harness/types.ts b/packages/agent/src/harness/types.ts index 12224706..64b27fdc 100644 --- a/packages/agent/src/harness/types.ts +++ b/packages/agent/src/harness/types.ts @@ -1,78 +1,126 @@ import type { ImageContent, Model, TextContent } from "@mariozechner/pi-ai"; -import type { AgentEvent, AgentMessage, ThinkingLevel } from "../index.js"; +import type { AgentEvent, AgentMessage, AgentTool, ThinkingLevel } from "../index.js"; import type { Session } from "./session/session.js"; -export type SourceScope = "user" | "project" | "temporary"; -export type SourceOrigin = "package" | "top-level"; - -export interface SourceInfo { - path: string; - source: string; - scope: SourceScope; - origin: SourceOrigin; - baseDir?: string; -} - export interface Skill { name: string; description: string; + content: string; filePath: string; - baseDir: string; - sourceInfo: SourceInfo; - disableModelInvocation: boolean; + disableModelInvocation?: boolean; } export interface PromptTemplate { name: string; - description: string; - argumentHint?: string; + description?: string; content: string; - sourceInfo: SourceInfo; - filePath: string; } -export interface SystemPromptInputs { - basePrompt?: string; - appendPrompt?: string; - contextFiles?: Array<{ path: string; content: string }>; +export interface AgentHarnessResources { + promptTemplates?: PromptTemplate[]; skills?: Skill[]; } +/** Kind of filesystem object as addressed by an {@link ExecutionEnv}. Symlinks are not followed automatically. */ +export type FileKind = "file" | "directory" | "symlink"; + +/** Stable, backend-independent file error codes thrown by {@link ExecutionEnv} file operations. */ +export type FileErrorCode = + | "not_found" + | "permission_denied" + | "not_directory" + | "is_directory" + | "invalid" + | "not_supported" + | "unknown"; + +/** Error thrown by {@link ExecutionEnv} file operations. */ +export class FileError extends Error { + constructor( + /** Backend-independent error code. */ + public code: FileErrorCode, + message: string, + /** Absolute addressed path associated with the failure, when available. */ + public path?: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "FileError"; + } +} + +/** Metadata for one filesystem object in an {@link ExecutionEnv}. */ +export interface FileInfo { + /** Basename of {@link path}. */ + name: string; + /** Absolute, syntactically normalized addressed path in the execution environment. Symlinks are not followed. */ + path: string; + /** Object kind. Symlink targets are not followed; use {@link ExecutionEnv.resolvePath} explicitly. */ + kind: FileKind; + /** Size in bytes for the addressed filesystem object. */ + size: number; + /** Modification time as milliseconds since Unix epoch. */ + mtimeMs: number; +} + +/** Options for {@link ExecutionEnv.exec}. */ export interface ExecutionEnvExecOptions { + /** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. */ cwd?: string; + /** Additional environment variables for the command. Values override the environment defaults. */ env?: Record; + /** Timeout in seconds. Implementations should reject when the command exceeds this duration. */ timeout?: number; + /** Abort signal used to terminate the command. */ signal?: AbortSignal; + /** Called with stdout chunks as they are produced. */ onStdout?: (chunk: string) => void; + /** Called with stderr chunks as they are produced. */ onStderr?: (chunk: string) => void; } +/** + * Filesystem and process execution environment used by the harness. + * + * Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by this interface are absolute + * addressed paths in the environment, but are not canonicalized through symlinks unless returned by {@link resolvePath}. + * + * File operations throw {@link FileError} for expected filesystem failures such as missing paths or permission errors. + */ export interface ExecutionEnv { + /** Current working directory for relative paths and command execution. */ cwd: string; + /** Execute a shell command in {@link cwd} unless `options.cwd` is provided. */ exec( command: string, options?: ExecutionEnvExecOptions, ): Promise<{ stdout: string; stderr: string; exitCode: number }>; + /** Read a UTF-8 text file. Throws {@link FileError}. */ readTextFile(path: string): Promise; + /** Read a binary file. Throws {@link FileError}. */ readBinaryFile(path: string): Promise; + /** Create or overwrite a file, creating parent directories when supported. Throws {@link FileError}. */ writeFile(path: string, content: string | Uint8Array): Promise; - stat(path: string): Promise<{ - isFile: boolean; - isDirectory: boolean; - isSymbolicLink: boolean; - size: number; - mtime: Date; - }>; - listDir(path: string): Promise; - pathExists(path: string): Promise; + /** Return metadata for the addressed path without following symlinks. Throws {@link FileError}. */ + fileInfo(path: string): Promise; + /** List direct children of a directory without following symlinks. Throws {@link FileError}. */ + listDir(path: string): Promise; + /** Return the canonical path for a path, following symlinks. Throws {@link FileError}. */ + realPath(path: string): Promise; + /** Return false for missing paths. Other errors, such as permission failures, may throw {@link FileError}. */ + exists(path: string): Promise; + /** Create a directory. */ createDir(path: string, options?: { recursive?: boolean }): Promise; + /** Remove a file or directory. */ remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise; + /** Create a temporary directory and return its absolute path. */ createTempDir(prefix?: string): Promise; + /** Create a temporary file and return its absolute path. */ createTempFile(options?: { prefix?: string; suffix?: string }): Promise; - resolvePath(path: string): string; + /** Release resources owned by the environment. */ cleanup(): Promise; } @@ -225,15 +273,13 @@ export interface AgentHarnessPendingMutations { model?: Model; thinkingLevel?: ThinkingLevel; activeToolNames?: string[]; - systemPromptInputs?: SystemPromptInputs; } export interface AgentHarnessConversationState { session: Session; - model: Model | undefined; + model: Model; thinkingLevel: ThinkingLevel; activeToolNames: string[]; - systemPromptInputs: SystemPromptInputs; nextTurnQueue: AgentMessage[]; } @@ -290,7 +336,7 @@ export interface BeforeAgentStartEvent { prompt: string; images?: ImageContent[]; systemPrompt: string; - systemPromptInputs: SystemPromptInputs; + resources: AgentHarnessResources; } export interface ContextEvent { @@ -521,13 +567,24 @@ export interface BranchSummaryResult { export interface AgentHarnessOptions { env: ExecutionEnv; session: Session; - promptTemplates?: PromptTemplate[]; - skills?: Skill[]; + tools?: AgentTool[]; + resources?: + | AgentHarnessResources + | ((context: AgentHarnessContext) => AgentHarnessResources | Promise); + systemPrompt?: + | string + | ((context: { + env: ExecutionEnv; + session: Session; + model: Model; + thinkingLevel: ThinkingLevel; + activeTools: AgentTool[]; + resources: AgentHarnessResources; + }) => string | Promise); requestAuth?: (model: Model) => Promise<{ apiKey: string; headers?: Record } | undefined>; - initialModel: Model; - initialThinkingLevel?: ThinkingLevel; - initialActiveToolNames?: string[]; - initialSystemPromptInputs?: SystemPromptInputs; + model: Model; + thinkingLevel?: ThinkingLevel; + activeToolNames?: string[]; } export type { AgentHarness } from "./agent-harness.js"; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index e4b7f66f..256a7ce6 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -30,6 +30,8 @@ export * from "./harness/session/repo/jsonl.js"; export * from "./harness/session/repo/memory.js"; export * from "./harness/session/repo/shared.js"; export * from "./harness/session/session.js"; +export * from "./harness/skills.js"; +export * from "./harness/system-prompt.js"; // Harness export * from "./harness/types.js"; export * from "./harness/utils/shell-output.js"; diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 94be88cb..bd59a41b 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -100,6 +100,37 @@ describe("Agent", () => { expect(eventCount).toBe(0); // Should not increase }); + it("emits full lifecycle events for thrown run failures", async () => { + const agent = new Agent({ + streamFn: () => { + throw new Error("provider exploded"); + }, + }); + const events: string[] = []; + agent.subscribe((event) => { + events.push(event.type); + }); + + await agent.prompt("hello"); + + expect(events).toEqual([ + "agent_start", + "turn_start", + "message_start", + "message_end", + "message_start", + "message_end", + "turn_end", + "agent_end", + ]); + const lastMessage = agent.state.messages[agent.state.messages.length - 1]; + expect(lastMessage?.role).toBe("assistant"); + if (lastMessage?.role !== "assistant") throw new Error("Expected assistant message"); + expect(lastMessage.stopReason).toBe("error"); + expect(lastMessage.errorMessage).toBe("provider exploded"); + expect(agent.state.errorMessage).toBe("provider exploded"); + }); + it("should await async subscribers before prompt resolves", async () => { const barrier = createDeferred(); const agent = new Agent({ diff --git a/packages/agent/test/harness/factory.test.ts b/packages/agent/test/harness/factory.test.ts index 0033d1a3..6faa134e 100644 --- a/packages/agent/test/harness/factory.test.ts +++ b/packages/agent/test/harness/factory.test.ts @@ -17,8 +17,11 @@ describe("harness factories", () => { it("creates agent harnesses", () => { const session = createSession(new InMemorySessionStorage()); const env = new NodeExecutionEnv({ cwd: process.cwd() }); - const harness = createAgentHarness({ env, session, initialModel: getModel("anthropic", "claude-sonnet-4-5") }); + const initialModel = getModel("anthropic", "claude-sonnet-4-5"); + const harness = createAgentHarness({ env, session, model: initialModel, systemPrompt: "You are helpful." }); expect(harness.env).toBe(env); expect(harness.conversation.session).toBe(session); + expect(harness.conversation.model).toBe(initialModel); + expect(harness.agent.state.model).toBe(initialModel); }); }); diff --git a/packages/agent/test/harness/nodejs-env.test.ts b/packages/agent/test/harness/nodejs-env.test.ts new file mode 100644 index 00000000..2059c075 --- /dev/null +++ b/packages/agent/test/harness/nodejs-env.test.ts @@ -0,0 +1,149 @@ +import { access, chmod, realpath, symlink } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { FileError, NodeExecutionEnv } from "../../src/harness/execution-env.js"; +import { createTempDir } from "./session-test-utils.js"; + +const chmodRestorePaths: string[] = []; + +afterEach(async () => { + for (const path of chmodRestorePaths.splice(0)) { + try { + await access(path); + await chmod(path, 0o700); + } catch {} + } +}); + +describe("NodeExecutionEnv", () => { + it("reads, writes, lists, and removes files and directories", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + await env.createDir("nested", { recursive: true }); + await env.writeFile("nested/file.txt", "hello"); + expect(await env.readTextFile("nested/file.txt")).toBe("hello"); + expect(Buffer.from(await env.readBinaryFile("nested/file.txt")).toString("utf8")).toBe("hello"); + + const entries = await env.listDir("nested"); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + name: "file.txt", + path: join(root, "nested/file.txt"), + kind: "file", + size: 5, + }); + expect(typeof entries[0]!.mtimeMs).toBe("number"); + + expect(await env.exists("nested/file.txt")).toBe(true); + await env.remove("nested/file.txt"); + expect(await env.exists("nested/file.txt")).toBe(false); + }); + + it("returns fileInfo for files, directories, and symlinks without following symlinks", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + await env.createDir("dir", { recursive: true }); + await env.writeFile("dir/file.txt", "hello"); + await symlink(join(root, "dir/file.txt"), join(root, "file-link")); + await symlink(join(root, "dir"), join(root, "dir-link")); + + expect(await env.fileInfo("dir")).toMatchObject({ name: "dir", path: join(root, "dir"), kind: "directory" }); + expect(await env.fileInfo("dir/file.txt")).toMatchObject({ + name: "file.txt", + path: join(root, "dir/file.txt"), + kind: "file", + size: 5, + }); + expect(await env.fileInfo("file-link")).toMatchObject({ + name: "file-link", + path: join(root, "file-link"), + kind: "symlink", + }); + expect(await env.fileInfo("dir-link")).toMatchObject({ + name: "dir-link", + path: join(root, "dir-link"), + kind: "symlink", + }); + expect(await env.realPath("file-link")).toBe(await realpath(join(root, "dir/file.txt"))); + }); + + it("lists symlinks as symlinks", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + await env.writeFile("target.txt", "hello"); + await symlink(join(root, "target.txt"), join(root, "link.txt")); + + const entries = await env.listDir("."); + expect( + entries.map((entry) => ({ name: entry.name, kind: entry.kind })).sort((a, b) => a.name.localeCompare(b.name)), + ).toEqual([ + { name: "link.txt", kind: "symlink" }, + { name: "target.txt", kind: "file" }, + ]); + }); + + it("throws FileError for missing paths and keeps exists false for missing paths", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + await expect(env.fileInfo("missing.txt")).rejects.toMatchObject({ + name: "FileError", + code: "not_found", + path: join(root, "missing.txt"), + }); + expect(await env.exists("missing.txt")).toBe(false); + }); + + it("throws FileError for listing non-directories", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + await env.writeFile("file.txt", "hello"); + await expect(env.listDir("file.txt")).rejects.toBeInstanceOf(FileError); + await expect(env.listDir("file.txt")).rejects.toMatchObject({ code: "not_directory" }); + }); + + it("creates temporary directories and files", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const tempDir = await env.createTempDir("node-env-test-"); + await expect(access(tempDir)).resolves.toBeUndefined(); + const tempFile = await env.createTempFile({ prefix: "prefix-", suffix: ".txt" }); + await expect(access(tempFile)).resolves.toBeUndefined(); + expect(tempFile.endsWith(".txt")).toBe(true); + }); + + it("executes commands in cwd with env overrides", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const result = await env.exec('printf \'%s:%s\' "$PWD" "$NODE_ENV_TEST"', { + env: { NODE_ENV_TEST: "ok" }, + }); + expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 }); + }); + + it("streams stdout and stderr chunks", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + let stdout = ""; + let stderr = ""; + const result = await env.exec("printf out; printf err >&2", { + onStdout: (chunk) => { + stdout += chunk; + }, + onStderr: (chunk) => { + stderr += chunk; + }, + }); + expect(result).toEqual({ stdout: "out", stderr: "err", exitCode: 0 }); + expect(stdout).toBe("out"); + expect(stderr).toBe("err"); + }); + + it("rejects aborted commands", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const controller = new AbortController(); + const promise = env.exec("sleep 5", { signal: controller.signal }); + controller.abort(); + await expect(promise).rejects.toThrow("aborted"); + }); +}); diff --git a/packages/agent/test/harness/prompt-templates.test.ts b/packages/agent/test/harness/prompt-templates.test.ts new file mode 100644 index 00000000..0902c7fd --- /dev/null +++ b/packages/agent/test/harness/prompt-templates.test.ts @@ -0,0 +1,46 @@ +import { symlink } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { NodeExecutionEnv } from "../../src/harness/execution-env.js"; +import { expandPromptTemplate, loadPromptTemplates } from "../../src/harness/prompt-templates.js"; +import { createTempDir } from "./session-test-utils.js"; + +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"); + + const templates = await loadPromptTemplates(env, ["a", "b"]); + + expect(templates).toEqual([ + { name: "one", description: "One template", content: "Hello $1" }, + { name: "two", description: "First line description", content: "First line description\nBody" }, + ]); + }); + + 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 symlink(join(root, "target.md"), join(root, "link.md")); + + expect(await loadPromptTemplates(env, ["target.md", "link.md"])).toEqual([ + { name: "target", description: "Target", content: "Target body" }, + { name: "link", description: "Target", content: "Target body" }, + ]); + }); +}); + +describe("expandPromptTemplate", () => { + it("substitutes command arguments", () => { + const content = "$1 $" + "{@:2} $ARGUMENTS"; + expect(expandPromptTemplate('/one "hello world" test', [{ name: "one", content }])).toBe( + "hello world test hello world test", + ); + }); +}); diff --git a/packages/agent/test/harness/session-repo.test.ts b/packages/agent/test/harness/repo.test.ts similarity index 100% rename from packages/agent/test/harness/session-repo.test.ts rename to packages/agent/test/harness/repo.test.ts diff --git a/packages/agent/test/harness/session-tree.test.ts b/packages/agent/test/harness/session.test.ts similarity index 100% rename from packages/agent/test/harness/session-tree.test.ts rename to packages/agent/test/harness/session.test.ts diff --git a/packages/agent/test/harness/skills.test.ts b/packages/agent/test/harness/skills.test.ts new file mode 100644 index 00000000..b7918f93 --- /dev/null +++ b/packages/agent/test/harness/skills.test.ts @@ -0,0 +1,65 @@ +import { symlink } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { NodeExecutionEnv } from "../../src/harness/execution-env.js"; +import { loadSkills } from "../../src/harness/skills.js"; +import { createTempDir } from "./session-test-utils.js"; + +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.writeFile( + ".agents/skills/example/SKILL.md", + `--- +name: example +description: Example skill +disable-model-invocation: true +--- +Use this skill. +`, + ); + + const skills = await loadSkills(env, ".agents/skills"); + + expect(skills).toEqual([ + { + name: "example", + description: "Example skill", + content: "Use this skill.", + filePath: join(root, ".agents/skills/example/SKILL.md"), + disableModelInvocation: true, + }, + ]); + }); + + 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.writeFile( + "actual/example/SKILL.md", + "---\nname: example\ndescription: Example skill\n---\nUse this skill.", + ); + await symlink(join(root, "actual"), join(root, "skills-link")); + + const skills = await loadSkills(env, "skills-link"); + + expect(skills.map((skill) => skill.name)).toEqual(["example"]); + expect(skills[0]?.filePath).toBe(join(root, "skills-link/example/SKILL.md")); + }); + + 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"); + + const skills = await loadSkills(env, "skills"); + + expect(skills.map((skill) => skill.name)).toEqual(["skills"]); + expect(skills[0]?.content).toBe("Root content"); + }); +}); diff --git a/packages/agent/test/harness/session-storage.test.ts b/packages/agent/test/harness/storage.test.ts similarity index 100% rename from packages/agent/test/harness/session-storage.test.ts rename to packages/agent/test/harness/storage.test.ts diff --git a/packages/agent/test/harness/system-prompt.test.ts b/packages/agent/test/harness/system-prompt.test.ts new file mode 100644 index 00000000..5e9dc8e6 --- /dev/null +++ b/packages/agent/test/harness/system-prompt.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { formatSkillsForSystemPrompt } from "../../src/harness/system-prompt.js"; + +describe("formatSkillsForSystemPrompt", () => { + it("formats visible skills and skips model-disabled skills", () => { + expect( + formatSkillsForSystemPrompt([ + { + name: "visible", + description: "Use & that", + content: "visible content", + filePath: "/skills/visible/SKILL.md", + }, + { + name: "hidden", + description: "Hidden", + content: "hidden content", + filePath: "/skills/hidden/SKILL.md", + disableModelInvocation: true, + }, + ]), + ).toContain("visible"); + expect( + formatSkillsForSystemPrompt([ + { + name: "visible", + description: "Use & that", + content: "visible content", + filePath: "/skills/visible/SKILL.md", + }, + ]), + ).toContain("Use <this> & that"); + expect( + formatSkillsForSystemPrompt([ + { + name: "hidden", + description: "Hidden", + content: "hidden content", + filePath: "/skills/hidden/SKILL.md", + disableModelInvocation: true, + }, + ]), + ).toBe(""); + }); +}); diff --git a/packages/agent/test/scratch/simple.ts b/packages/agent/test/scratch/simple.ts index 9c3f7dcf..172aaf36 100644 --- a/packages/agent/test/scratch/simple.ts +++ b/packages/agent/test/scratch/simple.ts @@ -1,13 +1,31 @@ import { getModel } from "@mariozechner/pi-ai"; import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js"; -import { createAgentHarness, NodeExecutionEnv, Session } from "../../src/index.js"; +import { + createAgentHarness, + formatSkillsForSystemPrompt, + loadSkills, + NodeExecutionEnv, + Session, +} from "../../src/index.js"; +const env = new NodeExecutionEnv({ cwd: process.cwd() }); +const skills = await loadSkills(env, "/Users/badlogic/.pi/agent/skills"); const session = new Session(new InMemorySessionStorage()); const agent = createAgentHarness({ - env: new NodeExecutionEnv({ cwd: process.cwd() }), + env, session, - initialModel: getModel("openai-codex", "gpt-5.5"), + model: getModel("openai", "gpt-5.5"), + thinkingLevel: "low", + systemPrompt: ({ env, resources }) => + [ + `You are a helpful assistant.`, + formatSkillsForSystemPrompt(resources.skills ?? []), + `Current working directory: ${env.cwd}`, + ] + .filter((part) => part.length > 0) + .join("\n\n"), + resources: { skills }, }); -const response = await agent.prompt("What is 2 + 2?"); +const response = await agent.prompt("What skills do you have?"); console.log(response);