refactor(agent): tighten harness environment and resources

This commit is contained in:
Mario Zechner
2026-05-06 19:41:17 +02:00
parent e1ca501da8
commit 617d8b317d
21 changed files with 1305 additions and 389 deletions

4
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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] });
}

View File

@@ -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<string, AgentTool>();
private listeners = new Set<(event: AgentHarnessEvent, signal?: AbortSignal) => Promise<void> | 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<AgentHarnessResources> {
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<string> {
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<void> {
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<void> {
@@ -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<AssistantMessage> {
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<void> {
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<void> {
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<AbortResult> {
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;
}
}

370
packages/agent/src/harness/env/nodejs.ts vendored Normal file
View File

@@ -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<boolean> {
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<string | null> {
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<string, string>): 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<string, string>;
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<string> {
const resolved = resolvePath(this.cwd, path);
try {
return await readFile(resolved, "utf8");
} catch (error) {
throw toFileError(error, resolved);
}
}
async readBinaryFile(path: string): Promise<Uint8Array> {
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<void> {
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<FileInfo> {
const resolved = resolvePath(this.cwd, path);
try {
return fileInfoFromStats(resolved, await lstat(resolved));
} catch (error) {
throw toFileError(error, resolved);
}
}
async listDir(path: string): Promise<FileInfo[]> {
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<string> {
const resolved = resolvePath(this.cwd, path);
try {
return await realpath(resolved);
} catch (error) {
throw toFileError(error, resolved);
}
}
async exists(path: string): Promise<boolean> {
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<void> {
await mkdir(resolvePath(this.cwd, path), { recursive: options?.recursive });
}
async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void> {
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<string> {
return await mkdtemp(join(tmpdir(), prefix));
}
async createTempFile(options?: { prefix?: string; suffix?: string }): Promise<string> {
const dir = await this.createTempDir("tmp-");
const filePath = join(dir, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`);
await writeFile(filePath, "");
return filePath;
}
async cleanup(): Promise<void> {
// nothing to clean up for the local node implementation
}
}

View File

@@ -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<string, string>): 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<string, string>;
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<string> {
return await readFile(resolvePath(this.cwd, path), "utf8");
}
async readBinaryFile(path: string): Promise<Uint8Array> {
return await readFile(resolvePath(this.cwd, path));
}
async writeFile(path: string, content: string | Uint8Array): Promise<void> {
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<string[]> {
return await readdir(resolvePath(this.cwd, path));
}
async pathExists(path: string): Promise<boolean> {
try {
await stat(resolvePath(this.cwd, path));
return true;
} catch {
return false;
}
}
async createDir(path: string, options?: { recursive?: boolean }): Promise<void> {
await mkdir(resolvePath(this.cwd, path), { recursive: options?.recursive });
}
async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void> {
await rm(resolvePath(this.cwd, path), { recursive: options?.recursive, force: options?.force });
}
async createTempDir(prefix: string = "tmp-"): Promise<string> {
return await mkdtemp(join(tmpdir(), prefix));
}
async createTempFile(options?: { prefix?: string; suffix?: string }): Promise<string> {
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<void> {
// 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";

View File

@@ -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<PromptTemplate[]> {
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<PromptTemplate[]> {
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<PromptTemplate | null> {
try {
const rawContent = await env.readTextFile(filePath);
const { frontmatter, body } = parseFrontmatter<PromptTemplateFrontmatter>(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<FileInfo | undefined> {
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<T extends Record<string, unknown>>(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[] = [];

View File

@@ -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<typeof ignore>;
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<Skill[]> {
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<ReturnType<ExecutionEnv["listDir"]>>;
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<void> {
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<SkillFrontmatter>(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<T extends Record<string, unknown>>(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<Awaited<ReturnType<ExecutionEnv["fileInfo"]>> | undefined> {
try {
return await env.fileInfo(path);
} catch {
return undefined;
}
}
async function resolveKind(
env: ExecutionEnv,
info: Awaited<ReturnType<ExecutionEnv["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 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(/^\/+/, "");
}

View File

@@ -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.",
"",
"<available_skills>",
];
for (const skill of visibleSkills) {
lines.push(" <skill>");
lines.push(` <name>${escapeXml(skill.name)}</name>`);
lines.push(` <description>${escapeXml(skill.description)}</description>`);
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
lines.push(" </skill>");
}
lines.push("</available_skills>");
return lines.join("\n");
}
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}

View File

@@ -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<string, string>;
/** 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<string>;
/** Read a binary file. Throws {@link FileError}. */
readBinaryFile(path: string): Promise<Uint8Array>;
/** Create or overwrite a file, creating parent directories when supported. Throws {@link FileError}. */
writeFile(path: string, content: string | Uint8Array): Promise<void>;
stat(path: string): Promise<{
isFile: boolean;
isDirectory: boolean;
isSymbolicLink: boolean;
size: number;
mtime: Date;
}>;
listDir(path: string): Promise<string[]>;
pathExists(path: string): Promise<boolean>;
/** Return metadata for the addressed path without following symlinks. Throws {@link FileError}. */
fileInfo(path: string): Promise<FileInfo>;
/** List direct children of a directory without following symlinks. Throws {@link FileError}. */
listDir(path: string): Promise<FileInfo[]>;
/** Return the canonical path for a path, following symlinks. Throws {@link FileError}. */
realPath(path: string): Promise<string>;
/** Return false for missing paths. Other errors, such as permission failures, may throw {@link FileError}. */
exists(path: string): Promise<boolean>;
/** Create a directory. */
createDir(path: string, options?: { recursive?: boolean }): Promise<void>;
/** Remove a file or directory. */
remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
/** Create a temporary directory and return its absolute path. */
createTempDir(prefix?: string): Promise<string>;
/** Create a temporary file and return its absolute path. */
createTempFile(options?: { prefix?: string; suffix?: string }): Promise<string>;
resolvePath(path: string): string;
/** Release resources owned by the environment. */
cleanup(): Promise<void>;
}
@@ -225,15 +273,13 @@ export interface AgentHarnessPendingMutations {
model?: Model<any>;
thinkingLevel?: ThinkingLevel;
activeToolNames?: string[];
systemPromptInputs?: SystemPromptInputs;
}
export interface AgentHarnessConversationState {
session: Session;
model: Model<any> | undefined;
model: Model<any>;
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<AgentHarnessResources>);
systemPrompt?:
| string
| ((context: {
env: ExecutionEnv;
session: Session;
model: Model<any>;
thinkingLevel: ThinkingLevel;
activeTools: AgentTool[];
resources: AgentHarnessResources;
}) => string | Promise<string>);
requestAuth?: (model: Model<any>) => Promise<{ apiKey: string; headers?: Record<string, string> } | undefined>;
initialModel: Model<any>;
initialThinkingLevel?: ThinkingLevel;
initialActiveToolNames?: string[];
initialSystemPromptInputs?: SystemPromptInputs;
model: Model<any>;
thinkingLevel?: ThinkingLevel;
activeToolNames?: string[];
}
export type { AgentHarness } from "./agent-harness.js";

View File

@@ -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";

View File

@@ -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({

View File

@@ -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);
});
});

View File

@@ -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");
});
});

View File

@@ -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",
);
});
});

View File

@@ -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");
});
});

View File

@@ -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 <this> & that",
content: "visible content",
filePath: "/skills/visible/SKILL.md",
},
{
name: "hidden",
description: "Hidden",
content: "hidden content",
filePath: "/skills/hidden/SKILL.md",
disableModelInvocation: true,
},
]),
).toContain("<name>visible</name>");
expect(
formatSkillsForSystemPrompt([
{
name: "visible",
description: "Use <this> & that",
content: "visible content",
filePath: "/skills/visible/SKILL.md",
},
]),
).toContain("Use &lt;this&gt; &amp; that");
expect(
formatSkillsForSystemPrompt([
{
name: "hidden",
description: "Hidden",
content: "hidden content",
filePath: "/skills/hidden/SKILL.md",
disableModelInvocation: true,
},
]),
).toBe("");
});
});

View File

@@ -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);