refactor(agent): tighten harness environment and resources

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

View File

@@ -100,6 +100,37 @@ describe("Agent", () => {
expect(eventCount).toBe(0); // Should not increase
});
it("emits full lifecycle events for thrown run failures", async () => {
const agent = new Agent({
streamFn: () => {
throw new Error("provider exploded");
},
});
const events: string[] = [];
agent.subscribe((event) => {
events.push(event.type);
});
await agent.prompt("hello");
expect(events).toEqual([
"agent_start",
"turn_start",
"message_start",
"message_end",
"message_start",
"message_end",
"turn_end",
"agent_end",
]);
const lastMessage = agent.state.messages[agent.state.messages.length - 1];
expect(lastMessage?.role).toBe("assistant");
if (lastMessage?.role !== "assistant") throw new Error("Expected assistant message");
expect(lastMessage.stopReason).toBe("error");
expect(lastMessage.errorMessage).toBe("provider exploded");
expect(agent.state.errorMessage).toBe("provider exploded");
});
it("should await async subscribers before prompt resolves", async () => {
const barrier = createDeferred();
const agent = new Agent({

View File

@@ -17,8 +17,11 @@ describe("harness factories", () => {
it("creates agent harnesses", () => {
const session = createSession(new InMemorySessionStorage());
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const harness = createAgentHarness({ env, session, initialModel: getModel("anthropic", "claude-sonnet-4-5") });
const initialModel = getModel("anthropic", "claude-sonnet-4-5");
const harness = createAgentHarness({ env, session, model: initialModel, systemPrompt: "You are helpful." });
expect(harness.env).toBe(env);
expect(harness.conversation.session).toBe(session);
expect(harness.conversation.model).toBe(initialModel);
expect(harness.agent.state.model).toBe(initialModel);
});
});

View File

@@ -0,0 +1,149 @@
import { access, chmod, realpath, symlink } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { FileError, NodeExecutionEnv } from "../../src/harness/execution-env.js";
import { createTempDir } from "./session-test-utils.js";
const chmodRestorePaths: string[] = [];
afterEach(async () => {
for (const path of chmodRestorePaths.splice(0)) {
try {
await access(path);
await chmod(path, 0o700);
} catch {}
}
});
describe("NodeExecutionEnv", () => {
it("reads, writes, lists, and removes files and directories", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.createDir("nested", { recursive: true });
await env.writeFile("nested/file.txt", "hello");
expect(await env.readTextFile("nested/file.txt")).toBe("hello");
expect(Buffer.from(await env.readBinaryFile("nested/file.txt")).toString("utf8")).toBe("hello");
const entries = await env.listDir("nested");
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
name: "file.txt",
path: join(root, "nested/file.txt"),
kind: "file",
size: 5,
});
expect(typeof entries[0]!.mtimeMs).toBe("number");
expect(await env.exists("nested/file.txt")).toBe(true);
await env.remove("nested/file.txt");
expect(await env.exists("nested/file.txt")).toBe(false);
});
it("returns fileInfo for files, directories, and symlinks without following symlinks", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.createDir("dir", { recursive: true });
await env.writeFile("dir/file.txt", "hello");
await symlink(join(root, "dir/file.txt"), join(root, "file-link"));
await symlink(join(root, "dir"), join(root, "dir-link"));
expect(await env.fileInfo("dir")).toMatchObject({ name: "dir", path: join(root, "dir"), kind: "directory" });
expect(await env.fileInfo("dir/file.txt")).toMatchObject({
name: "file.txt",
path: join(root, "dir/file.txt"),
kind: "file",
size: 5,
});
expect(await env.fileInfo("file-link")).toMatchObject({
name: "file-link",
path: join(root, "file-link"),
kind: "symlink",
});
expect(await env.fileInfo("dir-link")).toMatchObject({
name: "dir-link",
path: join(root, "dir-link"),
kind: "symlink",
});
expect(await env.realPath("file-link")).toBe(await realpath(join(root, "dir/file.txt")));
});
it("lists symlinks as symlinks", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.writeFile("target.txt", "hello");
await symlink(join(root, "target.txt"), join(root, "link.txt"));
const entries = await env.listDir(".");
expect(
entries.map((entry) => ({ name: entry.name, kind: entry.kind })).sort((a, b) => a.name.localeCompare(b.name)),
).toEqual([
{ name: "link.txt", kind: "symlink" },
{ name: "target.txt", kind: "file" },
]);
});
it("throws FileError for missing paths and keeps exists false for missing paths", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await expect(env.fileInfo("missing.txt")).rejects.toMatchObject({
name: "FileError",
code: "not_found",
path: join(root, "missing.txt"),
});
expect(await env.exists("missing.txt")).toBe(false);
});
it("throws FileError for listing non-directories", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.writeFile("file.txt", "hello");
await expect(env.listDir("file.txt")).rejects.toBeInstanceOf(FileError);
await expect(env.listDir("file.txt")).rejects.toMatchObject({ code: "not_directory" });
});
it("creates temporary directories and files", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const tempDir = await env.createTempDir("node-env-test-");
await expect(access(tempDir)).resolves.toBeUndefined();
const tempFile = await env.createTempFile({ prefix: "prefix-", suffix: ".txt" });
await expect(access(tempFile)).resolves.toBeUndefined();
expect(tempFile.endsWith(".txt")).toBe(true);
});
it("executes commands in cwd with env overrides", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = await env.exec('printf \'%s:%s\' "$PWD" "$NODE_ENV_TEST"', {
env: { NODE_ENV_TEST: "ok" },
});
expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 });
});
it("streams stdout and stderr chunks", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
let stdout = "";
let stderr = "";
const result = await env.exec("printf out; printf err >&2", {
onStdout: (chunk) => {
stdout += chunk;
},
onStderr: (chunk) => {
stderr += chunk;
},
});
expect(result).toEqual({ stdout: "out", stderr: "err", exitCode: 0 });
expect(stdout).toBe("out");
expect(stderr).toBe("err");
});
it("rejects aborted commands", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const controller = new AbortController();
const promise = env.exec("sleep 5", { signal: controller.signal });
controller.abort();
await expect(promise).rejects.toThrow("aborted");
});
});

View File

@@ -0,0 +1,46 @@
import { symlink } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/execution-env.js";
import { expandPromptTemplate, loadPromptTemplates } from "../../src/harness/prompt-templates.js";
import { createTempDir } from "./session-test-utils.js";
describe("loadPromptTemplates", () => {
it("loads markdown templates non-recursively from one or more dirs", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.createDir("a/nested", { recursive: true });
await env.createDir("b", { recursive: true });
await env.writeFile("a/one.md", "---\ndescription: One template\n---\nHello $1");
await env.writeFile("a/nested/ignored.md", "Ignored");
await env.writeFile("b/two.md", "First line description\nBody");
const templates = await loadPromptTemplates(env, ["a", "b"]);
expect(templates).toEqual([
{ name: "one", description: "One template", content: "Hello $1" },
{ name: "two", description: "First line description", content: "First line description\nBody" },
]);
});
it("loads explicit markdown files and symlinked files", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.writeFile("target.md", "---\ndescription: Target\n---\nTarget body");
await symlink(join(root, "target.md"), join(root, "link.md"));
expect(await loadPromptTemplates(env, ["target.md", "link.md"])).toEqual([
{ name: "target", description: "Target", content: "Target body" },
{ name: "link", description: "Target", content: "Target body" },
]);
});
});
describe("expandPromptTemplate", () => {
it("substitutes command arguments", () => {
const content = "$1 $" + "{@:2} $ARGUMENTS";
expect(expandPromptTemplate('/one "hello world" test', [{ name: "one", content }])).toBe(
"hello world test hello world test",
);
});
});

View File

@@ -0,0 +1,65 @@
import { symlink } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/execution-env.js";
import { loadSkills } from "../../src/harness/skills.js";
import { createTempDir } from "./session-test-utils.js";
describe("loadSkills", () => {
it("loads SKILL.md files through the execution environment", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.createDir(".agents/skills/example", { recursive: true });
await env.writeFile(
".agents/skills/example/SKILL.md",
`---
name: example
description: Example skill
disable-model-invocation: true
---
Use this skill.
`,
);
const skills = await loadSkills(env, ".agents/skills");
expect(skills).toEqual([
{
name: "example",
description: "Example skill",
content: "Use this skill.",
filePath: join(root, ".agents/skills/example/SKILL.md"),
disableModelInvocation: true,
},
]);
});
it("loads skills through symlinked directories", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.createDir("actual/example", { recursive: true });
await env.writeFile(
"actual/example/SKILL.md",
"---\nname: example\ndescription: Example skill\n---\nUse this skill.",
);
await symlink(join(root, "actual"), join(root, "skills-link"));
const skills = await loadSkills(env, "skills-link");
expect(skills.map((skill) => skill.name)).toEqual(["example"]);
expect(skills[0]?.filePath).toBe(join(root, "skills-link/example/SKILL.md"));
});
it("loads direct markdown children only from the root directory", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.createDir("skills/nested", { recursive: true });
await env.writeFile("skills/root.md", "---\ndescription: Root skill\n---\nRoot content");
await env.writeFile("skills/nested/ignored.md", "---\ndescription: Ignored\n---\nIgnored content");
const skills = await loadSkills(env, "skills");
expect(skills.map((skill) => skill.name)).toEqual(["skills"]);
expect(skills[0]?.content).toBe("Root content");
});
});

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { formatSkillsForSystemPrompt } from "../../src/harness/system-prompt.js";
describe("formatSkillsForSystemPrompt", () => {
it("formats visible skills and skips model-disabled skills", () => {
expect(
formatSkillsForSystemPrompt([
{
name: "visible",
description: "Use <this> & that",
content: "visible content",
filePath: "/skills/visible/SKILL.md",
},
{
name: "hidden",
description: "Hidden",
content: "hidden content",
filePath: "/skills/hidden/SKILL.md",
disableModelInvocation: true,
},
]),
).toContain("<name>visible</name>");
expect(
formatSkillsForSystemPrompt([
{
name: "visible",
description: "Use <this> & that",
content: "visible content",
filePath: "/skills/visible/SKILL.md",
},
]),
).toContain("Use &lt;this&gt; &amp; that");
expect(
formatSkillsForSystemPrompt([
{
name: "hidden",
description: "Hidden",
content: "hidden content",
filePath: "/skills/hidden/SKILL.md",
disableModelInvocation: true,
},
]),
).toBe("");
});
});

View File

@@ -1,13 +1,31 @@
import { getModel } from "@mariozechner/pi-ai";
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
import { createAgentHarness, NodeExecutionEnv, Session } from "../../src/index.js";
import {
createAgentHarness,
formatSkillsForSystemPrompt,
loadSkills,
NodeExecutionEnv,
Session,
} from "../../src/index.js";
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const skills = await loadSkills(env, "/Users/badlogic/.pi/agent/skills");
const session = new Session(new InMemorySessionStorage());
const agent = createAgentHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
env,
session,
initialModel: getModel("openai-codex", "gpt-5.5"),
model: getModel("openai", "gpt-5.5"),
thinkingLevel: "low",
systemPrompt: ({ env, resources }) =>
[
`You are a helpful assistant.`,
formatSkillsForSystemPrompt(resources.skills ?? []),
`Current working directory: ${env.cwd}`,
]
.filter((part) => part.length > 0)
.join("\n\n"),
resources: { skills },
});
const response = await agent.prompt("What is 2 + 2?");
const response = await agent.prompt("What skills do you have?");
console.log(response);