diff --git a/docs/task-agent-discovery.md b/docs/task-agent-discovery.md index 82156f310..c7643e206 100644 --- a/docs/task-agent-discovery.md +++ b/docs/task-agent-discovery.md @@ -141,11 +141,10 @@ In synchronous task execution (`TaskTool.#executeSync`): Runtime output schema precedence in `TaskTool.execute`: -1. task call `params.schema` when `task.simple` allows custom schemas -2. agent frontmatter `output` -3. parent session `outputSchema` +1. agent frontmatter `output` +2. parent session `outputSchema` -(`effectiveOutputSchema = outputSchema ?? effectiveAgent.output ?? this.session.outputSchema` when custom task schemas are enabled; otherwise task-call schema is skipped.) +(`effectiveOutputSchema = effectiveAgent.output ?? this.session.outputSchema` — the task call itself never carries a schema; ad-hoc structured workflows go through the eval bridge's `agent(prompt, schema)`.) Prompt-time guardrail text in `src/prompts/tools/task.md` warns about mismatch behavior for structured-output agents (`explore`, `reviewer`): output-format instructions in prose can conflict with built-in schema and produce `null` outputs. diff --git a/docs/tools/task.md b/docs/tools/task.md index 95d91cbaa..cbcedd3cd 100644 --- a/docs/tools/task.md +++ b/docs/tools/task.md @@ -1,6 +1,6 @@ # task -> Spawn one subagent per call to work in the background. +> Spawn subagents to work in the background — one per call, or a `tasks[]` batch per call (`task.batch`, default on). ## Source - Entry: `packages/coding-agent/src/task/index.ts` @@ -18,7 +18,6 @@ - `packages/coding-agent/src/task/worktree.ts` — worktree / FUSE / ProjFS setup, patch capture, branch merge. - `packages/coding-agent/src/task/output-manager.ts` — session-scoped `agent://` id allocation. - `packages/coding-agent/src/task/name-generator.ts` — default AdjectiveNoun agent ids. - - `packages/coding-agent/src/task/simple-mode.ts` — `default` / `schema-free` / `independent` schema gating. - `packages/coding-agent/src/internal-urls/agent-protocol.ts` — resolve `agent://` to saved subagent output. - `packages/coding-agent/src/internal-urls/history-protocol.ts` — resolve `history://` to a concise transcript. - `packages/coding-agent/src/tools/index.ts` — tool registration and recursion-depth gating. @@ -27,31 +26,37 @@ ## Inputs -One call spawns exactly one subagent. There is no batch parameter and no shared `context` parameter — shared background goes into a `local://` file (e.g. `local://ctx.md`) that each assignment references; subagents share the parent's `local://` root. +The wire schema is shape-swapped by `task.batch` (default on). One unit of work is the task item `{ id?, description?, assignment, isolated? }` (`isolated` only when `task.isolation.mode` is not `none`): + +- **Batch shape** (`task.batch` on): `{ agent, context, tasks: item[] }` — one subagent per item, all spawned in parallel as independent background jobs. `context` is **required** shared background rendered into every spawned subagent's system prompt (`CONTEXT` section); `isolated` is per item. +- **Flat shape** (`task.batch` off): `{ agent, ...item }` — exactly one spawn per call. Shared background goes into a `local://` file (e.g. `local://ctx.md`) that each assignment references; subagents share the parent's `local://` root. | Field | Type | Required | Description | | --- | --- | --- | --- | -| `agent` | `string` | Yes | Agent type to spawn. | -| `id` | `string` | No | Stable agent id, schema max length 48. Defaults to a generated AdjectiveNoun name. Uniquified per session by `AgentOutputManager`. | -| `description` | `string` | No | UI label only; the subagent never sees it. | -| `assignment` | `string` | Yes | The work — complete, self-contained instructions. Empty-after-trim is rejected. | -| `schema` | `string` | No | JSON-encoded JTD schema for the expected `yield` payload. Field exists only when `task.simple = "default"`. | -| `isolated` | `boolean` | No | Run in an isolated workspace and return patches. Field exists only when `task.isolation.mode` is not `none`. Isolated agents are torn down at completion — not revivable. | +| `agent` | `string` | Yes | Agent type to spawn (both shapes). | +| `context` | `string` | Yes (batch) | Shared background prepended to every spawn of the call via the subagent system prompt. Rejected when `task.batch` is off. | +| `tasks` | `array` | Yes (batch) | One task item per subagent. Provided ids must be unique within the call (case-insensitive). Rejected when `task.batch` is off. | +| `id` | `string` | No | Stable agent id, schema max length 48. Defaults to a generated AdjectiveNoun name. Uniquified per session by `AgentOutputManager`. Item field in batch shape, top-level in flat shape. | +| `description` | `string` | No | UI label only; the subagent never sees it. Item field in batch shape, top-level in flat shape. | +| `assignment` | `string` | Yes | The work — complete, self-contained instructions. Empty-after-trim is rejected. Item field in batch shape, top-level in flat shape. | +| `isolated` | `boolean` | No | Run in an isolated workspace and return patches. Exists only when `task.isolation.mode` is not `none`; per item in batch shape, top-level in flat shape. Isolated agents are torn down at completion — not revivable. | -Simple-mode gating (`task.simple`, one axis): `default` accepts the per-call `schema` override; `schema-free` and `independent` reject it (`validateTaskModeParams(...)`). `independent` additionally renders the subagent user prompt with the independent-mode flag. Agent frontmatter and inherited session schemas work in every mode. +Runtime stays permissive: the flat form is accepted even while `task.batch` is on (internal callers such as the commit flow's `analyze_files`, and stale transcripts). The model only ever sees one shape. + +There is no per-call `schema` parameter. Structured output comes from the agent definition's `output` frontmatter, the inherited parent session schema, or — for ad-hoc workflows — the eval bridge's `agent(prompt, schema)`. ## Outputs The tool returns one text block plus `details: TaskToolDetails`. Immediate (async) response — the normal case: -- `content`: `` Spawned agent `` (job ``). The result will be delivered when it yields. ... `` plus a coordination hint (`irc` DM when enabled, otherwise `job`). -- `details`: `{ projectAgentsDir: null, results: [], totalDurationMs: 0, progress: [], async: { state: "running", jobId, type: "task" } }`. -- Live progress keeps streaming into the same tool block via `onUpdate(...)`; the final result arrives later as an async-result injection into the parent conversation. The delivery text appends a follow-up hint: `` is now idle — message it via `irc` to follow up; transcript at history:// `` (aborted variant points at the transcript only). +- `content`: `` Spawned agent `` (job ``). The result will be delivered when it yields. ... `` plus a coordination hint (`irc` DM when enabled, otherwise `job`). A batch call instead returns `` Spawned N background agents using . ... `` with a per-agent `- `` (job ``)` listing. +- `details`: `{ projectAgentsDir: null, results: [], totalDurationMs: 0, progress: [], async: { state: "running", jobId, type: "task" } }`. A batch call keeps one shared `progress[]` snapshot; `async.jobId` is the first started job and `async.state` aggregates ("running" until every job settles, "failed" if any spawn failed). +- Live progress keeps streaming into the same tool block via `onUpdate(...)`; each final result arrives later as an async-result injection into the parent conversation. The delivery text appends a follow-up hint: `` is now idle — message it via `irc` to follow up; transcript at history:// `` (aborted variant points at the transcript only). Settled (sync-fallback or job-body) response: -- `content`: summary rendered from `packages/coding-agent/src/prompts/tools/task-summary.md` with a preview capped at 5000 chars; `agent://` holds the full output. -- `details.results`: at most one `SingleResult`; `usage`, `outputPaths` populated. +- `content`: summary rendered from `packages/coding-agent/src/prompts/tools/task-summary.md` with a preview capped at 5000 chars; `agent://` holds the full output. A sync batch concatenates the per-spawn summaries. +- `details.results`: one `SingleResult` per spawn; `usage`, `outputPaths` populated (aggregated across spawns for a sync batch). `SingleResult` includes: - identity: `index`, `id`, `agent`, `agentSource`, `description`, optional `assignment` @@ -68,21 +73,21 @@ Artifacts and side channels: ## Flow 1. `TaskTool.create(...)` discovers agents once per cwd through a process-level memo (`discoverAgentsForCreate`) to render the dynamic prompt description. -2. `execute(...)` repairs raw params (`repairTaskParams`), then validates: schema gating per `task.simple`, non-empty `agent`, non-empty `assignment`. +2. `execute(...)` repairs raw params (`repairTaskParams`), then validates: `schema` is always rejected; `tasks`/`context` are rejected unless `task.batch` is on; batch calls need a non-empty `tasks` (per-item assignments, unique provided ids), a non-empty shared `context`, and no top-level `assignment`; flat calls need `assignment`. The call is then normalized into its spawn list (`resolveSpawnItems`). 3. Sync fallback only when the session has no `AsyncJobManager` (orphaned host) or the selected agent definition declares `blocking: true`; the call then runs `#executeSync(...)` inline under the session-scoped semaphore. 4. Otherwise execution is always async: - - the agent id is allocated up front via `AgentOutputManager.allocate(params.id || generateTaskName())`; - - one `type: "task"` job is registered with `session.asyncJobManager` (`id` = agent id, `queued: true`, `ownerId` = caller agent id) and the tool returns immediately; - - the job body acquires the session-scoped `Semaphore` (one per `TaskTool` instance, sized from `task.maxConcurrency` at first use), marks the job running, runs `#executeSync(...)`, and reports progress through `buildAsyncDetails`/`onUpdate`; + - agent ids are allocated up front via `AgentOutputManager.allocate(item.id || generateTaskName())`, one per spawn; + - one `type: "task"` job per spawn is registered with `session.asyncJobManager` (`id` = agent id, `queued: true`, `ownerId` = caller agent id) and the tool returns immediately; + - each job body acquires the session-scoped `Semaphore` (one per `TaskTool` instance, sized from `task.maxConcurrency` at first use), marks the job running, runs `#executeSync(...)` with that spawn's params, and reports progress through the shared `buildAsyncDetails`/`onUpdate`; - a failed or aborted run throws `TaskJobError` so the job lands `failed`, but the agent itself stays registered and interrogable. 5. `#executeSync(...)` runs the spawn path (`#runSpawn`), which rediscovers agents from disk, so runtime resolution can differ from the create-time description. 6. It resolves the requested agent, rejects unknown or settings-disabled agents, and enforces parent spawn policy plus `PI_BLOCKED_AGENT` self-recursion prevention. -7. Output schema priority: task call `schema` (when `task.simple` allows) → agent frontmatter `output` → inherited parent session schema. +7. Output schema priority: agent frontmatter `output` → inherited parent session schema (the call itself never carries one). 8. Plan mode swaps in an `effectiveAgent` with a read-only tool subset and plan-mode prompt; `runSubprocess(...)` receives the effective agent. 9. If `isolated`, it requires a git repo (`getRepoRoot(...)` / `captureBaseline(...)`) and resolves the backend through isolation-backend resolution with platform fallback. 10. Artifacts dir comes from the parent session file when available, otherwise a temp dir. When the session is executing an approved plan, the plan reference is handed to the subagent. 11. Non-isolated spawns call `runSubprocess(...)` directly with parent cwd; isolated spawns run inside the isolation workspace, then commit to a branch (`mergeMode === "branch"`) or capture a patch, and always clean up the workspace. -12. `runSubprocess(...)` creates a child agent session with an isolated settings snapshot (forcing `async.enabled = false` and `bash.autoBackground.enabled = false` — subagents are internally synchronous), child `agentId` equal to the allocated id, child internal URL router/`AgentOutputManager`, output schema, and the IRC peer roster in the system prompt. +12. `runSubprocess(...)` creates a child agent session with an isolated settings snapshot (forcing `async.enabled = false` and `bash.autoBackground.enabled = false` — subagents are internally synchronous), child `agentId` equal to the allocated id, child internal URL router/`AgentOutputManager`, output schema, the shared `context` (batch calls) in the system prompt's `CONTEXT` section, and the IRC peer roster in the system prompt. 13. Child tool availability: explicit `agent.tools` if provided; auto-add `task` when the agent has `spawns` and depth allows; strip `task` at `task.maxRecursionDepth`; expand `exec` to `eval` + `bash`; strip parent-owned `todo`. 14. The child must finish through the hidden `yield` tool; up to 3 reminder prompts, the last forcing `toolChoice = yield` when supported. `finalizeSubprocessOutput(...)` reconciles raw text, `yield` payloads, structured schemas, `report_finding` data, and abort states. 15. End-of-run lifecycle (keep-alive, in `runSubprocess`'s finalizer): @@ -95,9 +100,9 @@ Artifacts and side channels: - Execution mode - Always-async background job — default; spawns go through `AsyncJobManager`. - Sync inline fallback — only when no job manager exists or the agent definition has `blocking: true`. -- Simple mode (`task.simple`) - - `default` — accepts per-call `schema`. - - `schema-free` / `independent` — reject `schema`; `independent` also flags the subagent user prompt as independent-mode. +- Batch mode (`task.batch`, default on) + - on — `{ agent, context, tasks[] }`: one independent background job per item, required `context` shared across the call's spawns, `isolated` per item. Lifecycle, revival, and concurrency semantics are identical to N parallel single calls. + - off — single spawn per call; `tasks`/`context` are rejected and removed from the schema. - Isolation backend: `none`, `worktree`, `fuse-overlay`, `fuse-projfs`. - Isolation merge strategy: patch mode (capture/apply root patches) or branch mode (commit to `omp/task/`, cherry-pick into parent). - Agent source precedence: project custom agents, then user custom agents, then bundled agents (`explore`, `plan`, `designer`, `reviewer`, `task`, `quick_task`, `librarian`, `oracle`). @@ -136,19 +141,21 @@ Artifacts and side channels: ## Errors - Parameter validation failures are returned as normal tool text with empty `results`: - - `schema` outside `task.simple = "default"` + - `schema` (never accepted) + - `tasks` / `context` while `task.batch` is disabled - missing/empty `agent` - - missing/empty `assignment` + - batch calls: missing/empty `tasks`, an item without `assignment`, duplicate provided ids, missing shared `context`, top-level `assignment` alongside `tasks` + - flat calls: missing/empty `assignment` - unknown or settings-disabled agent, spawn-policy denial, requesting `isolated` while isolation mode is `none` - Isolated execution without a git repo returns `Isolated task execution requires a git repository. ...`; backend resolution can hard-error (ProjFS init) or warn and fall back to `worktree`. -- Job registration failure returns `Failed to start background task job: ...`. +- Job registration failure returns `Failed to start background task job(s): ...`; a batch that schedules only some jobs reports the failed ids in the immediate text and keeps the started ones running. - Child failures surface as `SingleResult.exitCode = 1` with `stderr`/`error` populated; the async job is marked failed but the delivery text still carries the output plus a follow-up/transcript hint. - If the child omits `yield`, `finalizeSubprocessOutput(...)` injects warnings such as `SYSTEM WARNING: Subagent exited without calling yield tool after 3 reminders.` - `agent://` resolution errors are model-visible when another tool reads them: no session, no artifacts dir, missing id, conflicting extraction syntax, or invalid JSON for extraction. ## Notes -- Parallelism is parallel `task` calls in one assistant message; the session-scoped semaphore bounds the fan-out. There is no batch array. -- Shared background convention: write it once to a `local://` file and reference that path in each assignment — subagents share the parent's `local://` root. This replaces the removed `context` parameter. +- Parallelism is parallel `task` calls in one assistant message — or, with `task.batch`, a `tasks[]` batch in one call; either way the session-scoped semaphore bounds the fan-out and each spawn is an independent background job. +- Shared background convention without batch mode: write it once to a `local://` file and reference that path in each assignment — subagents share the parent's `local://` root. With `task.batch`, the required `context` parameter carries the shared background directly into each spawn's system prompt. - Prefer messaging an existing agent (`irc`) over a fresh spawn for follow-up work: it already holds the relevant context. `irc` op:"list" shows idle/parked candidates; messaging a parked agent revives it. `history://` shows what an agent has done. - `irc` availability is derived, not configured (`isIrcEnabled` in `packages/coding-agent/src/tools/irc.ts`): it exists exactly when there is someone to message — the session can spawn subagents, or it is a subagent itself. Messaging is the only follow-up path to a finished subagent, so task without irc would strand idle agents. - Subagents are internally synchronous: the executor forces `async.enabled = false` and `bash.autoBackground.enabled = false` in the child settings snapshot, so there are no fire-and-forget grandchildren. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c7a4f8777..6555bb198 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,12 +1,11 @@ # Changelog ## [Unreleased] - ### Breaking Changes - Removed the `resume` option from the `task` tool API and its resume execution path; continue work on finished subagents by sending follow-up messages via `irc` instead - Removed the `irc.enabled` setting: irc availability is now derived — the tool exists exactly when there is someone to message (the session can spawn subagents through `task`, or it is a subagent itself). A stale `irc.enabled` key in config is ignored -- The `task` tool was reworked to always run spawns in the background as independent, persistent agents: results arrive as async job deliveries (block with `job poll` only when genuinely needed). The batch `tasks[]` array and shared `context` parameter are now gated by the new `task.batch` setting (default on); a flat single-spawn form (top-level `id`/`description`/`assignment`) replaces the old one-element batch, and disabling `task.batch` removes the batch fields from the schema entirely — fan out with parallel `task` calls and share background via a `local://` file instead +- The `task` tool was reworked to always run spawns in the background as independent, persistent agents: results arrive as async job deliveries (block with `job poll` only when genuinely needed). The wire schema is now shape-swapped by the new `task.batch` setting (default on): `{ agent, context, tasks[] }` — one subagent per task item, per-item `isolated`, and a required shared `context` — or, when disabled, a flat single-spawn shape `{ agent, id?, description?, assignment, isolated? }` with shared background passed via `local://` files instead - Removed the `task.simple` setting and the task tool's per-call `schema` parameter outright: structured subagent output now comes only from the agent definition's `output` frontmatter or the inherited session schema, and ad-hoc structured workflows use eval `agent(prompt, schema)`. A stale `task.simple` key in config is migrated away - Reworked `irc` to `send`/`wait`/`inbox`/`list` ops over a per-agent mailbox bus: the blocking `awaitReply` auto-reply turn is removed — `send` is fire-and-forget with delivery receipts, and replies are real turns by the recipient observed via `wait` (or the `send` `await: true` sugar) - Removed the `context` argument from eval `agent()` in both the JS and Python preludes: pass shared background via a `local://` file referenced in the prompt @@ -26,10 +25,11 @@ - Added the `history://` protocol: `history://` lists every registered agent and `history://` renders a concise markdown transcript (tool calls collapsed to one line each, thinking elided) for live and parked agents alike - Added an IRC mailbox bus with bounded per-agent inboxes: `irc` `wait` blocks until a matching message arrives, `inbox` drains or peeks pending messages, and sending to an idle or parked agent wakes or revives it for a real turn - Added a dedicated TUI renderer for the `irc` tool: directional send/receive headers with delivery-outcome coloring, quoted message bodies with expand-aware truncation, per-recipient receipt trees for broadcasts and failures, and status-badged peer listings with unread counts -- Added the `task.batch` setting (default on): one `task` call may carry a `tasks[]` array — one subagent per item, each spawned as its own independent background job with the normal idle/parked lifecycle — plus an optional shared `context` string rendered into every spawned subagent's system prompt; disabling it strips both fields from the tool schema +- Added the `task.batch` setting (default on): the task tool's batch shape `{ agent, context, tasks[] }` spawns one subagent per item — each its own independent background job with the normal idle/parked lifecycle and optional per-item isolation — and prepends the required shared `context` to every spawned subagent's system prompt; disabling it restores the flat single-spawn schema ### Changed +- Changed task-tool sync execution to fan out multiple `tasks[]` items in parallel and return a merged result payload when no async job manager is available - Changed the compaction UX so the conversation no longer visually restarts: the TUI renders the full-history display transcript (`buildSessionContext({ transcript: true })`), with each compaction shown as a slim inline divider — `── 📷 compacted · ctrl+o ──` — at the point it fired; expanding (ctrl+o) reveals the summary and snapcompact frame count. Applies to live compaction, `/compact`, `/tree` navigation, and session resume - Changed `async.enabled` to gate async bash commands only — the `task` tool now runs asynchronously regardless of the setting - Changed `irc.timeoutMs` to be the default timeout for `irc` `wait` and `send` with `await: true` @@ -42,7 +42,8 @@ ### Fixed -- Fixed the `job` tool's TUI preview leaking the model-facing `` envelope for settled task jobs — the preview now shows the inner output body +- Fixed task-tool runtime compatibility so legacy flat `task` calls (`agent`, `assignment`) still execute under `task.batch` even though the wire schema is batch-first +- Fixed the `job` tool's TUI preview leaking the model-facing `` envelope for settled task jobs — the preview now shows the inner output body, and pretty-printed JSON bodies are flattened onto one line instead of previewing a lone `{` - Fixed npm CLI distribution bundles by embedding the stats dashboard client bundle so dashboard assets are served in prebuilt installs - Fixed the `resolve` tool's result block turning white after the leading icon: the accent-styled symbol embedded a foreground reset inside the inverse-rendered line, dropping the block color for the rest of the row - Fixed the CLI smoke-test command to start the stats server and verify dashboard HTML is served, catching bundled-asset regressions diff --git a/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts b/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts index 7d09113e7..f06e088c8 100644 --- a/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts +++ b/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts @@ -43,6 +43,9 @@ function buildToolSession( settings: options.settings, authStorage: options.authStorage, modelRegistry: options.modelRegistry, + // The task tool no longer takes a per-call schema; the inherited session + // schema drives structured output for every spawn from this session. + outputSchema: analyzeFileOutputSchema, }; } @@ -67,7 +70,7 @@ export function createAnalyzeFileTool(options: { // The tool's session semaphore bounds the parallel fan-out. const taskTool = await TaskTool.create(toolSession); const numstat = options.state.overview?.numstat ?? []; - const schema = JSON.stringify(analyzeFileOutputSchema); + const analyses = await Promise.all( params.files.map((file, index) => { const relatedFiles = formatRelatedFiles(params.files, file, numstat); @@ -81,7 +84,6 @@ export function createAnalyzeFileTool(options: { id: `AnalyzeFile${index + 1}`, description: `Analyze ${file}`, assignment, - schema, }; return taskTool.execute(`${toolCallId}-${index + 1}`, taskParams, signal); }), diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 216044fef..d2f9d3345 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1,5 +1,4 @@ import { THINKING_EFFORTS } from "@oh-my-pi/pi-ai"; -import { TASK_SIMPLE_MODES } from "../task/simple-mode"; import { AUTO_THINKING, getConfiguredThinkingLevelMetadata, getThinkingLevelMetadata } from "../thinking"; import { TINY_MODEL_DEVICE_DEFAULT, @@ -2752,31 +2751,14 @@ export const SETTINGS_SCHEMA = { }, }, - "task.simple": { - type: "enum", - values: TASK_SIMPLE_MODES, - default: "schema-free", + "task.batch": { + type: "boolean", + default: true, ui: { tab: "tasks", - label: "Task Input Mode", - description: "How much shared structure the task tool accepts (default, schema-free, or independent)", - options: [ - { - value: "default", - label: "Default", - description: "Shared context and custom task schema are available", - }, - { - value: "schema-free", - label: "Schema-free", - description: "Shared context stays available, but custom task schema is disabled", - }, - { - value: "independent", - label: "Independent", - description: "No shared context or custom task schema; each task must stand alone", - }, - ], + label: "Batch Task Calls", + description: + "Switch the task tool to its batch shape: one call carries { agent, context, tasks[] } — one subagent per item (with per-item isolation) and a required shared context prepended to every assignment. Each spawn still runs as an independent background agent with the normal idle/parked lifecycle. Disable to restore the flat single-spawn schema.", }, }, diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index ad08bce63..690fb63d8 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -745,6 +745,13 @@ export class Settings { delete isolationObj.enabled; } + // task.simple: removed — the task tool no longer accepts a per-call + // schema (workflows drive structured output via eval agent()) and the + // batch/context shape is gated by task.batch instead. + if (taskObj && "simple" in taskObj) { + delete taskObj.simple; + } + // task.isolation.mode: legacy values from before the pi-iso PAL refactor. // `worktree` was git worktree → now lives under `rcopy`. `fuse-overlay` // and `fuse-projfs` are now the platform-named `overlayfs` / `projfs` diff --git a/packages/coding-agent/src/eval/agent-bridge.ts b/packages/coding-agent/src/eval/agent-bridge.ts index d18bb48b4..9d1b3211a 100644 --- a/packages/coding-agent/src/eval/agent-bridge.ts +++ b/packages/coding-agent/src/eval/agent-bridge.ts @@ -109,7 +109,7 @@ function assertNotPlanMode(session: ToolSession): void { } function renderSubagentPrompt(assignment: string): string { - return prompt.render(subagentUserPromptTemplate, { assignment: assignment.trim(), independentMode: false }); + return prompt.render(subagentUserPromptTemplate, { assignment: assignment.trim() }); } function trimToUndefined(value: string | undefined): string | undefined { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index fcefa1e8b..225af3ee1 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -130,7 +130,7 @@ const HOST_DEFAULTED_SETTING_PATHS: SettingPath[] = [ "task.isolation.merge", "task.isolation.commits", "task.eager", - "task.simple", + "task.batch", "task.maxConcurrency", "task.maxRecursionDepth", "task.disabledAgents", diff --git a/packages/coding-agent/src/prompts/system/subagent-system-prompt.md b/packages/coding-agent/src/prompts/system/subagent-system-prompt.md index d6f4345cd..889c39c44 100644 --- a/packages/coding-agent/src/prompts/system/subagent-system-prompt.md +++ b/packages/coding-agent/src/prompts/system/subagent-system-prompt.md @@ -3,6 +3,13 @@ ROLE {{agent}} +{{#if context}} +CONTEXT +=================================== + +{{context}} +{{/if}} + {{#if planReference}} PLAN =================================== diff --git a/packages/coding-agent/src/prompts/tools/task.md b/packages/coding-agent/src/prompts/tools/task.md index ca780ac87..ab9a299f9 100644 --- a/packages/coding-agent/src/prompts/tools/task.md +++ b/packages/coding-agent/src/prompts/tools/task.md @@ -1,7 +1,7 @@ -Spawns ONE subagent per call to work in the background. +{{#if batchEnabled}}Spawns subagents to work in the background — one per `tasks[]` item; a single spawn is a one-item batch.{{else}}Spawns ONE subagent per call to work in the background.{{/if}} -- Spawning is non-blocking: the call returns immediately with the agent id and a job id; the result is delivered automatically when the agent yields. -- Parallelism = multiple `task` calls in one assistant message. Concurrency is bounded at {{MAX_CONCURRENCY}} running subagents per session. +- Spawning is non-blocking: the call returns immediately with the agent id{{#if batchEnabled}}s{{/if}} and job id{{#if batchEnabled}}s{{/if}}; each result is delivered automatically when that agent yields. +- Parallelism = {{#if batchEnabled}}`tasks[]` items in one call, and/or multiple `task` calls in one assistant message{{else}}multiple `task` calls in one assistant message{{/if}}. Concurrency is bounded at {{MAX_CONCURRENCY}} running subagents per session. - If genuinely blocked on a result, wait with `job poll`; otherwise keep working. `job cancel` terminates a task and **cannot carry a message** — only for stalled/abandoned work. {{#if ircEnabled}} - Coordinate with running agents via `irc` using their ids. Agents reach you and their siblings live the same way. @@ -14,20 +14,36 @@ Spawns ONE subagent per call to work in the background. - `agent`: agent type to spawn +{{#if batchEnabled}} +- `context`: shared background prepended to every assignment — goal, constraints, shared contract (see context-fmt); REQUIRED, session-specific only +- `tasks`: tasks to spawn — one subagent per item, all in parallel: + - `assignment`: complete self-contained instructions; one-liners and missing acceptance criteria are PROHIBITED + - `id`: stable agent id, CamelCase, ≤32 chars; generated when omitted + - `description`: UI label only — subagent never sees it +{{#if isolationEnabled}} + - `isolated`: run this spawn in an isolated env; returns patches. Isolated agents are torn down at completion — not addressable afterwards +{{/if}} +{{else}} - `id`: stable agent id, CamelCase, ≤32 chars; generated when omitted - `description`: UI label only — subagent never sees it - `assignment`: complete self-contained instructions; one-liners and missing acceptance criteria are PROHIBITED -{{#if customSchemaEnabled}}- `schema`: JTD schema for expected structured output (do not put format rules in assignments){{/if}} -{{#if isolationEnabled}}- `isolated`: run in isolated env; returns patches. Isolated agents are torn down at completion — not addressable afterwards{{/if}} +{{#if isolationEnabled}} +- `isolated`: run in isolated env; returns patches. Isolated agents are torn down at completion — not addressable afterwards +{{/if}} +{{/if}} -- **Maximize fan-out.** Issue the widest set of parallel `task` calls the work decomposes into. NEVER serialize work that could run concurrently. +- **Maximize fan-out.** Issue the widest {{#if batchEnabled}}`tasks[]` batch (or set of parallel `task` calls){{else}}set of parallel `task` calls{{/if}} the work decomposes into. NEVER serialize work that could run concurrently. - **Subagents do not verify, lint, or format.** Every assignment MUST instruct the subagent to skip all gates, formatters, and project-wide build/test/lint. You run them once at the end across the union of changed files. - No globs, no "update all", no package-wide scope. Fan out. - NEVER slow down or serialize because tasks might overlap on some files. Agents resolve collisions among themselves in real time. -- Subagents have no conversation history. Every fact, file path, and direction they need MUST be explicit in the `assignment`. +- Subagents have no conversation history. Every fact, file path, and direction they need MUST be explicit in {{#if batchEnabled}}`context` or the item's `assignment`{{else}}the `assignment`{{/if}}. +{{#if batchEnabled}} +- **Shared background** lives in `context` once — never duplicated across assignments. Pass large payloads via `local://` URIs, not inline. +{{else}} - **Shared background**: write it ONCE to a `local://` file (e.g. `local://ctx.md`) and reference that path in each assignment. Pass large payloads via `local://` URIs, not inline. +{{/if}} - Prefer agents that investigate **and** edit in one pass; only spin a read-only discovery step when affected files are genuinely unknown. - **Read-only agents**: Agents tagged READ-ONLY (e.g. `explore`) have no edit/write/command tools. NEVER hand them an assignment that requires changing files or running commands. Use them to investigate and report back; do the edits yourself or delegate to a writing agent (`task`, `oracle`, `designer`). - **No reasoning offload**: NEVER offload reasoning, analysis, design, or decision-making to `quick_task` or `explore` — they run minimal-effort / small models for mechanical lookups and data collection only. Keep judgment and synthesis in your own context; delegate hard thinking to `task`, `plan`, or `oracle`. @@ -46,6 +62,14 @@ Parallel when tasks touch disjoint files or are independent refactors/tests. {{#if ircEnabled}}Sequenced follow-ups SHOULD message the agent that produced the prerequisite — it already holds the context.{{/if}} +{{#if batchEnabled}} + +# Goal ← one sentence: what the batch accomplishes +# Constraints ← MUST/NEVER rules and session decisions +# Contract ← exact types/signatures if tasks share an interface + +{{/if}} + # Target ← exact files and symbols; explicit non-goals # Change ← step-by-step add/remove/rename; APIs and patterns diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 583d2d4e8..68aee57dd 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -183,6 +183,8 @@ export interface ExecutorOptions { agent: AgentDefinition; task: string; assignment?: string; + /** Shared background from the task call (`task.batch`), rendered into the subagent's system prompt. */ + context?: string; /** * The session's active overall plan, handed off so subagents spawned during * plan execution share the same plan context as the main agent. Omitted when @@ -1852,6 +1854,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise { const subagentPrompt = prompt.render(subagentSystemPromptTemplate, { agent: agent.systemPrompt, + context: options.context?.trim() ?? "", planReference: options.planReference?.content ?? "", planReferencePath: options.planReference?.path ?? "", worktree: worktree ?? "", diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index 64b74b8b5..990e08107 100644 --- a/packages/coding-agent/src/task/index.ts +++ b/packages/coding-agent/src/task/index.ts @@ -8,8 +8,8 @@ * * Supports: * - Single agent spawn per call (parallelism = parallel task calls) + * - Batch spawning + shared context per call when `task.batch` is enabled * - Non-blocking execution via the session's AsyncJobManager - * - Resuming idle/parked agents with follow-up assignments * - Progress tracking via JSON events * - Session artifacts for debugging */ @@ -17,6 +17,7 @@ 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 type { ToolSession } from ".."; import { resolveAgentModelPatterns } from "../config/model-resolver"; @@ -34,12 +35,14 @@ import { type AgentProgress, getTaskSchema, type SingleResult, + type TaskItem, type TaskParams, type TaskToolDetails, type TaskToolSchemaInstance, } from "./types"; // 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 } from "../registry/agent-registry"; @@ -49,10 +52,9 @@ import { type DiscoveryResult, discoverAgents, getAgent } from "./discovery"; import { runSubprocess } from "./executor"; import { generateTaskName } from "./name-generator"; import { AgentOutputManager } from "./output-manager"; -import { Semaphore } from "./parallel"; +import { mapWithConcurrencyLimit, Semaphore } from "./parallel"; import { renderResult, renderCall as renderTaskCall } from "./render"; import { repairTaskParams } from "./repair-args"; -import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode"; import { applyNestedPatches, captureBaseline, @@ -68,13 +70,51 @@ import { type WorktreeBaseline, } from "./worktree"; -function renderSubagentUserPrompt(assignment: string, simpleMode: TaskSimpleMode): string { +function renderSubagentUserPrompt(assignment: string): string { return prompt.render(subagentUserPromptTemplate, { assignment: assignment.trim(), - independentMode: simpleMode === "independent", }); } +function createUsageTotals(): Usage { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +function addUsageTotals(target: Usage, usage: Partial): void { + const input = usage.input ?? 0; + const output = usage.output ?? 0; + const cacheRead = usage.cacheRead ?? 0; + const cacheWrite = usage.cacheWrite ?? 0; + const totalTokens = usage.totalTokens ?? input + output + cacheRead + cacheWrite; + const cost = + usage.cost ?? + ({ + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + } satisfies Usage["cost"]); + + target.input += input; + target.output += output; + target.cacheRead += cacheRead; + target.cacheWrite += cacheWrite; + target.totalTokens += totalTokens; + target.cost.input += cost.input; + target.cost.output += cost.output; + target.cost.cacheRead += cost.cacheRead; + target.cost.cacheWrite += cost.cacheWrite; + target.cost.total += cost.total; +} + // Re-export types and utilities export { loadBundledAgents as BUNDLED_AGENTS } from "./agents"; export { discoverCommands, expandCommand, getCommand } from "./commands"; @@ -149,7 +189,7 @@ function renderDescription( maxConcurrency: number, isolationEnabled: boolean, disabledAgents: string[], - simpleMode: TaskSimpleMode, + batchEnabled: boolean, ircEnabled: boolean, parentSpawns: string, ): string { @@ -171,17 +211,13 @@ function renderDescription( description: agent.description, readOnly: isReadOnlyAgent(agent), })); - const { customSchemaEnabled } = getTaskSimpleModeCapabilities(simpleMode); return prompt.render(taskDescriptionTemplate, { agents: renderedAgents, spawningDisabled, MAX_CONCURRENCY: maxConcurrency, isolationEnabled, - customSchemaEnabled, + batchEnabled, ircEnabled, - defaultMode: simpleMode === "default", - schemaFreeMode: simpleMode === "schema-free", - independentMode: simpleMode === "independent", }); } @@ -192,29 +228,110 @@ function createTaskModeError(text: string): AgentToolResult { }; } -function validateTaskModeParams(simpleMode: TaskSimpleMode, params: TaskParams): string | undefined { - const { customSchemaEnabled } = getTaskSimpleModeCapabilities(simpleMode); - if (customSchemaEnabled || params.schema === undefined) { - return undefined; +/** + * 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`. + */ +function validateShapeParams(batchEnabled: boolean, params: TaskParams): string | undefined { + if ((params as Record).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)`."; } - return `task.simple is set to ${simpleMode}, so the task tool does not accept \`schema\`. Remove it and rely on the selected agent definition or inherited session schema.`; + if (!batchEnabled) { + const disallowed = (["tasks", "context"] as const).filter(field => params[field] !== undefined); + if (disallowed.length > 0) { + return `task.batch is disabled, so the task tool does not accept ${disallowed.map(f => `\`${f}\``).join(" or ")}. Spawn one agent per call with \`assignment\`, or enable the task.batch setting.`; + } + } + return undefined; } /** - * Validate the spawn parameter contract: `agent` and `assignment` are both - * required. Returns a problem description, or undefined when valid. + * Validate the spawn parameter contract against the wire shapes. `agent` is + * always required. With `task.batch` the model-facing shape is + * `{ agent, context, tasks[] }` — `tasks` non-empty with per-item assignments + * and unique ids, `context` non-empty, no top-level `assignment` alongside. + * The flat `{ agent, ...item }` form stays accepted at runtime under either + * setting (internal callers, stale transcripts). Returns a problem + * description, or undefined when valid. */ -function validateSpawnParams(params: TaskParams): string | undefined { +function validateSpawnParams(params: TaskParams, batchEnabled: boolean): string | undefined { const agent = typeof params.agent === "string" ? params.agent.trim() : ""; if (!agent) { return "Missing `agent`. Provide an agent type to spawn."; } - if (typeof params.assignment !== "string" || params.assignment.trim() === "") { - return "Missing `assignment`. Provide complete, self-contained instructions for the agent."; + const hasAssignment = typeof params.assignment === "string" && params.assignment.trim() !== ""; + const tasks = params.tasks; + if (batchEnabled && tasks !== undefined) { + if (!Array.isArray(tasks) || tasks.length === 0) { + return "Missing `tasks`. Provide at least one task item ({ id?, description?, assignment })."; + } + if (hasAssignment) { + return "Top-level `assignment` is not part of the batch shape. Put the work in `tasks[]` items."; + } + for (let i = 0; i < tasks.length; i++) { + const item = tasks[i]; + if (!item || typeof item.assignment !== "string" || item.assignment.trim() === "") { + return `Task ${i + 1}${item?.id ? ` (\`${item.id}\`)` : ""} is missing \`assignment\`. Every task needs complete, self-contained instructions.`; + } + } + const seen = new Map(); + for (const item of tasks) { + const id = item.id?.trim(); + if (!id) continue; + const key = id.toLowerCase(); + const existing = seen.get(key); + if (existing !== undefined) { + return `Duplicate task id ${existing === id ? `\`${id}\`` : `\`${existing}\` / \`${id}\``}. Provided ids must be unique within a call (case-insensitive).`; + } + seen.set(key, id); + } + if (typeof params.context !== "string" || params.context.trim() === "") { + return "Missing `context`. Provide the shared background for this batch — goal, constraints, and any contract the tasks share."; + } + return undefined; + } + if (!hasAssignment) { + return batchEnabled + ? "Missing `tasks`. Provide a `tasks` array (one subagent per item) with a shared `context`." + : "Missing `assignment`. Provide complete, self-contained instructions for the agent."; } return undefined; } +/** + * Normalize a validated call into its spawn list: the `tasks[]` batch when + * provided, otherwise the single top-level spawn. + */ +function resolveSpawnItems(params: TaskParams): TaskItem[] { + if (Array.isArray(params.tasks) && params.tasks.length > 0) { + return params.tasks; + } + return [{ id: params.id, description: params.description, assignment: params.assignment }]; +} + +/** + * Per-spawn params handed to the executor path: top-level call fields with the + * item's identity substituted in. `tasks` never leaks into a spawn; the shared + * `context` rides along unchanged. Keys are only materialized when present — + * `#runSpawn` distinguishes an absent `isolated` from an explicit one. The + * item's `isolated` (batch form) wins over the top-level flag (flat form). + */ +function spawnParamsFor(params: TaskParams, item: TaskItem): TaskParams { + const spawn: TaskParams = { agent: params.agent }; + if (item.id !== undefined) spawn.id = item.id; + if (item.description !== undefined) spawn.description = item.description; + if (item.assignment !== undefined) spawn.assignment = item.assignment; + if (params.context !== undefined) spawn.context = params.context; + if (item.isolated !== undefined) { + spawn.isolated = item.isolated; + } else if ("isolated" in params) { + spawn.isolated = params.isolated; + } + return spawn; +} + /** Sentinel for async jobs whose subagent finished with a failing result; progress is already updated. */ class TaskJobError extends Error {} @@ -256,9 +373,9 @@ function discoverAgentsForCreate(cwd: string): Promise { /** * Task tool - Delegate tasks to specialized agents. * - * Each call spawns ONE subagent (or resumes an existing one). Spawning is - * non-blocking: the call registers an AsyncJobManager job and returns - * immediately; the result is delivered when the agent yields. + * Each call spawns one subagent — or, with `task.batch`, one per `tasks[]` + * item. Spawning is non-blocking: the call registers AsyncJobManager jobs and + * returns immediately; each result is delivered when that agent yields. */ export class TaskTool implements AgentTool { readonly name = "task"; @@ -275,6 +392,22 @@ export class TaskTool implements AgentTool 1) { + lines.push(`+${tasks.length - 1} more task${tasks.length === 2 ? "" : "s"}`); + } + } return lines; }; readonly label = "Task"; @@ -297,7 +430,7 @@ export class TaskTool implements AgentTool[1], theme: Theme) { @@ -314,7 +447,7 @@ export class TaskTool implements AgentTool, ): Promise> { const params = repairTaskParams(rawParams as TaskParams); - const simpleMode = this.#getTaskSimpleMode(); - const validationError = validateTaskModeParams(simpleMode, params) ?? validateSpawnParams(params); + const batchEnabled = this.#isBatchEnabled(); + const validationError = validateShapeParams(batchEnabled, params) ?? validateSpawnParams(params, batchEnabled); if (validationError) { return createTaskModeError(validationError); } + const spawnItems = resolveSpawnItems(params); const selectedAgent = this.#discoveredAgents.find(agent => agent.name === params.agent); const manager = this.session.asyncJobManager; if (!manager || selectedAgent?.blocking === true) { @@ -366,49 +500,172 @@ export class TaskTool implements AgentTool null)); - const agentId = await outputManager.allocate(params.id?.trim() || generateTaskName()); - - const assignment = (params.assignment ?? "").trim(); const agentLabel = params.agent ?? "task"; - const progress: AgentProgress = { - index: 0, - id: agentId, - agent: agentLabel, - agentSource: selectedAgent?.source ?? "bundled", - status: "pending", - task: renderSubagentUserPrompt(assignment, simpleMode), - assignment, - description: params.description, - recentTools: [], - recentOutput: [], - toolCount: 0, - requests: 0, - tokens: 0, - cost: 0, - durationMs: 0, - }; + const agentSource = selectedAgent?.source ?? "bundled"; + const spawns: Array<{ agentId: string; item: TaskItem; progress: AgentProgress }> = []; + for (let index = 0; index < spawnItems.length; index++) { + const item = spawnItems[index]; + const agentId = await outputManager.allocate(item.id?.trim() || generateTaskName()); + const assignment = (item.assignment ?? "").trim(); + spawns.push({ + agentId, + item, + progress: { + index, + id: agentId, + agent: agentLabel, + agentSource, + status: "pending", + task: renderSubagentUserPrompt(assignment), + assignment, + description: item.description, + recentTools: [], + recentOutput: [], + toolCount: 0, + requests: 0, + tokens: 0, + cost: 0, + durationMs: 0, + }, + }); + } + // Aggregate async state for the one tool call: every spawn's job reports + // into the shared progress snapshot; the call stays "running" until all + // jobs settle, then turns "failed" if any spawn failed. The single-spawn + // case passes the job's own suggestion through (pre-batch behavior). + const single = spawns.length === 1; + let settledCount = 0; + let failedCount = 0; + let primaryJobId = spawns[0].agentId; const buildAsyncDetails = (state: "running" | "completed" | "failed", jobId: string): TaskToolDetails => ({ projectAgentsDir: null, results: [], totalDurationMs: 0, - progress: [{ ...progress }], - async: { state, jobId, type: "task" }, + progress: spawns.map(spawn => ({ ...spawn.progress })), + async: { + state: single ? state : settledCount < spawns.length ? "running" : failedCount > 0 ? "failed" : "completed", + jobId: single ? jobId : primaryJobId, + type: "task", + }, }); const ircEnabled = isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0); + const started: Array<{ agentId: string; jobId: string; description?: string }> = []; + const failedSchedules: string[] = []; + for (const spawn of spawns) { + try { + const jobId = this.#registerSpawnJob({ + manager, + toolCallId, + spawnParams: spawnParamsFor(params, spawn.item), + agentId: spawn.agentId, + progress: spawn.progress, + ircEnabled, + buildDetails: buildAsyncDetails, + onUpdate, + onSettled: failed => { + settledCount += 1; + if (failed) failedCount += 1; + }, + }); + if (started.length === 0) primaryJobId = jobId; + started.push({ agentId: spawn.agentId, jobId, description: spawn.item.description }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failedSchedules.push(`${spawn.agentId}: ${message}`); + spawn.progress.status = "failed"; + settledCount += 1; + failedCount += 1; + } + } + + if (started.length === 0) { + return { + content: [ + { + type: "text", + text: `Failed to start background task job${single ? "" : "s"}: ${failedSchedules.join("; ")}`, + }, + ], + details: { projectAgentsDir: null, results: [], totalDurationMs: 0 }, + }; + } + + if (single) { + const { agentId, jobId, description } = started[0]; + const coordinationHint = ircEnabled + ? `DM \`${agentId}\` via \`irc\` to coordinate while it runs; use \`job\` only to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task.` + : `Use \`job\` to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task.`; + const descriptionSuffix = description ? ` — ${description}` : ""; + onUpdate?.({ + content: [{ type: "text", text: `Spawned agent \`${agentId}\`...` }], + details: buildAsyncDetails("running", jobId), + }); + return { + content: [ + { + type: "text", + text: `Spawned agent \`${agentId}\` (job \`${jobId}\`)${descriptionSuffix}. The result will be delivered when it yields. ${coordinationHint}`, + }, + ], + details: buildAsyncDetails("running", jobId), + }; + } + + const coordinationHint = ircEnabled + ? `DM these ids via \`irc\` to coordinate while they run; use \`job\` only to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task.` + : `Use \`job\` to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task by id.`; + const scheduleFailureSummary = + failedSchedules.length > 0 + ? ` Failed to schedule ${failedSchedules.length} spawn${failedSchedules.length === 1 ? "" : "s"}: ${failedSchedules.join("; ")}.` + : ""; + const startedListing = started + .map(({ agentId, jobId, description }) => { + const prefix = `- \`${agentId}\` (job \`${jobId}\`)`; + return description ? `${prefix} — ${description}` : prefix; + }) + .join("\n"); + onUpdate?.({ + content: [{ type: "text", text: `Spawned ${started.length} agents...` }], + details: buildAsyncDetails("running", primaryJobId), + }); + return { + 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("running", primaryJobId), + }; + } + + /** + * Register one background job that runs a single spawn to completion and + * delivers its yield text. The job body mirrors the sync path; `buildDetails` + * supplies the (possibly batch-shared) progress snapshot and `onSettled` + * feeds the caller's aggregate counters. + */ + #registerSpawnJob(options: { + manager: AsyncJobManager; + toolCallId: string; + spawnParams: TaskParams; + agentId: string; + progress: AgentProgress; + ircEnabled: boolean; + buildDetails: (state: "running" | "completed" | "failed", jobId: string) => TaskToolDetails; + onUpdate?: AgentToolUpdateCallback; + onSettled?: (failed: boolean) => void; + }): string { + const { manager, toolCallId, spawnParams, agentId, progress, ircEnabled, buildDetails, onUpdate, onSettled } = + options; const buildFollowUpHint = (aborted: boolean): string => { if (aborted) { return `\n\n${agentId} was aborted — transcript at history://${agentId}`; @@ -416,128 +673,187 @@ export class TaskTool implements AgentTool { - const startedAt = Date.now(); - const semaphore = this.#getSpawnSemaphore(); - await semaphore.acquire(); - if (runSignal.aborted) { - semaphore.release(); - progress.status = "aborted"; - throw new Error("Aborted before execution"); - } - markRunning(); - progress.status = "running"; + return manager.register( + "task", + agentId, + async ({ jobId: ownJobId, signal: runSignal, reportProgress, markRunning }) => { + const startedAt = Date.now(); + const semaphore = this.#getSpawnSemaphore(); + await semaphore.acquire(); + if (runSignal.aborted) { + semaphore.release(); + progress.status = "aborted"; + onSettled?.(true); + throw new Error("Aborted before execution"); + } + markRunning(); + progress.status = "running"; + await reportProgress( + `Running background task ${agentId}...`, + buildDetails("running", ownJobId) as unknown as Record, + ); + try { + const result = await this.#executeSync(toolCallId, spawnParams, runSignal, undefined, agentId); + const finalText = result.content.find(part => part.type === "text")?.text ?? "(no output)"; + const singleResult = result.details?.results[0]; + // A missing result means the sync path failed at the tool level + // (results: []) — treat it as a failure, not success. + const resultFailed = !singleResult || (singleResult.aborted ?? false) || singleResult.exitCode !== 0; + progress.status = singleResult?.aborted ? "aborted" : resultFailed ? "failed" : "completed"; + progress.durationMs = singleResult?.durationMs ?? Math.max(0, Date.now() - startedAt); + progress.tokens = singleResult?.tokens ?? 0; + progress.requests = singleResult?.requests ?? 0; + progress.contextTokens = singleResult?.contextTokens; + progress.contextWindow = singleResult?.contextWindow; + progress.cost = singleResult?.usage?.cost.total ?? 0; + progress.extractedToolData = singleResult?.extractedToolData; + progress.retryFailure = singleResult?.retryFailure; + progress.retryState = undefined; + onSettled?.(resultFailed); + const statusText = resultFailed + ? `Background task ${agentId} failed.` + : `Background task ${agentId} complete.`; await reportProgress( - `Running background task ${agentId}...`, - buildAsyncDetails("running", ownJobId) as unknown as Record, + statusText, + buildDetails(resultFailed ? "failed" : "completed", ownJobId) as unknown as Record, ); - try { - const result = await this.#executeSync(toolCallId, params, runSignal, undefined, agentId); - const finalText = result.content.find(part => part.type === "text")?.text ?? "(no output)"; - const singleResult = result.details?.results[0]; - // A missing result means the sync path failed at the tool level - // (results: []) — treat it as a failure, not success. - const resultFailed = !singleResult || (singleResult.aborted ?? false) || singleResult.exitCode !== 0; - progress.status = singleResult?.aborted ? "aborted" : resultFailed ? "failed" : "completed"; - progress.durationMs = singleResult?.durationMs ?? Math.max(0, Date.now() - startedAt); - progress.tokens = singleResult?.tokens ?? 0; - progress.requests = singleResult?.requests ?? 0; - progress.contextTokens = singleResult?.contextTokens; - progress.contextWindow = singleResult?.contextWindow; - progress.cost = singleResult?.usage?.cost.total ?? 0; - progress.extractedToolData = singleResult?.extractedToolData; - progress.retryFailure = singleResult?.retryFailure; - progress.retryState = undefined; - const statusText = resultFailed - ? `Background task ${agentId} failed.` - : `Background task ${agentId} complete.`; - await reportProgress( - statusText, - buildAsyncDetails(resultFailed ? "failed" : "completed", ownJobId) as unknown as Record< - string, - unknown - >, - ); - onUpdate?.({ - content: [{ type: "text", text: statusText }], - details: buildAsyncDetails(resultFailed ? "failed" : "completed", ownJobId), - }); - const deliveryText = `${finalText}${buildFollowUpHint(singleResult?.aborted === true)}`; - if (resultFailed) { - // Mark the job itself failed; the failed agent stays interrogable. - throw new TaskJobError(deliveryText); - } - return deliveryText; - } catch (error) { - if (error instanceof TaskJobError) { - throw error; - } - progress.status = "failed"; - progress.durationMs = Math.max(0, Date.now() - startedAt); - const statusText = `Background task ${agentId} failed.`; - await reportProgress( - statusText, - buildAsyncDetails("failed", ownJobId) as unknown as Record, - ); - onUpdate?.({ - content: [{ type: "text", text: statusText }], - details: buildAsyncDetails("failed", ownJobId), - }); - const message = error instanceof Error ? error.message : String(error); - const hint = AgentRegistry.global().get(agentId) ? buildFollowUpHint(false) : ""; - throw new TaskJobError(`${message}${hint}`); - } finally { - semaphore.release(); + onUpdate?.({ + content: [{ type: "text", text: statusText }], + details: buildDetails(resultFailed ? "failed" : "completed", ownJobId), + }); + const deliveryText = `${finalText}${buildFollowUpHint(singleResult?.aborted === true)}`; + if (resultFailed) { + // Mark the job itself failed; the failed agent stays interrogable. + throw new TaskJobError(deliveryText); } + return deliveryText; + } catch (error) { + if (error instanceof TaskJobError) { + throw error; + } + progress.status = "failed"; + progress.durationMs = Math.max(0, Date.now() - startedAt); + onSettled?.(true); + const statusText = `Background task ${agentId} failed.`; + await reportProgress(statusText, buildDetails("failed", ownJobId) as unknown as Record); + onUpdate?.({ + content: [{ type: "text", text: statusText }], + details: buildDetails("failed", ownJobId), + }); + const message = error instanceof Error ? error.message : String(error); + const hint = AgentRegistry.global().get(agentId) ? buildFollowUpHint(false) : ""; + throw new TaskJobError(`${message}${hint}`); + } finally { + semaphore.release(); + } + }, + { + id: agentId, + queued: true, + ownerId: this.session.getAgentId?.() ?? undefined, + onProgress: (text, details) => { + const progressDetails = (details as TaskToolDetails | undefined) ?? buildDetails("running", agentId); + onUpdate?.({ content: [{ type: "text", text }], details: progressDetails }); }, - { - id: agentId, - queued: true, - ownerId: this.session.getAgentId?.() ?? undefined, - onProgress: (text, details) => { - const progressDetails = - (details as TaskToolDetails | undefined) ?? buildAsyncDetails("running", agentId); - onUpdate?.({ content: [{ type: "text", text }], details: progressDetails }); - }, - }, - ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - content: [{ type: "text", text: `Failed to start background task job: ${message}` }], - details: { projectAgentsDir: null, results: [], totalDurationMs: 0 }, - }; + }, + ); + } + + /** + * Sync fallback fan-out (no job manager, or a `blocking: true` agent): run + * every spawn to completion inline and merge the per-spawn payloads into a + * single tool result. The session-scoped semaphore still bounds concurrency + * across parallel task calls. + */ + async #executeSyncFanout( + toolCallId: string, + params: TaskParams, + spawnItems: TaskItem[], + signal?: AbortSignal, + onUpdate?: AgentToolUpdateCallback, + ): Promise> { + const semaphore = this.#getSpawnSemaphore(); + if (spawnItems.length === 1) { + await semaphore.acquire(); + try { + return await this.#executeSync(toolCallId, spawnParamsFor(params, spawnItems[0]), signal, onUpdate); + } finally { + semaphore.release(); + } } - const coordinationHint = ircEnabled - ? `DM \`${agentId}\` via \`irc\` to coordinate while it runs; use \`job\` only to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task.` - : `Use \`job\` to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task.`; - const descriptionSuffix = params.description ? ` — ${params.description}` : ""; + const startTime = Date.now(); + const latestProgress = new Map(); + const emitCombined = () => { + onUpdate?.({ + content: [{ type: "text", text: `Running ${spawnItems.length} agents...` }], + details: { + projectAgentsDir: null, + results: [], + totalDurationMs: Date.now() - startTime, + progress: Array.from(latestProgress.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([, progress]) => progress), + }, + }); + }; - onUpdate?.({ - content: [{ type: "text", text: `Spawned agent \`${agentId}\`...` }], - details: buildAsyncDetails("running", jobId), - }); + const { results: payloads } = await mapWithConcurrencyLimit( + spawnItems, + spawnItems.length, + async (item, index, workerSignal) => { + await semaphore.acquire(); + try { + const itemOnUpdate: AgentToolUpdateCallback | undefined = onUpdate + ? update => { + const progress = update.details?.progress?.[0]; + if (progress) { + latestProgress.set(index, { ...progress, index }); + emitCombined(); + } + } + : undefined; + return await this.#executeSync(toolCallId, spawnParamsFor(params, item), workerSignal, itemOnUpdate); + } finally { + semaphore.release(); + } + }, + signal, + ); + + const results: SingleResult[] = []; + const contentParts: string[] = []; + const outputPaths: string[] = []; + const usageTotals = createUsageTotals(); + let hasUsage = false; + let projectAgentsDir: string | null = null; + for (let index = 0; index < spawnItems.length; index++) { + const payload = payloads[index]; + if (!payload) { + contentParts.push(`Task ${spawnItems[index].id?.trim() || `#${index + 1}`}: cancelled before start.`); + continue; + } + projectAgentsDir ??= payload.details?.projectAgentsDir ?? null; + const text = payload.content.find(part => part.type === "text")?.text; + if (text) contentParts.push(text); + for (const result of payload.details?.results ?? []) { + results.push({ ...result, index }); + if (result.usage) { + addUsageTotals(usageTotals, result.usage); + hasUsage = true; + } + if (result.outputPath) outputPaths.push(result.outputPath); + } + } return { - content: [ - { - type: "text", - text: `Spawned agent \`${agentId}\` (job \`${jobId}\`)${descriptionSuffix}. The result will be delivered when it yields. ${coordinationHint}`, - }, - ], + content: [{ type: "text", text: contentParts.join("\n\n") }], details: { - projectAgentsDir: null, - results: [], - totalDurationMs: 0, - progress: [{ ...progress }], - async: { state: "running", jobId, type: "task" }, + projectAgentsDir, + results, + totalDurationMs: Date.now() - startTime, + usage: hasUsage ? usageTotals : undefined, + outputPaths: outputPaths.length > 0 ? outputPaths : undefined, }, }; } @@ -569,9 +885,7 @@ export class TaskTool implements AgentTool agent frontmatter > inherited parent session. - // task.simple can disable the task-call override while leaving agent/session schemas intact. - const effectiveOutputSchema = customSchemaEnabled - ? (outputSchema ?? effectiveAgent.output ?? this.session.outputSchema) - : (effectiveAgent.output ?? this.session.outputSchema); + // 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 repoRoot: string | null = null; let baseline: WorktreeBaseline | null = null; @@ -757,7 +1070,7 @@ export class TaskTool implements AgentTool | undefined, theme: Theme } lines.push(line); } + lines.push(...renderTaskItemLines(args.tasks, theme)); + return lines; +} + +/** + * Render the per-item list (`id` + ui `description`) for a batch call's + * streaming preview. The args stream in token by token, so the array grows + * over time and trailing entries may be partially parsed — every field access + * is defensive. + */ +function renderTaskItemLines(tasks: TaskItem[] | undefined, theme: Theme): string[] { + if (!Array.isArray(tasks) || tasks.length === 0) return []; + + const bullet = theme.fg("dim", "•"); + const cap = Math.min(tasks.length, 12); + const lines: string[] = []; + for (let i = 0; i < cap; i++) { + const task = tasks[i] as Partial | undefined; + const rawId = typeof task?.id === "string" ? task.id.trim() : ""; + const idLabel = rawId ? formatTaskId(rawId) : `#${i + 1}`; + let line = `${bullet} ${theme.fg("accent", theme.bold(idLabel))}`; + const desc = typeof task?.description === "string" ? task.description.trim() : ""; + if (desc) { + line += `: ${theme.fg("muted", truncateToWidth(replaceTabs(desc), 64))}`; + } + if (task?.isolated === true) { + line += theme.fg("dim", " [isolated]"); + } + lines.push(line); + } + if (cap < tasks.length) { + lines.push(`${bullet} ${theme.fg("dim", formatMoreItems(tasks.length - cap, "agent"))}`); + } return lines; } @@ -554,9 +587,26 @@ function createAssignmentSectionRenderer( // here too. The repair is idempotent on already-clean text. const assignment = repairDoubleEncodedJsonString(typeof args?.assignment === "string" ? args.assignment : "").trim(); if (!assignment) return undefined; + return createMarkdownSectionRenderer(assignment, theme); +} - const markdown = new Markdown(assignment, 0, 0, getMarkdownTheme(), { - color: text => theme.fg("muted", text), +/** + * Build the shared-context section (the `# Goal / # Constraints` background a + * batch call hands every subagent). Rendered like the assignment brief so the + * shared background stays visible for the whole task lifecycle. + */ +function createContextSectionRenderer( + args: Partial | undefined, + theme: Theme, +): AssignmentSectionRenderer | undefined { + const context = repairDoubleEncodedJsonString(typeof args?.context === "string" ? args.context : "").trim(); + if (!context) return undefined; + return createMarkdownSectionRenderer(context, theme); +} + +function createMarkdownSectionRenderer(text: string, theme: Theme): AssignmentSectionRenderer { + const markdown = new Markdown(text, 0, 0, getMarkdownTheme(), { + color: line => theme.fg("muted", line), }); return width => ({ lines: markdown.render(Math.max(1, width - ASSIGNMENT_FRAME_INSET)) }); } @@ -572,6 +622,7 @@ export function renderCall( const showIsolated = "isolated" in args && args.isolated === true; const header = renderStatusLine({ icon: "pending", title: "Task", description: args.agent }, theme); const assignmentSection = createAssignmentSectionRenderer(args, theme); + const contextSection = createContextSectionRenderer(args, theme); return framedBlock(theme, width => { const sections: Array<{ label?: string; lines: readonly string[]; separator?: boolean }> = []; @@ -584,6 +635,7 @@ export function renderCall( separator: true, lines: renderTaskCallLines(args, theme), }); + if (contextSection) sections.push(contextSection(width)); if (assignmentSection) sections.push(assignmentSection(width)); } @@ -1110,6 +1162,7 @@ export function renderResult( const details = result.details; const agentLabel = args?.agent?.trim() || undefined; const assignmentSection = createAssignmentSectionRenderer(args, theme); + const contextSection = createContextSectionRenderer(args, theme); if (!details) { const text = result.content.find(c => c.type === "text")?.text || ""; @@ -1127,6 +1180,7 @@ export function renderResult( return framedBlock(theme, width => ({ header, sections: [ + ...(contextSection ? [contextSection(width)] : []), ...(assignmentSection ? [assignmentSection(width)] : []), ...(text ? [{ separator: true, lines: [theme.fg("dim", truncateToWidth(text, width))] }] : []), ], @@ -1201,6 +1255,7 @@ export function renderResult( return { header, sections: [ + ...(contextSection ? [contextSection(width)] : []), ...(assignmentSection ? [assignmentSection(width)] : []), { separator: true, lines: [theme.fg("dim", truncateToWidth(text, width))] }, ], @@ -1231,6 +1286,7 @@ export function renderResult( return { header, sections: [ + ...(contextSection ? [contextSection(width)] : []), ...(assignmentSection ? [assignmentSection(width)] : []), ...(lines.length > 0 ? [{ separator: true, lines }] : []), ], diff --git a/packages/coding-agent/src/task/repair-args.ts b/packages/coding-agent/src/task/repair-args.ts index ec52a0000..dc361cf72 100644 --- a/packages/coding-agent/src/task/repair-args.ts +++ b/packages/coding-agent/src/task/repair-args.ts @@ -28,7 +28,7 @@ * tools (write/edit/bash/search), where a backslash or quote is load-bearing * and a false-positive unescape would silently corrupt a file or command. */ -import type { TaskParams } from "./types"; +import type { TaskItem, TaskParams } from "./types"; /** A backslash that escapes a structural char — `\"`, `\\`, `\/`, or `\uXXXX`. */ const STRUCTURAL_ESCAPE = /\\(?:["\\/]|u[0-9a-fA-F]{4})/; @@ -78,11 +78,23 @@ export function repairDoubleEncodedJsonString(value: string): string { return typeof decoded === "string" && decoded !== value ? decoded : value; } +/** Repair a single (possibly partial) task item's prose fields. */ +function repairTaskItem(task: TaskItem): TaskItem { + if (task === null || typeof task !== "object") return task; + const assignment = + typeof task.assignment === "string" ? repairDoubleEncodedJsonString(task.assignment) : task.assignment; + const description = + typeof task.description === "string" ? repairDoubleEncodedJsonString(task.description) : task.description; + if (assignment === task.assignment && description === task.description) return task; + return { ...task, assignment, description }; +} + /** - * Repair double-encoded prose in task-tool params (`assignment` and - * `description`). Returns the same reference when nothing changed so callers - * can cheaply skip work. Defensive against partially-streamed args - * (missing/undefined fields) so it is safe on the render path as well as on + * Repair double-encoded prose in task-tool params (`assignment`, + * `description`, shared `context`, and each batch task item's prose fields). + * Returns the same reference when nothing changed so callers can cheaply skip + * work. Defensive against partially-streamed args (missing/undefined fields, + * partial task arrays) so it is safe on the render path as well as on * execution. */ export function repairTaskParams(params: TaskParams): TaskParams { @@ -92,7 +104,26 @@ export function repairTaskParams(params: TaskParams): TaskParams { typeof params.assignment === "string" ? repairDoubleEncodedJsonString(params.assignment) : params.assignment; const description = typeof params.description === "string" ? repairDoubleEncodedJsonString(params.description) : params.description; + const context = typeof params.context === "string" ? repairDoubleEncodedJsonString(params.context) : params.context; - if (assignment === params.assignment && description === params.description) return params; - return { ...params, assignment, description }; + let tasks = params.tasks; + if (Array.isArray(params.tasks)) { + let changed = false; + const repaired = params.tasks.map(task => { + const next = repairTaskItem(task); + if (next !== task) changed = true; + return next; + }); + if (changed) tasks = repaired; + } + + if ( + assignment === params.assignment && + description === params.description && + context === params.context && + tasks === params.tasks + ) { + return params; + } + return { ...params, assignment, description, context, tasks }; } diff --git a/packages/coding-agent/src/task/simple-mode.ts b/packages/coding-agent/src/task/simple-mode.ts deleted file mode 100644 index 22c3c607d..000000000 --- a/packages/coding-agent/src/task/simple-mode.ts +++ /dev/null @@ -1,23 +0,0 @@ -export const TASK_SIMPLE_MODES = ["default", "schema-free", "independent"] as const; - -export type TaskSimpleMode = (typeof TASK_SIMPLE_MODES)[number]; - -interface TaskSimpleModeCapabilities { - customSchemaEnabled: boolean; -} - -const TASK_SIMPLE_MODE_CAPABILITIES: Record = { - default: { - customSchemaEnabled: true, - }, - "schema-free": { - customSchemaEnabled: false, - }, - independent: { - customSchemaEnabled: false, - }, -}; - -export function getTaskSimpleModeCapabilities(mode: TaskSimpleMode): TaskSimpleModeCapabilities { - return TASK_SIMPLE_MODE_CAPABILITIES[mode]; -} diff --git a/packages/coding-agent/src/task/types.ts b/packages/coding-agent/src/task/types.ts index c571cad08..3a458b45b 100644 --- a/packages/coding-agent/src/task/types.ts +++ b/packages/coding-agent/src/task/types.ts @@ -3,7 +3,6 @@ import type { Usage } from "@oh-my-pi/pi-ai"; import { $env } from "@oh-my-pi/pi-utils"; import * as z from "zod/v4"; import type { AgentSessionEvent } from "../session/agent-session"; -import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode"; import type { NestedRepoPatch } from "./worktree"; /** Source of an agent definition */ @@ -66,65 +65,88 @@ export interface SubagentLifecyclePayload { index: number; } -const createTaskSchema = (options: { isolationEnabled: boolean; customSchemaEnabled: boolean }) => { - let schema = z.object({ - agent: z.string().describe("agent type to spawn"), - id: z.string().max(48).optional().describe("stable agent id; default generated"), - description: z.string().optional().describe("ui label, not seen by subagent"), - assignment: z.string().describe("the work; self-contained instructions"), - }); - - if (options.customSchemaEnabled) { - schema = schema.extend({ - schema: z.string().optional().describe("jtd schema for expected response shape"), - }); - } - - if (options.isolationEnabled) { - schema = schema.extend({ - isolated: z.boolean().optional().describe("run in isolated env; returns patches"), - }); - } - - return schema; +/** + * One unit of work. The single-spawn schema is `{ agent, ...taskItemSchema }`; + * the batch schema (`task.batch`) is `{ agent, context, tasks: taskItemSchema[] }`. + * When task isolation is enabled, `isolated` joins the item shape (per-item in + * batch form, top-level in the flat form via the spread). + */ +const taskItemShape = { + id: z.string().max(48).optional().describe("stable agent id; default generated"), + description: z.string().optional().describe("ui label, not seen by subagent"), + assignment: z.string().describe("the work; self-contained instructions"), +}; +const isolatedShape = { + isolated: z.boolean().optional().describe("run in isolated env; returns patches"), +}; +const agentShape = { + agent: z.string().describe("agent type to spawn"), +}; +const contextShape = { + context: z.string().describe("shared background prepended to each assignment"), }; -export const taskSchema = createTaskSchema({ isolationEnabled: true, customSchemaEnabled: true }); -const taskSchemaNoIsolation = createTaskSchema({ isolationEnabled: false, customSchemaEnabled: true }); -const taskSchemaSchemaFree = createTaskSchema({ isolationEnabled: true, customSchemaEnabled: false }); -const taskSchemaSchemaFreeNoIsolation = createTaskSchema({ isolationEnabled: false, customSchemaEnabled: false }); -const ALL_TASK_SCHEMAS = [ - taskSchema, - taskSchemaNoIsolation, - taskSchemaSchemaFree, - taskSchemaSchemaFreeNoIsolation, -] as const; +export const taskItemSchema = z.object(taskItemShape); +const taskItemSchemaIsolated = z.object({ ...taskItemShape, ...isolatedShape }); -type DynamicTaskSchema = (typeof ALL_TASK_SCHEMAS)[number]; -export type TaskSchema = typeof taskSchema; -/** Active task tool parameter schema for the current simple-mode / isolation flags */ -export type TaskToolSchemaInstance = DynamicTaskSchema; - -export function getTaskSchema(options: { isolationEnabled: boolean; simpleMode: TaskSimpleMode }): DynamicTaskSchema { - const { customSchemaEnabled } = getTaskSimpleModeCapabilities(options.simpleMode); - if (customSchemaEnabled) { - return options.isolationEnabled ? taskSchema : taskSchemaNoIsolation; - } - return options.isolationEnabled ? taskSchemaSchemaFree : taskSchemaSchemaFreeNoIsolation; -} - -export interface TaskParams { - /** Agent type; required. */ - agent?: string; +/** Single task item. Fields are optional defensively: args stream in token by token. */ +export interface TaskItem { /** Stable agent id; default = generated AdjectiveNoun. */ id?: string; /** UI label, not seen by the subagent. */ description?: string; - /** The work; required. */ + /** The work; required by the schema. */ assignment?: string; - /** JTD schema for the expected yield shape; unchanged semantics. */ - schema?: string; - /** Run in an isolated worktree; isolated agents are torn down at completion. */ + /** Run this spawn in an isolated worktree (batch form; flat form carries it top-level). */ + isolated?: boolean; +} + +export const taskSchema = z.object({ ...agentShape, ...taskItemShape, ...isolatedShape }); +const taskSchemaNoIsolation = z.object({ ...agentShape, ...taskItemShape }); +const taskSchemaBatch = z.object({ + ...agentShape, + ...contextShape, + tasks: z.array(taskItemSchemaIsolated).describe("tasks to spawn; one subagent per item"), +}); +const taskSchemaBatchNoIsolation = z.object({ + ...agentShape, + ...contextShape, + tasks: z.array(taskItemSchema).describe("tasks to spawn; one subagent per item"), +}); +const ALL_TASK_SCHEMAS = [taskSchema, taskSchemaNoIsolation, taskSchemaBatch, taskSchemaBatchNoIsolation] as const; + +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 function getTaskSchema(options: { isolationEnabled: boolean; batchEnabled: boolean }): DynamicTaskSchema { + if (options.batchEnabled) { + return options.isolationEnabled ? taskSchemaBatch : taskSchemaBatchNoIsolation; + } + return options.isolationEnabled ? taskSchema : taskSchemaNoIsolation; +} + +/** + * Runtime params union over both wire shapes. The model sees exactly one shape + * (`{ agent, context, tasks[] }` when `task.batch` is on, `{ agent, ...item }` + * otherwise); runtime stays permissive so internal callers and stale + * transcripts using the flat form keep working under either setting. + */ +export interface TaskParams { + /** Agent type; required. */ + agent?: string; + /** Stable agent id (flat form); default = generated AdjectiveNoun. */ + id?: string; + /** UI label (flat form), not seen by the subagent. */ + description?: string; + /** The work (flat form). */ + assignment?: string; + /** Batch form (`task.batch`): one subagent per item. */ + tasks?: TaskItem[]; + /** Batch form: shared background prepended to every assignment; required by the batch schema. */ + context?: string; + /** Run in an isolated worktree (flat form; per-item in batch form). */ isolated?: boolean; } diff --git a/packages/coding-agent/src/tools/job.ts b/packages/coding-agent/src/tools/job.ts index 960e39115..8ca345691 100644 --- a/packages/coding-agent/src/tools/job.ts +++ b/packages/coding-agent/src/tools/job.ts @@ -398,6 +398,18 @@ function stripTaskResultEnvelope(text: string): string { return body?.trim() || text; } +/** + * Pretty-printed JSON output wastes the collapsed one-line preview on a lone + * "{" — flatten structured-looking bodies onto a single line. Slice first: + * downstream truncation keeps at most a few hundred columns, so collapsing + * whitespace across a multi-KB body would be pure waste. + */ +function flattenStructuredPreview(text: string): string { + const first = text[0]; + if (first !== "{" && first !== "[") return text; + return text.slice(0, PREVIEW_LINES_EXPANDED * PREVIEW_LINE_WIDTH * 2).replace(/\s+/g, " "); +} + function describeTarget(args: JobRenderArgs | undefined): string { const poll = args?.poll ?? []; const cancel = args?.cancel ?? []; @@ -514,7 +526,9 @@ export const jobToolRenderer = { lines.push(` ${uiTheme.fg("toolOutput", visibleLabelLines[i]!)}`); } - const preview = stripTaskResultEnvelope(job.errorText?.trim() || job.resultText?.trim() || ""); + const preview = flattenStructuredPreview( + stripTaskResultEnvelope(job.errorText?.trim() || job.resultText?.trim() || ""), + ); if (preview) { const maxLines = expanded ? PREVIEW_LINES_EXPANDED : PREVIEW_LINES_COLLAPSED; const previewLines = getPreviewLines(preview, maxLines, PREVIEW_LINE_WIDTH, Ellipsis.Unicode); diff --git a/packages/coding-agent/test/job-renderer-preview.test.ts b/packages/coding-agent/test/job-renderer-preview.test.ts index 0ad5f1882..03bf8ef54 100644 --- a/packages/coding-agent/test/job-renderer-preview.test.ts +++ b/packages/coding-agent/test/job-renderer-preview.test.ts @@ -81,6 +81,22 @@ describe("job renderer task-result preview", () => { expect(output).not.toContain(" { + const summary = prompt.render(taskSummaryTemplate, { + agentName: "quick_task", + id: "EchoAlpha", + status: "completed", + duration: "11.6s", + preview: '{\n "echo": "alpha",\n "ok": true\n}', + truncated: false, + mergeSummary: "", + }); + + const output = Bun.stripANSI(renderLines(summary)); + expect(output).toContain('{ "echo": "alpha", "ok": true }'); + expect(output.split("\n").some(line => line.trim() === "{")).toBe(false); + }); + it("passes non-envelope result text through unchanged", () => { const output = renderLines("42 pass, 0 fail (18.4s)"); expect(output).toContain("42 pass, 0 fail (18.4s)"); diff --git a/packages/coding-agent/test/task/task-batch.test.ts b/packages/coding-agent/test/task/task-batch.test.ts new file mode 100644 index 000000000..6e7ba6d6f --- /dev/null +++ b/packages/coding-agent/test/task/task-batch.test.ts @@ -0,0 +1,328 @@ +/** + * Contracts: task.batch gating (batch spawning + shared context). + * + * 1. The wire schema is shape-swapped by `task.batch`: `{ agent, context, + * tasks[] }` when on (per-spawn fields — including `isolated` — live in the + * items), the flat `{ agent, ...item }` 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 + * is disabled, top-level `assignment` in batch calls, empty/invalid items, + * duplicate ids, and a missing shared `context`. + * 3. A batch call registers one background job per item; every spawn receives + * the shared `context`, and each job delivers its own follow-up hint. The + * flat form stays accepted at runtime for internal callers. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; +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 } = {}): ToolSession { + return { + cwd: "/tmp", + hasUI: false, + settings: Settings.isolated(options.settings ?? {}), + getSessionFile: () => null, + getSessionSpawns: () => "*", + asyncJobManager: options.manager, + } as unknown as ToolSession; +} + +function getSchemaProperties(tool: TaskTool): Record { + const wire = toolWireSchema(tool) as { properties?: Record }; + return wire.properties ?? {}; +} + +function getFirstText(result: { content: Array<{ type: string; text?: string }> }): string { + const content = result.content.find(part => part.type === "text"); + return content?.type === "text" ? (content.text ?? "") : ""; +} + +function makeResult(id: string, overrides: Partial = {}): SingleResult { + return { + index: 0, + id, + agent: "task", + agentSource: "bundled", + task: "task prompt", + assignment: "Do the thing.", + exitCode: 0, + output: "All done.", + stderr: "", + truncated: false, + durationMs: 5, + tokens: 0, + requests: 1, + ...overrides, + }; +} + +function mockDiscovery(): void { + vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({ + agents: [taskAgent], + projectAgentsDir: null, + }); +} + +describe("task.batch schema gating", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("swaps between the flat and batch wire shapes", async () => { + mockDiscovery(); + + const off = await TaskTool.create(createSession({ settings: { "task.batch": false } })); + const offProperties = getSchemaProperties(off); + expect(offProperties.tasks).toBeUndefined(); + expect(offProperties.context).toBeUndefined(); + expect(offProperties.assignment).toBeDefined(); + expect(offProperties.id).toBeDefined(); + + const on = await TaskTool.create(createSession({ settings: { "task.batch": true } })); + const onProperties = getSchemaProperties(on); + expect(onProperties.tasks).toBeDefined(); + expect(onProperties.context).toBeDefined(); + // The batch shape is { agent, context, tasks[] } — the per-spawn fields + // live only inside the task items. + expect(onProperties.assignment).toBeUndefined(); + expect(onProperties.id).toBeUndefined(); + expect(onProperties.description).toBeUndefined(); + const items = (onProperties.tasks as { items?: { properties?: Record } }).items; + expect(items?.properties?.assignment).toBeDefined(); + expect(items?.properties?.id).toBeDefined(); + }); + + it("places isolated per item in the batch shape when isolation is enabled", async () => { + mockDiscovery(); + + const tool = await TaskTool.create( + createSession({ settings: { "task.batch": true, "task.isolation.mode": "auto" } }), + ); + const properties = getSchemaProperties(tool); + expect(properties.isolated).toBeUndefined(); + const items = (properties.tasks as { items?: { properties?: Record } }).items; + expect(items?.properties?.isolated).toBeDefined(); + }); + + it("never exposes a per-call schema input", async () => { + mockDiscovery(); + + for (const settings of [{ "task.batch": false }, { "task.batch": true }]) { + const tool = await TaskTool.create(createSession({ settings })); + expect(getSchemaProperties(tool).schema).toBeUndefined(); + } + }); + + it("documents the batch parameters only when enabled", async () => { + mockDiscovery(); + + const off = await TaskTool.create(createSession({ settings: { "task.batch": false } })); + expect(off.description).toContain("Spawns ONE subagent per call"); + expect(off.description).not.toContain("`context`: shared background"); + + const on = await TaskTool.create(createSession({ settings: { "task.batch": true } })); + expect(on.description).toContain("`tasks`: tasks to spawn"); + expect(on.description).toContain("`context`: shared background"); + }); +}); + +describe("task.batch validation", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function executeText(params: unknown, settings: Record = {}): Promise { + mockDiscovery(); + const tool = await TaskTool.create(createSession({ settings })); + const result = await tool.execute("tool-call", params); + return getFirstText(result); + } + + it("rejects a schema argument regardless of batch mode", async () => { + for (const batch of [false, true]) { + const text = await executeText( + { agent: "task", assignment: "Work.", schema: '{"properties":{}}' }, + { "task.batch": batch }, + ); + expect(text).toContain("does not accept `schema`"); + } + }); + + it("rejects tasks and context while task.batch is disabled", async () => { + const disabled = { "task.batch": false }; + const text = await executeText({ agent: "task", tasks: [{ assignment: "Work." }] }, disabled); + expect(text).toContain("task.batch is disabled"); + + const contextText = await executeText({ agent: "task", assignment: "Work.", context: "Background." }, disabled); + expect(contextText).toContain("task.batch is disabled"); + }); + + it("rejects top-level assignment in the batch shape", async () => { + const text = await executeText( + { agent: "task", assignment: "Work.", tasks: [{ assignment: "Other." }] }, + { "task.batch": true }, + ); + expect(text).toContain("not part of the batch shape"); + }); + + it("rejects empty task arrays and items without assignments", async () => { + const empty = await executeText({ agent: "task", tasks: [] }, { "task.batch": true }); + expect(empty).toContain("Missing `tasks`"); + + const missing = await executeText( + { agent: "task", tasks: [{ assignment: "Work." }, { id: "Beta" }] }, + { "task.batch": true }, + ); + expect(missing).toContain("Task 2 (`Beta`) is missing `assignment`"); + }); + + it("requires a shared context for batch calls", async () => { + const text = await executeText({ agent: "task", tasks: [{ assignment: "Work." }] }, { "task.batch": true }); + expect(text).toContain("Missing `context`"); + }); + + it("rejects duplicate provided ids case-insensitively", async () => { + const text = await executeText( + { + agent: "task", + tasks: [ + { id: "Anna", assignment: "A." }, + { id: "anna", assignment: "B." }, + ], + }, + { "task.batch": true }, + ); + expect(text).toContain("Duplicate task id"); + }); +}); + +describe("task.batch spawning", () => { + const managers: AsyncJobManager[] = []; + + function createManager(): AsyncJobManager { + const manager = new AsyncJobManager({ onJobComplete: () => {} }); + managers.push(manager); + return manager; + } + + beforeEach(() => { + AgentRegistry.resetGlobalForTests(); + AgentLifecycleManager.resetGlobalForTests(); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + for (const manager of managers.splice(0)) { + await manager.dispose({ timeoutMs: 1000 }); + } + AgentLifecycleManager.resetGlobalForTests(); + 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 }> = []; + vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { + seen.push({ id: options.id, context: options.context, assignment: options.assignment }); + return makeResult(options.id ?? "?"); + }); + + const manager = createManager(); + const tool = await TaskTool.create(createSession({ manager, settings: { "task.batch": true } })); + + const result = await tool.execute("tc-batch", { + agent: "task", + context: "# Goal\nShared background.", + tasks: [ + { id: "Alpha", description: "first", assignment: "Do A." }, + { id: "Beta", assignment: "Do B." }, + ], + } as TaskParams); + + const text = getFirstText(result); + expect(text).toContain("Spawned 2 background agents"); + expect(text).toContain("- `Alpha`"); + expect(text).toContain("- `Beta`"); + expect(result.details?.progress?.map(progress => progress.id)).toEqual(["Alpha", "Beta"]); + expect(result.details?.async?.state).toBe("running"); + + const alphaJob = manager.getJob("Alpha"); + const betaJob = manager.getJob("Beta"); + expect(alphaJob).toBeDefined(); + expect(betaJob).toBeDefined(); + 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(seen.map(spawn => spawn.assignment).sort()).toEqual(["Do A.", "Do B."]); + }); + + it("treats a one-item batch as a single spawn and forwards context", async () => { + mockDiscovery(); + let capturedContext: string | undefined; + vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { + capturedContext = options.context; + return makeResult(options.id ?? "?"); + }); + + const manager = createManager(); + const tool = await TaskTool.create(createSession({ manager, settings: { "task.batch": true } })); + + const result = await tool.execute("tc-single", { + agent: "task", + context: "Shared notes.", + tasks: [{ id: "Solo", assignment: "Do the thing." }], + } as TaskParams); + + expect(getFirstText(result)).toContain("Spawned agent `Solo`"); + const job = manager.getJob(result.details!.async!.jobId)!; + await job.promise; + expect(job.status).toBe("completed"); + expect(capturedContext).toBe("Shared notes."); + }); + + 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 ?? "?")); + + const manager = createManager(); + const tool = await TaskTool.create(createSession({ manager, settings: { "task.batch": true } })); + + const result = await tool.execute("tc-flat", { + agent: "task", + id: "Flat", + assignment: "Do the thing.", + } 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"); + }); +}); diff --git a/packages/coding-agent/test/task/task-schema.test.ts b/packages/coding-agent/test/task/task-schema.test.ts index 9d181a947..0327fd98a 100644 --- a/packages/coding-agent/test/task/task-schema.test.ts +++ b/packages/coding-agent/test/task/task-schema.test.ts @@ -4,8 +4,11 @@ import { TaskTool, taskSchema } from "@oh-my-pi/pi-coding-agent/task"; import * as discoveryModule from "@oh-my-pi/pi-coding-agent/task/discovery"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; -// Contract (rework-contracts.md §3): the task tool spawns ONE agent per call. -// `tasks[]` and `context` are gone; follow-ups go through `irc` messaging. +// 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. describe("task schema (single-spawn)", () => { it("accepts {agent, assignment}", () => { @@ -23,18 +26,21 @@ describe("task schema (single-spawn)", () => { expect(parsed.success).toBe(false); }); - it("carries no tasks/context fields", () => { + it("strips tasks/context/schema from the single-spawn schema", () => { const parsed = taskSchema.safeParse({ agent: "explore", assignment: "Map the auth module.", context: "shared background", tasks: [{ id: "A", assignment: "..." }], + schema: '{"properties":{}}', }); expect(parsed.success).toBe(true); if (parsed.success) { - // Unknown keys are stripped: the batch/context shape no longer exists. + // Unknown keys are stripped: batch/context exist only on the batch + // schema and the per-call schema input was removed outright. expect("tasks" in parsed.data).toBe(false); expect("context" in parsed.data).toBe(false); + expect("schema" in parsed.data).toBe(false); } }); }); @@ -48,7 +54,7 @@ describe("task spawn validation", () => { return { cwd: "/tmp", hasUI: false, - settings: Settings.isolated({ "task.isolation.mode": "none" }), + settings: Settings.isolated({ "task.isolation.mode": "none", "task.batch": false }), getSessionFile: () => null, getSessionSpawns: () => "*", } as unknown as ToolSession; diff --git a/packages/coding-agent/test/tools/task-simple-mode.test.ts b/packages/coding-agent/test/tools/task-simple-mode.test.ts deleted file mode 100644 index 3a1189ef3..000000000 --- a/packages/coding-agent/test/tools/task-simple-mode.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "bun:test"; -import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; -import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation"; -import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { TaskTool } from "@oh-my-pi/pi-coding-agent/task"; -import * as discoveryModule from "@oh-my-pi/pi-coding-agent/task/discovery"; -import type { TaskParams } from "@oh-my-pi/pi-coding-agent/task/types"; -import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; - -const TEST_AGENTS = [ - { - name: "task", - description: "General-purpose task agent", - systemPrompt: "You are a task agent.", - source: "bundled" as const, - }, -]; - -const ALL_MODES = ["default", "schema-free", "independent"] as const; - -function createSession(overrides: Partial> = {}): ToolSession { - return { - cwd: "/tmp", - hasUI: false, - settings: Settings.isolated(overrides), - getSessionFile: () => null, - getSessionSpawns: () => "*", - } as unknown as ToolSession; -} - -function getSchemaProperties(tool: TaskTool): Record { - const wire = toolWireSchema(tool) as { properties?: Record }; - return wire.properties ?? {}; -} - -function getFirstText(result: { content: Array<{ type: string; text?: string }> }): string { - const content = result.content.find(part => part.type === "text"); - return content?.type === "text" ? (content.text ?? "") : ""; -} - -function mockDiscovery(): void { - vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({ - agents: TEST_AGENTS, - projectAgentsDir: null, - }); -} - -describe("task.simple", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("exposes the custom schema input only in default mode", async () => { - mockDiscovery(); - - const defaultTool = await TaskTool.create(createSession({ "task.simple": "default" })); - expect(getSchemaProperties(defaultTool).schema).toBeDefined(); - expect(defaultTool.description).toContain("- `schema`:"); - - for (const mode of ["schema-free", "independent"] as const) { - const tool = await TaskTool.create(createSession({ "task.simple": mode })); - expect(getSchemaProperties(tool).schema).toBeUndefined(); - expect(tool.description).not.toContain("- `schema`:"); - } - }); - - it("never exposes batch tasks or shared context inputs in any mode", async () => { - mockDiscovery(); - - for (const mode of ALL_MODES) { - const tool = await TaskTool.create(createSession({ "task.simple": mode })); - const properties = getSchemaProperties(tool); - expect(properties.tasks).toBeUndefined(); - expect(properties.context).toBeUndefined(); - // The flat single-spawn contract is what replaced them. - expect(properties.assignment).toBeDefined(); - expect(properties.resume).toBeDefined(); - } - }); - - it("describes the non-blocking spawn and resume contract", async () => { - mockDiscovery(); - - const tool = await TaskTool.create(createSession({ "task.simple": "default" })); - expect(tool.description).toContain("Spawning is non-blocking"); - expect(tool.description).toContain("revives an idle/parked agent"); - }); - - it("rejects a direct schema input when the mode disables it", async () => { - mockDiscovery(); - - for (const mode of ["schema-free", "independent"] as const) { - const tool = await TaskTool.create(createSession({ "task.simple": mode })); - - // Execution-time guard: raw params carrying `schema` are refused. - const result = await tool.execute(`tool-${mode}`, { - agent: "task", - id: "One", - description: "label", - assignment: "Do the thing.", - schema: '{"properties":{"ok":{"type":"boolean"}}}', - } as TaskParams); - expect(getFirstText(result)).toContain("does not accept `schema`"); - - // Round-trip guard: wire validation passes the extraneous `schema` - // through, so the execution-time check must still refuse it. - const validated = validateToolArguments(tool, { - type: "toolCall", - id: `tool-${mode}-validated`, - name: tool.name, - arguments: { - agent: "task", - id: "One", - description: "label", - assignment: "Do the thing.", - schema: '{"properties":{"ok":{"type":"boolean"}}}', - }, - }) as TaskParams; - const validatedResult = await tool.execute(`tool-${mode}-validated`, validated); - expect(getFirstText(validatedResult)).toContain("does not accept `schema`"); - } - }); -});