refactor(agent): tighten harness environment and resources
This commit is contained in:
@@ -100,6 +100,37 @@ describe("Agent", () => {
|
||||
expect(eventCount).toBe(0); // Should not increase
|
||||
});
|
||||
|
||||
it("emits full lifecycle events for thrown run failures", async () => {
|
||||
const agent = new Agent({
|
||||
streamFn: () => {
|
||||
throw new Error("provider exploded");
|
||||
},
|
||||
});
|
||||
const events: string[] = [];
|
||||
agent.subscribe((event) => {
|
||||
events.push(event.type);
|
||||
});
|
||||
|
||||
await agent.prompt("hello");
|
||||
|
||||
expect(events).toEqual([
|
||||
"agent_start",
|
||||
"turn_start",
|
||||
"message_start",
|
||||
"message_end",
|
||||
"message_start",
|
||||
"message_end",
|
||||
"turn_end",
|
||||
"agent_end",
|
||||
]);
|
||||
const lastMessage = agent.state.messages[agent.state.messages.length - 1];
|
||||
expect(lastMessage?.role).toBe("assistant");
|
||||
if (lastMessage?.role !== "assistant") throw new Error("Expected assistant message");
|
||||
expect(lastMessage.stopReason).toBe("error");
|
||||
expect(lastMessage.errorMessage).toBe("provider exploded");
|
||||
expect(agent.state.errorMessage).toBe("provider exploded");
|
||||
});
|
||||
|
||||
it("should await async subscribers before prompt resolves", async () => {
|
||||
const barrier = createDeferred();
|
||||
const agent = new Agent({
|
||||
|
||||
@@ -17,8 +17,11 @@ describe("harness factories", () => {
|
||||
it("creates agent harnesses", () => {
|
||||
const session = createSession(new InMemorySessionStorage());
|
||||
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
const harness = createAgentHarness({ env, session, initialModel: getModel("anthropic", "claude-sonnet-4-5") });
|
||||
const initialModel = getModel("anthropic", "claude-sonnet-4-5");
|
||||
const harness = createAgentHarness({ env, session, model: initialModel, systemPrompt: "You are helpful." });
|
||||
expect(harness.env).toBe(env);
|
||||
expect(harness.conversation.session).toBe(session);
|
||||
expect(harness.conversation.model).toBe(initialModel);
|
||||
expect(harness.agent.state.model).toBe(initialModel);
|
||||
});
|
||||
});
|
||||
|
||||
149
packages/agent/test/harness/nodejs-env.test.ts
Normal file
149
packages/agent/test/harness/nodejs-env.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { access, chmod, realpath, symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { FileError, NodeExecutionEnv } from "../../src/harness/execution-env.js";
|
||||
import { createTempDir } from "./session-test-utils.js";
|
||||
|
||||
const chmodRestorePaths: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const path of chmodRestorePaths.splice(0)) {
|
||||
try {
|
||||
await access(path);
|
||||
await chmod(path, 0o700);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
describe("NodeExecutionEnv", () => {
|
||||
it("reads, writes, lists, and removes files and directories", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.createDir("nested", { recursive: true });
|
||||
await env.writeFile("nested/file.txt", "hello");
|
||||
expect(await env.readTextFile("nested/file.txt")).toBe("hello");
|
||||
expect(Buffer.from(await env.readBinaryFile("nested/file.txt")).toString("utf8")).toBe("hello");
|
||||
|
||||
const entries = await env.listDir("nested");
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toMatchObject({
|
||||
name: "file.txt",
|
||||
path: join(root, "nested/file.txt"),
|
||||
kind: "file",
|
||||
size: 5,
|
||||
});
|
||||
expect(typeof entries[0]!.mtimeMs).toBe("number");
|
||||
|
||||
expect(await env.exists("nested/file.txt")).toBe(true);
|
||||
await env.remove("nested/file.txt");
|
||||
expect(await env.exists("nested/file.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns fileInfo for files, directories, and symlinks without following symlinks", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.createDir("dir", { recursive: true });
|
||||
await env.writeFile("dir/file.txt", "hello");
|
||||
await symlink(join(root, "dir/file.txt"), join(root, "file-link"));
|
||||
await symlink(join(root, "dir"), join(root, "dir-link"));
|
||||
|
||||
expect(await env.fileInfo("dir")).toMatchObject({ name: "dir", path: join(root, "dir"), kind: "directory" });
|
||||
expect(await env.fileInfo("dir/file.txt")).toMatchObject({
|
||||
name: "file.txt",
|
||||
path: join(root, "dir/file.txt"),
|
||||
kind: "file",
|
||||
size: 5,
|
||||
});
|
||||
expect(await env.fileInfo("file-link")).toMatchObject({
|
||||
name: "file-link",
|
||||
path: join(root, "file-link"),
|
||||
kind: "symlink",
|
||||
});
|
||||
expect(await env.fileInfo("dir-link")).toMatchObject({
|
||||
name: "dir-link",
|
||||
path: join(root, "dir-link"),
|
||||
kind: "symlink",
|
||||
});
|
||||
expect(await env.realPath("file-link")).toBe(await realpath(join(root, "dir/file.txt")));
|
||||
});
|
||||
|
||||
it("lists symlinks as symlinks", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.writeFile("target.txt", "hello");
|
||||
await symlink(join(root, "target.txt"), join(root, "link.txt"));
|
||||
|
||||
const entries = await env.listDir(".");
|
||||
expect(
|
||||
entries.map((entry) => ({ name: entry.name, kind: entry.kind })).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
).toEqual([
|
||||
{ name: "link.txt", kind: "symlink" },
|
||||
{ name: "target.txt", kind: "file" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws FileError for missing paths and keeps exists false for missing paths", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await expect(env.fileInfo("missing.txt")).rejects.toMatchObject({
|
||||
name: "FileError",
|
||||
code: "not_found",
|
||||
path: join(root, "missing.txt"),
|
||||
});
|
||||
expect(await env.exists("missing.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("throws FileError for listing non-directories", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.writeFile("file.txt", "hello");
|
||||
await expect(env.listDir("file.txt")).rejects.toBeInstanceOf(FileError);
|
||||
await expect(env.listDir("file.txt")).rejects.toMatchObject({ code: "not_directory" });
|
||||
});
|
||||
|
||||
it("creates temporary directories and files", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const tempDir = await env.createTempDir("node-env-test-");
|
||||
await expect(access(tempDir)).resolves.toBeUndefined();
|
||||
const tempFile = await env.createTempFile({ prefix: "prefix-", suffix: ".txt" });
|
||||
await expect(access(tempFile)).resolves.toBeUndefined();
|
||||
expect(tempFile.endsWith(".txt")).toBe(true);
|
||||
});
|
||||
|
||||
it("executes commands in cwd with env overrides", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const result = await env.exec('printf \'%s:%s\' "$PWD" "$NODE_ENV_TEST"', {
|
||||
env: { NODE_ENV_TEST: "ok" },
|
||||
});
|
||||
expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 });
|
||||
});
|
||||
|
||||
it("streams stdout and stderr chunks", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const result = await env.exec("printf out; printf err >&2", {
|
||||
onStdout: (chunk) => {
|
||||
stdout += chunk;
|
||||
},
|
||||
onStderr: (chunk) => {
|
||||
stderr += chunk;
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ stdout: "out", stderr: "err", exitCode: 0 });
|
||||
expect(stdout).toBe("out");
|
||||
expect(stderr).toBe("err");
|
||||
});
|
||||
|
||||
it("rejects aborted commands", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const controller = new AbortController();
|
||||
const promise = env.exec("sleep 5", { signal: controller.signal });
|
||||
controller.abort();
|
||||
await expect(promise).rejects.toThrow("aborted");
|
||||
});
|
||||
});
|
||||
46
packages/agent/test/harness/prompt-templates.test.ts
Normal file
46
packages/agent/test/harness/prompt-templates.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NodeExecutionEnv } from "../../src/harness/execution-env.js";
|
||||
import { expandPromptTemplate, loadPromptTemplates } from "../../src/harness/prompt-templates.js";
|
||||
import { createTempDir } from "./session-test-utils.js";
|
||||
|
||||
describe("loadPromptTemplates", () => {
|
||||
it("loads markdown templates non-recursively from one or more dirs", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.createDir("a/nested", { recursive: true });
|
||||
await env.createDir("b", { recursive: true });
|
||||
await env.writeFile("a/one.md", "---\ndescription: One template\n---\nHello $1");
|
||||
await env.writeFile("a/nested/ignored.md", "Ignored");
|
||||
await env.writeFile("b/two.md", "First line description\nBody");
|
||||
|
||||
const templates = await loadPromptTemplates(env, ["a", "b"]);
|
||||
|
||||
expect(templates).toEqual([
|
||||
{ name: "one", description: "One template", content: "Hello $1" },
|
||||
{ name: "two", description: "First line description", content: "First line description\nBody" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("loads explicit markdown files and symlinked files", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.writeFile("target.md", "---\ndescription: Target\n---\nTarget body");
|
||||
await symlink(join(root, "target.md"), join(root, "link.md"));
|
||||
|
||||
expect(await loadPromptTemplates(env, ["target.md", "link.md"])).toEqual([
|
||||
{ name: "target", description: "Target", content: "Target body" },
|
||||
{ name: "link", description: "Target", content: "Target body" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandPromptTemplate", () => {
|
||||
it("substitutes command arguments", () => {
|
||||
const content = "$1 $" + "{@:2} $ARGUMENTS";
|
||||
expect(expandPromptTemplate('/one "hello world" test', [{ name: "one", content }])).toBe(
|
||||
"hello world test hello world test",
|
||||
);
|
||||
});
|
||||
});
|
||||
65
packages/agent/test/harness/skills.test.ts
Normal file
65
packages/agent/test/harness/skills.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NodeExecutionEnv } from "../../src/harness/execution-env.js";
|
||||
import { loadSkills } from "../../src/harness/skills.js";
|
||||
import { createTempDir } from "./session-test-utils.js";
|
||||
|
||||
describe("loadSkills", () => {
|
||||
it("loads SKILL.md files through the execution environment", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.createDir(".agents/skills/example", { recursive: true });
|
||||
await env.writeFile(
|
||||
".agents/skills/example/SKILL.md",
|
||||
`---
|
||||
name: example
|
||||
description: Example skill
|
||||
disable-model-invocation: true
|
||||
---
|
||||
Use this skill.
|
||||
`,
|
||||
);
|
||||
|
||||
const skills = await loadSkills(env, ".agents/skills");
|
||||
|
||||
expect(skills).toEqual([
|
||||
{
|
||||
name: "example",
|
||||
description: "Example skill",
|
||||
content: "Use this skill.",
|
||||
filePath: join(root, ".agents/skills/example/SKILL.md"),
|
||||
disableModelInvocation: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("loads skills through symlinked directories", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.createDir("actual/example", { recursive: true });
|
||||
await env.writeFile(
|
||||
"actual/example/SKILL.md",
|
||||
"---\nname: example\ndescription: Example skill\n---\nUse this skill.",
|
||||
);
|
||||
await symlink(join(root, "actual"), join(root, "skills-link"));
|
||||
|
||||
const skills = await loadSkills(env, "skills-link");
|
||||
|
||||
expect(skills.map((skill) => skill.name)).toEqual(["example"]);
|
||||
expect(skills[0]?.filePath).toBe(join(root, "skills-link/example/SKILL.md"));
|
||||
});
|
||||
|
||||
it("loads direct markdown children only from the root directory", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.createDir("skills/nested", { recursive: true });
|
||||
await env.writeFile("skills/root.md", "---\ndescription: Root skill\n---\nRoot content");
|
||||
await env.writeFile("skills/nested/ignored.md", "---\ndescription: Ignored\n---\nIgnored content");
|
||||
|
||||
const skills = await loadSkills(env, "skills");
|
||||
|
||||
expect(skills.map((skill) => skill.name)).toEqual(["skills"]);
|
||||
expect(skills[0]?.content).toBe("Root content");
|
||||
});
|
||||
});
|
||||
45
packages/agent/test/harness/system-prompt.test.ts
Normal file
45
packages/agent/test/harness/system-prompt.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatSkillsForSystemPrompt } from "../../src/harness/system-prompt.js";
|
||||
|
||||
describe("formatSkillsForSystemPrompt", () => {
|
||||
it("formats visible skills and skips model-disabled skills", () => {
|
||||
expect(
|
||||
formatSkillsForSystemPrompt([
|
||||
{
|
||||
name: "visible",
|
||||
description: "Use <this> & that",
|
||||
content: "visible content",
|
||||
filePath: "/skills/visible/SKILL.md",
|
||||
},
|
||||
{
|
||||
name: "hidden",
|
||||
description: "Hidden",
|
||||
content: "hidden content",
|
||||
filePath: "/skills/hidden/SKILL.md",
|
||||
disableModelInvocation: true,
|
||||
},
|
||||
]),
|
||||
).toContain("<name>visible</name>");
|
||||
expect(
|
||||
formatSkillsForSystemPrompt([
|
||||
{
|
||||
name: "visible",
|
||||
description: "Use <this> & that",
|
||||
content: "visible content",
|
||||
filePath: "/skills/visible/SKILL.md",
|
||||
},
|
||||
]),
|
||||
).toContain("Use <this> & that");
|
||||
expect(
|
||||
formatSkillsForSystemPrompt([
|
||||
{
|
||||
name: "hidden",
|
||||
description: "Hidden",
|
||||
content: "hidden content",
|
||||
filePath: "/skills/hidden/SKILL.md",
|
||||
disableModelInvocation: true,
|
||||
},
|
||||
]),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,31 @@
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
|
||||
import { createAgentHarness, NodeExecutionEnv, Session } from "../../src/index.js";
|
||||
import {
|
||||
createAgentHarness,
|
||||
formatSkillsForSystemPrompt,
|
||||
loadSkills,
|
||||
NodeExecutionEnv,
|
||||
Session,
|
||||
} from "../../src/index.js";
|
||||
|
||||
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
const skills = await loadSkills(env, "/Users/badlogic/.pi/agent/skills");
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const agent = createAgentHarness({
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
env,
|
||||
session,
|
||||
initialModel: getModel("openai-codex", "gpt-5.5"),
|
||||
model: getModel("openai", "gpt-5.5"),
|
||||
thinkingLevel: "low",
|
||||
systemPrompt: ({ env, resources }) =>
|
||||
[
|
||||
`You are a helpful assistant.`,
|
||||
formatSkillsForSystemPrompt(resources.skills ?? []),
|
||||
`Current working directory: ${env.cwd}`,
|
||||
]
|
||||
.filter((part) => part.length > 0)
|
||||
.join("\n\n"),
|
||||
resources: { skills },
|
||||
});
|
||||
|
||||
const response = await agent.prompt("What is 2 + 2?");
|
||||
const response = await agent.prompt("What skills do you have?");
|
||||
console.log(response);
|
||||
|
||||
Reference in New Issue
Block a user