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",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/pi-ai": "^0.72.1",
|
"@mariozechner/pi-ai": "^0.72.1",
|
||||||
"typebox": "^1.1.24"
|
"ignore": "^7.0.5",
|
||||||
|
"typebox": "^1.1.24",
|
||||||
|
"yaml": "^2.8.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.3.0",
|
"@types/node": "^24.3.0",
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/pi-ai": "^0.72.1",
|
"@mariozechner/pi-ai": "^0.72.1",
|
||||||
"typebox": "^1.1.24"
|
"ignore": "^7.0.5",
|
||||||
|
"typebox": "^1.1.24",
|
||||||
|
"yaml": "^2.8.2"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ai",
|
"ai",
|
||||||
|
|||||||
@@ -472,8 +472,9 @@ export class Agent {
|
|||||||
errorMessage: error instanceof Error ? error.message : String(error),
|
errorMessage: error instanceof Error ? error.message : String(error),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
} satisfies AgentMessage;
|
} satisfies AgentMessage;
|
||||||
this._state.messages.push(failureMessage);
|
await this.processEvents({ type: "message_start", message: failureMessage });
|
||||||
this._state.errorMessage = failureMessage.errorMessage;
|
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] });
|
await this.processEvents({ type: "agent_end", messages: [failureMessage] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import type { AssistantMessage, ImageContent, Model } from "@mariozechner/pi-ai";
|
import type { AssistantMessage, ImageContent, Model } from "@mariozechner/pi-ai";
|
||||||
import { Agent } from "../agent.js";
|
import { Agent } from "../agent.js";
|
||||||
import type { AgentEvent, AgentMessage, AgentTool, ThinkingLevel } from "../types.js";
|
import type { AgentEvent, AgentMessage, AgentTool, ThinkingLevel } from "../types.js";
|
||||||
@@ -15,34 +14,13 @@ import type {
|
|||||||
AgentHarnessOperationState,
|
AgentHarnessOperationState,
|
||||||
AgentHarnessOptions,
|
AgentHarnessOptions,
|
||||||
AgentHarnessOwnEvent,
|
AgentHarnessOwnEvent,
|
||||||
|
AgentHarnessResources,
|
||||||
ExecutionEnv,
|
ExecutionEnv,
|
||||||
NavigateTreeResult,
|
NavigateTreeResult,
|
||||||
PromptTemplate,
|
|
||||||
Session,
|
Session,
|
||||||
Skill,
|
Skill,
|
||||||
SystemPromptInputs,
|
|
||||||
} from "./types.js";
|
} 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 {
|
function createUserMessage(text: string, images?: ImageContent[]): AgentMessage {
|
||||||
const content: Array<{ type: "text"; text: string } | ImageContent> = [{ type: "text", text }];
|
const content: Array<{ type: "text"; text: string } | ImageContent> = [{ type: "text", text }];
|
||||||
if (images) content.push(...images);
|
if (images) content.push(...images);
|
||||||
@@ -56,8 +34,8 @@ export class AgentHarness {
|
|||||||
readonly operation: AgentHarnessOperationState;
|
readonly operation: AgentHarnessOperationState;
|
||||||
|
|
||||||
private session: Session;
|
private session: Session;
|
||||||
private promptTemplates: PromptTemplate[];
|
private resourcesInput?: AgentHarnessOptions["resources"];
|
||||||
private skills: Skill[];
|
private systemPrompt: AgentHarnessOptions["systemPrompt"];
|
||||||
private requestAuth?: AgentHarnessOptions["requestAuth"];
|
private requestAuth?: AgentHarnessOptions["requestAuth"];
|
||||||
private toolRegistry = new Map<string, AgentTool>();
|
private toolRegistry = new Map<string, AgentTool>();
|
||||||
private listeners = new Set<(event: AgentHarnessEvent, signal?: AbortSignal) => Promise<void> | void>();
|
private listeners = new Set<(event: AgentHarnessEvent, signal?: AbortSignal) => Promise<void> | void>();
|
||||||
@@ -67,23 +45,35 @@ export class AgentHarness {
|
|||||||
>();
|
>();
|
||||||
|
|
||||||
constructor(options: AgentHarnessOptions) {
|
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.env = options.env;
|
||||||
this.session = options.session;
|
this.session = options.session;
|
||||||
this.promptTemplates = options.promptTemplates ?? [];
|
this.resourcesInput = options.resources;
|
||||||
this.skills = options.skills ?? [];
|
this.systemPrompt = options.systemPrompt;
|
||||||
this.requestAuth = options.requestAuth;
|
this.requestAuth = options.requestAuth;
|
||||||
for (const tool of this.agent.state.tools) {
|
for (const tool of this.agent.state.tools) {
|
||||||
this.toolRegistry.set(tool.name, tool);
|
this.toolRegistry.set(tool.name, tool);
|
||||||
}
|
}
|
||||||
this.conversation = {
|
this.conversation = {
|
||||||
session: options.session,
|
session: options.session,
|
||||||
model: options.initialModel ?? this.agent.state.model,
|
model: options.model,
|
||||||
thinkingLevel: options.initialThinkingLevel ?? this.agent.state.thinkingLevel,
|
thinkingLevel: options.thinkingLevel ?? this.agent.state.thinkingLevel,
|
||||||
activeToolNames: options.initialActiveToolNames ?? this.agent.state.tools.map((tool) => tool.name),
|
activeToolNames: options.activeToolNames ?? this.agent.state.tools.map((tool) => tool.name),
|
||||||
systemPromptInputs: options.initialSystemPromptInputs ?? { skills: this.skills },
|
|
||||||
nextTurnQueue: [],
|
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 = {
|
this.operation = {
|
||||||
idle: true,
|
idle: true,
|
||||||
abortRequested: false,
|
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> {
|
private async emitOwn(event: AgentHarnessOwnEvent, signal?: AbortSignal): Promise<void> {
|
||||||
for (const listener of this.listeners) {
|
for (const listener of this.listeners) {
|
||||||
await listener(event, signal);
|
await listener(event, signal);
|
||||||
@@ -195,7 +210,7 @@ export class AgentHarness {
|
|||||||
if (context.model && this.conversation.model) {
|
if (context.model && this.conversation.model) {
|
||||||
// leave active model untouched; harness-level model is source of truth
|
// 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> {
|
private async applyPendingMutations(): Promise<void> {
|
||||||
@@ -232,12 +247,6 @@ export class AgentHarness {
|
|||||||
this.operation.pendingMutations.activeToolNames = undefined;
|
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();
|
await this.syncFromTree();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,8 +273,7 @@ export class AgentHarness {
|
|||||||
this.operation.pendingMutations.appendMessages.length > 0 ||
|
this.operation.pendingMutations.appendMessages.length > 0 ||
|
||||||
this.operation.pendingMutations.model !== undefined ||
|
this.operation.pendingMutations.model !== undefined ||
|
||||||
this.operation.pendingMutations.thinkingLevel !== undefined ||
|
this.operation.pendingMutations.thinkingLevel !== undefined ||
|
||||||
this.operation.pendingMutations.activeToolNames !== undefined ||
|
this.operation.pendingMutations.activeToolNames !== undefined;
|
||||||
this.operation.pendingMutations.systemPromptInputs !== undefined;
|
|
||||||
await this.emitOwn(
|
await this.emitOwn(
|
||||||
{ type: "save_point", liveOperationId: this.operation.liveOperationId ?? "unknown", hadPendingMutations },
|
{ type: "save_point", liveOperationId: this.operation.liveOperationId ?? "unknown", hadPendingMutations },
|
||||||
signal,
|
signal,
|
||||||
@@ -288,14 +296,18 @@ export class AgentHarness {
|
|||||||
const beforeLength = this.agent.state.messages.length;
|
const beforeLength = this.agent.state.messages.length;
|
||||||
this.operation.idle = false;
|
this.operation.idle = false;
|
||||||
this.operation.liveOperationId = randomUUID();
|
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)];
|
let messages: AgentMessage[] = [createUserMessage(expanded, options?.images)];
|
||||||
if (this.conversation.nextTurnQueue.length > 0) {
|
if (this.conversation.nextTurnQueue.length > 0) {
|
||||||
messages = [messages[0]!, ...this.conversation.nextTurnQueue];
|
messages = [messages[0]!, ...this.conversation.nextTurnQueue];
|
||||||
this.conversation.nextTurnQueue = [];
|
this.conversation.nextTurnQueue = [];
|
||||||
await this.emitQueueUpdate();
|
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(
|
const beforeResult = await this.emitHook(
|
||||||
"before_agent_start",
|
"before_agent_start",
|
||||||
{
|
{
|
||||||
@@ -303,7 +315,7 @@ export class AgentHarness {
|
|||||||
prompt: expanded,
|
prompt: expanded,
|
||||||
images: options?.images,
|
images: options?.images,
|
||||||
systemPrompt: this.agent.state.systemPrompt,
|
systemPrompt: this.agent.state.systemPrompt,
|
||||||
systemPromptInputs: this.conversation.systemPromptInputs,
|
resources,
|
||||||
},
|
},
|
||||||
this.agent.signal,
|
this.agent.signal,
|
||||||
);
|
);
|
||||||
@@ -324,11 +336,10 @@ export class AgentHarness {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async skill(name: string, args?: string): Promise<AssistantMessage> {
|
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}`);
|
if (!skill) throw new Error(`Unknown skill: ${name}`);
|
||||||
let content = readFileSync(skill.filePath, "utf8");
|
const prompt = args ? `${skill.content}\n\n${args}` : skill.content;
|
||||||
content = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "").trim();
|
|
||||||
const prompt = args ? `${content}\n\n${args}` : content;
|
|
||||||
return await this.prompt(prompt);
|
return await this.prompt(prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,21 +557,12 @@ export class AgentHarness {
|
|||||||
async setActiveTools(toolNames: string[]): Promise<void> {
|
async setActiveTools(toolNames: string[]): Promise<void> {
|
||||||
if (this.operation.idle) {
|
if (this.operation.idle) {
|
||||||
this.conversation.activeToolNames = [...toolNames];
|
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 {
|
} else {
|
||||||
this.operation.pendingMutations.activeToolNames = [...toolNames];
|
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> {
|
async abort(): Promise<AbortResult> {
|
||||||
this.operation.abortRequested = true;
|
this.operation.abortRequested = true;
|
||||||
const clearedSteer = [...this.operation.steerQueue];
|
const clearedSteer = [...this.operation.steerQueue];
|
||||||
@@ -600,15 +602,13 @@ export class AgentHarness {
|
|||||||
return () => handlers!.delete(handler as any);
|
return () => handlers!.delete(handler as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
private expandSkillCommand(text: string): string {
|
private expandSkillCommand(text: string, skills: Skill[]): string {
|
||||||
if (!text.startsWith("/skill:")) return text;
|
if (!text.startsWith("/skill:")) return text;
|
||||||
const spaceIndex = text.indexOf(" ");
|
const spaceIndex = text.indexOf(" ");
|
||||||
const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
|
const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
|
||||||
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
|
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;
|
if (!skill) return text;
|
||||||
let content = readFileSync(skill.filePath, "utf8");
|
return args ? `${skill.content}\n\n${args}` : skill.content;
|
||||||
content = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "").trim();
|
|
||||||
return args ? `${content}\n\n${args}` : 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";
|
export { NodeExecutionEnv } from "./env/nodejs.js";
|
||||||
import { randomUUID } from "node:crypto";
|
export type { ExecutionEnv, ExecutionEnvExecOptions, FileErrorCode, FileInfo, FileKind } from "./types.js";
|
||||||
import { existsSync } from "node:fs";
|
export { FileError } from "./types.js";
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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[] {
|
export function parseCommandArgs(argsString: string): string[] {
|
||||||
const args: 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 { 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";
|
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 {
|
export interface Skill {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
content: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
baseDir: string;
|
disableModelInvocation?: boolean;
|
||||||
sourceInfo: SourceInfo;
|
|
||||||
disableModelInvocation: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromptTemplate {
|
export interface PromptTemplate {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description?: string;
|
||||||
argumentHint?: string;
|
|
||||||
content: string;
|
content: string;
|
||||||
sourceInfo: SourceInfo;
|
|
||||||
filePath: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemPromptInputs {
|
export interface AgentHarnessResources {
|
||||||
basePrompt?: string;
|
promptTemplates?: PromptTemplate[];
|
||||||
appendPrompt?: string;
|
|
||||||
contextFiles?: Array<{ path: string; content: string }>;
|
|
||||||
skills?: Skill[];
|
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 {
|
export interface ExecutionEnvExecOptions {
|
||||||
|
/** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. */
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
/** Additional environment variables for the command. Values override the environment defaults. */
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
|
/** Timeout in seconds. Implementations should reject when the command exceeds this duration. */
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
/** Abort signal used to terminate the command. */
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
/** Called with stdout chunks as they are produced. */
|
||||||
onStdout?: (chunk: string) => void;
|
onStdout?: (chunk: string) => void;
|
||||||
|
/** Called with stderr chunks as they are produced. */
|
||||||
onStderr?: (chunk: string) => void;
|
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 {
|
export interface ExecutionEnv {
|
||||||
|
/** Current working directory for relative paths and command execution. */
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
|
||||||
|
/** Execute a shell command in {@link cwd} unless `options.cwd` is provided. */
|
||||||
exec(
|
exec(
|
||||||
command: string,
|
command: string,
|
||||||
options?: ExecutionEnvExecOptions,
|
options?: ExecutionEnvExecOptions,
|
||||||
): Promise<{ stdout: string; stderr: string; exitCode: number }>;
|
): Promise<{ stdout: string; stderr: string; exitCode: number }>;
|
||||||
|
|
||||||
|
/** Read a UTF-8 text file. Throws {@link FileError}. */
|
||||||
readTextFile(path: string): Promise<string>;
|
readTextFile(path: string): Promise<string>;
|
||||||
|
/** Read a binary file. Throws {@link FileError}. */
|
||||||
readBinaryFile(path: string): Promise<Uint8Array>;
|
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>;
|
writeFile(path: string, content: string | Uint8Array): Promise<void>;
|
||||||
stat(path: string): Promise<{
|
/** Return metadata for the addressed path without following symlinks. Throws {@link FileError}. */
|
||||||
isFile: boolean;
|
fileInfo(path: string): Promise<FileInfo>;
|
||||||
isDirectory: boolean;
|
/** List direct children of a directory without following symlinks. Throws {@link FileError}. */
|
||||||
isSymbolicLink: boolean;
|
listDir(path: string): Promise<FileInfo[]>;
|
||||||
size: number;
|
/** Return the canonical path for a path, following symlinks. Throws {@link FileError}. */
|
||||||
mtime: Date;
|
realPath(path: string): Promise<string>;
|
||||||
}>;
|
/** Return false for missing paths. Other errors, such as permission failures, may throw {@link FileError}. */
|
||||||
listDir(path: string): Promise<string[]>;
|
exists(path: string): Promise<boolean>;
|
||||||
pathExists(path: string): Promise<boolean>;
|
/** Create a directory. */
|
||||||
createDir(path: string, options?: { recursive?: boolean }): Promise<void>;
|
createDir(path: string, options?: { recursive?: boolean }): Promise<void>;
|
||||||
|
/** Remove a file or directory. */
|
||||||
remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
|
remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
|
||||||
|
/** Create a temporary directory and return its absolute path. */
|
||||||
createTempDir(prefix?: string): Promise<string>;
|
createTempDir(prefix?: string): Promise<string>;
|
||||||
|
/** Create a temporary file and return its absolute path. */
|
||||||
createTempFile(options?: { prefix?: string; suffix?: string }): Promise<string>;
|
createTempFile(options?: { prefix?: string; suffix?: string }): Promise<string>;
|
||||||
|
|
||||||
resolvePath(path: string): string;
|
/** Release resources owned by the environment. */
|
||||||
cleanup(): Promise<void>;
|
cleanup(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,15 +273,13 @@ export interface AgentHarnessPendingMutations {
|
|||||||
model?: Model<any>;
|
model?: Model<any>;
|
||||||
thinkingLevel?: ThinkingLevel;
|
thinkingLevel?: ThinkingLevel;
|
||||||
activeToolNames?: string[];
|
activeToolNames?: string[];
|
||||||
systemPromptInputs?: SystemPromptInputs;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentHarnessConversationState {
|
export interface AgentHarnessConversationState {
|
||||||
session: Session;
|
session: Session;
|
||||||
model: Model<any> | undefined;
|
model: Model<any>;
|
||||||
thinkingLevel: ThinkingLevel;
|
thinkingLevel: ThinkingLevel;
|
||||||
activeToolNames: string[];
|
activeToolNames: string[];
|
||||||
systemPromptInputs: SystemPromptInputs;
|
|
||||||
nextTurnQueue: AgentMessage[];
|
nextTurnQueue: AgentMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +336,7 @@ export interface BeforeAgentStartEvent {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
images?: ImageContent[];
|
images?: ImageContent[];
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
systemPromptInputs: SystemPromptInputs;
|
resources: AgentHarnessResources;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContextEvent {
|
export interface ContextEvent {
|
||||||
@@ -521,13 +567,24 @@ export interface BranchSummaryResult {
|
|||||||
export interface AgentHarnessOptions {
|
export interface AgentHarnessOptions {
|
||||||
env: ExecutionEnv;
|
env: ExecutionEnv;
|
||||||
session: Session;
|
session: Session;
|
||||||
promptTemplates?: PromptTemplate[];
|
tools?: AgentTool[];
|
||||||
skills?: Skill[];
|
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>;
|
requestAuth?: (model: Model<any>) => Promise<{ apiKey: string; headers?: Record<string, string> } | undefined>;
|
||||||
initialModel: Model<any>;
|
model: Model<any>;
|
||||||
initialThinkingLevel?: ThinkingLevel;
|
thinkingLevel?: ThinkingLevel;
|
||||||
initialActiveToolNames?: string[];
|
activeToolNames?: string[];
|
||||||
initialSystemPromptInputs?: SystemPromptInputs;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { AgentHarness } from "./agent-harness.js";
|
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/memory.js";
|
||||||
export * from "./harness/session/repo/shared.js";
|
export * from "./harness/session/repo/shared.js";
|
||||||
export * from "./harness/session/session.js";
|
export * from "./harness/session/session.js";
|
||||||
|
export * from "./harness/skills.js";
|
||||||
|
export * from "./harness/system-prompt.js";
|
||||||
// Harness
|
// Harness
|
||||||
export * from "./harness/types.js";
|
export * from "./harness/types.js";
|
||||||
export * from "./harness/utils/shell-output.js";
|
export * from "./harness/utils/shell-output.js";
|
||||||
|
|||||||
@@ -100,6 +100,37 @@ describe("Agent", () => {
|
|||||||
expect(eventCount).toBe(0); // Should not increase
|
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 () => {
|
it("should await async subscribers before prompt resolves", async () => {
|
||||||
const barrier = createDeferred();
|
const barrier = createDeferred();
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ describe("harness factories", () => {
|
|||||||
it("creates agent harnesses", () => {
|
it("creates agent harnesses", () => {
|
||||||
const session = createSession(new InMemorySessionStorage());
|
const session = createSession(new InMemorySessionStorage());
|
||||||
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
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.env).toBe(env);
|
||||||
expect(harness.conversation.session).toBe(session);
|
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 { getModel } from "@mariozechner/pi-ai";
|
||||||
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
|
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 session = new Session(new InMemorySessionStorage());
|
||||||
const agent = createAgentHarness({
|
const agent = createAgentHarness({
|
||||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
env,
|
||||||
session,
|
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);
|
console.log(response);
|
||||||
|
|||||||
Reference in New Issue
Block a user