diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8278d1171..a6343e17e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Made the task tool's per-spawn `effort` parameter opt-in through `task.enableEffort`, which defaults to false and omits the field from flat and batch schemas and tool guidance until enabled. + ## [17.1.5] - 2026-07-27 ### Added diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index a6921c12f..9971ba8ab 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -4476,6 +4476,18 @@ export const SETTINGS_SCHEMA = { }, }, + "task.enableEffort": { + type: "boolean", + default: false, + ui: { + tab: "tasks", + group: "Subagents", + label: "Per-Task Effort", + description: + "Expose the optional effort parameter on task spawns, allowing callers to override each subagent's thinking level", + }, + }, + "task.maxConcurrency": { type: "number", default: 32, diff --git a/packages/coding-agent/src/prompts/tools/task.md b/packages/coding-agent/src/prompts/tools/task.md index 04f2af90f..b5d818991 100644 --- a/packages/coding-agent/src/prompts/tools/task.md +++ b/packages/coding-agent/src/prompts/tools/task.md @@ -25,7 +25,8 @@ Agents marked BLOCKING run inline — results return in this call; non-blocking - `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. - - `effort`: Scale w/ complexity of this task: `"lo"`|`"med"`|`"hi"` +{{#if effortEnabled}} - `effort`: Scale w/ complexity of this task: `"lo"`|`"med"`|`"hi"` +{{/if}} - `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}} @@ -39,7 +40,8 @@ Agents marked BLOCKING run inline — results return in this call; non-blocking - `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. -- `effort`: Scale w/ complexity of this task: `"lo"`|`"med"`|`"hi"` +{{#if effortEnabled}}- `effort`: Scale w/ complexity of this task: `"lo"`|`"med"`|`"hi"` +{{/if}} - `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}} diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index 9e6c86a7a..ec90646e4 100644 --- a/packages/coding-agent/src/task/index.ts +++ b/packages/coding-agent/src/task/index.ts @@ -156,27 +156,31 @@ export function formatResultOutputFallback(result: Pick 0 ? `(no output) after ${result.requests} req` : "(no output)"; } -/** - * Render the tool description from a cached agent list and current settings. - */ -function renderDescription( - agents: AgentDefinition[], - isolationEnabled: boolean, - applyIsolatedChanges: boolean, - disabledAgents: string[], - batchEnabled: boolean, - asyncEnabled: boolean, - ircEnabled: boolean, - parentSpawns: string, -): string { - const spawnPolicy = resolveSpawnPolicy(parentSpawns); +interface TaskDescriptionOptions { + agents: AgentDefinition[]; + isolationEnabled: boolean; + applyIsolatedChanges: boolean; + disabledAgents: string[]; + batchEnabled: boolean; + effortEnabled: boolean; + asyncEnabled: boolean; + ircEnabled: boolean; + parentSpawns: string; +} + +/** Render the tool description from a cached agent list and current settings. */ +function renderDescription(options: TaskDescriptionOptions): string { + const spawnPolicy = resolveSpawnPolicy(options.parentSpawns); const spawningDisabled = !spawnPolicy.enabled; - let filteredAgents = disabledAgents.length > 0 ? agents.filter(a => !disabledAgents.includes(a.name)) : agents; + let filteredAgents = + options.disabledAgents.length > 0 + ? options.agents.filter(agent => !options.disabledAgents.includes(agent.name)) + : options.agents; if (spawningDisabled) { filteredAgents = []; } else if (spawnPolicy.allowedAgents !== null) { const allowed = new Set(spawnPolicy.allowedAgents); - filteredAgents = filteredAgents.filter(a => allowed.has(a.name)); + filteredAgents = filteredAgents.filter(agent => allowed.has(agent.name)); } const renderedAgents = filteredAgents.map(agent => ({ name: agent.name, @@ -188,12 +192,13 @@ function renderDescription( agents: renderedAgents, spawningDisabled, defaultAgent: spawnPolicy.defaultAgent, - isolationEnabled, - applyIsolatedChanges, - batchEnabled, - asyncEnabled, + isolationEnabled: options.isolationEnabled, + applyIsolatedChanges: options.applyIsolatedChanges, + batchEnabled: options.batchEnabled, + effortEnabled: options.effortEnabled, + asyncEnabled: options.asyncEnabled, hasBlockingAgents: renderedAgents.some(agent => agent.blocking), - ircEnabled, + ircEnabled: options.ircEnabled, }); } @@ -581,28 +586,34 @@ export class TaskTool implements AgentTool[1], theme: Theme) { return renderTaskCall(repairTaskParams(args as TaskParams), options, theme); } - /** Dynamic description that reflects current disabled-agent settings */ + /** Dynamic description that reflects current task 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, - !planMode && isolationMode !== "none", - this.session.settings.get("task.isolation.apply"), + return renderDescription({ + agents: this.#discoveredAgents, + isolationEnabled: !planMode && isolationMode !== "none", + applyIsolatedChanges: this.session.settings.get("task.isolation.apply"), disabledAgents, - this.#isBatchEnabled(), - this.session.settings.get("async.enabled"), - isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0), - this.session.getSessionSpawns() ?? "*", - ); + batchEnabled: this.#isBatchEnabled(), + effortEnabled: this.session.settings.get("task.enableEffort"), + asyncEnabled: this.session.settings.get("async.enabled"), + ircEnabled: isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0), + parentSpawns: this.session.getSessionSpawns() ?? "*", + }); } private constructor( private readonly session: ToolSession, diff --git a/packages/coding-agent/src/task/types.ts b/packages/coding-agent/src/task/types.ts index 16849af5c..d494908f6 100644 --- a/packages/coding-agent/src/task/types.ts +++ b/packages/coding-agent/src/task/types.ts @@ -115,7 +115,6 @@ export const taskItemSchema = type({ "name?": "string", agent: "string = 'task'", task: "string", - "effort?": effortRule, "outputSchema?": outputSchemaInputSchema, "schemaMode?": '"permissive" | "strict"', "+": "delete", @@ -124,7 +123,6 @@ const taskItemSchemaIsolated = type({ "name?": "string", agent: "string = 'task'", task: "string", - "effort?": effortRule, "outputSchema?": outputSchemaInputSchema, "schemaMode?": '"permissive" | "strict"', "isolated?": "boolean", @@ -153,7 +151,6 @@ export const taskSchema = type({ "name?": "string", agent: "string = 'task'", task: "string", - "effort?": effortRule, "outputSchema?": outputSchemaInputSchema, "schemaMode?": '"permissive" | "strict"', "isolated?": "boolean", @@ -163,7 +160,6 @@ const taskSchemaNoIsolation = type({ "name?": "string", agent: "string = 'task'", task: "string", - "effort?": effortRule, "outputSchema?": outputSchemaInputSchema, "schemaMode?": '"permissive" | "strict"', "+": "delete", @@ -200,15 +196,17 @@ function createTaskSchema(options: { isolationEnabled: boolean; batchEnabled: boolean; defaultAgent: string; + effortEnabled: boolean; }): BaseType { const agent = taskAgentSchemaRule(options.defaultAgent); + const effortField = options.effortEnabled ? { "effort?": effortRule } : {}; if (options.batchEnabled) { if (options.isolationEnabled) { const item = type.raw({ "name?": "string", agent, task: "string", - "effort?": effortRule, + ...effortField, "outputSchema?": outputSchemaInputSchema, "schemaMode?": '"permissive" | "strict"', "isolated?": "boolean", @@ -224,7 +222,7 @@ function createTaskSchema(options: { "name?": "string", agent, task: "string", - "effort?": effortRule, + ...effortField, "outputSchema?": outputSchemaInputSchema, "schemaMode?": '"permissive" | "strict"', "+": "delete", @@ -240,7 +238,7 @@ function createTaskSchema(options: { "name?": "string", agent, task: "string", - "effort?": effortRule, + ...effortField, "outputSchema?": outputSchemaInputSchema, "schemaMode?": '"permissive" | "strict"', "isolated?": "boolean", @@ -251,33 +249,30 @@ function createTaskSchema(options: { "name?": "string", agent, task: "string", - "effort?": effortRule, + ...effortField, "outputSchema?": outputSchemaInputSchema, "schemaMode?": '"permissive" | "strict"', "+": "delete", }); } -export function getTaskSchema(options: { isolationEnabled: boolean; batchEnabled: boolean }): DynamicTaskSchema; -export function getTaskSchema(options: { - isolationEnabled: boolean; - batchEnabled: boolean; - defaultAgent: string; -}): TaskToolSchemaInstance; +/** Build the task wire schema for the current settings and spawn policy. */ export function getTaskSchema(options: { isolationEnabled: boolean; batchEnabled: boolean; + effortEnabled?: boolean; defaultAgent?: string; }): TaskToolSchemaInstance { const defaultAgent = options.defaultAgent ?? "task"; - if (defaultAgent === "task") { + const effortEnabled = options.effortEnabled ?? false; + if (defaultAgent === "task" && !effortEnabled) { 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 key = `${options.isolationEnabled ? "iso" : "flat"}:${options.batchEnabled ? "batch" : "single"}:${effortEnabled ? "effort" : "default"}:${defaultAgent}`; const cached = taskSchemaCache.get(key); if (cached) return cached; - const schema = createTaskSchema({ ...options, defaultAgent }); + const schema = createTaskSchema({ ...options, effortEnabled, defaultAgent }); taskSchemaCache.set(key, schema); return schema; } diff --git a/packages/coding-agent/test/task/task-batch.test.ts b/packages/coding-agent/test/task/task-batch.test.ts index e92e03dc9..c51535c00 100644 --- a/packages/coding-agent/test/task/task-batch.test.ts +++ b/packages/coding-agent/test/task/task-batch.test.ts @@ -24,6 +24,7 @@ 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"; +import { isRecord } from "@oh-my-pi/pi-utils"; const taskAgent: AgentDefinition = { name: "task", @@ -53,8 +54,14 @@ function createSession( } function getSchemaProperties(tool: TaskTool): Record { - const wire = toolWireSchema(tool) as { properties?: Record }; - return wire.properties ?? {}; + const properties = toolWireSchema(tool).properties; + return isRecord(properties) ? properties : {}; +} + +function getBatchItemProperties(tool: TaskTool): Record { + const tasks = getSchemaProperties(tool).tasks; + if (!isRecord(tasks) || !isRecord(tasks.items) || !isRecord(tasks.items.properties)) return {}; + return tasks.items.properties; } function getFirstText(result: { content: Array<{ type: string; text?: string }> }): string { @@ -116,13 +123,35 @@ describe("task.batch schema gating", () => { expect(onProperties.agent).toBeUndefined(); expect(onProperties.outputSchema).toBeUndefined(); expect(onProperties.schemaMode).toBeUndefined(); - const items = (onProperties.tasks as { items?: { properties?: Record } }).items; - expect(items?.properties?.task).toBeDefined(); - expect(items?.properties?.name).toBeDefined(); - expect(items?.properties?.agent).toBeDefined(); - expect(items?.properties?.outputSchema).toBeDefined(); - expect(typeof items?.properties?.outputSchema).toBe("object"); - expect(items?.properties?.schemaMode).toBeDefined(); + const itemProperties = getBatchItemProperties(on); + expect(itemProperties.task).toBeDefined(); + expect(itemProperties.name).toBeDefined(); + expect(itemProperties.agent).toBeDefined(); + expect(itemProperties.outputSchema).toBeDefined(); + expect(typeof itemProperties.outputSchema).toBe("object"); + expect(itemProperties.schemaMode).toBeDefined(); + }); + + it("hides effort by default and exposes it when task.enableEffort is enabled", async () => { + mockDiscovery(); + + const flatSession = createSession({ settings: { "task.batch": false } }); + const flat = await TaskTool.create(flatSession); + expect(getSchemaProperties(flat).effort).toBeUndefined(); + expect(flat.description).not.toContain("`effort`"); + + flatSession.settings.override("task.enableEffort", true); + expect(getSchemaProperties(flat).effort).toBeDefined(); + expect(flat.description).toContain("`effort`"); + + const batchSession = createSession({ settings: { "task.batch": true } }); + const batch = await TaskTool.create(batchSession); + expect(getBatchItemProperties(batch).effort).toBeUndefined(); + expect(batch.description).not.toContain("`effort`"); + + batchSession.settings.override("task.enableEffort", true); + expect(getBatchItemProperties(batch).effort).toBeDefined(); + expect(batch.description).toContain("`effort`"); }); it("keeps isolation boolean-only and describes the configured apply behavior", async () => { @@ -133,13 +162,13 @@ describe("task.batch schema gating", () => { ); const properties = getSchemaProperties(tool); expect(properties.isolated).toBeUndefined(); - const items = (properties.tasks as { items?: { properties?: Record } }).items; - const isolatedSchema = items?.properties?.isolated; + const itemProperties = getBatchItemProperties(tool); + const isolatedSchema = itemProperties.isolated; if (!isolatedSchema || typeof isolatedSchema !== "object" || !("type" in isolatedSchema)) { throw new Error("Expected isolated to be a boolean schema"); } expect(isolatedSchema.type).toBe("boolean"); - expect(items?.properties?.apply).toBeUndefined(); + expect(itemProperties.apply).toBeUndefined(); expect(tool.description).toContain("automatically applied to the parent checkout"); const captureTool = await TaskTool.create( @@ -162,9 +191,8 @@ describe("task.batch schema gating", () => { settings: { "task.batch": true, "task.isolation.mode": "auto" }, }), ); - const properties = getSchemaProperties(tool); - const items = (properties.tasks as { items?: { properties?: Record } }).items; - expect(items?.properties?.isolated).toBeUndefined(); + const itemProperties = getBatchItemProperties(tool); + expect(itemProperties.isolated).toBeUndefined(); expect(tool.description).not.toContain("`isolated`"); });