feat: enable permission-aware subagents

This commit is contained in:
叶林立
2026-08-26 10:49:26 +08:00
parent d3bf562189
commit 7571ba6dd9
185 changed files with 48365 additions and 26 deletions
@@ -0,0 +1,264 @@
/**
* nested-delegation-e2e.test.ts — regression for opt-in nested delegation, run
* through the real stack: real pi loader + real extension + real runAgent + two
* real child sessions, on a faux model.
*
* Everything else that covers nesting stops short of a real session — the tool
* unit tests use a fake manager, and the runner tests assert against a mocked
* pi. That leaves the load-bearing integration facts unproven: that pi actually
* admits the injected `customTools` into a child session's ACTIVE tool set
* (they collide with EXCLUDED_TOOL_NAMES by design, so a registry gate could
* silently drop them), and that a grandchild's output travels back up two hops.
* Those are exactly the things that break quietly, so they are pinned here.
*
* Deliberately faux, not live: `PI_E2E_LIVE=1` cannot drive a three-level chain
* deterministically, and a live model choosing not to delegate would look like
* a passing test. Each run therefore pins `live: false` rather than trusting the
* env var to leave it alone — the pre-publish smoke sets it globally.
*/
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { type Context, fauxToolCall } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import { registerAgents } from "../src/agent-types.js";
import { loadCustomAgents } from "../src/custom-agents.js";
import { encodeCwd } from "../src/output-file.js";
import {
agentCall,
type FauxReply,
type PrintModeRun,
runPrintMode,
} from "./helpers/print-mode-runner.js";
vi.setConfig({ testTimeout: 30_000 });
/** Marker the deepest agent emits — it must survive two hops back to the parent. */
const WORKER_MARKER = "WORKER-REACHED-THE-TOP";
/** First user message of a session — the only stable way to tell three faux sessions apart. */
function firstUserText(context: Context): string {
const first = context.messages.find((m) => m.role === "user");
const content = first?.content;
if (typeof content === "string") return content;
return ((content ?? []) as Array<{ text?: string }>).map((b) => b.text ?? "").join("");
}
/** Tool results in a session context, newest last, with their tool names. */
function toolResultTexts(context: Context): Array<{ name: string; text: string }> {
const out: Array<{ name: string; text: string }> = [];
for (const m of context.messages) {
if (m.role !== "toolResult") continue;
const name = (m as { toolName?: string }).toolName ?? "";
const text = ((m.content ?? []) as Array<{ text?: string }>).map((b) => b.text ?? "").join("");
out.push({ name, text });
}
return out;
}
/** Every `.output` transcript beneath a root, at any session/tasks depth. */
function findOutputFiles(root: string): string[] {
let entries: string[];
try { entries = readdirSync(root); } catch { return []; }
return entries.flatMap((e) => {
const full = join(root, e);
if (statSync(full).isDirectory()) return findOutputFiles(full);
return full.endsWith(".output") ? [full] : [];
});
}
function writeAgents(cwd: string): void {
const dir = join(cwd, ".pi", "agents");
mkdirSync(dir, { recursive: true });
// Opts into nesting, restricted to one type — the allowlist path, not `all`.
writeFileSync(
join(dir, "orchestrator.md"),
"---\ndescription: Delegating orchestrator\ntools: read\nextensions: false\nallowed_subagents: worker\n---\nDelegate to worker.\n",
);
writeFileSync(
join(dir, "worker.md"),
"---\ndescription: Leaf worker\ntools: read\nextensions: false\n---\nDo the work.\n",
);
}
describe("nested delegation e2e (real pi-mono, faux model)", () => {
let run: PrintModeRun | undefined;
const tmpDirs: string[] = [];
afterEach(async () => {
await run?.dispose();
run = undefined;
for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
it("a child with allowed_subagents spawns its own child, and the output travels back up", async () => {
const cwd = mkdtempSync(join(tmpdir(), "nested-e2e-"));
tmpDirs.push(cwd);
writeAgents(cwd);
/** Tool names each session was actually offered, keyed by who it is. */
const toolsSeen = new Map<string, string[]>();
const respond = (context: Context): FauxReply => {
const text = firstUserText(context);
const names = (context.tools ?? []).map((t) => t.name);
// Leaf: no nested tools (it never opted in) — just answer.
if (text.includes("Do the leaf work")) {
toolsSeen.set("worker", names);
return WORKER_MARKER;
}
// Middle: opted in, so pi must have admitted the injected Agent tool.
if (text.includes("Delegate this downward")) {
toolsSeen.set("orchestrator", names);
const alreadySpawned = context.messages.some(
(m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent",
);
if (alreadySpawned) {
const result = [...context.messages]
.reverse()
.find((m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent");
const inner = ((result?.content ?? []) as Array<{ text?: string }>)
.map((b) => b.text ?? "")
.join("");
// Echo the child's own text: if it never arrived, the marker is absent
// and the top-level assertion fails rather than passing vacuously.
return `orchestrator saw: ${inner}`;
}
return agentCall({
subagent_type: "worker",
description: "leaf work",
prompt: "Do the leaf work.",
});
}
// Top-level parent.
toolsSeen.set("parent", names);
const spawned = context.messages.some(
(m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent",
);
if (spawned) {
const result = [...context.messages]
.reverse()
.find((m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent");
const inner = ((result?.content ?? []) as Array<{ text?: string }>)
.map((b) => b.text ?? "")
.join("");
return `parent saw: ${inner}`;
}
return agentCall({
subagent_type: "orchestrator",
description: "delegate",
prompt: "Delegate this downward.",
// Foreground: this test reads the parent's inline Agent tool result.
run_in_background: false,
});
};
run = await runPrintMode({
prompt: "Delegate the work.",
cwd,
respond,
live: false,
beforeRun: () => { registerAgents(loadCustomAgents(cwd)); },
});
// pi admitted the injected nested tools into the opted-in child's active set,
// despite their names colliding with the ones stripped from every subagent.
expect(toolsSeen.get("orchestrator")).toContain("Agent");
expect(toolsSeen.get("orchestrator")).toContain("get_subagent_result");
expect(toolsSeen.get("orchestrator")).toContain("steer_subagent");
// The leaf never opted in, so it must not have them.
expect(toolsSeen.get("worker")).toBeDefined();
expect(toolsSeen.get("worker")).not.toContain("Agent");
// Two hops home: worker → orchestrator → parent.
expect(run.responseText).toContain(WORKER_MARKER);
});
it("backgrounds a nested child, polls it by id, and streams its transcript", async () => {
const cwd = mkdtempSync(join(tmpdir(), "nested-e2e-bg-"));
tmpDirs.push(cwd);
writeAgents(cwd);
const transcriptRoot = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`, encodeCwd(cwd));
rmSync(transcriptRoot, { recursive: true, force: true });
const respond = (context: Context): FauxReply => {
const text = firstUserText(context);
if (text.includes("Do the leaf work")) return WORKER_MARKER;
if (text.includes("Delegate this downward")) {
const results = toolResultTexts(context);
const spawned = results.find((r) => r.name === "Agent")?.text ?? "";
const polled = results.find((r) => r.name === "get_subagent_result")?.text;
// Third turn: the poll came back — echo it so a lost result fails loudly.
if (polled !== undefined) return `orchestrator polled: ${polled}`;
// Second turn: the spawn returned an id; fetch by exactly that id, which
// also exercises the manager's ownership check from inside a child.
if (spawned) {
const id = /Agent ID:\s*(\S+)/.exec(spawned)?.[1];
expect(id).toBeTruthy();
return fauxToolCall("get_subagent_result", { agent_id: id, wait: true });
}
return agentCall({
subagent_type: "worker",
description: "leaf work",
prompt: "Do the leaf work.",
run_in_background: true,
});
}
if (toolResultTexts(context).some((r) => r.name === "Agent")) return "parent done";
return agentCall({
subagent_type: "orchestrator",
description: "delegate",
prompt: "Delegate this downward.",
// Foreground: this test reads the parent's inline Agent tool result.
run_in_background: false,
});
};
try {
run = await runPrintMode({
prompt: "Delegate the work.",
cwd,
respond,
live: false,
beforeRun: () => { registerAgents(loadCustomAgents(cwd)); },
});
// The background child ran and its output came back through the id the
// spawn handed out — so it was never queued behind its waiting parent.
const orchestratorResult = run.parentSession.messages
.filter((m) => m.role === "toolResult")
.flatMap((m) => (m.content as Array<{ text?: string }>).map((b) => b.text ?? ""))
.join("\n");
expect(orchestratorResult).toContain("orchestrator polled");
expect(orchestratorResult).toContain(WORKER_MARKER);
// Only the REAL manager wires onSessionCreated → streamToOutputFile for a
// nested spawn, and only real rootSessionId propagation puts the file under
// this root. Identify the WORKER's own transcript by the prompt in its
// initial entry — matching the marker alone would also match the
// orchestrator's transcript, which merely echoes it, and would pass even
// with nested transcripts switched off entirely.
// Match on the FIRST line — writeInitialEntry seeds each transcript with the
// prompt that agent was given. Searching the whole file would also match the
// orchestrator's, which records the same string inside its Agent tool-call
// arguments, and would pass with nested transcripts switched off entirely.
const transcripts = findOutputFiles(transcriptRoot).map((f) => readFileSync(f, "utf-8"));
const workerTranscript = transcripts.find((t) => {
const first = JSON.parse(t.split("\n")[0]) as { message?: { content?: unknown } };
return first.message?.content === "Do the leaf work.";
});
expect(workerTranscript).toBeDefined();
// ...and it streamed the child's own turn, not just the seeded prompt.
expect(workerTranscript).toContain(WORKER_MARKER);
} finally {
rmSync(transcriptRoot, { recursive: true, force: true });
}
});
});