feat(task): unified structured subagent execution

- Added per-invocation task schemas with strict and permissive validation.
- Shared task and eval agent policy, artifacts, isolation, and lifecycle handling.
- Enabled host-restricted plan-mode eval agents and persisted their capability clamp.

Fixes #5279
This commit is contained in:
vmcall
2026-07-12 18:51:35 +02:00
parent 0f9fceeea4
commit d944879f21
31 changed files with 2785 additions and 1182 deletions
+21
View File
@@ -247,6 +247,27 @@
### Changed
- Enhanced Anthropic credential and usage management to support organization-scoped accounts, including displaying organization names in /usage, /logout, omp token --list, and OAuth login success messages, resolving active-account matching for shared organizations, and deduplicating identities during migration.
- `omp usage` and the in-session `/usage` view now show the Anthropic organization next to the account for org-scoped credentials (with `--redact` masking applied per part in the CLI, falling back to the org id when no display name is available), attribute "no usage data" rows per organization, and match the "in use by this session" marker by organization so only the active subscription is flagged. The OAuth login success message names the account and organization that was stored — a login landing on an unintended subscription is visible immediately.
- `/logout` labels Anthropic accounts with their organization and marks only the credential of the active organization as active; `omp token --list` shows the organization next to each account. Two subscriptions sharing one email are distinguishable when selecting which to remove or mint a token for.
- `omp auth-broker migrate --from-local` dedupes Anthropic OAuth identities per organization, so a Team seat already on the broker no longer blocks uploading the personal plan under the same email.
- The status line invalidates its cached usage when the session rotates to a different Anthropic organization (previously the old subscription's quota could linger for the cache TTL), and `omp auth-gateway check` labels each credential with its organization so a failing row says which subscription needs re-login.
- `omp usage` "no usage data" attribution is org-decisive whenever either the stored account or a report carries an organization: an org-less legacy credential whose own fetch failed is no longer hidden by an org-attributed sibling report sharing the same email.
- Active-account matching for `/usage`, `/logout`, and `omp token --list` now treats a shared organization as a qualifier rather than a match: two Anthropic Team seats in one org (same org id, per-user pools) no longer flag each other's rows or reports as "in use by this session" — the base identity (account/email/project) is still required, with org-only sessions matching on the org alone.
- `omp usage` "no usage data" coverage now requires the member's own identity within a shared organization: a sibling Team member's same-org report no longer counts as coverage for an account whose own report is missing, while an org-only account remains covered by any same-org report.
- `omp auth-broker migrate --from-local` reruns now recognize an already-migrated org-only Anthropic row (login recovered neither email nor account) by its organization id instead of re-uploading it, which could overwrite the broker's newer refresh token with the stale local one.
- Updated tangential agent forks to ignore parent session history and focus exclusively on the new request
- Hardened `/tan` fork isolation: the clone's inherited todo list is cleared at fork (parent todo reminders no longer drag the tan back onto the parent's task), the fork notice warns that the parent is concurrently editing the same working directory, and the notice is re-injected after each compaction so the fork boundary survives summarization
- Added visual markers in the transcript for elided tool calls that have no corresponding result
- Updated status event log to prioritize the most recent entries in the display window
- Updated the snapcompact shape preview transcript to use the compact scope format shown to models during compaction.
### Removed
- Removed the unreliable Bing and Yahoo HTML-scraping web search providers
## [16.4.8] - 2026-07-12
### Added
- Added invocation-specific schemas to task subagents and unified task/eval agent execution, including host-enforced read-only plan-mode agents ([#5279](https://github.com/can1357/oh-my-pi/issues/5279))
### Fixed
@@ -13,9 +13,9 @@ import type { ExecutorOptions } from "../../task/executor";
import * as taskExecutor from "../../task/executor";
import * as isolationRunner from "../../task/isolation-runner";
import { AgentOutputManager } from "../../task/output-manager";
import type { AgentDefinition, AgentProgress, SingleResult } from "../../task/types";
import type { AgentDefinition, AgentProgress, SingleResult, StructuredSubagentOutput } from "../../task/types";
import type { ToolSession } from "../../tools";
import { EVAL_AGENT_MAX_DEPTH, runEvalAgent } from "../agent-bridge";
import { runEvalAgent } from "../agent-bridge";
import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../bridge-timeout";
import { IdleTimeout } from "../idle-timeout";
import { disposeAllVmContexts } from "../js/context-manager";
@@ -51,6 +51,7 @@ interface SessionOptions {
settings?: Settings;
outputManager?: AgentOutputManager;
planMode?: boolean;
outputSchema?: unknown;
}
function makeSession(options: SessionOptions = {}): ToolSession {
@@ -76,6 +77,7 @@ function makeSession(options: SessionOptions = {}): ToolSession {
getArtifactsDir: () => artifactsDir,
getSessionId: () => "test-session",
getEvalSessionId: () => "test-eval-session",
outputSchema: options.outputSchema,
getPlanModeState: options.planMode
? () =>
({
@@ -189,7 +191,7 @@ describe("runEvalAgent", () => {
);
});
it("enforces spawn restrictions and the eval recursion cap", async () => {
it("enforces shared spawn restrictions", async () => {
mockAgents();
const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
@@ -199,9 +201,6 @@ describe("runEvalAgent", () => {
await expect(
runEvalAgent({ prompt: "hello", agent: "task" }, { session: makeSession({ spawns: "reviewer" }) }),
).rejects.toThrow("Allowed: reviewer");
await expect(
runEvalAgent({ prompt: "hello" }, { session: makeSession({ depth: EVAL_AGENT_MAX_DEPTH }) }),
).rejects.toThrow("maximum depth");
expect(runSpy).not.toHaveBeenCalled();
});
@@ -219,12 +218,10 @@ describe("runEvalAgent", () => {
expect(runSpy.mock.calls[0]?.[0].agent.name).toBe("reviewer");
});
it("honors task.maxRecursionDepth on top of the hard eval ceiling", async () => {
it("honors task.maxRecursionDepth without an eval-specific ceiling", async () => {
mockAgents();
const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
// task.maxRecursionDepth=0 means "no spawning at all" — even depth 0 (the
// top-level agent) must be blocked, matching canSpawnAtDepth().
await expect(
runEvalAgent(
{ prompt: "hello" },
@@ -240,35 +237,45 @@ describe("runEvalAgent", () => {
),
).rejects.toThrow("maximum depth is 0");
// task.maxRecursionDepth=1 ("Single") lets the top spawn but a depth-1
// subagent cannot spawn further — even though the hard ceiling is 3.
await expect(
runEvalAgent(
{ prompt: "hello" },
{
session: makeSession({
depth: 1,
settings: Settings.isolated({
"async.enabled": false,
"task.isolation.mode": "none",
"task.maxRecursionDepth": 1,
}),
await runEvalAgent(
{ prompt: "hello" },
{
session: makeSession({
depth: 3,
settings: Settings.isolated({
"async.enabled": false,
"task.isolation.mode": "none",
"task.maxRecursionDepth": -1,
}),
},
),
).rejects.toThrow("maximum depth is 1");
expect(runSpy).not.toHaveBeenCalled();
}),
},
);
expect(runSpy).toHaveBeenCalledTimes(1);
});
it("throws instead of spawning from plan mode", async () => {
mockAgents();
it("runs plan-mode eval agents with an attenuated policy", async () => {
mockAgents([{ ...taskAgent, tools: ["ast_grep", "report_finding", "write"] }]);
const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
await expect(runEvalAgent({ prompt: "hello" }, { session: makeSession({ planMode: true }) })).rejects.toThrow(
"unavailable in plan mode",
);
expect(runSpy).not.toHaveBeenCalled();
await expect(
runEvalAgent({ prompt: "hello" }, { session: makeSession({ planMode: true }) }),
).resolves.toMatchObject({
text: "ok",
});
expect(runSpy).toHaveBeenCalledTimes(1);
expect(runSpy.mock.calls[0]?.[0].agent.tools).toEqual([
"read",
"grep",
"glob",
"web_search",
"ast_grep",
"report_finding",
]);
expect(runSpy.mock.calls[0]?.[0].agent.spawns).toBeUndefined();
await expect(
runEvalAgent({ prompt: "unsafe", isolated: true }, { session: makeSession({ planMode: true }) }),
).rejects.toThrow("isolation, apply, and merge controls are unavailable in plan mode");
expect(runSpy).toHaveBeenCalledTimes(1);
});
it("passes parent execution options and only sets outputSchema when schema is supplied", async () => {
@@ -310,8 +317,52 @@ describe("runEvalAgent", () => {
expect(secondOptions.outputSchema).toBeUndefined();
expect(secondOptions.outputSchemaOverridesAgent).toBeUndefined();
});
it("returns host-parsed data for caller, agent, and inherited schemas", async () => {
const agentSchema = { type: "object" };
const sessionSchema = { type: "object" };
const callerSchema = { type: "object" };
const frontmatterAgent = { ...reviewerAgent, name: "structured", output: agentSchema };
mockAgents([taskAgent, frontmatterAgent]);
const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
const source = options.outputSchemaOverridesAgent
? "caller"
: options.agent.name === "structured"
? "agent"
: "session";
const structuredOutput: StructuredSubagentOutput = {
source,
mode: options.outputSchemaMode ?? "permissive",
status: "valid",
data: { source },
};
return singleResult(options, { output: "not JSON", structuredOutput });
});
it("forces LSP off for bridge subagents even when task.enableLsp is on", async () => {
const caller = await runEvalAgent(
{ prompt: "caller", schema: callerSchema, schemaMode: "strict" },
{ session: makeSession({ outputSchema: sessionSchema }) },
);
const frontmatter = await runEvalAgent(
{ prompt: "agent", agent: "structured" },
{ session: makeSession({ outputSchema: sessionSchema }) },
);
const inherited = await runEvalAgent(
{ prompt: "session" },
{ session: makeSession({ outputSchema: sessionSchema }) },
);
expect(caller.data).toEqual({ source: "caller" });
expect(caller.details).toMatchObject({ schemaSource: "caller", schemaMode: "strict", schemaStatus: "valid" });
expect(frontmatter.data).toEqual({ source: "agent" });
expect(inherited.data).toEqual({ source: "session" });
expect(runSpy.mock.calls.map(([options]) => options.outputSchema)).toEqual([
callerSchema,
agentSchema,
sessionSchema,
]);
});
it("inherits non-plan LSP and IRC policy for bridge subagents", async () => {
mockAgents();
const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
// makeSession() defaults to enableLsp: true and task.enableLsp: true.
@@ -321,7 +372,8 @@ describe("runEvalAgent", () => {
const options = runSpy.mock.calls[0]?.[0];
if (!options) throw new Error("runSubprocess was not called");
expect(options.enableLsp).toBe(false);
expect(options.enableLsp).toBe(true);
expect(options.enableIrc).toBe(true);
expect(options.keepAlive).toBe(false);
});
@@ -485,16 +537,30 @@ describe("agent() through eval runtimes", () => {
vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options =>
singleResult(options, {
output: options.outputSchema ? '{"ok":true,"n":3}' : "hello from agent",
...(options.outputSchema
? {
structuredOutput: {
source: "caller",
mode: options.outputSchemaMode ?? "permissive",
status: "valid",
data: { ok: true, n: 3 },
} satisfies StructuredSubagentOutput,
}
: {}),
}),
);
const result = await executeJs(
'const text = await agent("hi"); const data = await agent("json", { schema: { type: "object" } }); return JSON.stringify([text, data]);',
'const text = await agent("hi"); const data = await agent("json", { schema: { type: "object" } }); const node = await agent("handle", { schema: { type: "object" }, handle: true }); return JSON.stringify({ text, data, node });',
{ cwd: tempDir.path(), sessionId: sharedJsSessionId, session, sessionFile },
);
expect(result.exitCode).toBe(0);
expect(JSON.parse(result.output.trim())).toEqual(["hello from agent", { ok: true, n: 3 }]);
const output = JSON.parse(result.output.trim());
expect(output.text).toBe("hello from agent");
expect(output.data).toEqual({ ok: true, n: 3 });
expect(output.node.data).toEqual({ ok: true, n: 3 });
expect(output.node.handle).toBe(`agent://${output.node.id}`);
});
it("bounds JavaScript parallel() by the task.maxConcurrency setting while preserving order", async () => {
@@ -547,23 +613,43 @@ describe("agent() through eval runtimes", () => {
const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "py-agent");
mockAgents();
vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options =>
singleResult(options, { output: "hello from python" }),
singleResult(options, {
output: options.outputSchema ? "not JSON" : "hello from python",
...(options.outputSchema
? {
structuredOutput: {
source: "caller",
mode: options.outputSchemaMode ?? "permissive",
status: "valid",
data: { ok: true },
} satisfies StructuredSubagentOutput,
}
: {}),
}),
);
const result = await executePython('print(agent("hi"))', {
cwd: tempDir.path(),
sessionId,
sessionFile,
kernelMode: "per-call",
toolSession: session,
});
const result = await executePython(
'import json\nprint(agent("hi"))\nprint(json.dumps(agent("structured", schema={"type": "object"})))\nnode = agent("handle", schema={"type": "object"}, handle=True)\nprint(json.dumps({"data": node["data"], "handle": node["handle"], "id": node["id"]}))',
{
cwd: tempDir.path(),
sessionId,
sessionFile,
kernelMode: "per-call",
toolSession: session,
},
);
if (result.exitCode === undefined && result.cancelled) {
expect(result.output).toBe("");
return; // kernel unavailable in this environment
}
expect(result.exitCode).toBe(0);
expect(result.output.trim()).toBe("hello from python");
const lines = result.output.trim().split("\n");
expect(lines[0]).toBe("hello from python");
expect(JSON.parse(lines[1] ?? "")).toEqual({ ok: true });
const node = JSON.parse(lines[2] ?? "");
expect(node.data).toEqual({ ok: true });
expect(node.handle).toBe(`agent://${node.id}`);
});
it("bounds Python parallel() by the task.maxConcurrency setting while preserving order", async () => {
@@ -772,7 +858,11 @@ describe("agent() through eval runtimes", () => {
it("pauses the idle watchdog while a quiet agent() runs past the budget", async () => {
using tempDir = TempDir.createSync("@omp-eval-agent-timeout-pause-");
const { session } = makeEvalSession(tempDir, "js-agent-timeout-pause");
const { session } = makeEvalSession(
tempDir,
"js-agent-timeout-pause",
Settings.isolated({ "task.maxRuntimeMs": 1 }),
);
mockAgents();
// runSubprocess runs far past the eval timeout budget and emits NO progress
@@ -788,7 +878,9 @@ describe("agent() through eval runtimes", () => {
const inFlight = new Promise<void>(resolve => {
markInFlight = resolve;
});
let observedMaxRuntimeMs: number | undefined;
vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
observedMaxRuntimeMs = options.maxRuntimeMs;
markInFlight?.();
await released;
return singleResult(options, { output: "done" });
@@ -812,6 +904,7 @@ describe("agent() through eval runtimes", () => {
// The bridge paused the watchdog; the subprocess is now blocked in flight.
await inFlight;
expect(observedMaxRuntimeMs).toBe(0);
// Burn far more than the 20ms budget while paused: the watchdog stays armed-off.
vi.advanceTimersByTime(1_000);
expect(idle.signal.aborted).toBe(false);
@@ -53,6 +53,35 @@ describe("eval js agent() handle", () => {
expect(out).toBe("hello world");
});
it("keeps positional isolation controls stable while appending schemaMode", async () => {
let seenArgs: Record<string, unknown> | undefined;
const sandbox = loadPrelude(async (_name, args) => {
seenArgs = args as Record<string, unknown>;
return { text: '{"ok":true}', details: { agent: "task", id: "legacy", structured: false } };
});
const positionalAgent = sandbox.agent as (
prompt: string,
options?: unknown,
...rest: unknown[]
) => Promise<unknown>;
const schema = { type: "object", properties: { ok: { type: "boolean" } } };
await positionalAgent("scout", "reviewer", "p/model", "Legacy", schema, true, false, true, "strict");
expect(seenArgs).toEqual({
prompt: "scout",
agent: "reviewer",
model: "p/model",
label: "Legacy",
schema,
isolated: true,
apply: false,
merge: true,
schemaMode: "strict",
handle: false,
});
});
it("carries the parsed object under data when schema and handle combine", async () => {
const payload = JSON.stringify({ k: 1 });
const sandbox = loadPrelude(async () => ({
+118 -472
View File
@@ -1,32 +1,15 @@
/**
* Host-side handler for the eval `agent()` helper.
*/
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { prompt, Snowflake } from "@oh-my-pi/pi-utils";
import { type } from "arktype";
import { resolveAgentModelPatterns } from "../config/model-resolver";
import type { LocalProtocolOptions } from "../internal-urls";
import { registerArtifactsDir } from "../internal-urls/registry-helpers";
import { MCPManager } from "../mcp/manager";
import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.md" with { type: "text" };
import { MAIN_AGENT_ID } from "../registry/agent-registry";
import * as taskDiscovery from "../task/discovery";
import type { ExecutorOptions } from "../task/executor";
import * as taskExecutor from "../task/executor";
import {
applyEligibleNestedPatches,
type IsolationContext,
makeIsolationCommitMessage,
mergeIsolatedChanges,
prepareIsolationContext,
runIsolatedSubprocess,
} from "../task/isolation-runner";
import { AgentOutputManager } from "../task/output-manager";
import { resolveSpawnPolicy } from "../task/spawn-policy";
import { type AgentDefinition, type AgentProgress, canSpawnAtDepth, type SingleResult } from "../task/types";
import { type NestedRepoPatch, parseIsolationMode } from "../task/worktree";
buildStructuredSubagentRecoveryHint,
runStructuredSubagent,
StructuredSubagentError,
type StructuredSubagentSchemaMode,
} from "../task/structured-subagent";
import type { AgentProgress, SingleResult } from "../task/types";
import type { NestedRepoPatch } from "../task/worktree";
import type { ToolSession } from "../tools";
import { ToolError } from "../tools/tool-errors";
import { withBridgeTimeoutPause } from "./bridge-timeout";
@@ -37,21 +20,13 @@ import "../tools/review";
/** Synthetic bridge name reserved for the `agent()` helper across both runtimes. */
export const EVAL_AGENT_BRIDGE_NAME = "__agent__";
/**
* Hard recursion ceiling for eval-driven subagents. The user setting
* `task.maxRecursionDepth` is honored on top of this — whichever is tighter
* wins, so a maintainer-friendly cap can't get raised by a user setting.
*/
export const EVAL_AGENT_MAX_DEPTH = 3;
const DEFAULT_AGENT_LABEL = "EvalAgent";
const agentArgsSchema = type({
prompt: "string>0",
"agent?": "string>0",
"model?": "string>0|string>0[]",
"label?": "string",
"schema?": "unknown",
"schemaMode?": "'permissive' | 'strict'",
"isolated?": "boolean",
"apply?": "boolean",
"merge?": "boolean",
@@ -64,30 +39,10 @@ interface EvalAgentArgs {
model?: string | string[];
label?: string;
schema?: unknown;
/**
* Run this subagent inside an isolation worktree (copy-on-write of the
* parent repo). Strict opt-in: defaults to `false` regardless of the
* session's `task.isolation.mode`, mirroring the `task` tool. Passing
* `true` while `task.isolation.mode === "none"` errors out instead of
* silently downgrading.
*/
schemaMode?: StructuredSubagentSchemaMode;
isolated?: boolean;
/**
* When isolated, apply the captured patch / merge the captured branch back
* to the parent repo (default `true`). Pass `false` to keep changes in the
* isolation worktree only — the patch artifact path / branch name lands in
* the result so the caller can inspect or apply manually.
*/
apply?: boolean;
/**
* When isolated, allow branch-merge mode (cherry-pick onto HEAD). Defaults
* to `true`, in which case the active `task.isolation.merge` setting picks
* patch vs branch. Pass `false` to force patch mode even when the setting
* is `"branch"` — useful when a fan-out cannot tolerate the per-call git
* lock + repo mutation that branch mode performs.
*/
merge?: boolean;
/** True when a runtime helper will return an `agent://` handle backed by the output artifacts. */
handle?: boolean;
}
@@ -99,28 +54,21 @@ export interface EvalAgentBridgeOptions {
export interface EvalAgentResult {
text: string;
/** Parsed structured data returned by the child executor. */
data?: unknown;
details: {
agent: string;
id: string;
model?: string | string[];
structured: boolean;
/** True iff this run executed inside an isolation worktree. */
schemaSource?: "caller" | "agent" | "session";
schemaMode?: StructuredSubagentSchemaMode;
schemaStatus?: "valid" | "invalid";
isolated?: boolean;
/** Captured patch artifact (patch mode) — surfaced regardless of `apply`. */
patchPath?: string;
/** Captured branch (branch mode) — surfaced regardless of `apply`. */
branchName?: string;
/** Captured nested repository patches — surfaced for isolated `apply=false` manual application. */
nestedPatches?: NestedRepoPatch[];
/**
* Tri-state apply outcome for isolated runs:
* - `true` — apply ran (or had nothing to do) and left the repo clean.
* - `false` — apply attempted and failed; artifacts preserved.
* - `null` — caller opted out via `apply=false`.
* Omitted for non-isolated runs.
*/
changesApplied?: boolean | null;
/** Human-readable isolation apply/merge summary; kept out of schema-backed `text`. */
isolationSummary?: string;
};
}
@@ -133,134 +81,28 @@ function parseAgentArgs(args: unknown): EvalAgentArgs {
return result;
}
function assertDepthAllowed(session: ToolSession): void {
const taskDepth = session.taskDepth ?? 0;
// Honor the user's `task.maxRecursionDepth` (mirroring the task tool's gate
// in tools/index.ts) but never above the hard ceiling. `< 0` means
// "Unlimited" in the same schema `canSpawnAtDepth` reads, so it falls back
// to the hard ceiling instead of going past it.
const settingMax = session.settings.get("task.maxRecursionDepth") ?? 2;
const effectiveMax = settingMax < 0 ? EVAL_AGENT_MAX_DEPTH : Math.min(settingMax, EVAL_AGENT_MAX_DEPTH);
if (!canSpawnAtDepth(effectiveMax, taskDepth)) {
throw new ToolError(
`agent() cannot spawn another agent at task depth ${taskDepth}; maximum depth is ${effectiveMax} (task.maxRecursionDepth=${settingMax}, hard ceiling=${EVAL_AGENT_MAX_DEPTH}).`,
);
}
}
function assertSpawnAllowed(session: ToolSession, agentName: string): void {
const spawnPolicy = resolveSpawnPolicy(session.getSessionSpawns());
if (!spawnPolicy.enabled) {
throw new ToolError(`Cannot spawn '${agentName}'. Allowed: ${spawnPolicy.allowedErrorText}`);
}
if (spawnPolicy.allowedAgents !== null && !spawnPolicy.allowedAgents.includes(agentName)) {
throw new ToolError(`Cannot spawn '${agentName}'. Allowed: ${spawnPolicy.allowedErrorText}`);
}
}
function assertAgentEnabled(session: ToolSession, agentName: string, agents: AgentDefinition[]): void {
const disabledAgents = session.settings.get("task.disabledAgents") as string[];
if (!disabledAgents.includes(agentName)) return;
const enabled = agents.filter(agent => !disabledAgents.includes(agent.name)).map(agent => agent.name);
throw new ToolError(
`Agent "${agentName}" is disabled in settings. Enable it via /agents, or use a different agent type.${enabled.length > 0 ? ` Available: ${enabled.join(", ")}` : ""}`,
);
}
function assertNotPlanMode(session: ToolSession): void {
if (session.getPlanModeState?.()?.enabled) {
throw new ToolError("agent() is unavailable in plan mode.");
}
}
function renderSubagentPrompt(assignment: string): string {
return prompt.render(subagentUserPromptTemplate, { assignment: assignment.trim() });
}
function trimToUndefined(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function outputIdBase(label: string | undefined, agentName: string): string {
const source = trimToUndefined(label) ?? agentName ?? DEFAULT_AGENT_LABEL;
const sanitized = source.replace(/[^A-Za-z0-9_-]+/g, "").slice(0, 48);
return sanitized || DEFAULT_AGENT_LABEL;
function formatEvalIsolationRecoveryHint(hint: string): string {
const prefix = "Recovery preserved at ";
const recovery = hint.trim();
if (!recovery.startsWith(prefix)) return hint;
const entries = recovery.slice(prefix.length).replace(/\.$/, "").split(", ");
return entries
.map(entry =>
entry.startsWith("branch ")
? ` Captured branch preserved as ${entry.slice("branch ".length)}.`
: ` Captured patch preserved at ${entry}.`,
)
.join("");
}
function getOutputManager(session: ToolSession): AgentOutputManager {
if (session.agentOutputManager) return session.agentOutputManager;
const manager = new AgentOutputManager(session.getArtifactsDir ?? (() => null));
session.agentOutputManager = manager;
return manager;
}
interface ArtifactPaths {
sessionFile: string | null;
artifactsDir: string;
unregisterArtifactsDir?: () => void;
/**
* True when `artifactsDir` was created off the session path (no session
* file). Caller is then free to `rm -rf` it once all isolated patch
* artifacts have been consumed or applied.
*/
tempArtifactsDir: boolean;
}
async function getArtifacts(session: ToolSession): Promise<ArtifactPaths> {
const sessionFile = session.getSessionFile();
const sessionArtifactsDir = sessionFile ? sessionFile.slice(0, -6) : null;
const tempArtifactsDir = sessionArtifactsDir === null;
const artifactsDir = sessionArtifactsDir ?? path.join(os.tmpdir(), `omp-eval-agent-${Snowflake.next()}`);
await fs.mkdir(artifactsDir, { recursive: true });
const unregisterArtifactsDir = tempArtifactsDir ? registerArtifactsDir(artifactsDir) : undefined;
return { sessionFile, artifactsDir, unregisterArtifactsDir, tempArtifactsDir };
}
/**
* Persist nested-repo patches to the per-call artifacts dir so an isolated
* apply failure can surface their paths in the thrown ToolError. The
* isolation worktree is already gone by the time we run, so without this the
* captured nested patches would be unrecoverable.
*/
async function persistNestedPatches(
artifactsDir: string,
agentId: string,
nestedPatches: NestedRepoPatch[],
): Promise<string[]> {
const written: string[] = [];
for (let index = 0; index < nestedPatches.length; index++) {
const patch = nestedPatches[index];
if (!patch) continue;
const slug = patch.relativePath.replace(/[^A-Za-z0-9._-]+/g, "_") || `nested-${index}`;
const out = path.join(artifactsDir, `${agentId}.nested-${index}-${slug}.patch`);
await Bun.write(out, patch.patch);
written.push(out);
}
return written;
}
/**
* Assemble the "captured X preserved at Y" recovery hint appended to
* isolated-run failure messages. Persists nested-repo patches to
* `artifactsDir` when present so their paths can be surfaced. Returns an
* empty string when the result carries no salvageable artifacts.
*/
async function buildIsolationRecoveryHint(result: SingleResult, artifactsDir: string): Promise<string> {
const parts: string[] = [];
if (result.patchPath) parts.push(`Captured patch preserved at ${result.patchPath}.`);
if (result.branchName) parts.push(`Captured branch preserved as ${result.branchName}.`);
if (result.nestedPatches?.length) {
const nestedPaths = await persistNestedPatches(artifactsDir, result.id, result.nestedPatches);
parts.push(
`Captured nested repository patches (${result.nestedPatches.length}) preserved at: ${nestedPaths.join(", ")}.`,
);
}
return parts.length > 0 ? ` ${parts.join(" ")}` : "";
}
function plainIsolationSummary(summary: string): string {
return summary.replace(/<\/?system-notification>/g, "").trim();
async function buildEvalIsolationRecoveryHint(result: SingleResult, artifactsDir: string): Promise<string> {
const recoveryHint = await buildStructuredSubagentRecoveryHint(result, artifactsDir);
return formatEvalIsolationRecoveryHint(recoveryHint);
}
function emitProgressStatus(emitStatus: ((event: JsStatusEvent) => void) | undefined, progress: AgentProgress): void {
@@ -285,15 +127,6 @@ function emitProgressStatus(emitStatus: ((event: JsStatusEvent) => void) | undef
});
}
/**
* Coalesce a subagent failure into a non-empty, human-meaningful error message.
*
* When the executor aborts a subagent (runtime limit, parent cancellation, …)
* the actionable explanation lives on `abortReason`, while `error`/`stderr`
* are routinely empty strings. Plain `??` coalescing stops at the empty string
* and ships an empty error through the bridge — Python then surfaces only the
* generic `bridge call '__agent__' failed`. See #2006.
*/
function buildSubagentFailureMessage(agentName: string, result: SingleResult): string {
const abortReason = trimToUndefined(result.abortReason);
if (result.aborted && abortReason) return abortReason;
@@ -310,288 +143,101 @@ function buildSubagentFailureMessage(agentName: string, result: SingleResult): s
*/
export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOptions): Promise<EvalAgentResult> {
const parsed = parseAgentArgs(args);
const agentName = parsed.agent ?? resolveSpawnPolicy(options.session.getSessionSpawns()).defaultAgent;
const structured = Object.hasOwn(parsed, "schema");
assertNotPlanMode(options.session);
assertDepthAllowed(options.session);
assertSpawnAllowed(options.session, agentName);
const turnBudget = options.session.getTurnBudget?.();
if (turnBudget?.hard && turnBudget.total !== null && turnBudget.spent >= turnBudget.total) {
throw new ToolError(
`agent() blocked: turn token budget exhausted (${turnBudget.spent}/${turnBudget.total} output tokens). Raise or drop the +Nk! ceiling to continue.`,
);
}
const isolation =
Object.hasOwn(parsed, "isolated") || Object.hasOwn(parsed, "apply") || Object.hasOwn(parsed, "merge")
? {
...(parsed.isolated !== undefined ? { requested: parsed.isolated } : {}),
...(parsed.merge === false ? { merge: "patch" as const } : {}),
...(parsed.apply !== undefined ? { apply: parsed.apply } : {}),
}
: undefined;
const { agents } = await taskDiscovery.discoverAgents(options.session.cwd);
const agent = taskDiscovery.getAgent(agents, agentName);
if (!agent) {
const available = agents.map(candidate => candidate.name).join(", ") || "none";
throw new ToolError(`Unknown agent "${agentName}". Available: ${available}`);
try {
const execution = await withBridgeTimeoutPause(
options.emitStatus,
() =>
runStructuredSubagent({
session: options.session,
invocationKind: "eval",
assignment: parsed.prompt,
...(parsed.agent !== undefined ? { agent: parsed.agent } : {}),
...(parsed.model !== undefined ? { model: parsed.model } : {}),
...(Object.hasOwn(parsed, "schema") ? { outputSchema: parsed.schema } : {}),
...(parsed.schemaMode !== undefined ? { schemaMode: parsed.schemaMode } : {}),
...(parsed.label !== undefined ? { identity: { label: parsed.label } } : {}),
...(isolation ? { isolation } : {}),
...(parsed.handle ? { retainArtifacts: true } : {}),
keepAlive: false,
maxRuntimeMs: 0,
shareEvalSession: false,
...(options.signal !== undefined ? { signal: options.signal } : {}),
...(options.emitStatus
? { onProgress: (progress: AgentProgress) => emitProgressStatus(options.emitStatus, progress) }
: {}),
}),
{ deferExternalAbort: true },
);
const { result, policy, mergeSummary, changesApplied, artifactsDir } = execution;
if (result.exitCode !== 0 || result.error || result.aborted) {
const failureMessage = buildSubagentFailureMessage(policy.agentName, result)
.replace(/<\/?system-notification>/g, "")
.trim();
const recoveryHint = policy.isIsolated ? await buildEvalIsolationRecoveryHint(result, artifactsDir) : "";
throw new ToolError(`${failureMessage}${recoveryHint}`);
}
if (policy.isIsolated && changesApplied === false) {
const summary = mergeSummary.replace(/<\/?system-notification>/g, "").trim();
const recoveryHint = await buildEvalIsolationRecoveryHint(result, artifactsDir);
throw new ToolError(
`agent() isolated apply failed for ${result.id}${summary ? `: ${summary}` : ""}${recoveryHint}`,
);
}
const structuredOutput = result.structuredOutput;
const structured = structuredOutput?.source !== undefined && structuredOutput.source !== "none";
if (structured && mergeSummary.includes("<system-notification>")) {
const recoveryHint = await buildEvalIsolationRecoveryHint(result, artifactsDir);
throw new ToolError(
`agent() isolated nested patch apply failed for ${result.id}: ${mergeSummary.replace(/<\/?system-notification>/g, "").trim()}${recoveryHint}`,
);
}
const hasData = structured && structuredOutput !== undefined && Object.hasOwn(structuredOutput, "data");
const data = structuredOutput?.data;
const text = structured ? result.output : result.output + mergeSummary;
const schemaSource = structuredOutput?.source === "none" ? undefined : structuredOutput?.source;
const schemaMode = structured ? structuredOutput?.mode : undefined;
const schemaStatus = structuredOutput?.status === "unavailable" ? undefined : structuredOutput?.status;
const model = result.resolvedModel ?? policy.modelOverride;
const nestedPatches = result.nestedPatches?.length ? result.nestedPatches : undefined;
const isolationSummary = mergeSummary ? mergeSummary.trim() : undefined;
return {
text,
...(hasData ? { data } : {}),
details: {
agent: result.agent,
id: result.id,
...(model !== undefined ? { model } : {}),
structured,
...(schemaSource !== undefined ? { schemaSource } : {}),
...(schemaMode !== undefined ? { schemaMode } : {}),
...(schemaStatus !== undefined ? { schemaStatus } : {}),
...(policy.isIsolated ? { isolated: true, changesApplied } : {}),
...(result.patchPath !== undefined ? { patchPath: result.patchPath } : {}),
...(result.branchName !== undefined ? { branchName: result.branchName } : {}),
...(nestedPatches !== undefined ? { nestedPatches } : {}),
...(isolationSummary !== undefined ? { isolationSummary } : {}),
},
};
} catch (error) {
if (error instanceof StructuredSubagentError) throw new ToolError(error.message);
throw error;
}
assertAgentEnabled(options.session, agentName, agents);
const effectiveAgent = agent;
const parentActiveModelPattern = options.session.getActiveModelString?.();
const agentModelOverrides = options.session.settings.get("task.agentModelOverrides");
const modelOverride = resolveAgentModelPatterns({
settingsOverride: parsed.model ?? agentModelOverrides[agentName],
agentModel: effectiveAgent.model,
settings: options.session.settings,
activeModelPattern: parentActiveModelPattern,
fallbackModelPattern: options.session.getModelString?.(),
});
const availableSkills = [...(options.session.skills ?? [])];
const resolvedAutoloadSkills =
effectiveAgent.autoloadSkills?.length && availableSkills.length > 0
? effectiveAgent.autoloadSkills
.map(name => availableSkills.find(skill => skill.name === name))
.filter((skill): skill is NonNullable<typeof skill> => skill !== undefined)
: [];
const contextFiles = options.session.contextFiles?.filter(
file => path.basename(file.path).toLowerCase() !== "agents.md",
);
const localProtocolOptions: LocalProtocolOptions = options.session.localProtocolOptions ?? {
getArtifactsDir: options.session.getArtifactsDir ?? (() => null),
getSessionId: options.session.getSessionId ?? (() => null),
};
const parentArtifactManager = options.session.getArtifactManager?.() ?? undefined;
const mcpManager = options.session.mcpManager ?? MCPManager.instance();
const { sessionFile, artifactsDir, unregisterArtifactsDir, tempArtifactsDir } = await getArtifacts(options.session);
const outputManager = getOutputManager(options.session);
const id = await outputManager.allocate(outputIdBase(parsed.label, agentName));
const assignment = parsed.prompt.trim();
// Isolation gating. Strict opt-in: only the explicit `isolated=true`
// argument turns it on; `task.isolation.mode` no longer drives the
// default. Mirrors the `task` tool so eval `agent()` and `task` callers
// see the same semantic. `isolated=true` while the mode is `"none"`
// surfaces a clear error instead of silently downgrading.
const isolationMode = options.session.settings.get("task.isolation.mode");
const isolationEnabledInSettings = isolationMode !== "none";
if (parsed.isolated === true && !isolationEnabledInSettings) {
throw new ToolError(`agent(isolated=True) requires task.isolation.mode to be set; current mode is "none".`);
}
const isIsolated = parsed.isolated === true;
const settingsMergeMode = options.session.settings.get("task.isolation.merge");
const mergeMode: "patch" | "branch" = parsed.merge === false ? "patch" : settingsMergeMode;
const applyChanges = parsed.apply !== false;
// Isolation context capture (prepareIsolationContext → captureBaseline)
// happens inside the timeout-pause closure below; on dirty/large repos the
// baseline walk can run long and must stay covered by the eval idle
// suspension.
const buildCommitMessage = makeIsolationCommitMessage(options.session);
const baseRunOptions: ExecutorOptions = {
cwd: options.session.cwd,
agent: effectiveAgent,
task: renderSubagentPrompt(assignment),
assignment,
description: trimToUndefined(parsed.label),
index: 0,
id,
taskDepth: options.session.taskDepth ?? 0,
modelOverride,
parentActiveModelPattern,
thinkingLevel: effectiveAgent.thinkingLevel,
...(structured ? { outputSchema: parsed.schema, outputSchemaOverridesAgent: true } : {}),
sessionFile,
persistArtifacts: Boolean(sessionFile),
artifactsDir,
// Eval `agent()` subagents are short-lived programmatic helpers (data
// collection, structured output, parallel() fan-out). LSP server
// cold-start costs tens of seconds and is pure overhead here, so it is
// forced off regardless of the `task.enableLsp` setting — that knob only
// governs LSP-aware delegation through the `task` tool.
enableLsp: false,
signal: options.signal,
eventBus: options.session.eventBus,
onProgress: progress => emitProgressStatus(options.emitStatus, progress),
authStorage: options.session.authStorage,
modelRegistry: options.session.modelRegistry,
settings: options.session.settings,
// Eval `agent()` subagents are never wall-clock capped: the parent
// cell's idle watchdog is suspended for the whole bridge call
// (withBridgeTimeoutPause), so a long-running phase/recovery workflow
// must not be killed by `task.maxRuntimeMs`. Force the limit off
// regardless of the inherited session setting.
maxRuntimeMs: 0,
keepAlive: false,
mcpManager,
contextFiles,
skills: availableSkills,
autoloadSkills: resolvedAutoloadSkills,
workspaceTree: options.session.workspaceTree,
promptTemplates: options.session.promptTemplates,
localProtocolOptions,
parentArtifactManager,
parentHindsightSessionState: options.session.getHindsightSessionState?.(),
parentMnemopiSessionState: options.session.getMnemopiSessionState?.(),
parentTelemetry: options.session.getTelemetry?.(),
parentAgentId: options.session.getAgentId?.() ?? MAIN_AGENT_ID,
// Live source of truth for `tier.subagent: inherit` (null = explicit none).
parentServiceTier: options.session.getServiceTierByFamily
? (options.session.getServiceTierByFamily() ?? null)
: undefined,
// Deliberately omit parentEvalSessionId: the parent's Python kernel is
// blocked on this bridge call, so sharing the eval session would deadlock
// (subagent queues behind the parent's in-flight execution, parent waits
// for subagent → circular). Each bridge-spawned subagent gets its own
// eval session with an independent kernel.
};
// Suspend eval timeout accounting through the WHOLE bridge call: the
// subagent subprocess plus any isolation post-processing (merge,
// nested-patch apply, cleanup). All of that is host-side work while the
// runtime is parked waiting for the result, and the cell timeout must
// not abort us mid-cherry-pick or mid-nested-commit. The clock restarts
// only after we hand control back to the runtime.
const { result, mergeSummary, changesApplied } = await withBridgeTimeoutPause(
options.emitStatus,
async () => {
let isolationContext: IsolationContext | null = null;
if (isIsolated) {
try {
isolationContext = await prepareIsolationContext(options.session.cwd);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new ToolError(`Isolated agent() execution requires a git repository. ${message}`);
}
}
const preferredBackend = isIsolated ? parseIsolationMode(isolationMode) : undefined;
const result = await (async () => {
if (!isolationContext) {
return taskExecutor.runSubprocess(baseRunOptions);
}
const taskStart = Date.now();
return runIsolatedSubprocess({
baseOptions: baseRunOptions,
context: isolationContext,
preferredBackend,
agentId: id,
mergeMode,
artifactsDir,
description: trimToUndefined(parsed.label),
buildCommitMessage,
buildFailureResult: err => {
const message = err instanceof Error ? err.message : String(err);
return {
index: 0,
id,
agent: effectiveAgent.name,
agentSource: effectiveAgent.source,
task: renderSubagentPrompt(assignment),
assignment,
description: trimToUndefined(parsed.label),
exitCode: 1,
output: "",
stderr: message,
truncated: false,
durationMs: Date.now() - taskStart,
tokens: 0,
requests: 0,
modelOverride,
error: message,
};
},
});
})();
if (result.exitCode !== 0 || result.error || result.aborted) {
const failureMessage = buildSubagentFailureMessage(agentName, result);
const recoveryHint = isIsolated ? await buildIsolationRecoveryHint(result, artifactsDir) : "";
throw new ToolError(`${failureMessage}${recoveryHint}`);
}
let mergeSummary = "";
let changesApplied: boolean | null = null;
if (isIsolated && isolationContext) {
if (applyChanges) {
const outcome = await mergeIsolatedChanges({
result,
repoRoot: isolationContext.repoRoot,
mergeMode,
});
mergeSummary = outcome.summary;
changesApplied = outcome.changesApplied;
if (outcome.changesApplied === false) {
const summaryText = outcome.summary.trim();
const recoveryHint = await buildIsolationRecoveryHint(result, artifactsDir);
throw new ToolError(
`agent() isolated apply failed for ${result.id}${summaryText ? `: ${summaryText}` : ""}${recoveryHint}`,
);
}
const nestedSummary = await applyEligibleNestedPatches({
result,
repoRoot: isolationContext.repoRoot,
mergeMode,
changesApplied: outcome.changesApplied,
mergedBranchForNestedPatches: outcome.mergedBranchForNestedPatches,
commitMessage: buildCommitMessage(),
});
mergeSummary += nestedSummary;
if (structured && nestedSummary.trim()) {
const recoveryHint = await buildIsolationRecoveryHint(
{ ...result, patchPath: undefined, branchName: undefined },
artifactsDir,
);
throw new ToolError(
`agent() isolated nested patch apply failed for ${result.id}: ${plainIsolationSummary(nestedSummary)}${recoveryHint}`,
);
}
} else if (result.branchName) {
mergeSummary = `\n\nIsolation: changes captured on branch \`${result.branchName}\` (apply=false). Not merged.`;
} else if (result.patchPath) {
mergeSummary = `\n\nIsolation: changes captured at \`${result.patchPath}\` (apply=false). Not applied.`;
} else {
const nestedPatches = result.nestedPatches ?? [];
if (nestedPatches.length > 0) {
mergeSummary = `\n\nIsolation: changes captured for ${nestedPatches.length} nested repositor${nestedPatches.length === 1 ? "y" : "ies"} (apply=false). Not applied.`;
} else {
mergeSummary = "\n\nIsolation: no changes captured.";
}
}
}
// Clean up the temp artifacts dir we created for this call only when the
// caller will not need files from it later. Keep it when the runtime helper
// will return an `agent://` handle (the `.md`/`.jsonl` backing files live
// here) and on `apply=false` (`changesApplied === null`) where the caller
// consumes `details.patchPath` / `details.branchName` /
// `details.nestedPatches` out of band. Failed isolated applies throw
// earlier with a recovery hint, so they never reach this gate.
const shouldCleanupTempArtifacts =
tempArtifactsDir && !parsed.handle && (!isIsolated || changesApplied === true);
if (shouldCleanupTempArtifacts) {
await fs.rm(artifactsDir, { recursive: true, force: true });
unregisterArtifactsDir?.();
}
options.session.recordEvalSubagentUsage?.(result.usage?.output ?? 0);
return { result, mergeSummary, changesApplied };
},
{ deferExternalAbort: true },
);
return {
text: structured ? result.output : result.output + mergeSummary,
details: {
agent: result.agent,
id: result.id,
model: result.resolvedModel ?? modelOverride,
structured,
isolated: isIsolated || undefined,
patchPath: result.patchPath,
branchName: result.branchName,
nestedPatches: result.nestedPatches?.length ? result.nestedPatches : undefined,
changesApplied: isIsolated ? changesApplied : undefined,
isolationSummary: mergeSummary ? mergeSummary.trim() : undefined,
},
};
}
+7 -6
View File
@@ -519,7 +519,7 @@ function completion(prompt::String; model="default", system=nothing, schema=noth
return schema === nothing ? text : Main.json_parse(string(text))
end
function agent(prompt::String; agent="task", model=nothing, label=nothing, schema=nothing, isolated=nothing, apply=nothing, merge=nothing, handle=false, kwargs...)
function agent(prompt::String; agent="task", model=nothing, label=nothing, schema=nothing, schema_mode=nothing, isolated=nothing, apply=nothing, merge=nothing, handle=false, kwargs...)
args_dict = Dict{String, Any}("prompt" => prompt)
if agent !== nothing
args_dict["agent"] = agent
@@ -533,8 +533,9 @@ function agent(prompt::String; agent="task", model=nothing, label=nothing, schem
if schema !== nothing
args_dict["schema"] = schema
end
# Isolation knobs mirror the `task` tool: strict opt-in via `isolated`,
# with `apply`/`merge` controlling the post-run patch/branch merge.
if schema_mode !== nothing
args_dict["schemaMode"] = schema_mode
end
if isolated !== nothing
args_dict["isolated"] = Bool(isolated)
end
@@ -548,13 +549,13 @@ function agent(prompt::String; agent="task", model=nothing, label=nothing, schem
for (k, v) in kwargs
args_dict[string(k)] = v
end
# Tell the bridge a handle is wanted so it preserves the backing artifacts.
if handle_result
args_dict["handle"] = true
end
res = __omp_call_bridge("__agent__", args_dict)
text = res isa AbstractDict ? get(res, "text", res) : res
parsed = schema === nothing ? text : Main.json_parse(string(text))
has_data = res isa AbstractDict && haskey(res, "data")
parsed = has_data ? res["data"] : (schema === nothing ? text : Main.json_parse(string(text)))
if !handle_result
return parsed
end
@@ -569,7 +570,7 @@ function agent(prompt::String; agent="task", model=nothing, label=nothing, schem
"id" => get(details, "id", nothing),
"agent" => get(details, "agent", nothing)
)
if schema !== nothing
if has_data || schema !== nothing
node["data"] = parsed
end
for (src_key, dst_key) in (
@@ -104,20 +104,21 @@ if (!globalThis.__omp_js_prelude_loaded__) {
"agent",
opts,
rest,
["agent", "model", "label", "schema", "isolated", "apply", "merge"],
"{ agent, model, label, schema, isolated, apply, merge, handle }",
["agent", "model", "label", "schema", "isolated", "apply", "merge", "schemaMode"],
"{ agent, model, label, schema, isolated, apply, merge, schemaMode, handle }",
);
const { handle, ...callArgs } = o;
const res = await globalThis.__omp_call_tool__("__agent__", { prompt, ...callArgs, handle: Boolean(handle) });
const text = res && typeof res === "object" ? res.text : res;
const parsed = hasOwn(callArgs, "schema") ? JSON.parse(text) : text;
const hasData = res && typeof res === "object" && hasOwn(res, "data");
const parsed = hasData ? res.data : hasOwn(callArgs, "schema") ? JSON.parse(text) : text;
if (!handle) return parsed;
const details = res && typeof res === "object" ? res.details : undefined;
if (!details || typeof details !== "object" || details.id == null) {
return { text, output: text, handle: null, id: null, agent: null };
}
const node = { text, output: text, handle: `agent://${details.id}`, id: details.id, agent: details.agent ?? null };
if (hasOwn(callArgs, "schema")) node.data = parsed;
if (hasData || hasOwn(callArgs, "schema")) node.data = parsed;
for (const key of ["isolated", "patchPath", "branchName", "nestedPatches", "changesApplied", "isolationSummary"]) {
if (details[key] !== undefined) node[key] = details[key];
}
+10 -38
View File
@@ -487,48 +487,17 @@ if "__omp_prelude_loaded__" not in globals():
model=None,
label=None,
schema=None,
schema_mode=None,
isolated=None,
apply=None,
merge=None,
handle=False,
):
"""Run a subagent and return its final output.
"""Run a subagent and return its final output or structured data.
`agent` selects the subagent definition (default "task"). Pass
`model` to override that agent's model, `label` for the output artifact
id, and `schema` to request structured JSON output; when `schema` is
supplied the parsed object is returned. Share background by writing a
local:// file and referencing it in the prompt.
Pass `isolated=True` to run the subagent inside an isolation worktree
(copy-on-write of the parent repo) so parallel `agent()` spawns can
edit overlapping files safely. Strict opt-in, mirroring the `task`
tool: the default is non-isolated regardless of `task.isolation.mode`.
`isolated=True` while the setting is `"none"` errors out instead of
silently downgrading.
When isolated, `apply=False` keeps captured changes inside the
worktree and surfaces the root patch path, branch name, and nested
repository patches through the DAG node dict (combine with
`handle=True` to receive them — see below; the bare return type
stays bytes/string/parsed object and has nowhere to expose artifacts).
`merge=False` forces patch mode even when `task.isolation.merge` is
`"branch"`, avoiding the per-call git lock + repo mutation that branch
mode performs.
Set `handle=True` to receive a DAG node dict instead of bare
text: ``{"text", "output", "handle", "id", "agent"}`` where ``handle``
is the spawned agent's recoverable ``agent://<id>`` URI. A downstream
``pipeline``/``parallel`` stage embeds that ``handle`` (or ``output``)
in its prompt so a large transcript flows through the graph by
reference, never re-inlined. When ``schema`` is also set the parsed
object lands under ``"data"``. When the spawn ran isolated the node
also carries ``"isolated"`` and, when present, ``"patch_path"``,
``"branch_name"``, ``"nested_patches"``, ``"changes_applied"``
(``True``/``False``/``None`` — ``None`` means ``apply=False``), and
``"isolation_summary"``. If
the bridge returns no recoverable id the node still resolves with
``handle=None`` — the helper never throws.
`schema` overrides agent and session schemas. `schema_mode` is
`"permissive"` or `"strict"`. `handle=True` returns the child output
reference and metadata, with parsed data under `"data"` when available.
"""
args = {"prompt": prompt}
if agent is not None:
@@ -539,6 +508,8 @@ if "__omp_prelude_loaded__" not in globals():
args["label"] = label
if schema is not None:
args["schema"] = schema
if schema_mode is not None:
args["schemaMode"] = schema_mode
if isolated is not None:
args["isolated"] = bool(isolated)
if apply is not None:
@@ -549,7 +520,8 @@ if "__omp_prelude_loaded__" not in globals():
args["handle"] = True
res = _bridge_call("__agent__", args)
text = res.get("text") if isinstance(res, dict) else res
parsed = json.loads(text) if schema is not None else text
has_data = isinstance(res, dict) and "data" in res
parsed = res["data"] if has_data else json.loads(text) if schema is not None else text
if not handle:
return parsed
details = res.get("details") if isinstance(res, dict) else None
@@ -568,7 +540,7 @@ if "__omp_prelude_loaded__" not in globals():
"id": details["id"],
"agent": details.get("agent"),
}
if schema is not None:
if has_data or schema is not None:
node["data"] = parsed
for src_key, dst_key in (
("isolated", "isolated"),
+5 -6
View File
@@ -392,22 +392,21 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded
schema.nil? ? text : JSON.parse(text)
end
def agent(prompt, agent: "task", model: nil, label: nil, schema: nil, isolated: nil, apply: nil, merge: nil, handle: false)
def agent(prompt, agent: "task", model: nil, label: nil, schema: nil, schema_mode: nil, isolated: nil, apply: nil, merge: nil, handle: false)
args = { "prompt" => prompt }
args["agent"] = agent unless agent.nil?
args["model"] = model unless model.nil?
args["label"] = label unless label.nil?
args["schema"] = schema unless schema.nil?
# Isolation knobs mirror the `task` tool: strict opt-in via `isolated`,
# with `apply`/`merge` controlling the post-run patch/branch merge.
args["schemaMode"] = schema_mode unless schema_mode.nil?
args["isolated"] = !!isolated unless isolated.nil?
args["apply"] = !!apply unless apply.nil?
args["merge"] = !!merge unless merge.nil?
# Tell the bridge a handle is wanted so it preserves the backing artifacts.
args["handle"] = true if handle
res = OmpBridge.call("__agent__", args)
text = res.is_a?(Hash) ? res["text"] : res
parsed = schema.nil? ? text : JSON.parse(text)
has_data = res.is_a?(Hash) && res.key?("data")
parsed = has_data ? res["data"] : (schema.nil? ? text : JSON.parse(text))
return parsed unless handle
details = res.is_a?(Hash) ? res["details"] : nil
if !details.is_a?(Hash) || details["id"].nil?
@@ -420,7 +419,7 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded
"id" => details["id"],
"agent" => details["agent"],
}
node["data"] = parsed unless schema.nil?
node["data"] = parsed if has_data || !schema.nil?
{
"isolated" => "isolated",
"patchPath" => "patch_path",
@@ -17,9 +17,12 @@ write(path, content) → str
env(key?=None, value?=None) → str | None | dict
output(*ids, format?="raw", query?=None, offset?=None, limit?=None) → str | dict | list[dict]
tool.<name>(args) → unknown
Invoke any session tool; `args` = its parameter object.
completion(prompt, model?="default"|"smol"|"slow", system?=None, schema?=None) → str | dict
{{#if spawns}}agent(prompt, agent?="{{spawnDefaultAgent}}", model?=None, schema?=None, handle?=False) → str | dict{{#if spawnAllowedAgentsText}} Allowed: {{spawnAllowedAgentsText}}.{{/if}}
{{#if js}} JS: agent(prompt, { agent, schema, handle }).{{/if}}
Oneshot, stateless (no history/tools). `model`: "smol" fast | "default" session | "slow" most capable. `schema` (JSON-Schema) → parsed object.
{{#if spawns}}agent(prompt, agent?="{{spawnDefaultAgent}}", model?=None, label?=None, schema?=None, schema{{#if js}}Mode{{else}}_mode{{/if}}?="permissive", isolated?=None, apply?=None, merge?=None, handle?=False) → str | dict
Run a subagent → final output. `agent` selects a discovered agent; omit it to use `{{spawnDefaultAgent}}`.{{#if spawnAllowedAgentsText}} Allowed agents: {{spawnAllowedAgentsText}}.{{/if}} `schema` overrides agent/session schemas; `schemaMode`/`schema_mode`: "permissive" | "strict". Effective schemas return parsed data. `isolated` requests a worktree; `apply`/`merge` control its changes. Background via `local://` files named in the prompt. `handle` → { text, output, handle: "agent://<id>", id, agent }, parsed `data` when structured.
{{#if js}} JS: ONE trailing object — agent(prompt, { agent, model, label, schema, schemaMode, isolated, apply, merge, handle }).{{/if}}
{{/if}}
parallel(thunks) → list pipeline(items, ...stages) → list
log(message) → None phase(title) → None
@@ -10,18 +10,22 @@ Agents marked BLOCKING run inline — results return in this call; non-blocking
# Inputs
{{#if batchEnabled}}
- `context`: Shared project state for the entire batch — don't duplicate into individual tasks.
- `tasks[]`: Subagents to spawn.
- `name`: CamelCase ≤32 chars (auto-generated if omitted).
- `agent`: specialist type (optional).
- `task`: Complete, self-contained instructions — no one-liners, no missing acceptance criteria.
- `context`: Shared project state, constraints, and contracts. Applies to the entire batch; do not duplicate this background into individual tasks.
- `tasks[]`: Array of subagents to spawn.
- `name`: A stable CamelCase identifier (≤32 chars), used to address the agent (IRC, job ids). Generated automatically if omitted.
- `agent`: The agent type running this item (e.g. `scout`, `reviewer`). Omitting it gives you the general-purpose worker (`{{defaultAgent}}`) — NEVER pass that name explicitly. Only omit it after checking the agent list below and finding no specialist that fits.{{#if allowedAgentsText}} Current spawn policy allows: {{allowedAgentsText}}.{{/if}}
- `task`: Complete, self-contained instructions. One-liners or missing acceptance criteria are PROHIBITED.
- `outputSchema`: Invocation-specific JSON Schema. Overrides the selected agent and parent-session schemas.
- `schemaMode`: `"permissive"` (default) accepts a retry-exhausted invalid result with a warning; `"strict"` fails it.
{{#if isolationEnabled}}
- `isolated`: Run in dedicated worktree, return patches. Destroyed on completion, cannot be addressed afterward.
{{/if}}
{{else}}
- `name`: CamelCase ≤32 chars (auto-generated if omitted).
- `agent`: specialist type (optional).
- `task`: Complete, self-contained instructions — no one-liners, no missing acceptance criteria.
- `name`: A stable CamelCase identifier (≤32 chars), used to address the agent (IRC, job ids). Generated automatically if omitted.
- `agent`: The agent type to spawn (e.g. `scout`, `reviewer`). Omitting it gives you the general-purpose worker (`{{defaultAgent}}`) — NEVER pass that name explicitly. Only omit it after checking the agent list below and finding no specialist that fits.{{#if allowedAgentsText}} Current spawn policy allows: {{allowedAgentsText}}.{{/if}}
- `task`: Complete, self-contained instructions. One-liners or missing acceptance criteria are PROHIBITED.
- `outputSchema`: Invocation-specific JSON Schema. Overrides the selected agent and parent-session schemas.
- `schemaMode`: `"permissive"` (default) accepts a retry-exhausted invalid result with a warning; `"strict"` fails it.
{{#if isolationEnabled}}
- `isolated`: Run in dedicated worktree, return patches.
{{/if}}
+128 -87
View File
@@ -34,6 +34,7 @@ import {
} from "./advisor";
import { type AsyncJob, AsyncJobManager } from "./async";
import { AutoLearnController, buildAutoLearnInstructions } from "./autolearn/controller";
import { createAutoresearchExtension } from "./autoresearch";
import { loadCapability } from "./capability";
import { type Rule, ruleCapability, setActiveRules } from "./capability/rule";
import { bucketRules } from "./capability/rule-buckets";
@@ -145,6 +146,7 @@ import {
} from "./system-prompt";
import { AgentOutputManager } from "./task/output-manager";
import { wrapStreamFnWithProviderConcurrency } from "./task/provider-concurrency";
import type { StructuredSubagentSchemaMode } from "./task/types";
import {
AUTO_THINKING,
type ConfiguredThinkingLevel,
@@ -482,20 +484,30 @@ export interface CreateAgentSessionOptions {
/** File-based slash commands. Default: discovered from commands/ directories */
slashCommands?: FileSlashCommand[];
/** Enable MCP server discovery from .mcp.json files. Default: true */
/**
* Enable MCP capabilities. `false` skips MCP discovery and ignores
* `mcpManager`, preventing process-global or inherited MCP access. Default:
* true.
*/
enableMCP?: boolean;
/** Existing MCP manager to reuse (skips discovery, propagates to toolSession). */
/** Existing MCP manager to reuse when MCP is enabled (skips discovery, propagates to toolSession). */
mcpManager?: MCPManager;
/** Enable LSP integration (tool, formatting, diagnostics, warmup). Default: true */
enableLsp?: boolean;
/** Whether this invocation may expose IRC. `false` removes it even for subagents. */
enableIrc?: boolean;
/** Skip subprocess-kernel availability checks and prelude warmup */
skipPythonPreflight?: boolean;
/** Tool names explicitly requested (enables disabled-by-default tools) */
toolNames?: string[];
/** Limit the session to explicitly supplied tool names, without discovered extras. */
restrictToolNames?: boolean;
/** Output schema for structured completion (subagents) */
/** Output schema for structured completion (subagents). */
outputSchema?: unknown;
/** Enforcement policy for {@link outputSchema}; defaults to legacy permissive behavior. */
outputSchemaMode?: StructuredSubagentSchemaMode;
/** Whether to include the yield tool by default */
requireYieldTool?: boolean;
/** Task recursion depth (for subagent sessions). Default: 0 */
@@ -1544,7 +1556,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
let session!: AgentSession;
let hasSession = false;
let hasRegistered = false;
const enableLsp = options.enableLsp ?? true;
const restrictToolNames = options.restrictToolNames === true;
const enableLsp = !restrictToolNames && (options.enableLsp ?? true);
const asyncMaxJobs = Math.min(100, Math.max(1, settings.get("async.maxJobs") ?? 100));
const ASYNC_INLINE_RESULT_MAX_CHARS = 12_000;
const ASYNC_PREVIEW_MAX_CHARS = 4_000;
@@ -1641,9 +1654,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
setActiveToolNames,
hasUI: options.hasUI ?? false,
enableLsp,
enableIrc: restrictToolNames ? false : options.enableIrc,
restrictToolNames,
get hasEditTool() {
const requestedToolNames = options.toolNames ? normalizeToolNames(options.toolNames) : undefined;
return !requestedToolNames || requestedToolNames.includes("edit");
return restrictToolNames
? requestedToolNames?.includes("edit") === true
: !requestedToolNames || requestedToolNames.includes("edit");
},
skipPythonPreflight: options.skipPythonPreflight,
contextFiles,
@@ -1655,6 +1672,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
rules: allRules,
eventBus,
outputSchema: options.outputSchema,
outputSchemaMode: options.outputSchemaMode,
requireYieldTool: options.requireYieldTool,
prewalkArmed: options.prewalk !== undefined,
taskDepth: options.taskDepth ?? 0,
@@ -1771,10 +1789,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// Create built-in tools (already wrapped with meta notice formatting)
const builtinTools = await logger.time("createAllTools", createTools, toolSession, options.toolNames);
// Discover MCP tools from .mcp.json files
let mcpManager: MCPManager | undefined = options.mcpManager;
// Restricted sessions cannot inherit or discover MCP capabilities.
const enableMCP = !restrictToolNames && (options.enableMCP ?? true);
let mcpManager: MCPManager | undefined = enableMCP ? options.mcpManager : undefined;
toolSession.mcpManager = mcpManager;
const enableMCP = options.enableMCP ?? true;
toolSession.enableMCP = enableMCP;
const deferMCPDiscoveryForUI = enableMCP && !mcpManager && options.hasUI === true;
const customTools: CustomTool[] = [];
let startDeferredMCPDiscovery: ((liveSession: AgentSession) => void) | undefined;
@@ -1860,58 +1879,63 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// to mirror the AsyncJobManager ownership rule.
if (mcpManager && !options.parentTaskPrefix) MCPManager.setInstance(mcpManager);
// Add image tools when generation is enabled and either no explicit tool
// whitelist was given or it names `generate_image`. Image gen is a
// discoverable custom tool: once it enters the registry the common
// partition presents it under xd:// (or routes it to BM25 discovery), so no
// source-specific force-activation is needed — only this eligibility gate.
const imageGenRequested = !options.toolNames || options.toolNames.includes("generate_image");
if (settings.get("generate_image.enabled") && imageGenRequested) {
const imageGenTools = await logger.time("getImageGenTools", () => getImageGenTools(modelRegistry, model));
if (imageGenTools.length > 0) {
customTools.push(...(imageGenTools as unknown as CustomTool[]));
}
}
if (settings.get("speechgen.enabled")) {
customTools.push(ttsTool as unknown as CustomTool);
}
// Add web search tools
if (options.toolNames?.includes("web_search")) {
customTools.push(...getSearchTools());
}
// Discover custom tools from `.omp/tools/`, `.claude/tools/`, plugins, etc.
// Subagents reuse the parent's scan via `preloadedCustomToolPaths` to skip
// the FS walk, but ALWAYS re-call `loadCustomTools` here so factories bind
// to THIS session's `CustomToolAPI` (cwd, exec, pushPendingAction, UI).
// Forwarding the parent's `LoadedCustomTool[]` directly would route tool
// execution back through the parent — wrong for isolated tasks and for
// pending-action queueing.
const builtInToolNames = builtinTools.map(t => t.name);
const customToolPaths: ToolPathWithSource[] =
options.preloadedCustomToolPaths ??
(await logger.time("discoverCustomToolPaths", () => discoverCustomToolPaths([], cwd)));
const customToolsLoadResult = await logger.time("loadCustomTools", () =>
loadCustomTools(customToolPaths, cwd, builtInToolNames, action => queueResolveHandler(toolSession, action)),
);
for (const { path, error } of customToolsLoadResult.errors) {
logger.error("Custom tool load failed", { path, error });
}
if (customToolsLoadResult.tools.length > 0) {
customTools.push(...customToolsLoadResult.tools.map(loaded => loaded.tool));
let customToolPaths: ToolPathWithSource[] = [];
const inlineExtensions: ExtensionFactory[] = [];
if (!restrictToolNames) {
// Add image tools when generation is enabled and either no explicit tool
// whitelist was given or it names `generate_image`. Unlike built-in tools
// (filtered in `createTools`), custom tools are force-activated via
// `alwaysInclude` below, so an explicit `--no-tools`/whitelist must be
// honored here or image-gen would leak past every filter (issue #5305).
const imageGenRequested = !options.toolNames || options.toolNames.includes("generate_image");
if (settings.get("generate_image.enabled") && imageGenRequested) {
const imageGenTools = await logger.time("getImageGenTools", () => getImageGenTools(modelRegistry, model));
if (imageGenTools.length > 0) {
customTools.push(...(imageGenTools as unknown as CustomTool[]));
}
}
if (settings.get("speechgen.enabled")) {
customTools.push(ttsTool as unknown as CustomTool);
}
// Add web search tools
if (options.toolNames?.includes("web_search")) {
customTools.push(...getSearchTools());
}
// Discover custom tools from `.omp/tools/`, `.claude/tools/`, plugins, etc.
// Subagents reuse the parent's scan via `preloadedCustomToolPaths` to skip
// the FS walk, but ALWAYS re-call `loadCustomTools` here so factories bind
// to THIS session's `CustomToolAPI` (cwd, exec, pushPendingAction, UI).
// Forwarding the parent's `LoadedCustomTool[]` directly would route tool
// execution back through the parent — wrong for isolated tasks and for
// pending-action queueing.
customToolPaths =
options.preloadedCustomToolPaths ??
(await logger.time("discoverCustomToolPaths", () => discoverCustomToolPaths([], cwd)));
const customToolsLoadResult = await logger.time("loadCustomTools", () =>
loadCustomTools(customToolPaths, cwd, builtInToolNames, action => queueResolveHandler(toolSession, action)),
);
for (const { path, error } of customToolsLoadResult.errors) {
logger.error("Custom tool load failed", { path, error });
}
if (customToolsLoadResult.tools.length > 0) {
customTools.push(...customToolsLoadResult.tools.map(loaded => loaded.tool));
}
inlineExtensions.push(...(options.extensions ?? []));
inlineExtensions.push(createAutoresearchExtension);
if (customTools.length > 0) {
inlineExtensions.push(createCustomToolsExtension(customTools));
}
}
// Forward the path list (NOT the loaded tools) to subagents so they
// re-bind under their own `CustomToolAPI` while skipping the FS scan.
toolSession.customToolPaths = customToolPaths;
const inlineExtensions: ExtensionFactory[] = options.extensions ? [...options.extensions] : [];
inlineExtensions.push((await import("./autoresearch")).createAutoresearchExtension);
if (customTools.length > 0) {
inlineExtensions.push(createCustomToolsExtension(customTools));
}
// Load extensions. Three paths:
// 1. `preloadedExtensions` (CLI): caller already loaded — reuse the
// Extension instances. Shallow-clone `extensions` so the inline
@@ -1926,7 +1950,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// the flag and pre-resolved the result already reflects that choice.
let extensionPaths: string[];
let extensionsResult: LoadExtensionsResult;
if (options.preloadedExtensions) {
if (restrictToolNames) {
// Allocate a session runtime without evaluating caller-provided extension
// instances, paths, or factories.
extensionPaths = [];
extensionsResult = await loadExtensions([], cwd, eventBus);
} else if (options.preloadedExtensions) {
extensionsResult = {
...options.preloadedExtensions,
extensions: [...options.preloadedExtensions.extensions],
@@ -2235,11 +2264,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
}
}
// Discover custom commands (TypeScript slash commands)
const customCommandsResult: CustomCommandsLoadResult = options.disableExtensionDiscovery
? { commands: [], errors: [] }
: await logger.time("discoverCustomCommands", loadCustomCommandsInternal, { cwd, agentDir });
if (!options.disableExtensionDiscovery) {
// Restricted sessions do not discover or evaluate custom command modules.
const customCommandsResult: CustomCommandsLoadResult =
options.disableExtensionDiscovery || restrictToolNames
? { commands: [], errors: [] }
: await logger.time("discoverCustomCommands", loadCustomCommandsInternal, { cwd, agentDir });
if (!options.disableExtensionDiscovery && !restrictToolNames) {
for (const { path, error } of customCommandsResult.errors) {
logger.error("Failed to load custom command", { path, error });
}
@@ -2284,8 +2314,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
});
const toolContextStore = new ToolContextStore(getSessionContext);
const registeredTools = extensionRunner.getAllRegisteredTools();
const sdkCustomTools = options.customTools?.filter(tool => !isLegacyBuiltinToolDefinition(tool)) ?? [];
const registeredTools = restrictToolNames ? [] : extensionRunner.getAllRegisteredTools();
const sdkCustomTools = restrictToolNames
? []
: (options.customTools?.filter(tool => !isLegacyBuiltinToolDefinition(tool)) ?? []);
const allCustomTools = [
...registeredTools,
...sdkCustomTools.map(tool => {
@@ -2308,7 +2340,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
toolRegistry.set(tool.name, tool);
builtInRegistryToolNames.add(tool.name);
}
if (!toolRegistry.has("goal") && settings.get("goal.enabled")) {
if (!restrictToolNames && !toolRegistry.has("goal") && settings.get("goal.enabled")) {
const goalTool = await logger.time("createTools:goal:session", HIDDEN_TOOLS.goal, toolSession);
if (goalTool) {
toolRegistry.set(goalTool.name, wrapToolWithMetaNotice(goalTool));
@@ -2361,7 +2393,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const hasDeferrableTools = Array.from(toolRegistry.values()).some(tool => tool.deferrable === true);
const hasXdevTools = (toolSession.xdevRegistry?.size ?? 0) > 0;
const planModeAvailable = settings.get("plan.enabled");
if (hasDeferrableTools || hasXdevTools || planModeAvailable || deferMCPDiscoveryForUI) {
if (!restrictToolNames && (hasDeferrableTools || hasXdevTools || planModeAvailable || deferMCPDiscoveryForUI)) {
await ensureWriteRegistered();
}
@@ -2401,8 +2433,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
): Promise<BuildSystemPromptResult> => {
toolContextStore.setToolNames(toolNames);
const promptTools = buildSystemPromptToolMetadata(tools);
const memoryBackend = await resolveMemoryBackend(settings);
const memoryInstructions = await memoryBackend.buildDeveloperInstructions(agentDir, settings, session);
const memoryBackend = restrictToolNames ? undefined : await resolveMemoryBackend(settings);
const memoryInstructions = memoryBackend
? await memoryBackend.buildDeveloperInstructions(agentDir, settings, session)
: undefined;
// Build combined append prompt: memory instructions + auto-learn guidance
// + MCP server instructions. For UI sessions MCP discovery is deferred, so
@@ -2417,10 +2451,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// session-start build — so a subagent that filtered them out, a mid-session
// enable that never built them, or a same-named custom tool while auto-learn
// is off all get no guidance.
const autoLearnInstructions = buildAutoLearnInstructions({
manageSkill: builtInToolNames.includes("manage_skill"),
learn: builtInToolNames.includes("learn"),
});
const autoLearnInstructions = restrictToolNames
? undefined
: buildAutoLearnInstructions({
manageSkill: builtInToolNames.includes("manage_skill"),
learn: builtInToolNames.includes("learn"),
});
const appendParts: string[] = [];
if (memoryInstructions) appendParts.push(memoryInstructions);
if (autoLearnInstructions) appendParts.push(autoLearnInstructions);
@@ -2452,7 +2488,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
cwd,
xdevTools: toolSession.xdevRegistry?.entries() ?? [],
xdevDocs: toolSession.xdevRegistry?.docsAll() ?? "",
autoQaEnabled: isAutoQaEnabled(settings),
autoQaEnabled: !restrictToolNames && isAutoQaEnabled(settings),
resolvedCustomPrompt: options.customSystemPrompt,
skills: session?.skills ?? skills,
contextFiles,
@@ -2469,11 +2505,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
eagerTasksAlways,
taskBatch: settings.get("task.batch"),
taskMaxConcurrency: settings.get("task.maxConcurrency"),
taskIrcEnabled: isIrcEnabled(settings, options.taskDepth ?? 0),
taskIrcEnabled: !restrictToolNames && isIrcEnabled(settings, options.taskDepth ?? 0),
secretsEnabled,
workspaceTree: workspaceTreePromise,
includeWorkspaceTree,
memoryRootEnabled: memoryBackend.id === "local",
memoryRootEnabled: memoryBackend?.id === "local",
model: getActiveModelString(),
includeModelInPrompt: settings.get("includeModelInPrompt"),
personality: agentKind === "sub" ? "none" : settings.get("personality"),
@@ -2514,7 +2550,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// exactly the builtins createTools built (`builtInToolNames` — provenance, so a
// same-named custom/extension tool is never force-activated when auto-learn is
// off) to keep guidance, controller, and the active set consistent.
if (explicitlyRequestedToolNames) {
if (!restrictToolNames && explicitlyRequestedToolNames) {
for (const name of ["manage_skill", "learn"]) {
if (builtInToolNames.includes(name) && !explicitlyRequestedToolNames.includes(name)) {
explicitlyRequestedToolNames.push(name);
@@ -2538,11 +2574,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
: requestedActiveToolNames.filter(name => !defaultInactiveToolNames.has(name));
let initialToolNames = [...initialRequestedActiveToolNames];
// Custom tools and extension-registered tools are always included regardless of toolNames filter
const alwaysInclude: string[] = [
...sdkCustomTools.map(t => (isCustomTool(t) ? t.name : t.name)),
...registeredTools.filter(t => !t.definition.defaultInactive).map(t => t.definition.name),
];
// Custom tools and extension-registered tools are always included regardless of toolNames filter.
// Restricted callers own the list, so never widen it with registered tools.
const alwaysInclude: string[] = restrictToolNames
? []
: [
...sdkCustomTools.map(t => (isCustomTool(t) ? t.name : t.name)),
...registeredTools.filter(t => !t.definition.defaultInactive).map(t => t.definition.name),
];
for (const name of alwaysInclude) {
if (toolRegistry.has(name) && !initialToolNames.includes(name)) {
initialToolNames.push(name);
@@ -3111,15 +3150,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// and the tools; the fire-time re-check in `#onAgentEnd` still handles a
// mid-session DISABLE. The subscription lives for the session's lifetime; the
// reference is intentionally discarded (the listener retains it).
if (settings.get("autolearn.enabled") && taskDepth === 0) {
await logger.time("startMemoryStartupTask", startMemoryBackend);
new AutoLearnController({
session,
settings,
capture: content => session.runAutolearnCapture(signal => runAutoLearnCapture(content, signal)),
});
} else {
void logger.time("startMemoryStartupTask", startMemoryBackend);
if (!restrictToolNames) {
if (settings.get("autolearn.enabled") && taskDepth === 0) {
await logger.time("startMemoryStartupTask", startMemoryBackend);
new AutoLearnController({
session,
settings,
capture: content => session.runAutolearnCapture(signal => runAutoLearnCapture(content, signal)),
});
} else {
void logger.time("startMemoryStartupTask", startMemoryBackend);
}
}
// Wire MCP manager callbacks to session for reactive tool updates.
@@ -1,5 +1,6 @@
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { ImageContent, MessageAttribution, ServiceTierByFamily, TextContent } from "@oh-my-pi/pi-ai";
import type { StructuredSubagentSchemaMode } from "../task/types";
export const CURRENT_SESSION_VERSION = 3;
@@ -164,8 +165,12 @@ export interface SessionInitEntry extends SessionEntryBase {
task: string;
/** Tools available to the agent */
tools: string[];
/** Output schema if structured output was requested */
/** Output schema if structured output was requested. */
outputSchema?: unknown;
/** Enforcement policy recorded with the output schema for faithful revival. */
outputSchemaMode?: StructuredSubagentSchemaMode;
/** Whether revival must retain only the explicitly persisted tool names. */
restrictToolNames?: boolean;
/** Spawn allowlist the subagent ran with ("" = none, "*" = any, else CSV); absent on pre-spawns files. */
spawns?: string;
/** The agent's `readSummarize` setting (`false` = read summarization disabled); absent uses the session default. */
@@ -18,6 +18,7 @@ import {
stringifyJson,
toError,
} from "@oh-my-pi/pi-utils";
import type { StructuredSubagentSchemaMode } from "../task/types";
import { ArtifactManager } from "./artifacts";
import { type BlobPutOptions, type BlobPutResult, BlobStore } from "./blob-store";
import {
@@ -1558,6 +1559,8 @@ export class SessionManager {
task: string;
tools: string[];
outputSchema?: unknown;
outputSchemaMode?: StructuredSubagentSchemaMode;
restrictToolNames?: boolean;
spawns?: string;
readSummarize?: boolean;
}): string {
@@ -1996,6 +1999,8 @@ export class SessionManager {
task: string;
tools: string[];
outputSchema?: unknown;
outputSchemaMode?: StructuredSubagentSchemaMode;
restrictToolNames?: boolean;
spawns?: string;
readSummarize?: boolean;
} | null;
@@ -2014,6 +2019,8 @@ export class SessionManager {
task: string;
tools: string[];
outputSchema?: unknown;
outputSchemaMode?: StructuredSubagentSchemaMode;
restrictToolNames?: boolean;
spawns?: string;
readSummarize?: boolean;
} | null = null;
@@ -2025,6 +2032,8 @@ export class SessionManager {
task: entry.task,
tools: entry.tools,
outputSchema: entry.outputSchema,
outputSchemaMode: entry.outputSchemaMode,
restrictToolNames: entry.restrictToolNames,
readSummarize: entry.readSummarize,
spawns: entry.spawns,
};
+102 -22
View File
@@ -61,6 +61,9 @@ import {
MAX_OUTPUT_BYTES,
MAX_OUTPUT_LINES,
type SingleResult,
type StructuredSubagentOutput,
type StructuredSubagentSchemaMode,
type StructuredSubagentSchemaSource,
TASK_SUBAGENT_EVENT_CHANNEL,
TASK_SUBAGENT_LIFECYCLE_CHANNEL,
TASK_SUBAGENT_PROGRESS_CHANNEL,
@@ -292,7 +295,12 @@ export interface ExecutorOptions {
*/
parentActiveModelPattern?: string;
thinkingLevel?: ConfiguredThinkingLevel;
/** Schema used to validate the final structured completion. */
outputSchema?: unknown;
/** Enforcement policy for {@link outputSchema}; defaults to legacy permissive behavior. */
outputSchemaMode?: StructuredSubagentSchemaMode;
/** Origin of the selected schema, preserved in {@link SingleResult.structuredOutput}. */
outputSchemaSource?: StructuredSubagentSchemaSource;
/**
* Caller supplied a schema that supersedes the agent's native output prompt.
* Eval `agent(..., schema=...)` sets this so built-in agents ignore stale yield labels.
@@ -307,7 +315,20 @@ export interface ExecutorOptions {
* watchdog is already suspended for the call's duration.
*/
maxRuntimeMs?: number;
/** Include IRC only when the invocation policy permits collaboration. */
enableIrc?: boolean;
enableLsp?: boolean;
/**
* Enable MCP capabilities for this child. `false` suppresses both inherited
* MCP proxy tools and session MCP discovery; it never consults the
* process-global MCP manager. Defaults to `true`.
*/
enableMCP?: boolean;
/**
* Limit the child to its explicit host tool names and the required yield
* tool, suppressing discovered and always-included capabilities.
*/
restrictToolNames?: boolean;
signal?: AbortSignal;
onProgress?: (progress: AgentProgress) => void;
/**
@@ -450,6 +471,8 @@ interface FinalizeSubprocessOutputArgs {
signalAborted: boolean;
yieldItems?: YieldItem[];
outputSchema: unknown;
outputSchemaMode?: StructuredSubagentSchemaMode;
outputSchemaSource?: StructuredSubagentSchemaSource;
lastAssistantText?: string;
}
@@ -459,6 +482,7 @@ interface FinalizeSubprocessOutputResult {
stderr: string;
abortedViaYield: boolean;
hasYield: boolean;
structuredOutput?: StructuredSubagentOutput;
}
export const SUBAGENT_WARNING_SCHEMA_OVERRIDDEN =
"SYSTEM WARNING: Subagent exhausted schema-retry budget; result was accepted despite failing the output schema.";
@@ -494,6 +518,10 @@ function buildSchemaViolationOutcome(
export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): FinalizeSubprocessOutputResult {
let { rawOutput, exitCode, stderr } = args;
const { yieldItems, doneAborted, signalAborted, outputSchema, lastAssistantText } = args;
const mode = args.outputSchemaMode ?? "permissive";
const source = args.outputSchemaSource ?? (outputSchema === undefined ? "none" : "session");
const includeStructuredOutput = source !== "none";
let structuredOutput: StructuredSubagentOutput | undefined;
let abortedViaYield = false;
const hasYield = Array.isArray(yieldItems) && yieldItems.length > 0;
const hadFailureBeforeYield = exitCode !== 0 && stderr.trim().length > 0;
@@ -514,15 +542,37 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
if (!assembled || assembled.missingData) {
rawOutput = rawOutput ? `${SUBAGENT_WARNING_NULL_YIELD}\n\n${rawOutput}` : SUBAGENT_WARNING_NULL_YIELD;
} else {
const { validator, error: schemaError } = buildOutputValidator(outputSchema);
const completeData = assembled.rawText ? assembled.data : parseStringifiedJson(assembled.data ?? null);
const result =
schemaError || assembled.schemaOverridden
? { success: true as const }
: (validator?.validate(completeData) ?? { success: true as const });
if (!result.success) {
const summary = summarizeValidationFailure(result, completeData, validator?.requiredFields ?? []);
const outcome = buildSchemaViolationOutcome(summary, completeData);
const { validator, error: schemaError, normalized } = buildOutputValidator(outputSchema);
const completeData = assembled.rawText
? assembled.data
: parseStringifiedJson(assembled.data ?? null);
const validation = validator?.validate(completeData);
const failure =
validation && !validation.success
? summarizeValidationFailure(validation, completeData, validator?.requiredFields ?? [])
: assembled.schemaOverridden
? { message: SUBAGENT_WARNING_SCHEMA_OVERRIDDEN, missingRequired: [] }
: schemaError
? { message: `invalid output schema: ${schemaError}`, missingRequired: [] }
: undefined;
if (includeStructuredOutput) {
structuredOutput =
schemaError || normalized === undefined
? {
source,
mode,
status: "unavailable",
data: completeData,
error: schemaError ? `invalid output schema: ${schemaError}` : undefined,
}
: failure
? { source, mode, status: "invalid", data: completeData, error: failure.message }
: { source, mode, status: "valid", data: completeData };
}
const mustReject =
failure !== undefined && (mode === "strict" || (!assembled.schemaOverridden && !schemaError));
if (mustReject && failure) {
const outcome = buildSchemaViolationOutcome(failure, completeData);
rawOutput = outcome.rawOutput;
stderr = outcome.stderr;
exitCode = outcome.exitCode;
@@ -540,9 +590,7 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
exitCode = 0;
stderr = assembled.schemaOverridden
? SUBAGENT_WARNING_SCHEMA_OVERRIDDEN
: schemaError
? `invalid output schema: ${schemaError}`
: "";
: (structuredOutput?.error ?? "");
} else if (!stderr) {
stderr = "Subagent failed after yielding a result.";
}
@@ -560,11 +608,22 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
const result = validator?.validate(completeData) ?? { success: true as const };
if (!result.success) {
const summary = summarizeValidationFailure(result, completeData, validator?.requiredFields ?? []);
if (includeStructuredOutput) {
structuredOutput = { source, mode, status: "invalid", data: completeData, error: summary.message };
}
const outcome = buildSchemaViolationOutcome(summary, completeData);
rawOutput = outcome.rawOutput;
stderr = outcome.stderr;
exitCode = outcome.exitCode;
} else {
if (includeStructuredOutput) {
structuredOutput = {
source,
mode,
status: "valid",
data: completeData,
};
}
try {
rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
} catch (err) {
@@ -587,7 +646,7 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
}
}
return { rawOutput, exitCode, stderr, abortedViaYield, hasYield };
return { rawOutput, exitCode, stderr, abortedViaYield, hasYield, structuredOutput };
}
/**
@@ -1710,6 +1769,8 @@ interface FinalizeRunArgs {
assignment?: string;
modelOverride?: string | string[];
outputSchema?: unknown;
outputSchemaMode?: StructuredSubagentSchemaMode;
outputSchemaSource?: StructuredSubagentSchemaSource;
signal?: AbortSignal;
artifactsDir?: string;
eventBus?: EventBus;
@@ -1747,6 +1808,8 @@ async function finalizeRunResult(args: FinalizeRunArgs): Promise<SingleResult> {
signalAborted: Boolean(signal?.aborted),
yieldItems,
outputSchema: args.outputSchema,
outputSchemaMode: args.outputSchemaMode,
outputSchemaSource: args.outputSchemaSource,
lastAssistantText: monitor.lastAssistantSalvageText(),
});
} finally {
@@ -1841,6 +1904,7 @@ async function finalizeRunResult(args: FinalizeRunArgs): Promise<SingleResult> {
output: truncatedOutput,
stderr,
truncated: Boolean(truncated),
...(finalized.structuredOutput ? { structuredOutput: finalized.structuredOutput } : {}),
durationMs: Date.now() - args.startTime,
tokens: progress.tokens,
requests: progress.requests,
@@ -1935,6 +1999,10 @@ export interface FollowUpTurnOptions {
message: string;
index?: number;
description?: string;
/** Structured-output state retained from the original invocation. */
outputSchema?: unknown;
outputSchemaMode?: StructuredSubagentSchemaMode;
outputSchemaSource?: StructuredSubagentSchemaSource;
signal?: AbortSignal;
onProgress?: (progress: AgentProgress) => void;
eventBus?: EventBus;
@@ -2019,6 +2087,9 @@ export async function runSubagentFollowUpTurn(options: FollowUpTurnOptions): Pro
id,
agent,
task: message,
outputSchema: options.outputSchema,
outputSchemaMode: options.outputSchemaMode,
outputSchemaSource: options.outputSchemaSource,
signal,
artifactsDir: options.artifactsDir,
eventBus: options.eventBus,
@@ -2107,6 +2178,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
const parentDepth = options.taskDepth ?? 0;
const childDepth = parentDepth + 1;
const atMaxDepth = maxRecursionDepth >= 0 && childDepth >= maxRecursionDepth;
const ircEnabled = options.enableIrc !== false && isIrcEnabled(subagentSettings, childDepth);
// Add tools if specified
let toolNames: string[] | undefined;
@@ -2121,9 +2193,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
if (atMaxDepth && toolNames?.includes("task")) {
toolNames = toolNames.filter(name => name !== "task");
}
// The hub is always available; the COOP prompt section advertises messaging,
// so a restricted whitelist must still carry `hub` for the subagent to use it.
if (toolNames && !toolNames.includes("hub")) {
// Ordinary agents retain the host's always-on collaboration capability.
// Restricted sessions must not widen their explicit host tool list with hub.
if (toolNames && !options.restrictToolNames && !toolNames.includes("hub")) {
toolNames = [...toolNames, "hub"];
}
if (toolNames?.includes("exec")) {
@@ -2145,7 +2217,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
: agent.spawns.join(",");
const lspEnabled = enableLsp ?? true;
const ircEnabled = isIrcEnabled(subagentSettings, childDepth);
const skipPythonPreflight = Array.isArray(toolNames) && !toolNames.includes("eval");
const monitor = createSubagentRunMonitor({
@@ -2354,8 +2425,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
}
sessionOpenedAt = performance.now();
const mcpProxyTools = options.mcpManager ? createMCPProxyTools(options.mcpManager) : [];
const enableMCP = !options.mcpManager;
const restrictToolNames = options.restrictToolNames === true;
const enableMCP = !restrictToolNames && (options.enableMCP ?? true);
const mcpManager = enableMCP ? options.mcpManager : undefined;
const mcpProxyTools = mcpManager ? createMCPProxyTools(mcpManager) : [];
// Derive subagent-scoped telemetry from the parent's config so the
// child loop's spans nest under the parent's active execute_tool span
@@ -2411,14 +2484,16 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
thinkingLevel: effectiveThinkingLevel,
toolNames,
outputSchema,
outputSchemaMode: options.outputSchemaMode,
restrictToolNames: options.restrictToolNames,
requireYieldTool: true,
contextFiles: options.contextFiles,
skills: options.skills,
promptTemplates: options.promptTemplates,
workspaceTree: options.workspaceTree,
rules: options.rules,
preloadedExtensionPaths: options.preloadedExtensionPaths,
preloadedCustomToolPaths: options.preloadedCustomToolPaths,
preloadedExtensionPaths: restrictToolNames ? [] : options.preloadedExtensionPaths,
preloadedCustomToolPaths: restrictToolNames ? [] : options.preloadedCustomToolPaths,
systemPrompt: defaultPrompt => {
const subagentPrompt = prompt.render(subagentSystemPromptTemplate, {
agent: agent.systemPrompt,
@@ -2447,9 +2522,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
agentId: id,
agentDisplayName: agent.name,
enableLsp: lspEnabled,
enableIrc: options.enableIrc,
skipPythonPreflight,
enableMCP,
mcpManager: options.mcpManager,
mcpManager,
customTools: mcpProxyTools.length > 0 ? mcpProxyTools : undefined,
localProtocolOptions: options.localProtocolOptions,
telemetry: subagentTelemetry,
@@ -2524,6 +2600,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
spawns: spawnsEnv,
readSummarize: agent.readSummarize,
outputSchema,
outputSchemaMode: options.outputSchemaMode,
restrictToolNames: restrictToolNames || undefined,
});
abortSignal.addEventListener(
@@ -2720,6 +2798,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
assignment,
modelOverride,
outputSchema,
outputSchemaMode: options.outputSchemaMode,
outputSchemaSource: options.outputSchemaSource,
signal,
artifactsDir: options.artifactsDir,
eventBus: options.eventBus,
+256 -427
View File
@@ -13,17 +13,12 @@
* - Progress tracking via JSON events
* - Session artifacts for debugging
*/
import * as fs from "node:fs/promises";
import * as os from "node:os";
import path from "node:path";
import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { Usage } from "@oh-my-pi/pi-ai";
import { $env, logger, prompt, Snowflake } from "@oh-my-pi/pi-utils";
import { $env, logger, prompt } from "@oh-my-pi/pi-utils";
import type { ToolSession } from "..";
import { resolveAgentModelPatterns } from "../config/model-resolver";
import { MCPManager } from "../mcp/manager";
import type { Theme } from "../modes/theme/theme";
import planModeSubagentPrompt from "../prompts/system/plan-mode-subagent.md" with { type: "text" };
import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.md" with { type: "text" };
import taskDescriptionTemplate from "../prompts/tools/task.md" with { type: "text" };
import taskSummaryTemplate from "../prompts/tools/task-summary.md" with { type: "text" };
@@ -45,25 +40,14 @@ import {
// Import review tools for side effects (registers subagent tool handlers)
import "../tools/review";
import type { AsyncJobManager } from "../async";
import type { LocalProtocolOptions } from "../internal-urls";
import { loadOverallPlanReference } from "../plan-mode/plan-handoff";
import { AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
import { type DiscoveryResult, discoverAgents, getAgent } from "./discovery";
import { runSubprocess } from "./executor";
import {
applyEligibleNestedPatches,
type IsolationContext,
makeIsolationCommitMessage,
mergeIsolatedChanges,
prepareIsolationContext,
runIsolatedSubprocess,
} from "./isolation-runner";
import { AgentRegistry } from "../registry/agent-registry";
import { type DiscoveryResult, discoverAgents } from "./discovery";
import { generateTaskName } from "./name-generator";
import { AgentOutputManager } from "./output-manager";
import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
import { mapWithConcurrencyLimitAllSettled, Semaphore } from "./parallel";
import { renderResult, renderCall as renderTaskCall } from "./render";
import { repairTaskParams } from "./repair-args";
import { parseIsolationMode } from "./worktree";
import { resolveEffectiveSubagentPolicy, runStructuredSubagent, StructuredSubagentError } from "./structured-subagent";
function renderSubagentUserPrompt(assignment: string): string {
return prompt.render(subagentUserPromptTemplate, {
@@ -154,7 +138,6 @@ export const READ_ONLY_TOOL_NAMES: ReadonlySet<string> = new Set([
"rewind",
]);
const PLAN_MODE_AGENT_TOOL_ALLOWLIST: ReadonlySet<string> = new Set(["ast_grep"]);
export function isReadOnlyAgent(agent: AgentDefinition): boolean {
return !!agent.tools?.length && agent.tools.every(tool => READ_ONLY_TOOL_NAMES.has(tool));
@@ -218,14 +201,13 @@ function createTaskModeError(text: string): AgentToolResult<TaskToolDetails> {
}
/**
* Reject fields the current configuration does not accept. `schema` is never
* accepted (structured output comes from the agent definition's `output`
* frontmatter, the inherited session schema, or an eval-workflow
* `agent(..., schema)` call); `tasks`/`context` require `task.batch`.
* Reject legacy fields and shape/configuration combinations the current tool
* cannot accept. `outputSchema` is a first-class per-spawn field; stale
* `schema` remains an eval-only alias and is rejected.
*/
function validateShapeParams(batchEnabled: boolean, params: TaskParams): string | undefined {
if ((params as Record<string, unknown>).schema !== undefined) {
return "The task tool does not accept `schema`. Rely on the selected agent definition's `output` schema or the inherited session schema; workflows needing ad-hoc structured output use eval `agent(prompt, schema)`.";
if (Object.hasOwn(params, "schema")) {
return "The task tool uses `outputSchema`; rename the stale `schema` field.";
}
if (!batchEnabled) {
const disallowed = (["tasks", "context"] as const).filter(field => params[field] !== undefined);
@@ -297,6 +279,8 @@ function resolveSpawnItems(params: TaskParams): TaskItem[] {
return params.tasks;
}
const item: TaskItem = { name: params.name, agent: params.agent, task: params.task };
if ("outputSchema" in params) item.outputSchema = params.outputSchema;
if ("schemaMode" in params) item.schemaMode = params.schemaMode;
if ("isolated" in params) item.isolated = params.isolated;
return [item];
}
@@ -315,6 +299,8 @@ function spawnParamsFor(params: TaskParams, item: TaskItem, defaultAgent: string
if (item.name !== undefined) spawn.name = item.name;
if (item.task !== undefined) spawn.task = item.task;
if (params.context !== undefined) spawn.context = params.context;
if ("outputSchema" in item) spawn.outputSchema = item.outputSchema;
if ("schemaMode" in item) spawn.schemaMode = item.schemaMode;
if (item.isolated !== undefined) {
spawn.isolated = item.isolated;
} else if ("isolated" in params) {
@@ -550,7 +536,8 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
#spawnSemaphore: Semaphore | undefined;
get parameters(): TaskToolSchemaInstance {
const isolationEnabled = this.session.settings.get("task.isolation.mode") !== "none";
const planMode = this.session.getPlanModeState?.()?.enabled === true;
const isolationEnabled = !planMode && this.session.settings.get("task.isolation.mode") !== "none";
const defaultAgent = resolveSpawnPolicy(this.session.getSessionSpawns()).defaultAgent;
return getTaskSchema({ isolationEnabled, batchEnabled: this.#isBatchEnabled(), defaultAgent });
}
@@ -562,10 +549,11 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
/** Dynamic description that reflects current disabled-agent settings */
get description(): string {
const disabledAgents = this.session.settings.get("task.disabledAgents") as string[];
const planMode = this.session.getPlanModeState?.()?.enabled === true;
const isolationMode = this.session.settings.get("task.isolation.mode");
return renderDescription(
this.#discoveredAgents,
isolationMode !== "none",
!planMode && isolationMode !== "none",
disabledAgents,
this.#isBatchEnabled(),
this.session.settings.get("async.enabled"),
@@ -599,6 +587,29 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
this.#getSpawnSemaphore().release();
}
/**
* Resolve the shared policy before detached work exists. The resulting
* policy intentionally stays local: executor dispatch resolves again from
* normalized task params rather than smuggling internal policy over the
* task wire contract.
*/
#resolveSpawnPreflight(params: TaskParams) {
return resolveEffectiveSubagentPolicy({
session: this.session,
invocationKind: "task",
assignment: (params.task ?? "").trim(),
context: this.#isBatchEnabled() ? params.context?.trim() || undefined : undefined,
agent: params.agent,
...(Object.hasOwn(params, "outputSchema") ? { outputSchema: params.outputSchema } : {}),
...(Object.hasOwn(params, "schemaMode") ? { schemaMode: params.schemaMode } : {}),
...("isolated" in params ? { isolation: { requested: params.isolated } } : {}),
blockedAgent: this.#blockedAgent,
enableLsp: (this.session.enableLsp ?? true) && this.session.settings.get("task.enableLsp"),
enableIrc: isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0),
maxRuntimeMs: this.session.settings.get("task.maxRuntimeMs"),
});
}
/**
* Create a TaskTool instance with async agent discovery.
*/
@@ -625,34 +636,103 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
}
const spawnItems = resolveSpawnItems(params);
const resolvedAgents = spawnItems.map(item => item.agent?.trim() || defaultAgent);
const normalizedSpawnParams = spawnItems.map(item => spawnParamsFor(params, item, defaultAgent));
const resolvedAgents = normalizedSpawnParams.map(spawn => spawn.agent ?? defaultAgent);
// Execution mode is per item: an item whose agent type declares
// `blocking: true` runs inline on this turn (the parent waits on its
// result); every other item becomes a background job when async
// execution is available.
const itemBlocking = resolvedAgents.map(
const provisionalBlocking = resolvedAgents.map(
name => this.#discoveredAgents.find(agent => agent.name === name)?.blocking === true,
);
const asyncEnabled = this.session.settings.get("async.enabled");
const manager = asyncEnabled ? this.session.asyncJobManager : undefined;
const asyncItems = manager ? spawnItems.filter((_, index) => !itemBlocking[index]) : [];
const provisionalAsyncItems = manager ? spawnItems.filter((_, index) => !provisionalBlocking[index]) : [];
const depthCapacity = canSpawnAtDepth(
this.session.settings.get("task.maxRecursionDepth") ?? 2,
this.session.taskDepth ?? 0,
);
const ircEnabled = isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0);
if (!manager || provisionalAsyncItems.length === 0) {
// Sync fallback: async execution disabled, orphaned host that never
// wired a job manager, or every item's agent type declares
// `blocking: true`. `runStructuredSubagent` performs its own shared
// preflight before reserving an id in these inline paths.
if (asyncEnabled && !this.session.asyncJobManager) {
logger.warn("task: no AsyncJobManager registered; falling back to sync execution");
}
const advisory = this.session.suppressSpawnAdvisory
? undefined
: composeSpawnAdvisory({
agents: resolvedAgents,
items: provisionalAsyncItems,
depthCapacity,
ircEnabled,
willRunAsync: false,
});
const result = await this.#executeSyncFanout(
toolCallId,
params,
spawnItems.map((item, index) => ({ item, index })),
defaultAgent,
signal,
onUpdate,
);
if (!advisory) return result;
let appended = false;
const content = result.content.map(part => {
if (!appended && part.type === "text" && typeof part.text === "string") {
appended = true;
return { ...part, text: `${part.text}\n\n${advisory}` };
}
return part;
});
if (!appended) content.push({ type: "text", text: advisory });
return { ...result, content };
}
// Async jobs are otherwise registered before their body can reach
// `runStructuredSubagent`. Resolve the shared policy first so policy
// failures remain synchronous and cannot leave a queued invalid job.
const preflights = await Promise.all(
normalizedSpawnParams.map(async spawn => {
try {
return { policy: await this.#resolveSpawnPreflight(spawn) };
} catch (error) {
return { error: error instanceof StructuredSubagentError ? error.message : String(error) };
}
}),
);
const preflightFailures = preflights
.map((preflight, index) => ("error" in preflight ? { index, error: preflight.error } : undefined))
.filter((failure): failure is { index: number; error: string } => failure !== undefined);
const renderPreflightFailures = () =>
preflightFailures
.map(({ index, error }) => {
const item = spawnItems[index]!;
return `Task ${item.name?.trim() || `#${index + 1}`} failed preflight: ${error}`;
})
.join("\n");
if (preflightFailures.length === spawnItems.length) {
return createTaskModeError(renderPreflightFailures());
}
const validIndices = preflights.flatMap((preflight, index) => (preflight.policy ? [index] : []));
const validSpawns = validIndices.map(index => ({ item: spawnItems[index]!, index }));
const itemBlocking = preflights.map(preflight => preflight.policy?.effectiveAgent.blocking === true);
const asyncItems = validIndices.filter(index => !itemBlocking[index]).map(index => spawnItems[index]!);
// Coordination only makes sense for spawns that keep running after this
// call returns (the async subset). Blocking items have already completed
// by then, so a "coordinate while they run" hint would misfire.
const willRunAsync = asyncItems.length > 0;
const advisory = this.session.suppressSpawnAdvisory
? undefined
: composeSpawnAdvisory({
agents: resolvedAgents,
agents: validIndices.map(index => resolvedAgents[index]!),
items: asyncItems,
depthCapacity,
ircEnabled,
willRunAsync,
willRunAsync: asyncItems.length > 0,
});
// Returns a fresh result (copied content array, copied text part) rather
// than mutating the caller's — task results are short-lived here, but an
@@ -670,22 +750,35 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
if (!appended) content.push({ type: "text", text: advisory });
return { ...result, content };
};
if (!manager || asyncItems.length === 0) {
// Sync fallback: async execution disabled, orphaned host that never
// wired a job manager, or every item's agent type declares
// `blocking: true`. The session-scoped semaphore still bounds fan-out
// across parallel task calls.
if (asyncEnabled && !this.session.asyncJobManager) {
logger.warn("task: no AsyncJobManager registered; falling back to sync execution");
}
return withAdvisory(
await this.#executeSyncFanout(toolCallId, params, spawnItems, defaultAgent, signal, onUpdate),
const withPreflightFailures = (result: AgentToolResult<TaskToolDetails>): AgentToolResult<TaskToolDetails> => {
if (preflightFailures.length === 0) return result;
const failures = renderPreflightFailures();
let prepended = false;
const content = result.content.map(part => {
if (!prepended && part.type === "text" && typeof part.text === "string") {
prepended = true;
return { ...part, text: `${failures}\n\n${part.text}` };
}
return part;
});
if (!prepended) content.unshift({ type: "text", text: failures });
return { ...result, content };
};
if (asyncItems.length === 0) {
return withPreflightFailures(
withAdvisory(
await this.#executeSyncFanout(toolCallId, params, validSpawns, defaultAgent, signal, onUpdate),
),
);
}
// Resolve agent ids up front so the immediate result can name them.
const outputManager =
this.session.agentOutputManager ?? new AgentOutputManager(this.session.getArtifactsDir ?? (() => null));
// Async IDs are claimed before job registration, so retain the fallback
// manager on the session rather than recreating it for every call.
let outputManager = this.session.agentOutputManager;
if (!outputManager) {
outputManager = new AgentOutputManager(this.session.getArtifactsDir ?? (() => null));
this.session.agentOutputManager = outputManager;
}
const callStartedAt = Date.now();
const spawns: Array<{
agentId: string;
@@ -694,10 +787,13 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
blocking: boolean;
progress: AgentProgress;
}> = [];
for (let index = 0; index < spawnItems.length; index++) {
const item = spawnItems[index];
const agentType = resolvedAgents[index];
const agentSource = this.#discoveredAgents.find(agent => agent.name === agentType)?.source ?? "bundled";
for (const index of validIndices) {
const item = spawnItems[index]!;
const agentType = resolvedAgents[index]!;
const preflight = preflights[index]!;
const policy = preflight.policy;
if (!policy) continue;
const agentSource = policy.agent.source;
const agentId = await outputManager.allocate(item.name?.trim() || generateTaskName());
const assignment = (item.task ?? "").trim();
spawns.push({
@@ -733,7 +829,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
// failed. Blocking spawns run inline below and land in `results` before
// the call returns, so post-return job updates never drop them.
let settledCount = 0;
let failedCount = 0;
let failedCount = preflightFailures.length;
let primaryJobId = asyncSpawns[0].agentId;
const syncResults: SingleResult[] = [];
let syncUsage: Usage | undefined;
@@ -783,7 +879,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
}
if (started.length === 0 && syncSpawns.length === 0) {
return {
return withPreflightFailures({
content: [
{
type: "text",
@@ -791,7 +887,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
},
],
details: { projectAgentsDir: null, results: [], totalDurationMs: 0 },
};
});
}
const scheduleFailureSummary =
@@ -814,30 +910,34 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
content: [{ type: "text", text: `Spawned agent \`${agentId}\`...` }],
details: buildAsyncDetails(),
});
return withAdvisory({
content: [
{
type: "text",
text: `Spawned agent \`${agentId}\` (job \`${jobId}\`). The result will be delivered when it yields. ${coordinationHint}`,
},
],
details: buildAsyncDetails(),
});
return withPreflightFailures(
withAdvisory({
content: [
{
type: "text",
text: `Spawned agent \`${agentId}\` (job \`${jobId}\`). The result will be delivered when it yields. ${coordinationHint}`,
},
],
details: buildAsyncDetails(),
}),
);
}
const startedListing = started.map(({ agentId, jobId }) => `- \`${agentId}\` (job \`${jobId}\`)`).join("\n");
onUpdate?.({
content: [{ type: "text", text: `Spawned ${started.length} agents...` }],
details: buildAsyncDetails(),
});
return withAdvisory({
content: [
{
type: "text",
text: `Spawned ${started.length} background agents using ${agentLabel}.${scheduleFailureSummary} Each result will be delivered when that agent yields.\n${startedListing}\n${coordinationHint}`,
},
],
details: buildAsyncDetails(),
});
return withPreflightFailures(
withAdvisory({
content: [
{
type: "text",
text: `Spawned ${started.length} background agents using ${agentLabel}.${scheduleFailureSummary} Each result will be delivered when that agent yields.\n${startedListing}\n${coordinationHint}`,
},
],
details: buildAsyncDetails(),
}),
);
}
// Mixed call: the async jobs above already run detached; the blocking
@@ -861,7 +961,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
spawns: syncSpawns.map(spawn => ({ item: spawn.item, index: spawn.index, preAllocatedId: spawn.agentId })),
onItemProgress: onUpdate
? (index, progress) => {
const spawn = spawns[index];
const spawn = spawns.find(candidate => candidate.index === index);
if (spawn) spawn.progress = { ...progress, index };
onUpdate({
content: [{ type: "text", text: `Running ${syncLabel} inline...` }],
@@ -902,10 +1002,12 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
const text = [merged.contentParts.join("\n\n"), spawnedSummary]
.filter(section => section.trim().length > 0)
.join("\n\n");
return withAdvisory({
content: [{ type: "text", text: text.length > 0 ? text : "No results." }],
details: buildAsyncDetails(),
});
return withPreflightFailures(
withAdvisory({
content: [{ type: "text", text: text.length > 0 ? text : "No results." }],
details: buildAsyncDetails(),
}),
);
}
/**
@@ -1050,12 +1152,13 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
async #executeSyncFanout(
toolCallId: string,
params: TaskParams,
spawnItems: TaskItem[],
spawns: SyncSpawnRef[],
defaultAgent: string,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
): Promise<AgentToolResult<TaskToolDetails>> {
if (spawnItems.length === 1) {
if (spawns.length === 1) {
const spawn = spawns[0]!;
const semaphore = this.#getSpawnSemaphore();
const invokedAt = Date.now();
await semaphore.acquire(signal);
@@ -1063,11 +1166,11 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
try {
return await this.#executeSync(
toolCallId,
spawnParamsFor(params, spawnItems[0], defaultAgent),
spawnParamsFor(params, spawn.item, defaultAgent),
signal,
onUpdate,
undefined,
0,
spawn.preAllocatedId,
spawn.index,
false,
{ invokedAt, acquiredAt },
);
@@ -1080,7 +1183,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
const latestProgress = new Map<number, AgentProgress>();
const emitCombined = () => {
onUpdate?.({
content: [{ type: "text", text: `Running ${spawnItems.length} agents...` }],
content: [{ type: "text", text: `Running ${spawns.length} agents...` }],
details: {
projectAgentsDir: null,
results: [],
@@ -1097,7 +1200,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
params,
defaultAgent,
signal,
spawns: spawnItems.map((item, index) => ({ item, index })),
spawns,
onItemProgress: onUpdate
? (index, progress) => {
latestProgress.set(index, { ...progress, index });
@@ -1106,10 +1209,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
: undefined,
});
const merged = mergeSyncPayloads(
spawnItems.map((item, index) => ({ item, index })),
payloads,
);
const merged = mergeSyncPayloads(spawns, payloads);
return {
content: [{ type: "text", text: merged.contentParts.join("\n\n") }],
details: {
@@ -1140,12 +1240,19 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
}): Promise<(AgentToolResult<TaskToolDetails> | undefined)[]> {
const { toolCallId, params, defaultAgent, spawns, signal, onItemProgress } = args;
const semaphore = this.#getSpawnSemaphore();
const { results } = await mapWithConcurrencyLimit(
const { results } = await mapWithConcurrencyLimitAllSettled(
spawns,
spawns.length,
async (spawn, _position, workerSignal) => {
const invokedAt = Date.now();
await semaphore.acquire(workerSignal);
let semaphoreHeld = false;
try {
await semaphore.acquire(workerSignal);
semaphoreHeld = true;
} catch (error) {
if (workerSignal.aborted) return undefined;
throw error;
}
const acquiredAt = Date.now();
try {
const itemOnUpdate: AgentToolUpdateCallback<TaskToolDetails> | undefined = onItemProgress
@@ -1165,12 +1272,26 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
{ invokedAt, acquiredAt },
);
} finally {
this.#releaseSpawnSemaphore();
if (semaphoreHeld) this.#releaseSpawnSemaphore();
}
},
signal,
);
return results;
return results.map((settled, position) => {
if (!settled) return undefined;
if (settled.status === "fulfilled") return settled.value;
const message = settled.reason instanceof Error ? settled.reason.message : String(settled.reason);
const item = spawns[position].item;
return {
content: [
{
type: "text",
text: `Task ${item.name?.trim() || `#${spawns[position].index + 1}`} failed: ${message}`,
},
],
details: { projectAgentsDir: null, results: [], totalDurationMs: 0 },
};
});
}
/**
@@ -1204,351 +1325,59 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
launchTiming?: { invokedAt: number; acquiredAt: number },
): Promise<AgentToolResult<TaskToolDetails>> {
const startTime = Date.now();
const { agents, projectAgentsDir } = await discoverAgents(this.session.cwd);
const agentName = params.agent ?? "";
const sharedContext = this.#isBatchEnabled() ? params.context?.trim() || undefined : undefined;
const assignment = (params.task ?? "").trim();
const isolationMode = this.session.settings.get("task.isolation.mode");
const isolationRequested = "isolated" in params ? params.isolated === true : false;
const isIsolated = isolationMode !== "none" && isolationRequested;
const mergeMode = this.session.settings.get("task.isolation.merge");
const taskDepth = this.session.taskDepth ?? 0;
const subagentLspEnabled = (this.session.enableLsp ?? true) && this.session.settings.get("task.enableLsp");
if (isolationMode === "none" && "isolated" in params) {
return {
content: [{ type: "text", text: "Task isolation is disabled." }],
details: { projectAgentsDir, results: [], totalDurationMs: 0 },
};
}
// Validate agent exists
const agent = getAgent(agents, agentName);
if (!agent) {
const available = agents.map(a => a.name).join(", ") || "none";
return {
content: [{ type: "text", text: `Unknown agent "${agentName}". Available: ${available}` }],
details: { projectAgentsDir, results: [], totalDurationMs: 0 },
};
}
// Check if agent is disabled in settings
const disabledAgents = this.session.settings.get("task.disabledAgents") as string[];
if (disabledAgents.length > 0 && disabledAgents.includes(agentName)) {
const enabled = agents.filter(a => !disabledAgents.includes(a.name)).map(a => a.name);
return {
content: [
{
type: "text",
text: `Agent "${agentName}" is disabled in settings. Enable it via /agents, or use a different agent type.${enabled.length > 0 ? ` Available: ${enabled.join(", ")}` : ""}`,
},
],
details: { projectAgentsDir, results: [], totalDurationMs: 0 },
};
}
const planModeState = this.session.getPlanModeState?.();
const planModeBaseTools = ["read", "grep", "glob", "lsp", "web_search"];
const planModeTools = [
...planModeBaseTools,
...(agent.tools ?? []).filter(
tool => PLAN_MODE_AGENT_TOOL_ALLOWLIST.has(tool) && !planModeBaseTools.includes(tool),
),
];
const effectiveAgent: typeof agent = planModeState?.enabled
? {
...agent,
systemPrompt: `${planModeSubagentPrompt}\n\n${agent.systemPrompt}`,
tools: planModeTools,
spawns: undefined,
// Read-only exploration: never arm prewalk (its plan/implement
// nudges assume edit tools the plan-mode toolset doesn't have).
prewalk: undefined,
}
: agent;
// Apply per-agent model override from settings (highest priority)
const agentModelOverrides = this.session.settings.get("task.agentModelOverrides");
const settingsModelOverride = agentModelOverrides[agentName];
const parentActiveModelPattern = this.session.getActiveModelString?.();
const modelOverride = resolveAgentModelPatterns({
settingsOverride: settingsModelOverride,
agentModel: effectiveAgent.model,
settings: this.session.settings,
activeModelPattern: parentActiveModelPattern,
fallbackModelPattern: this.session.getModelString?.(),
});
const thinkingLevelOverride = effectiveAgent.thinkingLevel;
// Output schema priority: agent frontmatter > inherited parent session.
// The task call itself never carries a schema; workflows needing ad-hoc
// structured output go through eval agent(prompt, schema).
const effectiveOutputSchema = effectiveAgent.output ?? this.session.outputSchema;
let isolationContext: IsolationContext | null = null;
if (isIsolated) {
try {
isolationContext = await prepareIsolationContext(this.session.cwd);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [{ type: "text", text: `Isolated task execution requires a git repository. ${message}` }],
details: { projectAgentsDir, results: [], totalDurationMs: Date.now() - startTime },
};
}
}
const repoRoot = isolationContext?.repoRoot ?? null;
const preferredIsolationBackend = parseIsolationMode(isolationMode);
// Derive artifacts directory
const sessionFile = this.session.getSessionFile();
const artifactsDir = sessionFile ? sessionFile.slice(0, -6) : null;
const tempArtifactsDir = artifactsDir ? null : path.join(os.tmpdir(), `omp-task-${Snowflake.next()}`);
const effectiveArtifactsDir = artifactsDir || tempArtifactsDir!;
const localProtocolOptions: LocalProtocolOptions = this.session.localProtocolOptions ?? {
getArtifactsDir: this.session.getArtifactsDir ?? (() => null),
getSessionId: this.session.getSessionId ?? (() => null),
};
// Subagents adopt the parent's ArtifactManager so artifact IDs are unique
// across the whole tree and outputs land flat in the parent's dir.
const parentArtifactManager = this.session.getArtifactManager?.() ?? undefined;
// When the session is executing an approved plan, hand the overall plan to
// every subagent so they share the main agent's plan context. Skipped in
// plan mode (read-only exploration uses planModeSubagentPrompt instead) and
// when no plan file exists at the session's reference path.
const planReference = planModeState?.enabled
? undefined
: await loadOverallPlanReference(
this.session.getPlanReferencePath?.() ?? "local://PLAN.md",
localProtocolOptions,
);
const context = this.#isBatchEnabled() ? params.context?.trim() || undefined : undefined;
let latestProgress: AgentProgress | undefined;
try {
// Check self-recursion prevention
if (this.#blockedAgent && agentName === this.#blockedAgent) {
return {
content: [
{
type: "text",
text: `Cannot spawn ${this.#blockedAgent} agent from within itself (recursion prevention). Use a different agent type.`,
},
],
details: { projectAgentsDir, results: [], totalDurationMs: Date.now() - startTime },
};
}
// Check spawn restrictions from parent
const spawnPolicy = resolveSpawnPolicy(this.session.getSessionSpawns());
const spawnAllowed =
spawnPolicy.enabled &&
(spawnPolicy.allowedAgents === null || spawnPolicy.allowedAgents.includes(agentName));
if (!spawnAllowed) {
return {
content: [
{ type: "text", text: `Cannot spawn '${agentName}'. Allowed: ${spawnPolicy.allowedErrorText}` },
],
details: { projectAgentsDir, results: [], totalDurationMs: Date.now() - startTime },
};
}
await fs.mkdir(effectiveArtifactsDir, { recursive: true });
// Allocate a unique ID across the session to prevent artifact collisions
let agentId: string;
if (preAllocatedId) {
agentId = preAllocatedId;
} else {
const outputManager =
this.session.agentOutputManager ?? new AgentOutputManager(this.session.getArtifactsDir ?? (() => null));
agentId = await outputManager.allocate(params.name?.trim() || generateTaskName());
}
const availableSkills = [...(this.session.skills ?? [])];
// Resolve autoload skills from agent definition against available skills
const resolvedAutoloadSkills =
agent.autoloadSkills?.length && availableSkills.length > 0
? agent.autoloadSkills
.map(name => availableSkills.find(s => s.name === name))
.filter((s): s is NonNullable<typeof s> => s !== undefined)
: [];
const contextFiles = this.session.contextFiles?.filter(
file => path.basename(file.path).toLowerCase() !== "agents.md",
);
const promptTemplates = this.session.promptTemplates;
const parentEvalSessionId = this.session.getEvalSessionId?.() ?? undefined;
const mcpManager = this.session.mcpManager ?? MCPManager.instance();
// Progress tracking for the single agent
let latestProgress: AgentProgress = {
index: spawnIndex,
id: agentId,
agent: agentName,
agentSource: agent.source,
status: "pending",
task: renderSubagentUserPrompt(assignment),
const execution = await runStructuredSubagent({
session: this.session,
invocationKind: "task",
assignment,
recentTools: [],
recentOutput: [],
toolCount: 0,
requests: 0,
tokens: 0,
cost: 0,
durationMs: 0,
modelOverride,
};
const emitProgress = () => {
onUpdate?.({
content: [{ type: "text", text: `Running agent ${agentId}...` }],
details: {
projectAgentsDir,
results: [],
totalDurationMs: Date.now() - startTime,
progress: [latestProgress],
},
});
};
emitProgress();
const buildCommitMessageFn = makeIsolationCommitMessage(this.session);
const sharedRunOptions = {
cwd: this.session.cwd,
agent: effectiveAgent,
task: renderSubagentUserPrompt(assignment),
assignment,
context: sharedContext,
planReference,
context,
agent: params.agent,
...(Object.hasOwn(params, "outputSchema") ? { outputSchema: params.outputSchema } : {}),
...(Object.hasOwn(params, "schemaMode") ? { schemaMode: params.schemaMode } : {}),
identity: { id: preAllocatedId, label: params.name },
index: spawnIndex,
parentToolCallId: toolCallId,
detached,
id: agentId,
taskDepth,
invokedAt: launchTiming?.invokedAt,
acquiredAt: launchTiming?.acquiredAt,
modelOverride,
parentActiveModelPattern,
thinkingLevel: thinkingLevelOverride,
outputSchema: effectiveOutputSchema,
sessionFile,
persistArtifacts: !!artifactsDir,
artifactsDir: effectiveArtifactsDir,
enableLsp: subagentLspEnabled,
...("isolated" in params ? { isolation: { requested: params.isolated } } : {}),
blockedAgent: this.#blockedAgent,
enableLsp: (this.session.enableLsp ?? true) && this.session.settings.get("task.enableLsp"),
enableIrc: isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0),
maxRuntimeMs: this.session.settings.get("task.maxRuntimeMs"),
signal,
eventBus: this.session.eventBus,
onProgress: (progress: AgentProgress) => {
// Shallow snapshot; recentTools is mutated in place by the
// executor, the rest is reassigned or immutable. A deep clone
// here cost O(extractedToolData) per progress event.
onProgress: progress => {
latestProgress = { ...progress, recentTools: progress.recentTools.slice() };
emitProgress();
onUpdate?.({
content: [{ type: "text", text: `Running agent ${progress.id}...` }],
details: {
projectAgentsDir: null,
results: [],
totalDurationMs: Date.now() - startTime,
progress: [latestProgress],
},
});
},
authStorage: this.session.authStorage,
modelRegistry: this.session.modelRegistry,
settings: this.session.settings,
mcpManager,
contextFiles,
skills: availableSkills,
autoloadSkills: resolvedAutoloadSkills,
workspaceTree: this.session.workspaceTree,
promptTemplates,
rules: this.session.rules,
preloadedExtensionPaths: this.session.extensionPaths,
preloadedCustomToolPaths: this.session.customToolPaths,
localProtocolOptions,
parentArtifactManager,
parentHindsightSessionState: this.session.getHindsightSessionState?.(),
parentMnemopiSessionState: this.session.getMnemopiSessionState?.(),
parentTelemetry: this.session.getTelemetry?.(),
parentEvalSessionId,
parentAgentId: this.session.getAgentId?.() ?? MAIN_AGENT_ID,
// Live source of truth for `tier.subagent: inherit`. When the session
// exposes a tier accessor, pass the per-family map or null (null =
// explicit none, e.g. /fast off); otherwise leave undefined so inherit
// falls back to the subagent's configured tier.* settings.
parentServiceTier: this.session.getServiceTierByFamily
? (this.session.getServiceTierByFamily() ?? null)
: undefined,
};
const runTask = async (): Promise<SingleResult> => {
if (!isIsolated) {
return runSubprocess(sharedRunOptions);
}
if (!isolationContext) {
throw new Error("Isolated task execution not initialized.");
}
const taskStart = Date.now();
return runIsolatedSubprocess({
baseOptions: sharedRunOptions,
context: isolationContext,
preferredBackend: preferredIsolationBackend,
agentId,
mergeMode,
artifactsDir: effectiveArtifactsDir,
buildCommitMessage: buildCommitMessageFn,
buildFailureResult: err => {
const message = err instanceof Error ? err.message : String(err);
return {
index: spawnIndex,
id: agentId,
agent: agent.name,
agentSource: agent.source,
task: renderSubagentUserPrompt(assignment),
assignment,
exitCode: 1,
output: "",
stderr: message,
truncated: false,
durationMs: Date.now() - taskStart,
tokens: 0,
requests: 0,
modelOverride,
error: message,
};
},
});
};
const result = await runTask();
let mergeSummary = "";
let changesApplied: boolean | null = null;
let mergedBranchForNestedPatches = false;
if (isIsolated && repoRoot) {
const outcome = await mergeIsolatedChanges({ result, repoRoot, mergeMode });
mergeSummary = outcome.summary;
changesApplied = outcome.changesApplied;
mergedBranchForNestedPatches = outcome.mergedBranchForNestedPatches;
}
// Apply nested repo patches (separate from parent git).
if (isIsolated && repoRoot) {
mergeSummary += await applyEligibleNestedPatches({
result,
repoRoot,
mergeMode,
changesApplied,
mergedBranchForNestedPatches,
commitMessage: buildCommitMessageFn(),
});
}
// Cleanup temp directory if used
const shouldCleanupTempArtifacts =
tempArtifactsDir && (!isIsolated || changesApplied === true || changesApplied === null);
if (shouldCleanupTempArtifacts) {
await fs.rm(tempArtifactsDir, { recursive: true, force: true });
}
return this.#buildResultPayload(result, projectAgentsDir, Date.now() - startTime, mergeSummary);
} catch (err) {
});
return this.#buildResultPayload(
execution.result,
execution.policy.discovery.projectAgentsDir,
Date.now() - startTime,
execution.mergeSummary,
);
} catch (error) {
const message = error instanceof StructuredSubagentError ? error.message : String(error);
return {
content: [{ type: "text", text: `Task execution failed: ${err}` }],
details: { projectAgentsDir, results: [], totalDurationMs: Date.now() - startTime },
content: [{ type: "text", text: `Task execution failed: ${message}` }],
details: {
projectAgentsDir: null,
results: [],
totalDurationMs: Date.now() - startTime,
...(latestProgress ? { progress: [latestProgress] } : {}),
},
};
}
}
@@ -83,6 +83,49 @@ export async function mapWithConcurrencyLimit<T, R>(
return { results, aborted: signal?.aborted ?? false };
}
/** Result of a concurrency-limited operation that waits for every launched item. */
export interface ParallelSettledResult<R> {
/** Settled results in original input order; absent entries were never launched after cancellation. */
results: (PromiseSettledResult<R> | undefined)[];
/** Whether cancellation prevented scheduling all items. */
aborted: boolean;
}
/**
* Execute items with a concurrency limit without failing fast. Rejections are
* captured at their input position and already launched siblings always settle
* before this function returns. Cancellation stops new launches but preserves
* the settled state of every item that began.
*/
export async function mapWithConcurrencyLimitAllSettled<T, R>(
items: T[],
concurrency: number,
fn: (item: T, index: number, signal: AbortSignal) => Promise<R>,
signal?: AbortSignal,
): Promise<ParallelSettledResult<R>> {
const normalizedConcurrency = Number.isFinite(concurrency) ? Math.floor(concurrency) : items.length;
const effectiveConcurrency = normalizedConcurrency > 0 ? normalizedConcurrency : items.length;
const limit = Math.max(1, Math.min(effectiveConcurrency, items.length));
const results: (PromiseSettledResult<R> | undefined)[] = new Array(items.length);
const workerSignal = signal ?? new AbortController().signal;
let nextIndex = 0;
const worker = async (): Promise<void> => {
while (!workerSignal.aborted) {
const index = nextIndex++;
if (index >= items.length) return;
try {
results[index] = { status: "fulfilled", value: await fn(items[index], index, workerSignal) };
} catch (reason) {
results[index] = { status: "rejected", reason };
}
}
};
await Promise.all(Array.from({ length: limit }, () => worker()));
return { results, aborted: workerSignal.aborted };
}
/**
* Simple counting semaphore for limiting concurrency across independently-scheduled async work.
*
@@ -79,9 +79,10 @@ export function createPersistedSubagentReviverFactory(
});
const artifactManager = ctx.session.sessionManager.getArtifactManager();
if (artifactManager) reopened.adoptArtifactManager(artifactManager);
// Reuse the parent's live MCP connections via proxy tools (no
// re-discovery), exactly as the executor does for live subagents.
const mcpManager = MCPManager.instance();
// A restricted persisted contract must not consult process-global MCP
// state: same-name MCP tools are untrusted capability sources.
const restrictToolNames = init.restrictToolNames === true;
const mcpManager = restrictToolNames ? undefined : MCPManager.instance();
const mcpProxyTools = mcpManager ? createMCPProxyTools(mcpManager) : [];
const { session } = await createAgentSession({
cwd: ctx.session.sessionManager.getCwd(),
@@ -99,16 +100,27 @@ export function createPersistedSubagentReviverFactory(
taskDepth,
toolNames: init.tools,
outputSchema: init.outputSchema,
outputSchemaMode: init.outputSchemaMode,
restrictToolNames: restrictToolNames || undefined,
requireYieldTool: true,
systemPrompt: () => [init.systemPrompt],
// Old files predate persisted spawns: deny re-spawning rather than let
// createAgentSession default to wildcard ("*").
spawns: init.spawns ?? "",
hasUI: false,
enableLsp: ctx.enableLsp,
enableMCP: !mcpManager,
mcpManager,
customTools: mcpProxyTools.length > 0 ? mcpProxyTools : undefined,
enableLsp: restrictToolNames ? false : ctx.enableLsp,
...(restrictToolNames
? {
enableIrc: false,
enableMCP: false,
preloadedExtensionPaths: [],
preloadedCustomToolPaths: [],
}
: {
enableMCP: !mcpManager,
mcpManager,
customTools: mcpProxyTools.length > 0 ? mcpProxyTools : undefined,
}),
});
// Clamp the active set to the persisted list: createAgentSession's
// `alwaysInclude` can re-add non-defaultInactive extension/custom tools
@@ -0,0 +1,650 @@
/**
* Shared policy resolution and execution for task and eval subagents.
*
* The two public frontends deliberately retain their presentation concerns, but
* every decision that affects what a child may run lives here.
*/
import * as fs from "node:fs/promises";
import * as os from "node:os";
import path from "node:path";
import { $env, prompt, Snowflake } from "@oh-my-pi/pi-utils";
import { resolveAgentModelPatterns } from "../config/model-resolver";
import type { LocalProtocolOptions } from "../internal-urls";
import { registerArtifactsDir } from "../internal-urls/registry-helpers";
import { MCPManager } from "../mcp/manager";
import { loadOverallPlanReference } from "../plan-mode/plan-handoff";
import planModeSubagentPrompt from "../prompts/system/plan-mode-subagent.md" with { type: "text" };
import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.md" with { type: "text" };
import { MAIN_AGENT_ID } from "../registry/agent-registry";
import type { ToolSession } from "../tools";
import { isIrcEnabled } from "../tools/hub";
import { buildOutputValidator } from "../tools/output-schema-validator";
import { type DiscoveryResult, discoverAgents, getAgent } from "./discovery";
import { type ExecutorOptions, runSubprocess } from "./executor";
import {
applyEligibleNestedPatches,
type IsolationContext,
makeIsolationCommitMessage,
mergeIsolatedChanges,
prepareIsolationContext,
runIsolatedSubprocess,
} from "./isolation-runner";
import { generateTaskName } from "./name-generator";
import { AgentOutputManager } from "./output-manager";
import { resolveSpawnPolicy } from "./spawn-policy";
import {
type AgentDefinition,
type AgentProgress,
canSpawnAtDepth,
type SingleResult,
type StructuredSubagentOutput,
} from "./types";
import { type NestedRepoPatch, parseIsolationMode } from "./worktree";
/** Validation behavior requested for an effective output schema. */
export type StructuredSubagentSchemaMode = "permissive" | "strict";
/** Where an effective output schema came from. */
export type StructuredSubagentSchemaSource = "caller" | "agent" | "session" | "none";
/** Final structured completion metadata returned for a schema-bearing run. */
export type StructuredSubagentSchemaResult = StructuredSubagentOutput;
/** A schema validation or extraction error attached to structured completion metadata. */
export type StructuredSubagentSchemaError = NonNullable<StructuredSubagentOutput["error"]>;
/** A selected schema paired with its source and enforcement mode. */
export interface StructuredSubagentSchemaResolution {
schema: unknown;
source: StructuredSubagentSchemaSource;
mode: StructuredSubagentSchemaMode;
outputSchemaOverridesAgent: boolean;
}
/** Isolation controls shared by the task and eval surfaces. */
export interface StructuredSubagentIsolationControls {
requested?: boolean;
merge?: "patch" | "branch";
apply?: boolean;
}
/** Identity and presentation metadata supplied by the calling surface. */
export interface StructuredSubagentIdentity {
/** A previously reserved output/registry id. */
id?: string;
/** Stable user-facing label used when allocating a new id. */
label?: string;
}
/** One normalized child invocation. */
export interface StructuredSubagentRequest {
session: ToolSession;
invocationKind: "task" | "eval";
assignment: string;
context?: string;
agent?: string;
model?: string | string[];
/** Presence, rather than truthiness, makes this the highest-priority schema. */
outputSchema?: unknown;
schemaMode?: StructuredSubagentSchemaMode;
identity?: StructuredSubagentIdentity;
index?: number;
parentToolCallId?: string;
detached?: boolean;
invokedAt?: number;
acquiredAt?: number;
isolation?: StructuredSubagentIsolationControls;
/** The parent agent name forbidden from recursively spawning itself. */
blockedAgent?: string;
/** Preserve a completed temporary artifacts directory for an agent:// handle. */
retainArtifacts?: boolean;
/** Task UI agents keep live registry references; eval one-shots normally do not. */
keepAlive?: boolean;
/** Task subagents share their parent's eval kernel; eval bridge children must not. */
shareEvalSession?: boolean;
/** Task frontends may inherit LSP; eval frontends normally set this false. */
enableLsp?: boolean;
/** Explicitly pass false for plan mode or invocation kinds that must not use IRC. */
enableIrc?: boolean;
/** `0` disables executor wall-clock timeout. Undefined inherits settings. */
maxRuntimeMs?: number;
signal?: AbortSignal;
onProgress?: (progress: AgentProgress) => void;
}
/** A normalized preflight result, reusable by tests and adapters. */
export interface EffectiveSubagentPolicy {
discovery: DiscoveryResult;
agentName: string;
agent: AgentDefinition;
effectiveAgent: AgentDefinition;
modelOverride?: string | string[];
parentActiveModelPattern?: string;
schema: StructuredSubagentSchemaResolution;
planMode: boolean;
isIsolated: boolean;
mergeMode: "patch" | "branch";
applyChanges: boolean;
enableLsp: boolean;
enableIrc: boolean;
}
/** Settled child execution plus data needed by the frontends' own rendering. */
export interface StructuredSubagentResult {
result: SingleResult;
policy: EffectiveSubagentPolicy;
mergeSummary: string;
changesApplied: boolean | null;
artifactsDir: string;
temporaryArtifacts: boolean;
}
/** Machine-readable failure category so adapters can retain their native errors. */
export class StructuredSubagentError extends Error {
readonly kind: "preflight" | "isolation" | "execution";
constructor(kind: "preflight" | "isolation" | "execution", message: string, options?: ErrorOptions) {
super(message, options);
this.name = "StructuredSubagentError";
this.kind = kind;
}
}
const PLAN_MODE_TOOLS = ["read", "grep", "glob", "web_search"] as const;
const PLAN_MODE_AGENT_TOOL_ALLOWLIST = new Set(["ast_grep", "report_finding"]);
function renderSubagentPrompt(assignment: string): string {
return prompt.render(subagentUserPromptTemplate, { assignment: assignment.trim() });
}
function trimToUndefined(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed || undefined;
}
function sanitizeAgentId(value: string | undefined): string | undefined {
const trimmed = trimToUndefined(value);
const sanitized = trimmed?.replace(/[^A-Za-z0-9_-]+/g, "").slice(0, 48);
return sanitized || undefined;
}
function resolveSchema(request: StructuredSubagentRequest, agent: AgentDefinition): StructuredSubagentSchemaResolution {
const mode = request.schemaMode ?? request.session.outputSchemaMode ?? "permissive";
if (Object.hasOwn(request, "outputSchema")) {
return { schema: request.outputSchema, source: "caller", mode, outputSchemaOverridesAgent: true };
}
if (agent.output !== undefined) {
return { schema: agent.output, source: "agent", mode, outputSchemaOverridesAgent: false };
}
if (request.session.outputSchema !== undefined) {
return { schema: request.session.outputSchema, source: "session", mode, outputSchemaOverridesAgent: false };
}
return { schema: undefined, source: "none", mode, outputSchemaOverridesAgent: false };
}
function createPlanModeAgent(agent: AgentDefinition): AgentDefinition {
const tools = [
...PLAN_MODE_TOOLS,
...(agent.tools ?? []).filter(
tool =>
PLAN_MODE_AGENT_TOOL_ALLOWLIST.has(tool) &&
!PLAN_MODE_TOOLS.includes(tool as (typeof PLAN_MODE_TOOLS)[number]),
),
];
return {
...agent,
systemPrompt: `${planModeSubagentPrompt}\n\n${agent.systemPrompt}`,
tools,
spawns: undefined,
prewalk: undefined,
};
}
function assertPlanControlsAllowed(request: StructuredSubagentRequest, planMode: boolean): void {
if (!planMode) return;
const isolation = request.isolation;
if (
isolation &&
(Object.hasOwn(isolation, "requested") || Object.hasOwn(isolation, "apply") || Object.hasOwn(isolation, "merge"))
) {
throw new StructuredSubagentError(
"preflight",
"Subagent isolation, apply, and merge controls are unavailable in plan mode.",
);
}
}
function assertDepthAndSpawnAllowed(request: StructuredSubagentRequest, agentName: string): void {
const taskDepth = request.session.taskDepth ?? 0;
const maxDepth = request.session.settings.get("task.maxRecursionDepth") ?? 2;
if (!canSpawnAtDepth(maxDepth, taskDepth)) {
throw new StructuredSubagentError(
"preflight",
`Cannot spawn another agent at task depth ${taskDepth}; maximum depth is ${maxDepth}.`,
);
}
const blockedAgent = request.blockedAgent ?? $env.PI_BLOCKED_AGENT;
if (blockedAgent && blockedAgent === agentName) {
throw new StructuredSubagentError(
"preflight",
`Cannot spawn ${blockedAgent} agent from within itself (recursion prevention). Use a different agent type.`,
);
}
const spawnPolicy = resolveSpawnPolicy(request.session.getSessionSpawns());
if (!spawnPolicy.enabled || (spawnPolicy.allowedAgents !== null && !spawnPolicy.allowedAgents.includes(agentName))) {
throw new StructuredSubagentError(
"preflight",
`Cannot spawn '${agentName}'. Allowed: ${spawnPolicy.allowedErrorText}`,
);
}
}
/**
* Resolve every policy shared by task and eval before allocating artifacts or
* dispatching work. Callers translate {@link StructuredSubagentError} into
* their own wire-level error surface.
*/
export async function resolveEffectiveSubagentPolicy(
request: StructuredSubagentRequest,
): Promise<EffectiveSubagentPolicy> {
const spawnPolicy = resolveSpawnPolicy(request.session.getSessionSpawns());
const agentName = request.agent?.trim() || spawnPolicy.defaultAgent;
const planMode = request.session.getPlanModeState?.()?.enabled === true;
assertPlanControlsAllowed(request, planMode);
assertDepthAndSpawnAllowed(request, agentName);
const discovery = await discoverAgents(request.session.cwd);
const agent = getAgent(discovery.agents, agentName);
if (!agent) {
const available = discovery.agents.map(candidate => candidate.name).join(", ") || "none";
throw new StructuredSubagentError("preflight", `Unknown agent "${agentName}". Available: ${available}`);
}
const disabledAgents = request.session.settings.get("task.disabledAgents") as string[];
if (disabledAgents.includes(agentName)) {
const enabled = discovery.agents
.filter(candidate => !disabledAgents.includes(candidate.name))
.map(candidate => candidate.name);
throw new StructuredSubagentError(
"preflight",
`Agent "${agentName}" is disabled in settings. Enable it via /agents, or use a different agent type.${enabled.length > 0 ? ` Available: ${enabled.join(", ")}` : ""}`,
);
}
const effectiveAgent = planMode ? createPlanModeAgent(agent) : agent;
const schema = resolveSchema(request, effectiveAgent);
if (schema.source === "caller" || (schema.source !== "none" && schema.mode === "strict")) {
const { error } = buildOutputValidator(schema.schema);
if (error) {
const scope =
schema.source === "caller" ? (schema.mode === "strict" ? "strict caller" : "caller") : "strict effective";
throw new StructuredSubagentError("preflight", `Invalid ${scope} output schema: ${error}`);
}
}
const agentModelOverrides = request.session.settings.get("task.agentModelOverrides");
const parentActiveModelPattern = request.session.getActiveModelString?.();
const modelOverride = resolveAgentModelPatterns({
settingsOverride: request.model ?? agentModelOverrides[agentName],
agentModel: effectiveAgent.model,
settings: request.session.settings,
activeModelPattern: parentActiveModelPattern,
fallbackModelPattern: request.session.getModelString?.(),
});
const isolationMode = request.session.settings.get("task.isolation.mode");
const isIsolated = request.isolation?.requested === true;
if (isIsolated && isolationMode === "none") {
throw new StructuredSubagentError(
"preflight",
`Subagent isolated execution requires task.isolation.mode to be set; current mode is "none".`,
);
}
return {
discovery,
agentName,
agent,
effectiveAgent,
modelOverride,
parentActiveModelPattern,
schema,
planMode,
isIsolated,
mergeMode: request.isolation?.merge ?? request.session.settings.get("task.isolation.merge"),
applyChanges: request.isolation?.apply !== false,
enableLsp:
!planMode &&
(request.enableLsp ?? ((request.session.enableLsp ?? true) && request.session.settings.get("task.enableLsp"))),
enableIrc:
!planMode &&
(request.enableIrc ??
(request.session.enableIrc !== false &&
isIrcEnabled(request.session.settings, request.session.taskDepth ?? 0))),
};
}
/** Reserve a session-global agent id only after preflight has succeeded. */
export async function reserveStructuredSubagentId(
session: ToolSession,
identity: StructuredSubagentIdentity | undefined,
): Promise<string> {
if (identity?.id) return identity.id;
const manager = session.agentOutputManager ?? new AgentOutputManager(session.getArtifactsDir ?? (() => null));
session.agentOutputManager ??= manager;
return manager.allocate(sanitizeAgentId(identity?.label) ?? generateTaskName());
}
interface ArtifactLease {
sessionFile: string | null;
artifactsDir: string;
temporary: boolean;
unregister: (() => void) | undefined;
}
async function leaseArtifacts(
session: ToolSession,
invocationKind: StructuredSubagentRequest["invocationKind"],
): Promise<ArtifactLease> {
const sessionFile = session.getSessionFile();
if (sessionFile) {
const artifactsDir = sessionFile.slice(0, -6);
await fs.mkdir(artifactsDir, { recursive: true });
return { sessionFile, artifactsDir, temporary: false, unregister: undefined };
}
const artifactsDir = path.join(
os.tmpdir(),
`${invocationKind === "eval" ? "omp-eval-agent" : "omp-task"}-${Snowflake.next()}`,
);
await fs.mkdir(artifactsDir, { recursive: true });
return { sessionFile: null, artifactsDir, temporary: true, unregister: registerArtifactsDir(artifactsDir) };
}
function resolveAutoloadSkills(session: ToolSession, agent: AgentDefinition) {
const skills = [...(session.skills ?? [])];
const autoloadSkills = agent.autoloadSkills?.length
? agent.autoloadSkills.map(name => skills.find(skill => skill.name === name)).filter(skill => skill !== undefined)
: [];
return { skills, autoloadSkills };
}
function buildExecutorOptions(
request: StructuredSubagentRequest,
policy: EffectiveSubagentPolicy,
lease: ArtifactLease,
id: string,
): ExecutorOptions {
const { session } = request;
const { skills, autoloadSkills } = resolveAutoloadSkills(session, policy.agent);
const localProtocolOptions: LocalProtocolOptions = session.localProtocolOptions ?? {
getArtifactsDir: session.getArtifactsDir ?? (() => null),
getSessionId: session.getSessionId ?? (() => null),
};
const enableMCP = !policy.planMode && (session.enableMCP ?? true);
return {
cwd: session.cwd,
agent: policy.effectiveAgent,
task: renderSubagentPrompt(request.assignment),
assignment: request.assignment.trim(),
context: request.context?.trim() || undefined,
planReference: undefined,
description: trimToUndefined(request.identity?.label),
index: request.index ?? 0,
parentToolCallId: request.parentToolCallId,
detached: request.detached,
id,
taskDepth: session.taskDepth ?? 0,
invokedAt: request.invokedAt,
acquiredAt: request.acquiredAt,
modelOverride: policy.modelOverride,
parentActiveModelPattern: policy.parentActiveModelPattern,
thinkingLevel: policy.effectiveAgent.thinkingLevel,
...(policy.schema.source === "none"
? {}
: {
outputSchemaSource: policy.schema.source,
outputSchema: policy.schema.schema,
outputSchemaOverridesAgent: policy.schema.outputSchemaOverridesAgent,
outputSchemaMode: policy.schema.mode,
}),
sessionFile: lease.sessionFile,
persistArtifacts: !lease.temporary,
artifactsDir: lease.artifactsDir,
enableLsp: policy.enableLsp,
enableIrc: policy.enableIrc,
maxRuntimeMs: request.maxRuntimeMs,
restrictToolNames: policy.planMode,
keepAlive: request.keepAlive,
signal: request.signal,
eventBus: session.eventBus,
onProgress: request.onProgress,
authStorage: session.authStorage,
modelRegistry: session.modelRegistry,
settings: session.settings,
mcpManager: enableMCP ? (session.mcpManager ?? MCPManager.instance()) : undefined,
enableMCP,
contextFiles: session.contextFiles?.filter(file => path.basename(file.path).toLowerCase() !== "agents.md"),
skills,
autoloadSkills,
workspaceTree: session.workspaceTree,
promptTemplates: session.promptTemplates,
rules: session.rules,
preloadedExtensionPaths: policy.planMode ? [] : session.extensionPaths,
preloadedCustomToolPaths: policy.planMode ? [] : session.customToolPaths,
localProtocolOptions,
parentArtifactManager: session.getArtifactManager?.() ?? undefined,
parentHindsightSessionState: session.getHindsightSessionState?.(),
parentMnemopiSessionState: session.getMnemopiSessionState?.(),
parentTelemetry: session.getTelemetry?.(),
parentEvalSessionId: request.shareEvalSession === false ? undefined : (session.getEvalSessionId?.() ?? undefined),
parentAgentId: session.getAgentId?.() ?? MAIN_AGENT_ID,
parentServiceTier: session.getServiceTierByFamily ? (session.getServiceTierByFamily() ?? null) : undefined,
};
}
async function loadPlanReference(
request: StructuredSubagentRequest,
policy: EffectiveSubagentPolicy,
): Promise<{ path: string; content: string } | undefined> {
if (policy.planMode) return undefined;
const localProtocolOptions: LocalProtocolOptions = request.session.localProtocolOptions ?? {
getArtifactsDir: request.session.getArtifactsDir ?? (() => null),
getSessionId: request.session.getSessionId ?? (() => null),
};
return loadOverallPlanReference(request.session.getPlanReferencePath?.() ?? "local://PLAN.md", localProtocolOptions);
}
function buildFailureResult(
request: StructuredSubagentRequest,
policy: EffectiveSubagentPolicy,
id: string,
startedAt: number,
) {
return (error: unknown): SingleResult => {
const message = error instanceof Error ? error.message : String(error);
return {
index: request.index ?? 0,
id,
agent: policy.agent.name,
agentSource: policy.agent.source,
task: renderSubagentPrompt(request.assignment),
assignment: request.assignment.trim(),
description: trimToUndefined(request.identity?.label),
exitCode: 1,
output: "",
stderr: message,
truncated: false,
durationMs: Date.now() - startedAt,
tokens: 0,
requests: 0,
modelOverride: policy.modelOverride,
error: message,
};
};
}
async function persistNestedPatches(
artifactsDir: string,
agentId: string,
nestedPatches: NestedRepoPatch[],
): Promise<string[]> {
const saved: string[] = [];
for (const [index, nestedPatch] of nestedPatches.entries()) {
const destination = path.join(
artifactsDir,
`${agentId}.nested-${index}-${nestedPatch.relativePath.replace(/[^a-zA-Z0-9._-]/g, "_") || "root"}.patch`,
);
try {
await fs.writeFile(destination, nestedPatch.patch);
saved.push(destination);
} catch {}
}
return saved;
}
async function isolationRecoveryHint(result: SingleResult, artifactsDir: string): Promise<string> {
const hints: string[] = [];
if (result.patchPath) hints.push(`Captured patch preserved at ${result.patchPath}.`);
for (const nestedPath of await persistNestedPatches(artifactsDir, result.id, result.nestedPatches ?? [])) {
hints.push(`Captured nested patch preserved at ${nestedPath}.`);
}
if (result.branchName) hints.push(`Captured branch preserved as ${result.branchName}.`);
return hints.length > 0 ? ` ${hints.join(" ")}` : "";
}
function attachStructuredOutputMetadata(result: SingleResult, schema: StructuredSubagentSchemaResolution): void {
if (schema.source === "none") {
delete result.structuredOutput;
return;
}
if (result.structuredOutput) return;
let fallbackData: unknown = result.output;
try {
fallbackData = JSON.parse(result.output);
} catch {}
const output: StructuredSubagentOutput = {
source: schema.source,
mode: schema.mode,
status: result.exitCode === 0 ? "valid" : "invalid",
data: fallbackData,
...(result.error ? { error: result.error } : {}),
};
result.structuredOutput = output;
}
/**
* Execute a validated subagent. Preflight errors occur before any artifact
* lease or child dispatch; callers keep responsibility for their result text.
*/
export async function runStructuredSubagent(request: StructuredSubagentRequest): Promise<StructuredSubagentResult> {
const policy = await resolveEffectiveSubagentPolicy(request);
const lease = await leaseArtifacts(request.session, request.invocationKind);
let changesApplied: boolean | null = null;
let mergeSummary = "";
let requiresRecoveryArtifacts = false;
let completedSuccessfully = false;
try {
const id = await reserveStructuredSubagentId(request.session, {
...request.identity,
label: request.identity?.label ?? (request.invocationKind === "eval" ? "EvalAgent" : undefined),
});
const baseOptions = buildExecutorOptions(request, policy, lease, id);
baseOptions.planReference = await loadPlanReference(request, policy);
let isolationContext: IsolationContext | null = null;
if (policy.isIsolated) {
try {
isolationContext = await prepareIsolationContext(request.session.cwd);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new StructuredSubagentError(
"isolation",
`Isolated subagent execution requires a git repository. ${message}`,
{ cause: error },
);
}
}
const result = !isolationContext
? await runSubprocess(baseOptions)
: await runIsolatedSubprocess({
baseOptions,
context: isolationContext,
preferredBackend: parseIsolationMode(request.session.settings.get("task.isolation.mode")),
agentId: id,
mergeMode: policy.mergeMode,
artifactsDir: lease.artifactsDir,
description: trimToUndefined(request.identity?.label),
buildCommitMessage: makeIsolationCommitMessage(request.session),
buildFailureResult: buildFailureResult(request, policy, id, Date.now()),
});
attachStructuredOutputMetadata(result, policy.schema);
requiresRecoveryArtifacts =
policy.isIsolated &&
(result.exitCode !== 0 || result.error !== undefined || result.aborted === true) &&
(result.patchPath !== undefined || result.branchName !== undefined || (result.nestedPatches?.length ?? 0) > 0);
if (
policy.isIsolated &&
isolationContext &&
policy.applyChanges &&
result.exitCode === 0 &&
!result.error &&
!result.aborted
) {
const outcome = await mergeIsolatedChanges({
result,
repoRoot: isolationContext.repoRoot,
mergeMode: policy.mergeMode,
});
mergeSummary = outcome.summary;
changesApplied = outcome.changesApplied;
if (outcome.changesApplied !== false) {
const nestedPatchSummary = await applyEligibleNestedPatches({
result,
repoRoot: isolationContext.repoRoot,
mergeMode: policy.mergeMode,
changesApplied: outcome.changesApplied,
mergedBranchForNestedPatches: outcome.mergedBranchForNestedPatches,
commitMessage: makeIsolationCommitMessage(request.session)(),
});
mergeSummary += nestedPatchSummary;
requiresRecoveryArtifacts ||=
nestedPatchSummary.includes("<system-notification>") && (result.nestedPatches?.length ?? 0) > 0;
}
} else if (policy.isIsolated && isolationContext && !policy.applyChanges) {
if (result.branchName)
mergeSummary = `\n\nIsolation: changes captured on branch \`${result.branchName}\` (apply=false). Not merged.`;
else if (result.patchPath)
mergeSummary = `\n\nIsolation: changes captured at \`${result.patchPath}\` (apply=false). Not applied.`;
else if ((result.nestedPatches?.length ?? 0) > 0)
mergeSummary = `\n\nIsolation: changes captured for ${result.nestedPatches?.length} nested ${(result.nestedPatches?.length ?? 0) === 1 ? "repository" : "repositories"} (apply=false). Not applied.`;
else mergeSummary = "\n\nIsolation: no changes captured.";
}
completedSuccessfully = result.exitCode === 0 && !result.error && !result.aborted;
return {
result,
policy,
mergeSummary,
changesApplied,
artifactsDir: lease.artifactsDir,
temporaryArtifacts: lease.temporary,
};
} catch (error) {
if (error instanceof StructuredSubagentError) throw error;
throw new StructuredSubagentError(
"execution",
`Subagent execution failed: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
} finally {
const shouldRetainArtifacts =
(request.retainArtifacts && completedSuccessfully) ||
(policy.isIsolated && (!policy.applyChanges || changesApplied === false || requiresRecoveryArtifacts));
const shouldCleanup = lease.temporary && !shouldRetainArtifacts;
if (shouldCleanup) {
await fs.rm(lease.artifactsDir, { recursive: true, force: true });
lease.unregister?.();
}
}
}
/** Build the recovery suffix used by adapters after an isolated failure. */
export async function buildStructuredSubagentRecoveryHint(result: SingleResult, artifactsDir: string): Promise<string> {
return isolationRecoveryHint(result, artifactsDir);
}
+58
View File
@@ -7,6 +7,35 @@ import type { NestedRepoPatch } from "./worktree";
/** Source of an agent definition */
export type AgentSource = "bundled" | "user" | "project";
/**
* Enforcement policy for a structured subagent output schema.
*
* `permissive` preserves legacy retry-budget overrides; `strict` turns every
* invalid final payload, including an exhausted retry override, into a failed
* `schema_violation` result.
*/
export type StructuredSubagentSchemaMode = "permissive" | "strict";
/** Origin of the schema selected for a structured subagent invocation. */
export type StructuredSubagentSchemaSource = "caller" | "agent" | "session" | "none";
/** Final validation state of a structured subagent invocation. */
export type StructuredSubagentValidationStatus = "valid" | "invalid" | "unavailable";
/**
* Parsed structured completion and its schema-validation metadata.
*
* `data` is present whenever a payload could be assembled or parsed, even when
* strict validation rejects it. `error` explains unavailable or invalid
* validation without requiring consumers to parse presentation text.
*/
export interface StructuredSubagentOutput {
source: StructuredSubagentSchemaSource;
mode: StructuredSubagentSchemaMode;
status: StructuredSubagentValidationStatus;
data?: unknown;
error?: string;
}
const parseNumber = (value: string | undefined, defaultValue: number): number => {
if (value) {
@@ -81,12 +110,16 @@ export const taskItemSchema = type({
"name?": "string",
agent: "string = 'task'",
task: "string",
"outputSchema?": "unknown",
"schemaMode?": '"permissive" | "strict"',
"+": "delete",
});
const taskItemSchemaIsolated = type({
"name?": "string",
agent: "string = 'task'",
task: "string",
"outputSchema?": "unknown",
"schemaMode?": '"permissive" | "strict"',
"isolated?": "boolean",
"+": "delete",
});
@@ -99,6 +132,10 @@ export interface TaskItem {
agent?: string;
/** The work; required by the schema. */
task?: string;
/** Caller-provided output schema; its presence overrides the selected agent's schema. */
outputSchema?: unknown;
/** Validation behavior for a caller-provided or inherited output schema. */
schemaMode?: "permissive" | "strict";
/** Run this spawn in an isolated worktree (batch form; flat form carries it top-level). */
isolated?: boolean;
}
@@ -107,6 +144,8 @@ export const taskSchema = type({
"name?": "string",
agent: "string = 'task'",
task: "string",
"outputSchema?": "unknown",
"schemaMode?": '"permissive" | "strict"',
"isolated?": "boolean",
"+": "delete",
});
@@ -114,6 +153,8 @@ const taskSchemaNoIsolation = type({
"name?": "string",
agent: "string = 'task'",
task: "string",
"outputSchema?": "unknown",
"schemaMode?": '"permissive" | "strict"',
"+": "delete",
});
const taskSchemaBatch = type({
@@ -156,6 +197,8 @@ function createTaskSchema(options: {
"name?": "string",
agent,
task: "string",
"outputSchema?": "unknown",
"schemaMode?": '"permissive" | "strict"',
"isolated?": "boolean",
"+": "delete",
});
@@ -169,6 +212,8 @@ function createTaskSchema(options: {
"name?": "string",
agent,
task: "string",
"outputSchema?": "unknown",
"schemaMode?": '"permissive" | "strict"',
"+": "delete",
});
return type.raw({
@@ -182,6 +227,8 @@ function createTaskSchema(options: {
"name?": "string",
agent,
task: "string",
"outputSchema?": "unknown",
"schemaMode?": '"permissive" | "strict"',
"isolated?": "boolean",
"+": "delete",
});
@@ -190,6 +237,8 @@ function createTaskSchema(options: {
"name?": "string",
agent,
task: "string",
"outputSchema?": "unknown",
"schemaMode?": '"permissive" | "strict"',
"+": "delete",
});
}
@@ -231,6 +280,10 @@ export interface TaskParams {
agent?: string;
/** The work (flat form). */
task?: string;
/** Caller-provided output schema; its presence overrides the selected agent's schema. */
outputSchema?: unknown;
/** Validation behavior for a caller-provided or inherited output schema. */
schemaMode?: "permissive" | "strict";
/** Batch form (`task.batch`): one subagent per item. */
tasks?: TaskItem[];
/** Batch form: shared background prepended to every assignment; required by the batch schema. */
@@ -413,6 +466,11 @@ export interface SingleResult {
output: string;
stderr: string;
truncated: boolean;
/**
* Parsed structured completion and validation metadata, when this invocation
* selected an output schema or strict schema mode.
*/
structuredOutput?: StructuredSubagentOutput;
durationMs: number;
/** Cumulative input + output + cacheWrite tokens across all turns. Excludes cacheRead (re-reads cached context every turn, making cumulative sum misleading). */
tokens: number;
+42 -14
View File
@@ -28,7 +28,7 @@ import type { UsageStatistics } from "../session/session-entries";
import type { ToolChoiceQueue } from "../session/tool-choice-queue";
import { TaskTool } from "../task";
import type { AgentOutputManager } from "../task/output-manager";
import { canSpawnAtDepth } from "../task/types";
import { canSpawnAtDepth, type StructuredSubagentSchemaMode } from "../task/types";
import type { EventBus } from "../utils/event-bus";
import { WebSearchTool } from "../web/search";
import type { WorkspaceTree } from "../workspace-tree";
@@ -45,7 +45,7 @@ import { resolveEvalBackends } from "./eval-backends";
import { GithubTool } from "./gh";
import { GlobTool } from "./glob";
import { GrepTool } from "./grep";
import { HubTool } from "./hub";
import { HubTool, isIrcEnabled } from "./hub";
import { InspectImageTool } from "./inspect-image";
import { LearnTool } from "./learn";
import { ManageSkillTool } from "./manage-skill";
@@ -183,17 +183,31 @@ export interface ToolSession {
customToolPaths?: ToolPathWithSource[];
/** Whether LSP integrations are enabled */
enableLsp?: boolean;
/** Whether this invocation may expose IRC. `false` removes it even for subagents. */
enableIrc?: boolean;
/**
* Whether MCP capabilities may be forwarded to child sessions. `false`
* prohibits inherited-manager and process-global MCP fallback.
*/
enableMCP?: boolean;
/** Whether an edit-capable tool is available in this session (controls hashline output) */
hasEditTool?: boolean;
/** Event bus for tool/extension communication */
eventBus?: EventBus;
/** Output schema for structured completion (subagents) */
/** Output schema for structured completion (subagents). */
outputSchema?: unknown;
/** Enforcement policy for {@link outputSchema}; defaults to legacy permissive behavior. */
outputSchemaMode?: StructuredSubagentSchemaMode;
/** Whether to include the yield tool by default */
requireYieldTool?: boolean;
/** Session starts with a prewalk hand-off armed. Keeps `todo` in yield-gated
* (subagent) registries: the prewalk plan nudge + todo gate need it. */
prewalkArmed?: boolean;
/**
* Constrain the active set to the caller's explicit built-in names (plus a
* required yield tool). Suppresses automatic tool-set expansion.
*/
restrictToolNames?: boolean;
/** Task recursion depth (0 = top-level, 1 = first child, etc.) */
taskDepth?: number;
/** Get shared eval executor session ID. Subagents inherit this to share JS/Python/Ruby/Julia state. */
@@ -403,13 +417,18 @@ export type ToolName = BuiltinToolName;
* Create tools from BUILTIN_TOOLS registry.
*/
export async function createTools(session: ToolSession, toolNames?: string[]): Promise<Tool[]> {
const restrictToolNames = session.restrictToolNames === true;
const includeYield = session.requireYieldTool === true;
const enableLsp = session.enableLsp ?? true;
let requestedTools = toolNames && toolNames.length > 0 ? normalizeToolNames(toolNames) : undefined;
const requestedTools = restrictToolNames
? normalizeToolNames(toolNames ?? [])
: toolNames && toolNames.length > 0
? normalizeToolNames(toolNames)
: undefined;
const goalEnabled = session.settings.get("goal.enabled");
const goalModeActive = goalEnabled && session.getGoalModeState?.()?.enabled === true;
const goalModeActive = !restrictToolNames && goalEnabled && session.getGoalModeState?.()?.enabled === true;
if (goalModeActive && requestedTools && !requestedTools.includes("goal")) {
requestedTools = [...requestedTools, "goal"];
requestedTools.push("goal");
}
const backends = resolveEvalBackends(session);
const allowPython = backends.python;
@@ -466,8 +485,9 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
// unreachable, in which case eval dispatches exclusively to the others.
const allowEval = effectivePythonAllowed || allowJs || effectiveRubyAllowed || effectiveJuliaAllowed;
// Auto-include AST counterparts when their text-based sibling is present
if (requestedTools) {
// Auto-include AST counterparts when their text-based sibling is present.
// Restricted callers own the active list and must not have it widened.
if (requestedTools && !restrictToolNames) {
if (
requestedTools.includes("grep") &&
!requestedTools.includes("ast_grep") &&
@@ -522,6 +542,9 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
if (name === "ask") return session.settings.get("ask.enabled");
if (name === "browser") return session.settings.get("browser.enabled");
if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled");
if (name === "hub") {
return !restrictToolNames && session.enableIrc !== false && isIrcEnabled(session.settings, session.taskDepth ?? 0);
}
if (name === "retain" || name === "recall" || name === "reflect") {
return ["hindsight", "mnemopi"].includes(session.settings.get("memory.backend") ?? "");
}
@@ -569,10 +592,11 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
);
let tools = baseResults.filter((r): r is Tool => r !== null);
// Always create the xd:// registry when enabled so SDK assembly can mount
// discoverable custom/MCP tools later. Explicitly requested built-ins keep
// their top-level presentation; default tool sets mount discoverable built-ins.
const xdevEnabled = session.settings.get("tools.xdev");
// Ordinary sessions use xd:// for discoverable built-ins, custom tools, and
// MCP tools. Structured children must expose only their host-provided names,
// so never allocate a registry that later SDK assembly could populate.
// Explicitly requested built-ins retain their top-level presentation.
const xdevEnabled = !restrictToolNames && session.settings.get("tools.xdev");
const mountBuiltinTools = requestedTools === undefined;
if (xdevEnabled) {
const mounted: Tool[] = [];
@@ -595,13 +619,17 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
// (e.g. ast_edit) also resolve through a `write` to xd://resolve/reject. Retain
// both whenever any device is mounted or a deferrable tool can stage one.
const xdevMounted = (session.xdevRegistry?.size ?? 0) > 0;
if ((tools.some(tool => tool.deferrable === true) || xdevMounted) && !tools.some(tool => tool.name === "write")) {
if (
!restrictToolNames &&
(tools.some(tool => tool.deferrable === true) || xdevMounted) &&
!tools.some(tool => tool.name === "write")
) {
const writeTool = await logger.time("createTools:write", BUILTIN_TOOLS.write, session);
if (writeTool) {
tools.push(wrapToolWithMetaNotice(writeTool));
}
}
if (xdevMounted && !tools.some(tool => tool.name === "read")) {
if (!restrictToolNames && xdevMounted && !tools.some(tool => tool.name === "read")) {
const readTool = await logger.time("createTools:read", BUILTIN_TOOLS.read, session);
if (readTool) {
tools.push(wrapToolWithMetaNotice(readTool));
@@ -5,10 +5,10 @@ import type { LocalProtocolOptions } from "@oh-my-pi/pi-coding-agent/internal-ur
import type { MCPManager } from "@oh-my-pi/pi-coding-agent/mcp";
import * as taskDiscovery from "@oh-my-pi/pi-coding-agent/task/discovery";
import * as taskExecutor from "@oh-my-pi/pi-coding-agent/task/executor";
import type { AgentDefinition, SingleResult } from "@oh-my-pi/pi-coding-agent/task/types";
import type { AgentDefinition, SingleResult, StructuredSubagentOutput } from "@oh-my-pi/pi-coding-agent/task/types";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
function createResult(): SingleResult {
function createResult(overrides: Partial<SingleResult> = {}): SingleResult {
return {
index: 0,
id: "0-Task",
@@ -22,6 +22,7 @@ function createResult(): SingleResult {
durationMs: 1,
tokens: 0,
requests: 0,
...overrides,
};
}
@@ -63,4 +64,33 @@ describe("runEvalAgent", () => {
expect(options?.localProtocolOptions).toBe(localProtocolOptions);
expect(options?.parentAgentId).toBe("BridgeParent");
});
it("returns executor-parsed structured data through the public eval bridge", async () => {
const agent: AgentDefinition = {
name: "task",
description: "Task agent",
systemPrompt: "Handle task",
source: "bundled",
output: { type: "object" },
};
const structuredOutput: StructuredSubagentOutput = {
source: "agent",
mode: "strict",
status: "valid",
data: { status: "ok" },
};
vi.spyOn(taskDiscovery, "discoverAgents").mockResolvedValue({ agents: [agent], projectAgentsDir: null });
vi.spyOn(taskExecutor, "runSubprocess").mockResolvedValue(createResult({ output: "not JSON", structuredOutput }));
const session = {
cwd: "/tmp",
settings: Settings.isolated(),
getSessionSpawns: () => "*",
getSessionFile: () => null,
} as unknown as ToolSession;
const result = await runEvalAgent({ prompt: "do work", agent: "task", schemaMode: "strict" }, { session });
expect(result.data).toEqual({ status: "ok" });
expect(result.details).toMatchObject({ structured: true, schemaSource: "agent", schemaMode: "strict" });
});
});
@@ -5,9 +5,10 @@ import * as path from "node:path";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { CustomTool } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/types";
import type { MCPManager } from "@oh-my-pi/pi-coding-agent/mcp/manager";
import {
type CreateAgentSessionOptions,
type CustomTool,
createAgentSession,
discoverAuthStorage,
type ExtensionFactory,
@@ -39,6 +40,16 @@ const toolActivationExtension: ExtensionFactory = pi => {
});
};
const sdkCustomTool = {
name: "sdk_custom_tool",
label: "SDK Custom Tool",
description: "SDK-provided custom tool used to verify activation boundaries.",
parameters: type({}),
async execute() {
return { content: [{ type: "text", text: "sdk custom" }] };
},
} satisfies CustomTool;
describe("createAgentSession defaultInactive tool activation", () => {
const tempDirs: string[] = [];
@@ -353,4 +364,117 @@ describe("createAgentSession defaultInactive tool activation", () => {
await session.dispose();
}
});
it("keeps restricted host tool lists isolated from configured custom capabilities", async () => {
const restrictedDir = makeTempDir();
const normalDir = makeTempDir();
const configuredSettings = () =>
Settings.isolated({
"providers.image": "openai",
"generate_image.enabled": true,
"speechgen.enabled": true,
"memory.backend": "hindsight",
"autolearn.enabled": true,
});
const inheritedManager = {
getServerInstructions: () => new Map([["private-server", "must not reach restricted child"]]),
} as unknown as MCPManager;
const { session: restricted } = await createAgentSession({
...baseOptions(restrictedDir),
settings: configuredSettings(),
extensions: [toolActivationExtension],
customTools: [sdkCustomTool],
toolNames: ["read", "lsp", "hub"],
requireYieldTool: true,
restrictToolNames: true,
enableMCP: true,
mcpManager: inheritedManager,
enableLsp: true,
enableIrc: true,
});
try {
expect(restricted.getAllToolNames()).toEqual(["read", "yield"]);
expect(restricted.getActiveToolNames()).toEqual(["read", "yield"]);
for (const name of [
"generate_image",
"tts",
"recall",
"retain",
"reflect",
"learn",
"manage_skill",
"default_active_tool",
"default_inactive_tool",
"sdk_custom_tool",
"lsp",
"hub",
]) {
expect(restricted.getToolByName(name)).toBeUndefined();
}
expect(restricted.getXdevToolEntries()).toEqual([]);
expect(restricted.systemPrompt.join("\n")).not.toContain("private-server");
expect(restricted.systemPrompt.join("\n")).not.toContain("MCP Server Instructions");
} finally {
await restricted.dispose();
}
const { session: normal } = await createAgentSession({
...baseOptions(normalDir),
settings: configuredSettings(),
extensions: [toolActivationExtension],
customTools: [sdkCustomTool],
toolNames: ["read", "generate_image"],
requireYieldTool: true,
restrictToolNames: false,
});
try {
const activeToolNames = normal.getActiveToolNames();
expect(activeToolNames).toEqual(
expect.arrayContaining(["read", "yield", "generate_image", "learn", "manage_skill", "write"]),
);
for (const name of ["tts", "default_active_tool", "sdk_custom_tool"]) {
expect(activeToolNames).not.toContain(name);
}
expect(normal.getXdevToolEntries().map(entry => entry.name)).toEqual(
expect.arrayContaining(["tts", "default_active_tool", "sdk_custom_tool"]),
);
expect(normal.getAllToolNames()).toEqual(
expect.arrayContaining([
"generate_image",
"tts",
"default_active_tool",
"sdk_custom_tool",
"recall",
"retain",
"reflect",
]),
);
} finally {
await normal.dispose();
}
});
it("ignores an inherited MCP manager when MCP is disabled", async () => {
const tempDir = makeTempDir();
const inheritedManager = {
getServerInstructions: () => new Map([["private-server", "must not reach restricted child"]]),
} as unknown as MCPManager;
const { session } = await createAgentSession({
...baseOptions(tempDir),
enableMCP: false,
mcpManager: inheritedManager,
});
try {
expect(session.systemPrompt.join("\n")).not.toContain("private-server");
expect(session.systemPrompt.join("\n")).not.toContain("MCP Server Instructions");
} finally {
await session.dispose();
}
});
});
@@ -52,6 +52,7 @@ describe("SessionManager.peekSessionInit", () => {
tools: ["read", "bash", "yield"],
spawns: "task",
readSummarize: false,
restrictToolNames: true,
});
// Flush buffered entries (header + inits) so the lock-free peek can read them off disk.
manager.appendMessage(assistantMessage("flush"));
@@ -63,6 +64,7 @@ describe("SessionManager.peekSessionInit", () => {
expect(peek?.init?.tools).toEqual(["read", "bash", "yield"]);
expect(peek?.init?.spawns).toBe("task");
expect(peek?.init?.readSummarize).toBe(false);
expect(peek?.init?.restrictToolNames).toBe(true);
});
it("returns init: null for a session file with no session_init (a main/legacy session)", async () => {
@@ -12,6 +12,7 @@ import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-regis
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { ToolPathWithSource } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools";
import type { LoadExtensionsResult } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types";
import type { MCPManager } from "@oh-my-pi/pi-coding-agent/mcp/manager";
import type { CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk";
import * as sdkModule from "@oh-my-pi/pi-coding-agent/sdk";
import type { AgentSession, AgentSessionEvent, PromptOptions } from "@oh-my-pi/pi-coding-agent/session/agent-session";
@@ -166,6 +167,72 @@ describe("runSubprocess parent-discovery pass-through (issue #2190)", () => {
expect(forwarded?.parentTaskPrefix).toBe("ChildAgent");
});
it("removes all MCP and discovered capability sources for a restricted child", async () => {
const session = yieldEmittingSession();
const persistedInits: Array<{ restrictToolNames?: boolean; tools: string[] }> = [];
vi.spyOn(session.sessionManager, "appendSessionInit").mockImplementation(init => {
persistedInits.push(init);
return "session-init";
});
const spy = vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue(createSessionResult(session));
const preloadedExtensionPaths = ["/hostile/extensions/read.ts"];
const preloadedCustomToolPaths: ToolPathWithSource[] = [
{ path: "/hostile/tools/read.ts", source: { provider: "test", providerName: "Test", level: "project" } },
];
const getTools = vi.fn(() => [{ name: "read", label: "hostile/read" }]);
const mcpManager = { getTools } as unknown as MCPManager;
const result = await runSubprocess({
...baseOptions,
id: "restricted-child",
restrictToolNames: true,
mcpManager,
preloadedExtensionPaths,
preloadedCustomToolPaths,
outputSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
outputSchemaMode: "strict",
});
expect(result.exitCode).toBe(0);
const forwarded = spy.mock.calls[0]?.[0];
expect(forwarded?.restrictToolNames).toBe(true);
expect(forwarded?.enableMCP).toBe(false);
expect(forwarded?.mcpManager).toBeUndefined();
expect(forwarded?.customTools).toBeUndefined();
expect(forwarded?.preloadedExtensionPaths).toEqual([]);
expect(forwarded?.preloadedCustomToolPaths).toEqual([]);
expect(getTools).not.toHaveBeenCalled();
expect(forwarded?.outputSchemaMode).toBe("strict");
expect(persistedInits).toHaveLength(1);
expect(persistedInits[0]).toMatchObject({ restrictToolNames: true, tools: ["read", "yield"] });
});
it("retains inherited MCP proxy tools for normal children", async () => {
const session = yieldEmittingSession();
const spy = vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue(createSessionResult(session));
const mcpManager = {
getTools: () => [{ name: "mcp__private_read", label: "private/read" }],
} as unknown as MCPManager;
const result = await runSubprocess({ ...baseOptions, id: "normal-child", mcpManager });
expect(result.exitCode).toBe(0);
const forwarded = spy.mock.calls[0]?.[0];
expect(forwarded?.enableMCP).toBe(true);
expect(forwarded?.mcpManager).toBe(mcpManager);
expect(forwarded?.customTools?.map(tool => tool.name)).toEqual(["mcp__private_read"]);
});
it("preserves the legacy result shape when no output schema is selected", async () => {
const session = yieldEmittingSession();
vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue(createSessionResult(session));
const result = await runSubprocess({ ...baseOptions, id: "legacy-output-child" });
expect(result.exitCode).toBe(0);
expect(Object.hasOwn(result, "structuredOutput")).toBe(false);
});
it("resolves an explicit task-role effort suffix over the agent-definition default", async () => {
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist");
@@ -374,4 +374,32 @@ describe("subagent warning injection", () => {
expect(result.exitCode).toBe(0);
expect(result.rawOutput).toBe("plain final answer");
});
it("rejects exhausted schema retries in strict mode and retains parsed validation metadata", () => {
const result = finalizeSubprocessOutput({
rawOutput: "",
exitCode: 0,
stderr: "",
doneAborted: false,
signalAborted: false,
yieldItems: [{ status: "success", data: { ok: "wrong" }, schemaOverridden: true }],
outputSchema: {
type: "object",
required: ["ok"],
properties: { ok: { type: "boolean" } },
},
outputSchemaMode: "strict",
outputSchemaSource: "caller",
});
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("schema_violation");
expect(result.structuredOutput).toEqual({
source: "caller",
mode: "strict",
status: "invalid",
data: { ok: "wrong" },
error: expect.any(String),
});
});
});
@@ -0,0 +1,58 @@
import { describe, expect, it } from "bun:test";
import { mapWithConcurrencyLimitAllSettled } from "@oh-my-pi/pi-coding-agent/task/parallel";
describe("mapWithConcurrencyLimitAllSettled", () => {
it("waits for valid siblings after one item rejects and keeps input order", async () => {
const started: number[] = [];
const secondGate = Promise.withResolvers<void>();
const secondStarted = Promise.withResolvers<void>();
const thirdStarted = Promise.withResolvers<void>();
const pending = mapWithConcurrencyLimitAllSettled([0, 1, 2], 2, async item => {
started.push(item);
if (item === 0) throw new Error("first failed");
if (item === 1) {
secondStarted.resolve();
await secondGate.promise;
}
if (item === 2) thirdStarted.resolve();
return `item-${item}`;
});
await secondStarted.promise;
await thirdStarted.promise;
secondGate.resolve();
const settled = await pending;
expect(started).toEqual([0, 1, 2]);
expect(settled.results.map(result => result?.status)).toEqual(["rejected", "fulfilled", "fulfilled"]);
const second = settled.results[1];
const third = settled.results[2];
expect(second).toEqual({ status: "fulfilled", value: "item-1" });
expect(third).toEqual({ status: "fulfilled", value: "item-2" });
});
it("stops scheduling after cancellation while awaiting an already launched sibling", async () => {
const controller = new AbortController();
const release = Promise.withResolvers<void>();
const firstStarted = Promise.withResolvers<void>();
const started: number[] = [];
const pending = mapWithConcurrencyLimitAllSettled(
[0, 1],
1,
async item => {
started.push(item);
firstStarted.resolve();
await release.promise;
return item;
},
controller.signal,
);
await firstStarted.promise;
controller.abort();
release.resolve();
const settled = await pending;
expect(started).toEqual([0]);
expect(settled.aborted).toBe(true);
expect(settled.results).toEqual([{ status: "fulfilled", value: 0 }, undefined]);
});
});
@@ -0,0 +1,155 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { MCPManager } from "@oh-my-pi/pi-coding-agent/mcp/manager";
import type { AgentRef } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
import type { CreateAgentSessionOptions, CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk";
import * as sdkModule from "@oh-my-pi/pi-coding-agent/sdk";
import type { AgentSession, AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { createPersistedSubagentReviverFactory } from "@oh-my-pi/pi-coding-agent/task/persisted-revive";
import { TempDir } from "@oh-my-pi/pi-utils";
const tempDirs: TempDir[] = [];
function makeTempDir(prefix: string): string {
const dir = TempDir.createSync(prefix);
tempDirs.push(dir);
return dir.path();
}
function createRef(sessionFile: string): AgentRef {
return {
id: "persisted-restricted",
displayName: "Persisted Restricted",
kind: "sub",
parentId: "Main",
status: "parked",
session: null,
sessionFile,
createdAt: 0,
lastActivity: 0,
};
}
function createRevivedSession(activeToolNames: string[][]): AgentSession {
return {
setActiveToolsByName: async (names: string[]) => {
activeToolNames.push(names);
},
subscribe: (_listener: (event: AgentSessionEvent) => void) => () => {},
} as unknown as AgentSession;
}
async function createPersistedSession(cwd: string, restrictToolNames?: boolean): Promise<string> {
const manager = SessionManager.create(cwd, path.join(cwd, "sessions"));
const sessionFile = manager.getSessionFile();
if (!sessionFile) throw new Error("Expected a persisted session file");
manager.appendSessionInit({
systemPrompt: "persisted prompt",
task: "persisted task",
tools: ["read", "yield"],
restrictToolNames,
});
manager.appendMessage({
role: "assistant",
provider: "anthropic",
model: "claude-sonnet-4-5",
content: [{ type: "text", text: "persisted" }],
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
api: "anthropic-messages",
stopReason: "stop",
timestamp: Date.now(),
});
await manager.close();
return sessionFile;
}
function createFactory(cwd: string) {
const parentSession = {
sessionManager: {
getCwd: () => cwd,
getArtifactManager: () => undefined,
},
} as unknown as AgentSession;
return createPersistedSubagentReviverFactory({
session: parentSession,
authStorage: {} as never,
modelRegistry: { authStorage: {} } as ModelRegistry,
settings: Settings.isolated(),
enableLsp: true,
});
}
afterEach(async () => {
vi.restoreAllMocks();
MCPManager.resetForTests();
await Promise.all(tempDirs.splice(0).map(dir => dir.remove()));
});
describe("persisted subagent revival", () => {
it("cold-revives a restricted contract without loading hostile same-name capabilities", async () => {
const cwd = makeTempDir("@pi-restricted-revive-");
const sessionFile = await createPersistedSession(cwd, true);
const hostileMcpGetTools = vi.fn(() => [{ name: "read", label: "hostile/read" }]);
MCPManager.setInstance({ getTools: hostileMcpGetTools } as unknown as MCPManager);
const activeToolNames: string[][] = [];
let capturedOptions: CreateAgentSessionOptions | undefined;
const attemptedDiscovery: string[] = [];
vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async options => {
capturedOptions = options;
if (options?.preloadedExtensionPaths === undefined) attemptedDiscovery.push("extension:read");
if (options?.preloadedCustomToolPaths === undefined) attemptedDiscovery.push("custom:read");
if (options?.mcpManager !== undefined || options?.customTools !== undefined)
attemptedDiscovery.push("mcp:read");
return { session: createRevivedSession(activeToolNames) } as CreateAgentSessionResult;
});
const reviver = await createFactory(cwd)(createRef(sessionFile));
if (!reviver) throw new Error("Expected a persisted reviver");
await reviver();
expect(capturedOptions?.restrictToolNames).toBe(true);
expect(capturedOptions?.enableMCP).toBe(false);
expect(capturedOptions?.enableLsp).toBe(false);
expect(capturedOptions?.enableIrc).toBe(false);
expect(capturedOptions?.mcpManager).toBeUndefined();
expect(capturedOptions?.customTools).toBeUndefined();
expect(capturedOptions?.preloadedExtensionPaths).toEqual([]);
expect(capturedOptions?.preloadedCustomToolPaths).toEqual([]);
expect(hostileMcpGetTools).not.toHaveBeenCalled();
expect(attemptedDiscovery).toEqual([]);
expect(activeToolNames).toEqual([["read", "yield"]]);
});
it("preserves normal revival capability wiring for contracts without the marker", async () => {
const cwd = makeTempDir("@pi-normal-revive-");
const sessionFile = await createPersistedSession(cwd);
const hostileMcp = {
getTools: () => [{ name: "mcp__server_read", label: "server/read" }],
} as unknown as MCPManager;
MCPManager.setInstance(hostileMcp);
let capturedOptions: CreateAgentSessionOptions | undefined;
vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async options => {
capturedOptions = options;
return { session: createRevivedSession([]) } as CreateAgentSessionResult;
});
const reviver = await createFactory(cwd)(createRef(sessionFile));
if (!reviver) throw new Error("Expected a persisted reviver");
await reviver();
expect(capturedOptions?.restrictToolNames).toBeUndefined();
expect(capturedOptions?.enableLsp).toBe(true);
expect(capturedOptions?.mcpManager).toBe(hostileMcp);
expect(capturedOptions?.customTools?.map(tool => tool.name)).toEqual(["mcp__server_read"]);
});
});
@@ -0,0 +1,400 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import path from "node:path";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import {
artifactsDirsFromRegistry,
resetRegisteredArtifactDirsForTests,
} from "@oh-my-pi/pi-coding-agent/internal-urls/registry-helpers";
import * as planHandoff from "@oh-my-pi/pi-coding-agent/plan-mode/plan-handoff";
import * as discoveryModule from "@oh-my-pi/pi-coding-agent/task/discovery";
import * as executorModule from "@oh-my-pi/pi-coding-agent/task/executor";
import * as isolationRunner from "@oh-my-pi/pi-coding-agent/task/isolation-runner";
import {
buildStructuredSubagentRecoveryHint,
resolveEffectiveSubagentPolicy,
runStructuredSubagent,
StructuredSubagentError,
type StructuredSubagentRequest,
} from "@oh-my-pi/pi-coding-agent/task/structured-subagent";
import type { AgentDefinition, SingleResult } from "@oh-my-pi/pi-coding-agent/task/types";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
const AGENT: AgentDefinition = {
name: "worker",
description: "Test worker",
systemPrompt: "Do the assigned work.",
source: "bundled",
tools: ["read", "write", "ast_grep", "report_finding"],
output: { type: "object", properties: { agent: { type: "boolean" } } },
};
function session(
options: { planMode?: boolean; outputSchema?: unknown; maxDepth?: number; isolationMode?: "none" | "worktree" } = {},
): ToolSession {
return {
cwd: "/tmp",
hasUI: false,
outputSchema: options.outputSchema,
settings: Settings.isolated({
"task.maxRecursionDepth": options.maxDepth ?? 2,
"task.isolation.mode": options.isolationMode ?? "none",
"task.enableLsp": true,
}),
getSessionFile: () => null,
getSessionSpawns: () => "*",
getPlanModeState: () => (options.planMode ? { enabled: true } : undefined),
} as unknown as ToolSession;
}
function request(overrides: Partial<StructuredSubagentRequest> = {}): StructuredSubagentRequest {
return {
session: session(),
invocationKind: "task",
assignment: "Inspect the target.",
agent: "worker",
...overrides,
};
}
function result(): SingleResult {
return {
index: 0,
id: "Worker",
agent: "worker",
agentSource: "bundled",
task: "Inspect the target.",
exitCode: 0,
output: '{"ok":true}',
stderr: "",
truncated: false,
durationMs: 1,
tokens: 0,
requests: 1,
};
}
function mockDiscovery(agent: AgentDefinition = AGENT): void {
vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({ agents: [agent], projectAgentsDir: null });
}
afterEach(() => {
vi.restoreAllMocks();
resetRegisteredArtifactDirsForTests();
});
describe("structured subagent primitive", () => {
it("uses caller, agent, then session schemas in precedence order", async () => {
mockDiscovery();
const callerSchema = { type: "object", properties: { caller: { type: "string" } } };
const caller = await resolveEffectiveSubagentPolicy(
request({ outputSchema: callerSchema, schemaMode: "strict" }),
);
expect(caller.schema).toEqual({
schema: callerSchema,
source: "caller",
mode: "strict",
outputSchemaOverridesAgent: true,
});
const agent = await resolveEffectiveSubagentPolicy(
request({ session: session({ outputSchema: { session: true } }) }),
);
expect(agent.schema.source).toBe("agent");
expect(agent.schema.schema).toBe(AGENT.output);
const noAgentOutput = { ...AGENT, output: undefined };
mockDiscovery(noAgentOutput);
const inheritedSession = session({ outputSchema: { session: true } });
inheritedSession.outputSchemaMode = "strict";
const inherited = await resolveEffectiveSubagentPolicy(request({ session: inheritedSession }));
expect(inherited.schema).toMatchObject({ source: "session", mode: "strict", outputSchemaOverridesAgent: false });
});
it("gives task and eval invocations identical blocked-agent preflight errors", async () => {
const previous = Bun.env.PI_BLOCKED_AGENT;
Bun.env.PI_BLOCKED_AGENT = "worker";
try {
const discover = vi.spyOn(discoveryModule, "discoverAgents");
const taskRequest = request();
const evalRequest = request({ session: taskRequest.session, invocationKind: "eval" });
const messages: string[] = [];
for (const candidate of [taskRequest, evalRequest]) {
try {
await resolveEffectiveSubagentPolicy(candidate);
} catch (error) {
expect(error).toBeInstanceOf(StructuredSubagentError);
messages.push((error as Error).message);
}
}
expect(messages).toEqual([
"Cannot spawn worker agent from within itself (recursion prevention). Use a different agent type.",
"Cannot spawn worker agent from within itself (recursion prevention). Use a different agent type.",
]);
expect(discover).not.toHaveBeenCalled();
} finally {
if (previous === undefined) delete Bun.env.PI_BLOCKED_AGENT;
else Bun.env.PI_BLOCKED_AGENT = previous;
}
});
it("attenuates plan-mode agents and rejects mutable isolation controls before discovery", async () => {
mockDiscovery();
const policy = await resolveEffectiveSubagentPolicy(
request({ session: session({ planMode: true }), enableLsp: true, enableIrc: true }),
);
expect(policy.effectiveAgent.tools).toEqual(["read", "grep", "glob", "web_search", "ast_grep", "report_finding"]);
expect(policy.effectiveAgent.spawns).toBeUndefined();
expect(policy.enableLsp).toBe(false);
expect(policy.enableIrc).toBe(false);
vi.restoreAllMocks();
const discover = vi.spyOn(discoveryModule, "discoverAgents");
await expect(
resolveEffectiveSubagentPolicy(
request({ session: session({ planMode: true }), isolation: { requested: false } }),
),
).rejects.toThrow("isolation, apply, and merge controls are unavailable in plan mode");
expect(discover).not.toHaveBeenCalled();
});
it("leases temporary artifacts for a retained invocation and registers them for agent URLs", async () => {
mockDiscovery();
let artifactsDir: string | undefined;
vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => {
artifactsDir = options.artifactsDir;
expect(await fs.stat(options.artifactsDir ?? "")).toBeDefined();
return result();
});
const settled = await runStructuredSubagent(request({ retainArtifacts: true }));
expect(settled.temporaryArtifacts).toBe(true);
expect(artifactsDir).toBe(settled.artifactsDir);
expect(artifactsDirsFromRegistry()).toContain(settled.artifactsDir);
expect(settled.result.structuredOutput).toMatchObject({
source: "agent",
mode: "permissive",
data: { ok: true },
});
expect(path.basename(settled.artifactsDir)).toStartWith("omp-task-");
await fs.rm(settled.artifactsDir, { recursive: true, force: true });
});
it("uses identical non-plan LSP and IRC policy for task and eval invocations", async () => {
mockDiscovery();
const taskPolicy = await resolveEffectiveSubagentPolicy(request());
const evalPolicy = await resolveEffectiveSubagentPolicy(request({ invocationKind: "eval" }));
expect(evalPolicy.enableLsp).toBe(taskPolicy.enableLsp);
expect(evalPolicy.enableIrc).toBe(taskPolicy.enableIrc);
});
it("rejects an invalid caller schema before executor dispatch in both modes", async () => {
mockDiscovery();
const dispatch = vi.spyOn(executorModule, "runSubprocess");
for (const schemaMode of ["permissive", "strict"] as const) {
await expect(runStructuredSubagent(request({ outputSchema: false, schemaMode }))).rejects.toThrow(
schemaMode === "strict"
? "Invalid strict caller output schema: boolean false schema rejects all outputs"
: "Invalid caller output schema: boolean false schema rejects all outputs",
);
}
expect(dispatch).not.toHaveBeenCalled();
});
it("does not return unavailable structured metadata without an effective schema", async () => {
const unstructuredAgent = { ...AGENT, output: undefined };
mockDiscovery(unstructuredAgent);
vi.spyOn(executorModule, "runSubprocess").mockImplementation(async () => {
const completed = result();
completed.structuredOutput = { source: "none", mode: "permissive", status: "unavailable" };
return completed;
});
const settled = await runStructuredSubagent(request({ retainArtifacts: true }));
expect(settled.result).not.toHaveProperty("structuredOutput");
await fs.rm(settled.artifactsDir, { recursive: true, force: true });
});
it("keeps invalid inherited schemas permissive but rejects them when session strict mode is inherited", async () => {
const invalidAgent = { ...AGENT, output: false };
mockDiscovery(invalidAgent);
expect((await resolveEffectiveSubagentPolicy(request())).schema).toMatchObject({
source: "agent",
mode: "permissive",
});
const noAgentOutput = { ...AGENT, output: undefined };
mockDiscovery(noAgentOutput);
const strictSession = session({ outputSchema: false });
strictSession.outputSchemaMode = "strict";
await expect(resolveEffectiveSubagentPolicy(request({ session: strictSession }))).rejects.toThrow(
"Invalid strict effective output schema: boolean false schema rejects all outputs",
);
});
it("persists nested patch text with the compatible recovery path and wording", async () => {
const artifactsDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-structured-subagent-"));
const completed = result();
completed.patchPath = "/recovery/Worker.patch";
completed.branchName = "omp/task/Worker";
completed.nestedPatches = [{ relativePath: "sub/nested", patch: "diff --git a/file b/file\n" }];
const hint = await buildStructuredSubagentRecoveryHint(completed, artifactsDir);
const nestedPath = path.join(artifactsDir, "Worker.nested-0-sub_nested.patch");
expect(hint).toContain("Captured patch preserved at /recovery/Worker.patch.");
expect(hint).toContain(`Captured nested patch preserved at ${nestedPath}.`);
expect(hint).toContain("Captured branch preserved as omp/task/Worker.");
expect(await fs.readFile(nestedPath, "utf8")).toBe("diff --git a/file b/file\n");
await fs.rm(artifactsDir, { recursive: true, force: true });
});
it("cleans ephemeral artifacts when isolation setup fails without recovery", async () => {
mockDiscovery();
vi.spyOn(isolationRunner, "prepareIsolationContext").mockRejectedValue(new Error("not a repository"));
await expect(
runStructuredSubagent(
request({ session: session({ isolationMode: "worktree" }), isolation: { requested: true } }),
),
).rejects.toThrow("Isolated subagent execution requires a git repository");
expect(artifactsDirsFromRegistry()).toEqual([]);
});
it("reuses a cached output manager across concurrent allocations and sanitizes artifact ids", async () => {
mockDiscovery();
const sharedSession = session();
const ids: string[] = [];
vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => {
ids.push(options.id);
return result();
});
const settled = await Promise.all([
runStructuredSubagent(
request({ session: sharedSession, identity: { label: "../../Worker" }, retainArtifacts: true }),
),
runStructuredSubagent(
request({ session: sharedSession, identity: { label: "../../Worker" }, retainArtifacts: true }),
),
]);
expect(ids.sort()).toEqual(["Worker", "Worker-2"]);
expect(sharedSession.agentOutputManager).toBeDefined();
for (const run of settled) await fs.rm(run.artifactsDir, { recursive: true, force: true });
});
it("suppresses plan capability sources while preserving non-plan propagation", async () => {
mockDiscovery();
const mcpManager = {} as NonNullable<ToolSession["mcpManager"]>;
const extensionPaths = ["/plugins/example.ts"];
const customToolPaths = [{ path: "/tools/example.ts", source: "project" }] as unknown as NonNullable<
ToolSession["customToolPaths"]
>;
const planSession = session({ planMode: true });
Object.assign(planSession, { mcpManager, extensionPaths, customToolPaths });
const nonPlanSession = session();
Object.assign(nonPlanSession, { mcpManager, extensionPaths, customToolPaths });
const mcpDisabledSession = session();
mcpDisabledSession.enableMCP = false;
const options = [] as executorModule.ExecutorOptions[];
vi.spyOn(executorModule, "runSubprocess").mockImplementation(async executorOptions => {
options.push(executorOptions);
return result();
});
const planRun = await runStructuredSubagent(request({ session: planSession, retainArtifacts: true }));
const nonPlanRun = await runStructuredSubagent(request({ session: nonPlanSession, retainArtifacts: true }));
const mcpDisabledRun = await runStructuredSubagent(
request({ session: mcpDisabledSession, retainArtifacts: true }),
);
expect(options[0]).toMatchObject({
enableMCP: false,
restrictToolNames: true,
preloadedExtensionPaths: [],
preloadedCustomToolPaths: [],
});
expect(options[0]?.mcpManager).toBeUndefined();
expect(options[1]).toMatchObject({
enableMCP: true,
mcpManager,
preloadedExtensionPaths: extensionPaths,
preloadedCustomToolPaths: customToolPaths,
});
expect(options[1]?.restrictToolNames).toBe(false);
expect(options[2]).toMatchObject({ enableMCP: false });
expect(options[2]?.mcpManager).toBeUndefined();
await fs.rm(planRun.artifactsDir, { recursive: true, force: true });
await fs.rm(nonPlanRun.artifactsDir, { recursive: true, force: true });
await fs.rm(mcpDisabledRun.artifactsDir, { recursive: true, force: true });
});
it("unregisters and removes a temporary lease when output ID allocation fails", async () => {
mockDiscovery();
const failingSession = session();
failingSession.agentOutputManager = {
allocate: async () => {
throw new Error("allocate failed");
},
} as unknown as ToolSession["agentOutputManager"];
const remove = vi.spyOn(fs, "rm");
await expect(runStructuredSubagent(request({ session: failingSession }))).rejects.toThrow(
"Subagent execution failed: allocate failed",
);
const artifactsDir = remove.mock.calls[0]?.[0];
expect(typeof artifactsDir).toBe("string");
expect(artifactsDirsFromRegistry()).toEqual([]);
await expect(fs.stat(artifactsDir as string)).rejects.toThrow();
});
it("unregisters and removes a temporary lease when plan reference loading fails", async () => {
mockDiscovery();
vi.spyOn(planHandoff, "loadOverallPlanReference").mockRejectedValue(new Error("plan unavailable"));
const remove = vi.spyOn(fs, "rm");
await expect(runStructuredSubagent(request())).rejects.toThrow("Subagent execution failed: plan unavailable");
const artifactsDir = remove.mock.calls[0]?.[0];
expect(typeof artifactsDir).toBe("string");
expect(artifactsDirsFromRegistry()).toEqual([]);
await expect(fs.stat(artifactsDir as string)).rejects.toThrow();
});
it("cleans failed nonisolated handle artifacts", async () => {
mockDiscovery();
let artifactsDir: string | undefined;
vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => {
artifactsDir = options.artifactsDir;
return { ...result(), exitCode: 1, error: "agent failed" };
});
await runStructuredSubagent(request({ invocationKind: "eval", retainArtifacts: true }));
expect(artifactsDirsFromRegistry()).toEqual([]);
await expect(fs.stat(artifactsDir ?? "")).rejects.toThrow();
});
it("retains isolated failure artifacts needed for recovery", async () => {
mockDiscovery();
let artifactsDir: string | undefined;
vi.spyOn(isolationRunner, "prepareIsolationContext").mockResolvedValue({ repoRoot: "/tmp" } as never);
vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async ({ baseOptions }) => {
artifactsDir = baseOptions.artifactsDir;
return { ...result(), exitCode: 1, error: "agent failed", patchPath: "/recovery/Worker.patch" };
});
const settled = await runStructuredSubagent(
request({ session: session({ isolationMode: "worktree" }), isolation: { requested: true } }),
);
expect(artifactsDirsFromRegistry()).toContain(settled.artifactsDir);
expect(await fs.stat(artifactsDir ?? "")).toBeDefined();
await fs.rm(settled.artifactsDir, { recursive: true, force: true });
});
});
@@ -2,11 +2,10 @@
* Contracts: task.batch gating (batch spawning + shared context).
*
* 1. The wire schema is shape-swapped by `task.batch`: `{ context, tasks[] }`
* when on (per-spawn fields — including `isolated` — live in the items),
* the flat `{ name?, agent?, task, isolated? }` when off. Neither
* shape exposes a per-call `schema` input (structured output comes from
* agent frontmatter / inherited session schema / eval agent()).
* 2. Shape validation rejects `schema` always, `tasks`/`context` while batch
* when on (per-spawn fields — including `isolated`, `outputSchema`, and
* `schemaMode` — live in the items), the flat form exposes those fields
* directly. The stale `schema` field is never accepted.
* 2. Shape validation rejects stale `schema`, `tasks`/`context` while batch
* is disabled, top-level `task` in batch calls, empty/invalid items,
* duplicate names, and a missing shared `context`.
* 3. With `async.enabled=true`, a batch call registers one background job per
@@ -34,7 +33,12 @@ const taskAgent: AgentDefinition = {
};
function createSession(
options: { manager?: AsyncJobManager; settings?: Record<string, unknown>; agentId?: string } = {},
options: {
manager?: AsyncJobManager;
settings?: Record<string, unknown>;
agentId?: string;
planMode?: boolean;
} = {},
): ToolSession {
return {
cwd: "/tmp",
@@ -43,6 +47,7 @@ function createSession(
getSessionFile: () => null,
getSessionSpawns: () => "*",
getAgentId: () => options.agentId ?? null,
getPlanModeState: options.planMode ? () => ({ enabled: true }) : undefined,
asyncJobManager: options.manager,
} as unknown as ToolSession;
}
@@ -76,13 +81,12 @@ function makeResult(id: string, overrides: Partial<SingleResult> = {}): SingleRe
};
}
function mockDiscovery(): void {
function mockDiscovery(agent: AgentDefinition = taskAgent): void {
vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({
agents: [taskAgent],
agents: [agent],
projectAgentsDir: null,
});
}
describe("task.batch schema gating", () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -97,6 +101,8 @@ describe("task.batch schema gating", () => {
expect(offProperties.context).toBeUndefined();
expect(offProperties.task).toBeDefined();
expect(offProperties.name).toBeDefined();
expect(offProperties.outputSchema).toBeDefined();
expect(offProperties.schemaMode).toBeDefined();
const on = await TaskTool.create(createSession({ settings: { "task.batch": true } }));
const onProperties = getSchemaProperties(on);
@@ -107,10 +113,14 @@ describe("task.batch schema gating", () => {
expect(onProperties.task).toBeUndefined();
expect(onProperties.name).toBeUndefined();
expect(onProperties.agent).toBeUndefined();
expect(onProperties.outputSchema).toBeUndefined();
expect(onProperties.schemaMode).toBeUndefined();
const items = (onProperties.tasks as { items?: { properties?: Record<string, unknown> } }).items;
expect(items?.properties?.task).toBeDefined();
expect(items?.properties?.name).toBeDefined();
expect(items?.properties?.agent).toBeDefined();
expect(items?.properties?.outputSchema).toBeDefined();
expect(items?.properties?.schemaMode).toBeDefined();
});
it("places isolated per item in the batch shape when isolation is enabled", async () => {
@@ -125,13 +135,29 @@ describe("task.batch schema gating", () => {
expect(items?.properties?.isolated).toBeDefined();
});
it("never exposes a per-call schema input", async () => {
it("hides isolation from the dynamic batch schema in plan mode", async () => {
mockDiscovery();
const tool = await TaskTool.create(
createSession({
planMode: true,
settings: { "task.batch": true, "task.isolation.mode": "auto" },
}),
);
const properties = getSchemaProperties(tool);
const items = (properties.tasks as { items?: { properties?: Record<string, unknown> } }).items;
expect(items?.properties?.isolated).toBeUndefined();
expect(tool.description).not.toContain("`isolated`");
});
it("exposes outputSchema but never the stale schema field", async () => {
mockDiscovery();
for (const settings of [{ "task.batch": false }, { "task.batch": true }]) {
const tool = await TaskTool.create(createSession({ settings }));
expect(getSchemaProperties(tool).schema).toBeUndefined();
}
const flat = await TaskTool.create(createSession({ settings: { "task.batch": false } }));
expect(getSchemaProperties(flat).outputSchema).toBeDefined();
expect(getSchemaProperties(flat).schema).toBeUndefined();
const batch = await TaskTool.create(createSession({ settings: { "task.batch": true } }));
expect(getSchemaProperties(batch).schema).toBeUndefined();
});
});
@@ -147,13 +173,13 @@ describe("task.batch validation", () => {
return getFirstText(result);
}
it("rejects a schema argument regardless of batch mode", async () => {
it("rejects the stale schema argument regardless of batch mode", async () => {
for (const batch of [false, true]) {
const text = await executeText(
{ agent: "task", task: "Work.", schema: '{"properties":{}}' },
{ "task.batch": batch },
);
expect(text).toContain("does not accept `schema`");
expect(text).toContain("uses `outputSchema`");
}
});
@@ -221,15 +247,31 @@ describe("task.batch spawning", () => {
AgentRegistry.resetGlobalForTests();
});
it("spawns one background job per task item and forwards the shared context", async () => {
mockDiscovery();
const seen: Array<{ id?: string; context?: string; assignment?: string; parentAgentId?: string }> = [];
it("spawns one background job per task item and forwards independent schemas with shared context", async () => {
mockDiscovery({
...taskAgent,
output: { type: "object", properties: { staleAgentOutput: { type: "boolean" } } },
});
const seen: Array<{
id?: string;
context?: string;
assignment?: string;
parentAgentId?: string;
outputSchema?: unknown;
outputSchemaMode?: "permissive" | "strict";
outputSchemaSource?: "caller" | "agent" | "session" | "none";
outputSchemaOverridesAgent?: boolean;
}> = [];
vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => {
seen.push({
id: options.id,
context: options.context,
assignment: options.assignment,
parentAgentId: options.parentAgentId,
outputSchema: options.outputSchema,
outputSchemaMode: options.outputSchemaMode,
outputSchemaSource: options.outputSchemaSource,
outputSchemaOverridesAgent: options.outputSchemaOverridesAgent,
});
return makeResult(options.id ?? "?");
});
@@ -238,12 +280,13 @@ describe("task.batch spawning", () => {
const tool = await TaskTool.create(
createSession({ manager, agentId: "ParentA", settings: { "async.enabled": true, "task.batch": true } }),
);
const alphaSchema = { type: "object", properties: { alpha: { type: "string" } } };
const betaSchema = { type: "object", properties: { beta: { type: "number" } } };
const result = await tool.execute("tc-batch", {
context: "# Goal\nShared background.",
tasks: [
{ name: "Alpha", task: "Do A." },
{ name: "Beta", task: "Do B." },
{ name: "Alpha", task: "Do A.", outputSchema: alphaSchema, schemaMode: "strict" },
{ name: "Beta", task: "Do B.", outputSchema: betaSchema, schemaMode: "permissive" },
],
} as TaskParams);
@@ -261,18 +304,18 @@ describe("task.batch spawning", () => {
await alphaJob!.promise;
await betaJob!.promise;
expect(alphaJob!.status).toBe("completed");
expect(betaJob!.status).toBe("completed");
expect(alphaJob!.resultText).toContain("Alpha is now idle");
expect(betaJob!.resultText).toContain("history://Beta");
expect(seen).toHaveLength(2);
for (const spawn of seen) {
expect(spawn.context).toBe("# Goal\nShared background.");
expect(spawn.outputSchemaSource).toBe("caller");
expect(spawn.outputSchemaOverridesAgent).toBe(true);
}
const byId = new Map(seen.map(spawn => [spawn.id, spawn]));
expect(byId.get("Alpha")?.outputSchema).toEqual(alphaSchema);
expect(byId.get("Alpha")?.outputSchemaMode).toBe("strict");
expect(byId.get("Beta")?.outputSchema).toEqual(betaSchema);
expect(byId.get("Beta")?.outputSchemaMode).toBe("permissive");
expect(seen.map(spawn => spawn.assignment).sort()).toEqual(["Do A.", "Do B."]);
// Every spawn is parented to the spawning agent (not to itself): the
// registry "of <parent>" link must be the caller, never the child's id.
for (const spawn of seen) expect(spawn.parentAgentId).toBe("ParentA");
});
@@ -304,24 +347,47 @@ describe("task.batch spawning", () => {
it("accepts the flat single-spawn form at runtime under batch mode", async () => {
// Internal callers (e.g. the commit flow) and stale transcripts use the
// flat shape directly; the wire schema is batch-only but runtime is not.
mockDiscovery();
vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => makeResult(options.id ?? "?"));
mockDiscovery({ ...taskAgent, output: { type: "object", properties: { agent: { type: "string" } } } });
let captured:
| {
outputSchema?: unknown;
outputSchemaMode?: "permissive" | "strict";
outputSchemaSource?: "caller" | "agent" | "session" | "none";
outputSchemaOverridesAgent?: boolean;
}
| undefined;
vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => {
captured = {
outputSchema: options.outputSchema,
outputSchemaMode: options.outputSchemaMode,
outputSchemaSource: options.outputSchemaSource,
outputSchemaOverridesAgent: options.outputSchemaOverridesAgent,
};
return makeResult(options.id ?? "?");
});
const manager = createManager();
const tool = await TaskTool.create(
createSession({ manager, settings: { "async.enabled": true, "task.batch": true } }),
);
const callerSchema = { type: "object", properties: { caller: { type: "number" } } };
const result = await tool.execute("tc-flat", {
agent: "task",
name: "Flat",
task: "Do the thing.",
outputSchema: callerSchema,
schemaMode: "strict",
} as TaskParams);
expect(getFirstText(result)).toContain("Spawned agent `Flat`");
const job = manager.getJob(result.details!.async!.jobId)!;
await job.promise;
expect(job.status).toBe("completed");
expect(captured?.outputSchema).toEqual(callerSchema);
expect(captured?.outputSchemaMode).toBe("strict");
expect(captured?.outputSchemaSource).toBe("caller");
expect(captured?.outputSchemaOverridesAgent).toBe(true);
});
it("blocks batch execution when async.enabled is false even with a job manager", async () => {
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async/job-manager";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentLifecycleManager } from "@oh-my-pi/pi-coding-agent/registry/agent-lifecycle";
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
import { TaskTool } from "@oh-my-pi/pi-coding-agent/task";
import * as discoveryModule from "@oh-my-pi/pi-coding-agent/task/discovery";
import * as executorModule from "@oh-my-pi/pi-coding-agent/task/executor";
import type { AgentDefinition, SingleResult, TaskParams } from "@oh-my-pi/pi-coding-agent/task/types";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
const taskAgent: AgentDefinition = {
name: "task",
description: "General-purpose task agent",
systemPrompt: "You are a task agent.",
source: "bundled",
};
function createSession(options: {
manager: AsyncJobManager;
settings?: Record<string, unknown>;
spawns?: string | boolean;
}): ToolSession {
return {
cwd: "/tmp",
hasUI: false,
settings: Settings.isolated({ "async.enabled": true, ...options.settings }),
getSessionFile: () => null,
getSessionSpawns: () => options.spawns ?? "*",
asyncJobManager: options.manager,
} as unknown as ToolSession;
}
function textOf(result: { content: Array<{ type: string; text?: string }> }): string {
const content = result.content.find(part => part.type === "text");
return content?.type === "text" ? (content.text ?? "") : "";
}
function resultFor(id: string): SingleResult {
return {
index: 0,
id,
agent: "task",
agentSource: "bundled",
task: "prompt",
assignment: "work",
exitCode: 0,
output: "done",
stderr: "",
truncated: false,
durationMs: 1,
tokens: 0,
requests: 1,
};
}
function mockDiscovery(agents: AgentDefinition[] = [taskAgent]): void {
vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({ agents, projectAgentsDir: null });
}
describe("task async preflight", () => {
const managers: AsyncJobManager[] = [];
beforeEach(() => {
AgentRegistry.resetGlobalForTests();
AgentLifecycleManager.resetGlobalForTests();
});
afterEach(async () => {
vi.restoreAllMocks();
for (const manager of managers.splice(0)) await manager.dispose({ timeoutMs: 1_000 });
AgentLifecycleManager.resetGlobalForTests();
AgentRegistry.resetGlobalForTests();
});
function manager(): AsyncJobManager {
const result = new AsyncJobManager({ onJobComplete: () => {} });
managers.push(result);
return result;
}
it.each([
{
name: "Unknown",
params: { agent: "missing", name: "Unknown", task: "Work." },
expectation: 'Unknown agent "missing"',
},
{
name: "Disabled",
params: { agent: "task", name: "Disabled", task: "Work." },
settings: { "task.disabledAgents": ["task"] },
expectation: 'Agent "task" is disabled',
},
{
name: "Disallowed",
params: { agent: "task", name: "Disallowed", task: "Work." },
spawns: "scout",
expectation: "Cannot spawn 'task'",
},
])("returns $name policy errors before registering an async job", async ({
name,
params,
settings,
spawns,
expectation,
}) => {
mockDiscovery();
const jobs = manager();
const tool = await TaskTool.create(createSession({ manager: jobs, settings, spawns }));
const result = await tool.execute("preflight", params as TaskParams);
expect(textOf(result)).toContain(expectation);
expect(jobs.getJob(name)).toBeUndefined();
});
it("reports an invalid batch item synchronously while launching its valid sibling", async () => {
mockDiscovery();
const seen: string[] = [];
vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => {
seen.push(options.id ?? "");
return resultFor(options.id ?? "");
});
const jobs = manager();
const tool = await TaskTool.create(createSession({ manager: jobs, settings: { "task.batch": true } }));
const result = await tool.execute("mixed-preflight", {
context: "Shared context.",
tasks: [
{ name: "Invalid", agent: "missing", task: "Do invalid work." },
{ name: "Valid", agent: "task", task: "Do valid work." },
],
} as TaskParams);
const text = textOf(result);
expect(text).toContain('Task Invalid failed preflight: Unknown agent "missing"');
expect(text).toContain("Spawned agent `Valid`");
expect(text.indexOf("Task Invalid failed preflight")).toBeLessThan(text.indexOf("Spawned agent `Valid`"));
expect(jobs.getJob("Invalid")).toBeUndefined();
const valid = jobs.getJob("Valid");
expect(valid).toBeDefined();
await valid!.promise;
expect(valid!.status).toBe("completed");
expect(seen).toEqual(["Valid"]);
});
});
@@ -6,10 +6,10 @@ import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { type } from "arktype";
// Contract: the single-spawn schema (`task.batch: false`; the exported
// `taskSchema` instance) carries no batch fields. The batch shape (`tasks[]` +
// shared `context`) is gated by the `task.batch` setting (default on, covered
// by test/task/task-batch.test.ts), and a per-call `schema` input no longer
// exists at all; follow-ups go through `irc` messaging.
// `taskSchema` instance) carries no batch fields while accepting a caller
// `outputSchema` and its validation mode. The batch shape (`tasks[]` + shared
// `context`) is gated by the `task.batch` setting (default on, covered by
// test/task/task-batch.test.ts).
describe("task schema (single-spawn)", () => {
it("accepts {agent, task}", () => {
@@ -30,18 +30,21 @@ describe("task schema (single-spawn)", () => {
expect(parsed instanceof type.errors).toBe(true);
});
it("strips tasks/context/schema from the single-spawn schema", () => {
it("retains caller outputSchema and schemaMode while stripping stale keys", () => {
const outputSchema = { type: "object", properties: { answer: { type: "string" } } };
const parsed = taskSchema({
agent: "explore",
task: "Map the auth module.",
outputSchema,
schemaMode: "strict",
context: "shared background",
tasks: [{ name: "A", task: "..." }],
schema: '{"properties":{}}',
});
expect(parsed instanceof type.errors).toBe(false);
if (!(parsed instanceof type.errors)) {
// Unknown keys are stripped: batch/context exist only on the batch
// schema and the per-call schema input was removed outright.
expect(parsed.outputSchema).toEqual(outputSchema);
expect(parsed.schemaMode).toBe("strict");
expect("tasks" in parsed).toBe(false);
expect("context" in parsed).toBe(false);
expect("schema" in parsed).toBe(false);