refactor(agent): tighten harness environment and resources
This commit is contained in:
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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] });
|
||||
}
|
||||
|
||||
|
||||
@@ -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
370
packages/agent/src/harness/env/nodejs.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
265
packages/agent/src/harness/skills.ts
Normal file
265
packages/agent/src/harness/skills.ts
Normal 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(/^\/+/, "");
|
||||
}
|
||||
34
packages/agent/src/harness/system-prompt.ts
Normal file
34
packages/agent/src/harness/system-prompt.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
149
packages/agent/test/harness/nodejs-env.test.ts
Normal file
149
packages/agent/test/harness/nodejs-env.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
46
packages/agent/test/harness/prompt-templates.test.ts
Normal file
46
packages/agent/test/harness/prompt-templates.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
65
packages/agent/test/harness/skills.test.ts
Normal file
65
packages/agent/test/harness/skills.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
45
packages/agent/test/harness/system-prompt.test.ts
Normal file
45
packages/agent/test/harness/system-prompt.test.ts
Normal 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 <this> & that");
|
||||
expect(
|
||||
formatSkillsForSystemPrompt([
|
||||
{
|
||||
name: "hidden",
|
||||
description: "Hidden",
|
||||
content: "hidden content",
|
||||
filePath: "/skills/hidden/SKILL.md",
|
||||
disableModelInvocation: true,
|
||||
},
|
||||
]),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user