fix(task): respected restricted spawn defaults

Resolved eval agent() and task tool defaults from the active spawn policy so restricted agents advertise and execute an allowed default.

Fixes #3973
This commit is contained in:
roboomp
2026-07-01 02:44:08 +00:00
parent b2a859a7c5
commit 8614b4c086
11 changed files with 287 additions and 59 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed eval and task spawn defaults to respect restricted agent `spawns` lists. ([#3973](https://github.com/can1357/oh-my-pi/issues/3973))
## [16.2.11] - 2026-07-01
### Fixed
@@ -196,15 +196,29 @@ describe("runEvalAgent", () => {
await expect(runEvalAgent({ prompt: "hello" }, { session: makeSession({ spawns: "" }) })).rejects.toThrow(
"spawns disabled",
);
await expect(runEvalAgent({ prompt: "hello" }, { session: makeSession({ spawns: "reviewer" }) })).rejects.toThrow(
"Allowed: reviewer",
);
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();
});
it("defaults to the first allowed spawn under restricted eval policies", async () => {
mockAgents();
const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options =>
singleResult(options, {
output: options.agent.name,
}),
);
const result = await runEvalAgent({ prompt: "hello" }, { session: makeSession({ spawns: "reviewer,task" }) });
expect(result.text).toBe("reviewer");
expect(runSpy.mock.calls[0]?.[0].agent.name).toBe("reviewer");
});
it("throws instead of spawning from plan mode", async () => {
mockAgents();
const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
@@ -24,6 +24,7 @@ import {
runIsolatedSubprocess,
} from "../task/isolation-runner";
import { AgentOutputManager } from "../task/output-manager";
import { resolveSpawnPolicy } from "../task/spawn-policy";
import type { AgentDefinition, AgentProgress, SingleResult } from "../task/types";
import { type NestedRepoPatch, parseIsolationMode } from "../task/worktree";
import type { ToolSession } from "../tools";
@@ -39,7 +40,6 @@ export const EVAL_AGENT_BRIDGE_NAME = "__agent__";
/** Hard recursion limit for eval-driven subagents. */
export const EVAL_AGENT_MAX_DEPTH = 3;
const DEFAULT_AGENT_TYPE = "task";
const DEFAULT_AGENT_LABEL = "EvalAgent";
const agentArgsSchema = type({
@@ -139,14 +139,12 @@ function assertDepthAllowed(session: ToolSession): void {
}
function assertSpawnAllowed(session: ToolSession, agentName: string): void {
const parentSpawns = session.getSessionSpawns() ?? "*";
if (parentSpawns === "*") return;
if (parentSpawns === "") {
throw new ToolError(`Cannot spawn '${agentName}'. Allowed: none (spawns disabled for this agent)`);
const spawnPolicy = resolveSpawnPolicy(session.getSessionSpawns());
if (!spawnPolicy.enabled) {
throw new ToolError(`Cannot spawn '${agentName}'. Allowed: ${spawnPolicy.allowedErrorText}`);
}
const allowedSpawns = parentSpawns.split(",").map(spawn => spawn.trim());
if (!allowedSpawns.includes(agentName)) {
throw new ToolError(`Cannot spawn '${agentName}'. Allowed: ${parentSpawns}`);
if (spawnPolicy.allowedAgents !== null && !spawnPolicy.allowedAgents.includes(agentName)) {
throw new ToolError(`Cannot spawn '${agentName}'. Allowed: ${spawnPolicy.allowedErrorText}`);
}
}
@@ -283,7 +281,7 @@ 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 ?? DEFAULT_AGENT_TYPE;
const agentName = parsed.agent ?? resolveSpawnPolicy(options.session.getSessionSpawns()).defaultAgent;
const structured = Object.hasOwn(parsed, "schema");
assertNotPlanMode(options.session);
@@ -39,8 +39,8 @@ tool.<name>(args) → unknown
Invoke any session tool; `args` = its parameter object.
completion(prompt, model?="default", system?=None, schema?=None) → str | dict
Oneshot, stateless (no history/tools). `model`: "smol" fast | "default" session | "slow" most capable. `schema` (JSON-Schema) → structured output, parsed object.
{{#if spawns}}agent(prompt, agent?="task", model?=None, label?=None, schema?=None, handle?=False) → str | dict
Run a subagent → final output. `agent` picks another discovered agent; `schema` as in completion(). Background via `local://` files named in the prompt. `handle` → DAG node dict { text, output, handle: "agent://<id>", id, agent } (parsed under `data` when `schema` set).
{{#if spawns}}agent(prompt, agent?="{{spawnDefaultAgent}}", model?=None, label?=None, schema?=None, handle?=False) → str | dict
Run a subagent → final output. `agent` picks another discovered agent; omit it to use `{{spawnDefaultAgent}}`.{{#if spawnAllowedAgentsText}} Allowed agents: {{spawnAllowedAgentsText}}.{{/if}} `schema` as in completion(). Background via `local://` files named in the prompt. `handle` → DAG node dict { text, output, handle: "agent://<id>", id, agent } (parsed under `data` when `schema` set).
{{#if js}} JS: options are ONE trailing object — agent(prompt, { agent, schema, handle }).
{{/if}}
{{/if}}
@@ -10,7 +10,7 @@ Execution blocks your turn: the call only returns once the work is completely fi
- **One-pass agents:** Prefer agents that investigate **and** edit in a single pass; only spin a read-only discovery step (e.g. `explore`) when the affected files are genuinely unknown.
# Inputs
- `agent` (optional): The base agent type to use (e.g., `explore`, `reviewer`). Defaults to `task` (the general-purpose worker) — omit it for the default worker instead of passing `agent: "task"`.
- `agent` (optional): The base agent type to use (e.g., `explore`, `reviewer`). Defaults to `{{defaultAgent}}`{{#if defaultAgentIsGeneric}} (the general-purpose worker){{/if}} — omit it for the default worker instead of passing `agent: "{{defaultAgent}}"`.{{#if allowedAgentsText}} Current spawn policy allows: {{allowedAgentsText}}.{{/if}}
{{#if batchEnabled}}
- `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.
+23 -27
View File
@@ -30,6 +30,7 @@ import taskSummaryTemplate from "../prompts/tools/task-summary.md" with { type:
import { truncateForPrompt } from "../tools/approval";
import { isIrcEnabled } from "../tools/irc";
import { formatBytes, formatDuration } from "../tools/render-utils";
import { DEFAULT_SPAWN_AGENT, resolveSpawnPolicy } from "./spawn-policy";
import {
type AgentDefinition,
type AgentProgress,
@@ -187,17 +188,13 @@ function renderDescription(
ircEnabled: boolean,
parentSpawns: string,
): string {
const spawningDisabled = parentSpawns === "";
const spawnPolicy = resolveSpawnPolicy(parentSpawns);
const spawningDisabled = !spawnPolicy.enabled;
let filteredAgents = disabledAgents.length > 0 ? agents.filter(a => !disabledAgents.includes(a.name)) : agents;
if (spawningDisabled) {
filteredAgents = [];
} else if (parentSpawns !== "*") {
const allowed = new Set(
parentSpawns
.split(",")
.map(s => s.trim())
.filter(Boolean),
);
} else if (spawnPolicy.allowedAgents !== null) {
const allowed = new Set(spawnPolicy.allowedAgents);
filteredAgents = filteredAgents.filter(a => allowed.has(a.name));
}
const renderedAgents = filteredAgents.map(agent => ({
@@ -208,6 +205,9 @@ function renderDescription(
return prompt.render(taskDescriptionTemplate, {
agents: renderedAgents,
spawningDisabled,
defaultAgent: spawnPolicy.defaultAgent,
defaultAgentIsGeneric: spawnPolicy.defaultAgent === DEFAULT_SPAWN_AGENT,
allowedAgentsText: spawnPolicy.allowedPromptText,
MAX_CONCURRENCY: maxConcurrency,
isolationEnabled,
batchEnabled,
@@ -331,9 +331,6 @@ function spawnParamsFor(params: TaskParams, item: TaskItem): TaskParams {
return spawn;
}
/** Agent type spawned when a `task` call omits `agent`; mirrors the schema default in `getTaskSchema`. */
const DEFAULT_TASK_AGENT = "task";
/** Generic worker agents whose output sharpens with a tailored `role` rather than the bare type. */
const GENERIC_SPAWN_AGENTS: ReadonlySet<string> = new Set(["task", "sonic"]);
@@ -511,7 +508,8 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
get parameters(): TaskToolSchemaInstance {
const isolationEnabled = this.session.settings.get("task.isolation.mode") !== "none";
return getTaskSchema({ isolationEnabled, batchEnabled: this.#isBatchEnabled() });
const defaultAgent = resolveSpawnPolicy(this.session.getSessionSpawns()).defaultAgent;
return getTaskSchema({ isolationEnabled, batchEnabled: this.#isBatchEnabled(), defaultAgent });
}
renderCall(args: unknown, options: Parameters<typeof renderTaskCall>[1], theme: Theme) {
@@ -566,13 +564,14 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
): Promise<AgentToolResult<TaskToolDetails>> {
const repaired = repairTaskParams(rawParams as TaskParams);
// The schema defaults `agent` to `task` for model calls, but internal
// callers and stale transcripts build params directly and bypass arktype.
// Normalize once here so every downstream path sees the resolved agent.
// Schema defaults run for model calls, but internal callers and stale
// transcripts can bypass arktype. Normalize once so every downstream path
// sees the session's actual default agent.
const defaultAgent = resolveSpawnPolicy(this.session.getSessionSpawns()).defaultAgent;
const params =
typeof repaired.agent === "string" && repaired.agent.trim() !== ""
? repaired
: { ...repaired, agent: DEFAULT_TASK_AGENT };
: { ...repaired, agent: defaultAgent };
const batchEnabled = this.#isBatchEnabled();
const validationError = validateShapeParams(batchEnabled, params) ?? validateSpawnParams(params, batchEnabled);
if (validationError) {
@@ -1189,18 +1188,15 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
}
// Check spawn restrictions from parent
const parentSpawns = this.session.getSessionSpawns() ?? "*";
const allowedSpawns = parentSpawns.split(",").map(s => s.trim());
const isSpawnAllowed = (): boolean => {
if (parentSpawns === "") return false; // Empty = deny all
if (parentSpawns === "*") return true; // Wildcard = allow all
return allowedSpawns.includes(agentName);
};
if (!isSpawnAllowed()) {
const allowed = parentSpawns === "" ? "none (spawns disabled for this agent)" : parentSpawns;
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: ${allowed}` }],
content: [
{ type: "text", text: `Cannot spawn '${agentName}'. Allowed: ${spawnPolicy.allowedErrorText}` },
],
details: { projectAgentsDir, results: [], totalDurationMs: Date.now() - startTime },
};
}
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { Settings } from "../config/settings";
import type { ToolSession } from "../tools";
import * as taskDiscovery from "./discovery";
import { TaskTool } from "./index";
import type { AgentDefinition } from "./types";
import { getTaskSchema } from "./types";
const factFinderAgent = {
name: "fact-finder",
description: "Find facts.",
systemPrompt: "Find facts.",
source: "project",
} satisfies AgentDefinition;
const oracleAgent = {
name: "oracle",
description: "Answer hard questions.",
systemPrompt: "Answer hard questions.",
source: "bundled",
} satisfies AgentDefinition;
function makeSession(spawns: string): ToolSession {
const settings = Settings.isolated({
"async.enabled": false,
"task.batch": true,
"task.isolation.mode": "none",
});
return {
cwd: process.cwd(),
hasUI: false,
settings,
getSessionFile: () => null,
getSessionSpawns: () => spawns,
};
}
describe("task spawn policy surfaces", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("uses the first allowed spawn as the schema default", () => {
const schema = getTaskSchema({ isolationEnabled: false, batchEnabled: false, defaultAgent: "fact-finder" });
const parsed = schema({ assignment: "check" });
expect(parsed).toEqual({ agent: "fact-finder", assignment: "check" });
});
it("renders the restricted spawn default in the task description", async () => {
vi.spyOn(taskDiscovery, "discoverAgents").mockResolvedValue({
agents: [factFinderAgent, oracleAgent],
projectAgentsDir: null,
});
const tool = await TaskTool.create(makeSession("fact-finder,oracle"));
const description = tool.description;
expect(description).toContain("Defaults to `fact-finder`");
expect(description).toContain("Current spawn policy allows: `fact-finder`, `oracle`.");
expect(description).not.toContain("Defaults to `task`");
});
});
@@ -0,0 +1,58 @@
/** Default agent used when a session has unrestricted spawning. */
export const DEFAULT_SPAWN_AGENT = "task";
/** Spawn policy derived from a parent agent's `spawns` frontmatter. */
export interface ResolvedSpawnPolicy {
/** True when at least one subagent may be spawned. */
enabled: boolean;
/** Agent used when the caller omits the agent field. */
defaultAgent: string;
/** Explicitly allowed agents, or `null` when the policy is unrestricted. */
allowedAgents: readonly string[] | null;
/** Text used in spawn rejection messages. */
allowedErrorText: string;
/** Backtick-quoted explicit agents for prompt descriptions. */
allowedPromptText?: string;
}
/** Resolves spawn frontmatter into the default and prompt/error surfaces. */
export function resolveSpawnPolicy(parentSpawns: string | boolean | null | undefined): ResolvedSpawnPolicy {
let normalized: string;
if (parentSpawns === false) {
normalized = "";
} else if (parentSpawns === true || parentSpawns === null || parentSpawns === undefined) {
normalized = "*";
} else {
normalized = parentSpawns.trim();
}
if (normalized === "*") {
return {
enabled: true,
defaultAgent: DEFAULT_SPAWN_AGENT,
allowedAgents: null,
allowedErrorText: "*",
};
}
const allowedAgents = normalized
.split(",")
.map(spawn => spawn.trim())
.filter(Boolean);
if (allowedAgents.length === 0) {
return {
enabled: false,
defaultAgent: DEFAULT_SPAWN_AGENT,
allowedAgents,
allowedErrorText: "none (spawns disabled for this agent)",
};
}
return {
enabled: true,
defaultAgent: allowedAgents[0] ?? DEFAULT_SPAWN_AGENT,
allowedAgents,
allowedErrorText: allowedAgents.join(","),
allowedPromptText: allowedAgents.map(agent => `\`${agent}\``).join(", "),
};
}
+78 -7
View File
@@ -1,7 +1,7 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Usage } from "@oh-my-pi/pi-ai";
import { $env } from "@oh-my-pi/pi-utils";
import { type } from "arktype";
import { type BaseType, type } from "arktype";
import type { AgentSessionEvent } from "../session/agent-session";
import type { NestedRepoPatch } from "./worktree";
@@ -144,13 +144,84 @@ const ALL_TASK_SCHEMAS = [taskSchema, taskSchemaNoIsolation, taskSchemaBatch, ta
type DynamicTaskSchema = (typeof ALL_TASK_SCHEMAS)[number];
export type TaskSchema = typeof taskSchema;
/** Active task tool parameter schema for the current isolation / batch flags */
export type TaskToolSchemaInstance = DynamicTaskSchema;
export type TaskToolSchemaInstance = DynamicTaskSchema | BaseType;
export function getTaskSchema(options: { isolationEnabled: boolean; batchEnabled: boolean }): DynamicTaskSchema {
if (options.batchEnabled) {
return options.isolationEnabled ? taskSchemaBatch : taskSchemaBatchNoIsolation;
const TASK_AGENT_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
const taskSchemaCache = new Map<string, BaseType>();
function taskAgentSchemaRule(defaultAgent: string): string {
const trimmed = defaultAgent.trim();
if (TASK_AGENT_NAME_PATTERN.test(trimmed)) {
return `string = '${trimmed}'`;
}
return options.isolationEnabled ? taskSchema : taskSchemaNoIsolation;
return "string";
}
function createTaskSchema(options: {
isolationEnabled: boolean;
batchEnabled: boolean;
defaultAgent: string;
}): BaseType {
const agent = taskAgentSchemaRule(options.defaultAgent);
if (options.batchEnabled) {
if (options.isolationEnabled) {
return type.raw({
agent,
context: "string",
tasks: taskItemSchemaIsolated.array(),
"+": "delete",
});
}
return type.raw({
agent,
context: "string",
tasks: taskItemSchema.array(),
"+": "delete",
});
}
if (options.isolationEnabled) {
return type.raw({
agent,
"id?": "string",
"description?": "string",
"role?": ROLE_INPUT_SCHEMA,
assignment: "string",
"isolated?": "boolean",
"+": "delete",
});
}
return type.raw({
agent,
"id?": "string",
"description?": "string",
"role?": ROLE_INPUT_SCHEMA,
assignment: "string",
"+": "delete",
});
}
export function getTaskSchema(options: { isolationEnabled: boolean; batchEnabled: boolean }): DynamicTaskSchema;
export function getTaskSchema(options: {
isolationEnabled: boolean;
batchEnabled: boolean;
defaultAgent: string;
}): TaskToolSchemaInstance;
export function getTaskSchema(options: {
isolationEnabled: boolean;
batchEnabled: boolean;
defaultAgent?: string;
}): TaskToolSchemaInstance {
const defaultAgent = options.defaultAgent ?? "task";
if (defaultAgent === "task") {
if (options.batchEnabled) return options.isolationEnabled ? taskSchemaBatch : taskSchemaBatchNoIsolation;
return options.isolationEnabled ? taskSchema : taskSchemaNoIsolation;
}
const key = `${options.isolationEnabled ? "iso" : "flat"}:${options.batchEnabled ? "batch" : "single"}:${defaultAgent}`;
const cached = taskSchemaCache.get(key);
if (cached) return cached;
const schema = createTaskSchema({ ...options, defaultAgent });
taskSchemaCache.set(key, schema);
return schema;
}
/**
@@ -160,7 +231,7 @@ export function getTaskSchema(options: { isolationEnabled: boolean; batchEnabled
* transcripts using the flat form keep working under either setting.
*/
export interface TaskParams {
/** Agent type to spawn; defaults to `"task"` (the general-purpose worker) when omitted. */
/** Agent type to spawn; omitted values resolve from the session spawn policy. */
agent?: string;
/** Stable agent id (flat form); default = generated AdjectiveNoun. */
id?: string;
@@ -0,0 +1,19 @@
import { describe, expect, it } from "bun:test";
import { getEvalToolDescription } from "../eval";
describe("eval tool description", () => {
it("advertises the first allowed spawn as the agent() default", () => {
const description = getEvalToolDescription({ py: true, js: false, spawns: "fact-finder,oracle" });
expect(description).toContain('agent(prompt, agent?="fact-finder"');
expect(description).toContain("omit it to use `fact-finder`");
expect(description).toContain("Allowed agents: `fact-finder`, `oracle`.");
});
it("omits agent() when spawning is disabled", () => {
const description = getEvalToolDescription({ py: true, js: false, spawns: "" });
expect(description).not.toContain("agent(prompt");
expect(description).not.toContain("<dag>");
});
});
+15 -10
View File
@@ -10,6 +10,7 @@ import { defaultEvalSessionId } from "../eval/session-id";
import type { EvalCellResult, EvalDisplayOutput, EvalLanguage, EvalStatusEvent, EvalToolDetails } from "../eval/types";
import evalDescription from "../prompts/tools/eval.md" with { type: "text" };
import { DEFAULT_MAX_BYTES, OutputSink, type OutputSummary, TailBuffer } from "../session/streaming-output";
import { resolveSpawnPolicy } from "../task/spawn-policy";
import { webpExclusionForModel } from "../utils/image-loading";
import { formatDimensionNote, resizeImage } from "../utils/image-resize";
import type { ToolSession } from ".";
@@ -164,13 +165,10 @@ export interface EvalToolDescriptionOptions {
rb?: boolean;
jl?: boolean;
/**
* Whether `agent()` is allowed in this session. Driven by the parent's
* spawn policy (`getSessionSpawns`). Defaults to `true` for backward
* compatibility — when the session forbids spawning, the prelude doc
* omits the `agent()` entry so the model does not promise itself a
* helper that will only ever throw "spawns disabled".
* Parent spawn policy (`getSessionSpawns`). `true`/omitted means unrestricted,
* `false`/`""` hides `agent()`, and a comma list drives the advertised default.
*/
spawns?: boolean;
spawns?: boolean | string | null;
}
export function getEvalToolDescription(options: EvalToolDescriptionOptions = {}): string {
@@ -178,8 +176,16 @@ export function getEvalToolDescription(options: EvalToolDescriptionOptions = {})
const js = options.js ?? true;
const rb = options.rb ?? false;
const jl = options.jl ?? false;
const spawns = options.spawns ?? true;
return prompt.render(evalDescription, { py, js, rb, jl, spawns });
const spawnPolicy = resolveSpawnPolicy(options.spawns ?? true);
return prompt.render(evalDescription, {
py,
js,
rb,
jl,
spawns: spawnPolicy.enabled,
spawnDefaultAgent: spawnPolicy.defaultAgent,
spawnAllowedAgentsText: spawnPolicy.allowedPromptText,
});
}
export interface EvalToolOptions {
@@ -294,13 +300,12 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
if (!this.session) return getEvalToolDescription();
const backends = resolveEvalBackends(this.session);
const sessionSpawns = this.session.getSessionSpawns?.() ?? "*";
const spawnsAllowed = sessionSpawns !== "" && sessionSpawns !== null;
return getEvalToolDescription({
py: backends.python,
js: backends.js,
rb: backends.ruby,
jl: backends.julia,
spawns: spawnsAllowed,
spawns: sessionSpawns,
});
}
/** All reuse-chain examples; the `examples` getter filters by enabled languages. */