feat: initialize Hypothesis Machine Pi extension

This commit is contained in:
Emil
2026-07-31 22:36:16 +03:00
commit 528983ee50
57 changed files with 7790 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { parseAgentSpec, serializeAgentSpec, AgentSpecError } from "../src/agent-spec.js";
const spec = { id: "statistical-reviewer", name: "Statistical Reviewer", parent_id: "lead", root_run_id: "run-1", depth: 2, model: "inherit", thinking_level: "inherit", can_spawn_agents: true, max_children: 6, tools: ["read", "spawn_agent"], role: "Independent statistical reviewer", goal: "Review EXP-014 for leakage", context: "experiment/EXP-014", responsibilities: "Check metrics and split", completion_criteria: "Reproduced or invalidated", expected_output: "Structured review" };
describe("agent markdown", () => {
it("round-trips validated frontmatter and sections", () => expect(parseAgentSpec(serializeAgentSpec(spec))).toEqual(spec));
it("rejects missing required sections", () => expect(() => parseAgentSpec("---\nid: okay\n---\n# Role\nX")).toThrow(AgentSpecError));
it("rejects unsafe ids", () => expect(() => parseAgentSpec(serializeAgentSpec({ ...spec, id: "../escape" }))).toThrow(/kebab-case/));
});
+21
View File
@@ -0,0 +1,21 @@
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { AgentTree, AgentTreeError } from "../src/agent-tree.js";
import { DEFAULT_CONFIG } from "../src/config.js";
import { RunStore } from "../src/run-store.js";
import { FakeRuntimeFactory } from "./helpers.js";
const request = (parentId: string, name: string, task: string, background = false) => ({ parentId, name, role: `${name} specialist`, task, expectedOutput: "Evidence report", completionCriteria: "Report is saved", background });
function setup(limits = DEFAULT_CONFIG, delay = 0) { const dir = mkdtempSync(resolve(tmpdir(), "hm-tree-")); const store = new RunStore(dir); const factory = new FakeRuntimeFactory(delay); const tree = new AgentTree(store, factory, limits, { goal: "Investigate a testable question" }); return { dir, store, factory, tree }; }
describe("AgentTree", () => {
it("builds parent → child → grandchild lineage and returns structured results", async () => { const { tree } = setup(); const child = await tree.spawn(request(tree.rootId, "Literature", "Survey primary literature for mechanism alpha")); const grandchild = await tree.spawn(request(child.id, "Verifier", "Verify the strongest primary source independently")); expect(grandchild.lineage).toEqual([tree.rootId, child.id]); expect(grandchild.depth).toBe(2); const result = await tree.start(grandchild.id); expect(result.structured).toEqual({ id: grandchild.id }); expect(tree.render()).toContain("└─ literature-"); expect(tree.render()).toContain(" └─ verifier-"); });
it("blocks duplicates unless explicitly independent replication", async () => { const { tree } = setup(); const first = await tree.spawn(request(tree.rootId, "One", "Reproduce published numerical result number one")); await expect(tree.spawn(request(tree.rootId, "Two", "Reproduce published numerical result number one"))).rejects.toThrow(/Duplicate/); await expect(tree.spawn({ ...request(tree.rootId, "Replica", "Reproduce published numerical result number one"), replicationOf: first.id, independentContext: true })).resolves.toBeTruthy(); });
it("enforces configurable depth and child limits", async () => { const { tree } = setup({ ...DEFAULT_CONFIG, max_depth: 1, max_children_per_agent: 1 }); const child = await tree.spawn(request(tree.rootId, "Only", "Complete a sufficiently concrete unique assignment")); await expect(tree.spawn(request(tree.rootId, "Extra", "Complete a different sufficiently concrete assignment"))).rejects.toThrow(/child limit/); await expect(tree.spawn(request(child.id, "Deep", "Explore a deeper sufficiently concrete assignment"))).rejects.toThrow(/depth/); });
it("supports parallel children, steering, follow-up, and recursive cancellation", async () => { const { tree, factory } = setup(DEFAULT_CONFIG, 40); const one = await tree.spawn(request(tree.rootId, "One", "Parallel investigation branch number one", true)); const two = await tree.spawn(request(tree.rootId, "Two", "Parallel investigation branch number two", true)); await new Promise((resolve) => setTimeout(resolve, 5)); await tree.steer(one.id, "focus"); await tree.followUp(two.id, "cite sources"); expect(factory.runtimes.get(one.id)?.steered).toEqual(["focus"]); await tree.cancelBranch(tree.rootId); expect(tree.inspect(one.id).status).toBe("cancelled"); expect(tree.inspect(two.id).status).toBe("cancelled"); });
it("restores relationships and marks active work interrupted", async () => { const { store, tree } = setup(); const child = await tree.spawn(request(tree.rootId, "Crash", "Investigate recovery behavior after process crash")); const path = store.manifestPath(tree.runId); const manifest = JSON.parse(readFileSync(path, "utf8")); manifest.agents[child.id].status = "running"; writeFileSync(path, JSON.stringify(manifest)); const restored = AgentTree.restore(store, new FakeRuntimeFactory(), DEFAULT_CONFIG, tree.runId); expect(restored.inspect(child.id).status).toBe("interrupted"); expect(restored.inspect(child.id).parentId).toBe(tree.rootId); });
it("rejects vague tasks", async () => { const { tree } = setup(); await expect(tree.spawn(request(tree.rootId, "Vague", "look"))).rejects.toThrow(AgentTreeError); });
it("bridges partial child results to the Supervisor root", async () => { const dir = mkdtempSync(resolve(tmpdir(), "hm-tree-message-")); const updates: string[] = []; const tree = new AgentTree(new RunStore(dir), new FakeRuntimeFactory(), DEFAULT_CONFIG, { goal: "Receive upward results", onRootMessage: (from, message) => updates.push(`${from}:${message}`) }); await tree.message(tree.rootId, "partial evidence", "child-1"); expect(updates).toEqual(["child-1:partial evidence"]); });
});
+12
View File
@@ -0,0 +1,12 @@
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { ExperimentRunner, dockerArguments, validateTestPlan } from "../src/tools/experiment.js";
const plan = { hypothesis: "A improves B", data: "fixed.csv", baseline: "mean", split: "predefined", metrics: ["rmse"], successCriterion: "rmse < 1", refutationCriterion: "rmse >= 1", confounders: ["leakage"], resourceLimits: "1 CPU" };
describe("ExperimentRunner", () => {
it("requires precommitted test criteria", () => { expect(() => validateTestPlan(plan)).not.toThrow(); expect(() => validateTestPlan({ ...plan, baseline: "" })).toThrow(/baseline/); });
it("constructs networkless resource-limited Docker arguments", () => { const args = dockerArguments({ image: "python", cpus: 1.5, memory_mb: 512, timeout_seconds: 30 }, "/tmp/exp", "python", "python source/test.py"); expect(args).toEqual(expect.arrayContaining(["--network", "none", "--read-only", "--cpus", "1.5", "--memory", "512m", "--cap-drop", "ALL"])); expect(args.join(" ")).not.toMatch(/\.pi|HOME|API_KEY/); });
it("requires a different agent for independent review", () => { const dir = mkdtempSync(resolve(tmpdir(), "hm-review-")); const expDir = resolve(dir, "experiments", "exp-aabbccdd"); mkdirSync(expDir, { recursive: true }); writeFileSync(resolve(expDir, "experiment-manifest.json"), JSON.stringify({ createdBy: "implementer", planHash: "abc", hypothesisStatus: "testing" })); const runner = new ExperimentRunner(dir, { image: "none", cpus: 1, memory_mb: 128, timeout_seconds: 1 }); expect(() => runner.review({ experimentId: "exp-aabbccdd", reviewerId: "implementer", verdict: "supported", summary: "Looks good", limitations: "Small sample" })).toThrow(/other than/); expect(runner.review({ experimentId: "exp-aabbccdd", reviewerId: "reviewer", verdict: "inconclusive", summary: "Metric is unstable", limitations: "Small sample" }).status).toBe("inconclusive"); });
});
+6
View File
@@ -0,0 +1,6 @@
import { describe, expect, it } from "vitest";
import extension from "../src/index.js";
describe("Pi extension smoke", () => {
it("loads as an extension factory and registers all commands", () => { const commands: string[] = []; const events: string[] = []; const renderers: string[] = []; const fakePi = { on: (name: string) => events.push(name), registerCommand: (name: string) => commands.push(name), registerMessageRenderer: (name: string) => renderers.push(name) } as any; extension(fakePi); expect(commands).toEqual(expect.arrayContaining(["team", "research", "research-status", "research-pause", "research-resume", "research-stop", "findings", "hypotheses"])); expect(events).toEqual(expect.arrayContaining(["session_start", "session_shutdown"])); expect(renderers).toContain("hypothesis-machine-agent-update"); });
});
+18
View File
@@ -0,0 +1,18 @@
import type { AgentRecord, AgentResult, AgentRuntime, AgentRuntimeFactory, AgentSpec } from "../src/types.js";
export class FakeRuntime implements AgentRuntime {
sessionFile: string | undefined;
steered: string[] = []; followed: string[] = []; cancelled = false;
constructor(readonly id: string, private readonly delay = 0) { this.sessionFile = `/fake/${id}.jsonl`; }
async start(prompt: string): Promise<AgentResult> { if (this.delay) await new Promise((resolve) => setTimeout(resolve, this.delay)); return { status: this.cancelled ? "cancelled" : "completed", summary: `result:${this.id}:${prompt.includes(this.id)}`, structured: { id: this.id }, completedAt: new Date().toISOString() }; }
async steer(message: string) { this.steered.push(message); }
async followUp(message: string) { this.followed.push(message); }
async cancel() { this.cancelled = true; }
dispose() {}
}
export class FakeRuntimeFactory implements AgentRuntimeFactory {
runtimes = new Map<string, FakeRuntime>();
constructor(private readonly delay = 0) {}
async create(record: AgentRecord, spec: AgentSpec): Promise<AgentRuntime> { void spec; const runtime = new FakeRuntime(record.id, this.delay); this.runtimes.set(record.id, runtime); return runtime; }
}
+11
View File
@@ -0,0 +1,11 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { ResearchMemory } from "../src/research-memory.js";
describe("ResearchMemory", () => {
it("persists findings and performs rebuildable full-text search", () => { const memory = new ResearchMemory(mkdtempSync(resolve(tmpdir(), "hm-memory-"))); const id = memory.save({ type: "fact", status: "corroborated", createdBy: "verifier", runId: "run-1", title: "Catalyst result", statement: "Catalyst alpha improves the measured yield.", evidence: "Independent measurements agree.", sources: ["source-a", "source-b"], limitations: "Small sample" }); expect(memory.search("catalyst")[0]?.id).toBe(id); memory.close(); expect(memory.rebuildIndex()).toBe(1); expect(memory.search("yield")[0]?.id).toBe(id); memory.close(); });
it("does not allow unsourced claims to become corroborated facts", () => { const memory = new ResearchMemory(mkdtempSync(resolve(tmpdir(), "hm-memory-"))); expect(() => memory.save({ type: "fact", status: "corroborated", createdBy: "agent", runId: "run", title: "Claim", statement: "Unsupported" })).toThrow(/requires sources/); memory.close(); });
it("keeps negative results searchable", () => { const memory = new ResearchMemory(mkdtempSync(resolve(tmpdir(), "hm-memory-"))); memory.save({ type: "experiment_result", status: "rejected", createdBy: "runner", runId: "run", title: "Null replication", statement: "No measurable effect", negativeResult: true }); expect(memory.search("replication")).toHaveLength(1); memory.close(); });
});
@@ -0,0 +1,26 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { createAssistantMessageEventStream, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { describe, expect, it } from "vitest";
import { AgentTree } from "../src/agent-tree.js";
import { DEFAULT_CONFIG } from "../src/config.js";
import { PiAgentRuntimeFactory } from "../src/pi-runtime.js";
import { ResearchMemory } from "../src/research-memory.js";
import { RunStore } from "../src/run-store.js";
import { ExperimentRunner } from "../src/tools/experiment.js";
import { WebGateway } from "../src/tools/web.js";
describe("PiAgentRuntimeFactory", () => {
it("runs a child through a real AgentSession with a fake official ModelRuntime", async () => {
const cwd = mkdtempSync(resolve(tmpdir(), "hm-pi-runtime-"));
const model: Model<any> = { id: "fake-model", name: "Fake model", api: "openai-completions", provider: "hm-fake", baseUrl: "http://invalid.test", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 32_000, maxTokens: 2_000 };
const runtime = await ModelRuntime.create({ authPath: resolve(cwd, "auth.json"), modelsPath: null });
runtime.registerNativeProvider({ id: "hm-fake", name: "HM fake provider", auth: { apiKey: { name: "fake", resolve: async () => ({ auth: { apiKey: "not-a-real-secret" }, source: "test" }) } }, getModels: () => [model], stream: () => { throw new Error("simple stream expected"); }, streamSimple: () => { const stream = createAssistantMessageEventStream(); const message: AssistantMessage = { role: "assistant", content: [{ type: "text", text: "fake child result" }], api: model.api, provider: model.provider, model: model.id, usage: { input: 1, output: 3, cacheRead: 0, cacheWrite: 0, totalTokens: 4, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: "stop", timestamp: Date.now() }; queueMicrotask(() => stream.end(message)); return stream; } });
const stateDir = resolve(cwd, ".hypothesis-machine"); const store = new RunStore(stateDir); const memory = new ResearchMemory(stateDir); const web = new WebGateway(DEFAULT_CONFIG, memory); const experiments = new ExperimentRunner(stateDir, DEFAULT_CONFIG.experiment);
const factory = new PiAgentRuntimeFactory({ cwd, config: DEFAULT_CONFIG, store, memory, web, experiments, modelRuntime: runtime, model, thinkingLevel: "off" }); const tree = new AgentTree(store, factory, DEFAULT_CONFIG, { goal: "Test the official child runtime" }); factory.attachTree(tree);
const child = await tree.spawn({ parentId: tree.rootId, name: "Runtime Child", role: "Runtime verifier", task: "Return the deterministic fake model response", expectedOutput: "Text result", completionCriteria: "A response is persisted", tools: [], background: false });
const result = await tree.start(child.id); expect(result.summary).toBe("fake child result"); expect(tree.inspect(child.id).sessionFile).toMatch(/\.jsonl$/); expect(tree.inspect(child.id).status).toBe("completed"); memory.close();
});
});
+15
View File
@@ -0,0 +1,15 @@
import { spawn } from "node:child_process";
import { resolve } from "node:path";
const pi = process.env.PI_SMOKE_BIN || resolve("node_modules/.bin/pi");
const child = spawn(pi, ["--mode", "rpc", "--no-session", "--no-extensions", "--extension", "./src/index.ts"], { cwd: process.cwd(), env: { ...process.env, PI_OFFLINE: "1" }, stdio: ["pipe", "pipe", "pipe"] });
let stdout = "", stderr = "";
child.stdout.on("data", (chunk) => stdout += String(chunk)); child.stderr.on("data", (chunk) => stderr += String(chunk));
child.stdin.end('{"type":"get_commands"}\n');
const code = await new Promise((resolveCode, reject) => { const timer = setTimeout(() => { child.kill("SIGKILL"); reject(new Error("Pi RPC smoke timed out")); }, 15_000); child.on("close", (value) => { clearTimeout(timer); resolveCode(value); }); child.on("error", reject); });
if (code !== 0) throw new Error(`Pi exited ${code}: ${stderr}`);
const responses = stdout.trim().split("\n").flatMap((line) => { try { return [JSON.parse(line)]; } catch { return []; } });
const commands = responses.find((item) => item.type === "response" && item.command === "get_commands");
const names = commands?.data?.commands?.map((item) => item.name) ?? [];
for (const required of ["team", "research", "research-stop", "findings", "hypotheses"]) if (!names.includes(required)) throw new Error(`Missing Pi command ${required}`);
process.stdout.write(`Pi RPC extension smoke passed via ${pi} (${names.filter((name) => ["team", "research", "research-stop", "findings", "hypotheses"].includes(name)).length} commands checked)\n`);
+12
View File
@@ -0,0 +1,12 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { DEFAULT_CONFIG } from "../src/config.js";
import { ResearchLoop } from "../src/research-loop.js";
const report = (newFindings = 0) => ({ goal: "goal", tasks: ["task"], activeAgents: [], expectedOutput: "finding", state: "synthesized", newFindings, closedQuestions: 0, contradictions: 0, reason: "evaluation" });
describe("ResearchLoop", () => {
it("stops after configured iterations without information gain", () => { const loop = new ResearchLoop(mkdtempSync(resolve(tmpdir(), "hm-loop-")), "run", "goal", { ...DEFAULT_CONFIG, max_iterations_without_progress: 2 }); loop.start(); loop.record(report()); expect(loop.record(report()).status).toBe("completed"); expect(loop.snapshot().stopReason).toMatch(/without information gain/); });
it("resets no-progress counter and handles pause/resume", () => { const loop = new ResearchLoop(mkdtempSync(resolve(tmpdir(), "hm-loop-")), "run", "goal", DEFAULT_CONFIG); loop.start(); loop.record(report()); loop.record(report(1)); expect(loop.snapshot().noProgressIterations).toBe(0); loop.pause(); expect(loop.snapshot().status).toBe("paused"); loop.resume(); expect(loop.snapshot().status).toBe("running"); });
});
+8
View File
@@ -0,0 +1,8 @@
import { describe, expect, it } from "vitest";
import { assertPublicUrl, isPrivateAddress, normalizeUrl } from "../src/tools/web.js";
describe("web gateway security", () => {
it("normalizes and removes tracking parameters", () => expect(normalizeUrl("HTTPS://Example.COM:443/a/?utm_source=x&b=2&a=1#x")).toBe("https://example.com/a?a=1&b=2"));
it.each(["127.0.0.1", "10.2.3.4", "172.16.1.1", "192.168.2.2", "169.254.169.254", "::1", "fd00::1"])("blocks private address %s", (ip) => expect(isPrivateAddress(ip)).toBe(true));
it("blocks localhost and metadata endpoints", async () => { await expect(assertPublicUrl("http://localhost/test")).rejects.toThrow(/Blocked/); await expect(assertPublicUrl("http://169.254.169.254/latest/meta-data")).rejects.toThrow(/Blocked/); });
});