diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d898b58c..732f585ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename Dedicated Nordic å in `/model` is treated as that shortcut when Add Provider is offered. +### Breaking + +- `task` is removed. Use `spawn_agent` to start workers and `wait_agents` to + collect reports; `search_agents` profile ids now dispatch through + `spawn_agent(agent=...)`. + ### Changed - Cancelling a `task` or `wait_agents` worker reports wait status `interrupted`, @@ -36,6 +42,10 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename API endpoints keep dollar estimates. - CLI `--help` / `-h` is recognized in any argument position. Value flags no longer swallow `--*` or `-h` as their option values. +- `wait_agents` no longer collects a stale completed or interrupted stamp + when a followup is already in flight. +- `interrupt_agent` flips the wait mailbox so soft interrupt unblocks + `wait_agents` while the background run is still in flight. ## [0.3.11] - 2026-08-31 @@ -61,6 +71,9 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ### Fixed +- Mid-run Enter delivers a steer into the live reactor (`Agent.deliver`) + instead of starting a second `send`. `/clear` and `/new` drop queued + input so it cannot land in the next session. - Failed sessions with an `error` string in `run.json` are valid resume candidates, not corrupt files. A truly unreadable session id prints one recovery line; parse diagnostics go to the structured log, not the @@ -259,13 +272,13 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ### Fixed -- Retained worker sessions (`spawn_agent`, resumable via `resume_agent`/`followup_task`) now have - their own retention cap, separate from the TUI's finished-session display cap. Previously they - shared that 20-item cap, so `resume_agent` on an early worker failed with a bare `not_found` once - a fan-out of more than 20 workers had finished. A session dropped by the retention cap still - releases its sidecars/reactor/lock entry, always evicts least-recently-used first, and never - evicts a running session. `resume_agent`/`followup_task` against an evicted session now report - its terminal status plus a pointer to `read_agent_trace`, instead of `not_found`. +- Retained worker sessions (`spawn_agent`, resumable via `resume_agent`) now have their own + retention cap, separate from the TUI's finished-session display cap. Previously they shared that + 20-item cap, so `resume_agent` on an early worker failed with a bare `not_found` once a fan-out + of more than 20 workers had finished. A session dropped by the retention cap still releases its + sidecars/reactor/lock entry, always evicts least-recently-used first, and never evicts a running + session. `resume_agent` against an evicted session now reports its terminal status plus a pointer + to `read_agent_trace`, instead of `not_found`. ## [0.3.0] - 2026-08-24 @@ -306,10 +319,10 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename operator interrupts it (`interrupt_agent`) rather than the harness enforcing a count. -- Added `interrupt_agent({ target })` and `followup_task({ target, message })`, +- Added `interrupt_agent({ target })` and `resume_agent({ target, message })`, the second half of reusable worker sessions: `interrupt_agent` stops a retained worker's current turn while keeping it and its context alive - (distinct from the permanent `close_agent`), and `followup_task` sends new + (distinct from the permanent `close_agent`), and `resume_agent` sends new work into a retained worker's existing session, reusing its prior context and tool outputs rather than starting fresh. Both are gated to orchestrator tiers via the existing fleet-verb mechanism, denied to leaves. `interrupt_agent` diff --git a/README.md b/README.md index 7caa70988..fe10fec8e 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ prompts. Pass `--no-auto` to start in ask-on-every-consequential-action mode ### What auto allows - File tools inside the workspace: `write_file`, `edit_file`, `delete_file` (and - other non-shell built-ins such as `manage_tasks`, `task`, …) + other non-shell built-ins such as `manage_tasks`, `spawn_agent`, `wait_agents`, …) - Unconstrained shell (builds, tests, git, one-off commands that match no deny/ask rule) - Read-only tools (`read_file`, `grep`, `search_files`, `list_dir`, `lsp`, …) @@ -152,9 +152,8 @@ Details live in `docs/PRODUCT.md` (safety model) and `docs/ARCHITECTURE.md` Corbits Code is a single-process CLI built on Interchange primitives. The primary session is always the **orchestrator** (Skywalker): it can act directly and -delegates substantial work through a closed director fleet via `spawn_agent` / -`wait_agents` / `search_agents` (`task` remains a fused spawn-plus-wait -wrapper). +delegates substantial work through a closed director fleet via `spawn_agent`, +`wait_agents`, and `search_agents`. ``` CLI (src/index.ts) @@ -202,10 +201,9 @@ Corbits Code keeps repository guidance and the closed director fleet separate: - `.agents/agents/` — optional local profile additions; this directory is not required and may be absent -Named workers resolve through `spawn_agent` / `task` (`resolveDirector`): closed -directors first, then enabled agent plugins, then local -`.agents/agents/*.json|*.yaml` profiles. Use `search_agents` to discover ids -before dispatching. +Named workers resolve through `spawn_agent(agent=...)`: closed directors first, +then enabled agent plugins, then local `.agents/agents/*.json|*.yaml` profiles. +Use `search_agents` to discover ids before dispatching. ## Contributing diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5578d44c8..7ea8640f4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -114,7 +114,7 @@ Two directors, selected by role: - **ChatDirector** (interactive, `src/agent/director.ts`) — Extends `DefaultDirector` with task list tracking, workflow nudges, LSP auto-activation, and multi-turn chat semantics. It never terminates the session: operator declines are surfaced as replies and the reactor stays alive for the next message. Auto mode is toggled by CLI flags (`--auto` / `--no-auto`); there is currently no in-session key to toggle it (default on; constrained envelope — workspace writes and unconstrained shell auto-allow; installs, recursive rm, force/uncontained worktree changes, sensitive-path and opaque-wrapper shell still ask; contained non-force `git worktree add`/`remove`/`prune` and `list` auto-allow; shell file-mutation denied). It is not a separate edit/plan mode. - **SubAgentDirector** (delegated work, `src/subagent/index.ts`) — Drives a dispatched worker until a turn arrives with no tool calls, then replies with the final assistant text and ends the run. A tool-less turn **after tools** completes only with the four-heading envelope (Summary, Findings, Blockers, Paths); a missing envelope nudges once (**incomplete-report**) and a second tool-less turn still without the envelope salvages as **incomplete-report-stop**. Explore/read-only workers that used tools then replied with findings remain normal completes; `requireEvidence` (off by default, set per director) additionally requires at least one read before a tool-less spawn-only reply can complete. Reads done through `run_shell` count as evidence too — `src/subagent/shell-evidence.ts` classifies shell reads (`cat`, `grep`, `sed` without `-i`, …) over the same subject expansion the auto-shell policy uses — but there is no corresponding shell-write evidence or file-write requirement: a run that never touches a file still completes normally once it replies with the envelope. There is no turn budget. Operator/parent cancel after any progress returns a **cancelled** salvage report (partial findings + tool activity) instead of a bare cancel string; cancel before progress still surfaces as cancelled-by-operator. There is no repetition/no-progress/never-acted/never-edited hard stop and no fingerprint-based re-dispatch block — a genuinely stuck leaf runs until it completes, stalls, hits an opt-in wall-clock deadline, or is cancelled. - Optional `task(tier=)` (`fast` | `standard` | `clever`) overrides profile inference, profile tier, and the parent provider for that spawn only, and fails closed when the tier is unconfigured. The parent `task` tool keeps a session-scoped brief-dispatch ledger (`src/subagent/brief-dispatch.ts`) that only counts dispatches per fingerprint (prompt + agent + intent + success_criteria + do_not, not description/tier) and resets on a successful complete — it never refuses a re-dispatch. Deadline salvage prepends an advisory parent hint suggesting continuation plus a longer deadline if more wall-clock time is warranted; cancelled salvage suggests continuing from Findings rather than redoing completed work. Either way the hint is advisory only — an identical re-dispatch is still admitted. + `spawn_agent` starts each worker and records it in the caller's fleet mailbox; `wait_agents` collects terminal reports from that mailbox. Deadline salvage prepends an advisory parent hint suggesting continuation plus a longer deadline if more wall-clock time is warranted; cancelled salvage suggests continuing from Findings rather than redoing completed work. Either way the hint is advisory only — an identical re-dispatch is still admitted. #### Model-family policy (`src/agent/model-family-policy.ts`) @@ -182,7 +182,7 @@ Compaction replaces older turns with a structured, workflow-aware summary rather - `present` — Renders structured UI from a JSON view spec instead of pasting tables into chat. - `submit_output` — Completes a workflow step when `step` is set. The step id is compared atomically against the current step (`complete()`); already-complete ids (behind the cursor) and not-current ids (future or unknown) are acknowledged without advancing. Always advertised so activating a workflow does not grow the tools array. -Core agent tools (advertised in every chat turn) include `manage_tasks`, `tool_search`, `use_skill`, **`task`** (spawn a sub-agent), and **`search_agents`** when sub-agent profiles are available — see Sub-agents below. +Core agent tools (advertised in every chat turn) include `manage_tasks`, `tool_search`, `use_skill`, **`spawn_agent`** / **`wait_agents`** (spawn and collect sub-agents), and **`search_agents`** when sub-agent profiles are available — see Sub-agents below. ### Workflows (`src/workflows/`) @@ -201,17 +201,17 @@ Invocation: workflows are **not** top-level slash commands. Recipe definitions l Three distinct concepts (do not conflate them): -| Concept | What it is | Surface | -| ------------- | -------------------------------------------------------- | --------------------------------------------------------- | -| **Agent** | A runtime entity with its own loop, tools, and context | Primary session or a spawned child | -| **Task** | A checklist item owned by _one_ agent via `manage_tasks` | Local work plan — not a spawn | -| **Sub-agent** | A short-lived child agent for one self-contained job | Spawned with **`spawn_agent`** (or deprecated **`task`**) | +| Concept | What it is | Surface | +| ------------- | -------------------------------------------------------- | ---------------------------------------------------------------- | +| **Agent** | A runtime entity with its own loop, tools, and context | Primary session or a spawned child | +| **Task** | A checklist item owned by _one_ agent via `manage_tasks` | Local work plan — not a spawn | +| **Sub-agent** | A short-lived child agent for one self-contained job | Spawned with **`spawn_agent`**, collected with **`wait_agents`** | -The **`task`** tool **spawns a sub-agent** on a separate inference source (tier/profile resolved from settings). The dispatch brief separates durable `context`, actionable `prompt`, and optional `goals` (checklist seeds for the _child's_ own `manage_tasks` list). The child returns a structured report (`Summary` / `Findings` / `Blockers` / `Paths`) plus a tools-used footer. Parent and child never share a `manage_tasks` list. +The **`spawn_agent`** tool starts a sub-agent on a separate inference source (tier/profile resolved from settings) and returns immediately with an `agent_id`; **`wait_agents`** collects reports later. The dispatch brief separates durable `context`, actionable `prompt`, and optional `goals` (checklist seeds for the _child's_ own `manage_tasks` list). The child returns a structured report (`Summary` / `Findings` / `Blockers` / `Paths`) plus a tools-used footer. Parent and child never share a `manage_tasks` list. -When profiles exist (local `.agents/agents/` and/or enabled **`kind: "agent"`** plugins, including **data-only** markdown plugins with no `index.ts`), the chat model also receives **`search_agents`** — a lexical index over profile id, description, and role text so the model can discover ids before calling `task(agent=...)`. Results include each match's full loaded system prompt / body so the parent can inspect plugin or Claude marketplace agents without `read_file` on paths outside the session cwd (path-escape blocks those roots by design; writes remain blocked). `task` and `search_agents` are core tools on the primary session. +When profiles exist (local `.agents/agents/` and/or enabled **`kind: "agent"`** plugins, including **data-only** markdown plugins with no `index.ts`), the chat model also receives **`search_agents`** — a lexical index over profile id, description, and role text so the model can discover ids before calling `spawn_agent(agent=...)`. Results include each match's full loaded system prompt / body so the parent can inspect plugin or Claude marketplace agents without `read_file` on paths outside the session cwd (path-escape blocks those roots by design; writes remain blocked). `spawn_agent` and `search_agents` are core tools on the primary session. -Profiles with `orchestrator: true` may themselves call `task` (one hop only): nested dispatch installs `task` and the mailbox-scoped fleet verbs (`spawn_agent`, `wait_agents`, `list_agents`, …) with `allowOrchestrator: false` so the tree bottoms out. Fleet discovery (`search_agents`) stays Tier 1 only. Unknown `agent` ids fail closed. +Built-in directors with `spawn.maySpawn` may themselves call `spawn_agent` (one hop only): nested dispatch installs the mailbox-scoped fleet verbs (`spawn_agent`, `wait_agents`, `list_agents`, …) with `allowOrchestrator: false` so the tree bottoms out. Profile-sourced `orchestrator: true` is rejected before a session starts because it has no trusted tier/authority semantics today. Fleet discovery (`search_agents`) stays Tier 1 only. Unknown `agent` ids fail closed. #### Fleet authority tiers (`src/subagent/authority.ts`) (CL-6941) @@ -225,13 +225,13 @@ Every director package carries a required `tier: SubagentTier` field (`src/agent Enforcement is runtime code at the existing tool-mount point, not prompt wording — this is the fix for four prior mechanisms (`writePaths`, `report.requiredSections`, a `--config` comment, the thrash matcher) that were documented-as-enforced while enforcing nothing: -- **Mount-time gate — live today, and fails closed.** `task-tool.ts` resolves the caller's tier at dispatch time — a closed director's `DirectorPackage.tier` — and forwards it as `RunSubAgentParams.orchestratorTier`. `runSubAgent` (`src/subagent/run.ts`) then calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing fleet verbs, treating a **missing** `orchestratorTier` as `"leaf"` — deny, not skip. This is the case that matters most: a project-local or plugin `AgentProfile` with `orchestrator: true` is outside the closed director set and is **not** trusted with fleet verbs just because `orchestrator: true` is set — there is no profile-level opt-in today, so the mount always throws `FleetAuthorityError` for a profile-sourced orchestrator. `FLEET_VERBS` in `authority.ts` names the live verbs (`task`, `spawn_agent`, `wait_agents`, `list_agents`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, `read_agent_trace`, `search_agents`) so every mount site inherits the same gate. `list_agents` is the non-blocking mailbox-scoped list of this install's own `spawn_agent` workers (same scope as `wait_agents`); nested orchestrators may mount it. Fleet discovery (`search_agents`) remains Tier 1 only. -- **Subtree authority — wired for addressing verbs.** `assertCanTargetAgent(actor, targetId, nodes)` implements the "root owns its tree; a child manages only its own descendants" rule over the `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks. Production call sites: `read_agent_trace`, `send_input`, `interrupt_agent`, `close_agent`, and `resume_agent`. Nested mounts pass `{actorId, tier, getNodes}` from `run.ts`; a missing `actorId` fails closed. Tier-1 primary omits authority and stays unrestricted. `spawn_agent` records `parentSessionId` on nested workers so `close_agent`'s descendant walk can see them. `wait_agents` with omitted targets waits only on that caller's per-install wait mailbox over the shared session store, not every running session. Wait JSON is a projection of stored lifecycle plus mailbox membership/pin/collected/interrupt override — not a second terminal store. `list_agents` reports that same mailbox without blocking. `interrupt_agent` stamps the session `interrupted`; wait JSON projects that stored lifecycle and does not write a mailbox overlay. `send_input` with `interrupt:true` sets the mailbox interrupt overlay so wait unblocks while a queued followup may already be running. The wait path collects a terminal status so a later followup cannot resurrect an already-observed interrupt. `close_agent` terminalizes the wait mailbox before teardown. Operator cancel (`cancel` / `cancelAll`) projects wait status `interrupted`. -- `task()` remains the deprecated fused spawn+wait fallback. `spawn_agent` + `wait_agents` is the supported parallel path. The tier check still gates which packages may mount any fleet verb. +- **Mount-time gate — live today, and fails closed.** `spawn_agent` resolves the caller's tier at dispatch time — a closed director's `DirectorPackage.tier` — and forwards it as `RunSubAgentParams.orchestratorTier`. `runSubAgent` (`src/subagent/run.ts`) then calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing fleet verbs, treating a **missing** `orchestratorTier` as `"leaf"` — deny, not skip. This is the case that matters most: a project-local or plugin `AgentProfile` with `orchestrator: true` is outside the closed director set and is **not** trusted with fleet verbs just because `orchestrator: true` is set — there is no profile-level opt-in today, so `spawn_agent` rejects a profile-sourced orchestrator before starting a session. `FLEET_VERBS` in `authority.ts` names the live verbs (`spawn_agent`, `wait_agents`, `list_agents`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, `read_agent_trace`, `search_agents`) so every mount site inherits the same gate. `list_agents` is the non-blocking mailbox-scoped list of this install's own `spawn_agent` workers (same scope as `wait_agents`); nested orchestrators may mount it. Fleet discovery (`search_agents`) remains Tier 1 only. +- **Subtree authority — wired for addressing verbs.** `assertCanTargetAgent(actor, targetId, nodes)` implements the "root owns its tree; a child manages only its own descendants" rule over the `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks. Production call sites: `read_agent_trace`, `wait_agents` explicit targets, `send_input`, `interrupt_agent`, `close_agent`, and `resume_agent`. Nested mounts pass `{actorId, tier, getNodes}` from `run.ts`; a missing `actorId` fails closed. Tier-1 primary omits authority and stays unrestricted. `spawn_agent` records `parentSessionId` on nested workers so `close_agent`'s descendant walk can see them. `wait_agents` with omitted targets waits only on that caller's per-install wait mailbox over the shared session store, not every running session. Wait JSON is a projection of stored lifecycle plus mailbox membership/pin/collected/interrupt override — not a second terminal store. `list_agents` reports that same mailbox without blocking. `interrupt_agent` stamps the session `interrupted`; wait JSON projects that stored lifecycle and does not write a mailbox overlay. `send_input` with `interrupt:true` sets the mailbox interrupt overlay so wait unblocks while a queued followup may already be running. The wait path collects a terminal status so a later followup cannot resurrect an already-observed interrupt. `close_agent` terminalizes the wait mailbox before teardown. Operator cancel (`cancel` / `cancelAll`) projects wait status `interrupted`. +- `spawn_agent` + `wait_agents` is the only spawn path. The tier check still gates which packages may mount any fleet verb. #### Closed director fleet (`src/agent/directors/`) -Every shipped specialist is a **director package** — a prompt-first `DirectorPackage` (system prompt, tool envelope, spawn rights, nudge budget, report contract, `modelRole`, fleet authority `tier`) registered in a **closed** set of 16 ids. There is no catch-all worker: `task` without `agent` or non-general `intent`, and `task(intent="general")`, fail closed so the primary reclassifies. Nested directors with a spawn allowlist reject off-list children at `createTaskTool` (not prompt-only). Skywalker is the primary session identity: `task(agent="skywalker")` is refused, and `directorProfiles()` omits it from the spawn catalog. +Every shipped specialist is a **director package** — a prompt-first `DirectorPackage` (system prompt, tool envelope, spawn rights, nudge budget, report contract, `modelRole`, fleet authority `tier`) registered in a **closed** set of 16 ids. There is no catch-all worker: `spawn_agent` without `agent` or non-general `intent`, and `spawn_agent(intent="general")`, fail closed so the primary reclassifies. Nested directors with a spawn allowlist reject off-list children at `spawn_agent` dispatch time (not prompt-only). Skywalker is the primary session identity: `spawn_agent(agent="skywalker")` is refused, and `directorProfiles()` omits it from the spawn catalog. **Primary** @@ -269,7 +269,7 @@ Every shipped specialist is a **director package** — a prompt-first `DirectorP | testsmith | Test design only (what/how to test) | | tester | Runtime verification; never fix product code | -**Intent → director** (`task(intent=…)` when `agent` is omitted) +**Intent → director** (`spawn_agent(intent=…)` when `agent` is omitted) | Intent | Default director | | --------- | -------------------------------- | @@ -285,9 +285,9 @@ Every shipped specialist is a **director package** — a prompt-first `DirectorP | --------------------------- | ----------------------------- | | skywalker (primary session) | Full closed fleet | | greybeard | intern, explorer, critic only | -| All other directors | no `task` | +| All other directors | no fleet delegation tools | -**Tool envelopes** prefer small `tools.allow` mounts over deny-everything. Shipped docs/design directors (shakespeare, rand, bruckheimer) mount write tools with no path-level lock. Lane routing is spawn policy (shakespeare = P/A/I docs, rand = DESIGN.md, bruckheimer = product discovery), not a file lock. There is no static per-package write-path declaration (CL-6952 removed it — no shipped director ever set one); instead the task tool records, without blocking, when two concurrently running dispatches land on the same cwd (see `intervention-log.ts`'s `conflict` class). +**Tool envelopes** prefer small `tools.allow` mounts over deny-everything. Shipped docs/design directors (shakespeare, rand, bruckheimer) mount write tools with no path-level lock. Lane routing is spawn policy (shakespeare = P/A/I docs, rand = DESIGN.md, bruckheimer = product discovery), not a file lock. There is no static per-package write-path declaration (CL-6952 removed it — no shipped director ever set one); instead spawn_agent records, without blocking, when two concurrently running dispatches land on the same cwd (see `intervention-log.ts`'s `conflict` class). **Typical chain:** bruckheimer → counsel → greybeard → builder (+ intern) → critic (+ optional neckbeard), with skywalker coordinating throughout. @@ -303,11 +303,11 @@ Data-only agent plugins (`src/plugins/data-only-agent.ts`) synthesize `agentPlug ### System Prompt (`src/agent/prompts.ts`) -The primary session identity is **Skywalker** (`buildChatRole` → `createSkywalkerSystemPrompt`). Product name remains Corbits Code; when asked its name, the primary answers Skywalker. Role: orchestrate — classify, DIY tiny/single-file/one-route product edits, dispatch closed directors via `task` for substantial work, track the fleet, synthesize. Product mutation tools (`write_file` / `edit_file` / `delete_file`) are mounted on the primary session (CORE and `SKYWALKER_TOOLS`) so Skywalker can DIY bounded edits; spawn remains the default for substantial, multi-file, parallel, or specialist work. Shell file-writes stay denied by auto-shell policy. MCP tools are not re-filtered by a product-write deny list (that list is gone). There is no static per-leaf write-path lock; concurrent lanes sharing a cwd are instead flagged (not blocked) as a `conflict` intervention. A frontier model already knows how to code; the static prompt carries harness-specific facts and the closed-fleet orchestration policy. The base is three individually-exported sections: +The primary session identity is **Skywalker** (`buildChatRole` → `createSkywalkerSystemPrompt`). Product name remains Corbits Code; when asked its name, the primary answers Skywalker. Role: orchestrate — classify, DIY tiny/single-file/one-route product edits, dispatch closed directors via `spawn_agent`/`wait_agents` for substantial work, track the fleet, synthesize. Product mutation tools (`write_file` / `edit_file` / `delete_file`) are mounted on the primary session (CORE and `SKYWALKER_TOOLS`) so Skywalker can DIY bounded edits; spawn remains the default for substantial, multi-file, parallel, or specialist work. Shell file-writes stay denied by auto-shell policy. MCP tools are not re-filtered by a product-write deny list (that list is gone). There is no static per-leaf write-path lock; concurrent lanes sharing a cwd are instead flagged (not blocked) as a `conflict` intervention. A frontier model already knows how to code; the static prompt carries harness-specific facts and the closed-fleet orchestration policy. The base is three individually-exported sections: - `buildChatRole` — Skywalker primary identity (orchestrate; DIY tiny/bounded product edits; spawn for substantial work). - `buildHarnessFacts` — the non-derivable rules: shell file-writes are blocked, path tools are the DIY surface on primary (spawn builder/docs directors for substantial work), dependency installs and off-limits paths need approval, images are native multimodal input, only core tools are resident (load the rest via `tool_search`; use `search_agents` before dispatching specialists), workflows run only from slash-command steps, and session memory lives at `.corbits/MEMORY.md`. -- `buildGuidelines` — be concise, prefer `task` for substantial product work, DIY tiny/bounded edits on the parent, answer questions and diagnose visual/product feedback before editing, work autonomously for explicit coding tasks, use `lsp` for symbol work, and verify changes when practical. +- `buildGuidelines` — be concise, prefer `spawn_agent`/`wait_agents` for substantial product work, DIY tiny/bounded edits on the parent, answer questions and diagnose visual/product feedback before editing, work autonomously for explicit coding tasks, use `lsp` for symbol work, and verify changes when practical. - `buildPromptDisciplineBlock` — a shared, prohibition-form section appended exactly once to every built prompt (chat and sub-agent, every provider family). Primary vs leaf wording differs for product writes: leaves are told to use `read_file`/`edit_file`/`write_file`; Skywalker is told to DIY tiny/bounded edits with those path tools and spawn directors for substantial work. Shared rules: never `cat`/`sed`/heredoc/`echo` for file work, no setting or exporting environment variables (recurring needs belong in project settings), `web_fetch`/`web_search` instead of `curl`/`wget`/hand-rolled queries, one operation per `run_shell` call, turn semantics (a tool-less reply is the final answer, no repeat searches, stop and change approach after three failed attempts, batch independent reads in parallel), and TTY output rules (short bold headers, one-line bullets, backticks for paths/commands, no wide tables). **Provider-conditional residuals.** Per-family additions layer on top of the shared block via the same `ModelFamilyPolicy` mechanism the directors use (`src/subagent/provider-family.ts`, `src/agent/model-family-policy.ts`) — additive lines, never prompt forks. **Grok** leaves get `buildGrokLeafAntiThrashNote` (gated by `shouldApplyGrokAntiThrash` / `applyGrokFinishBias`, withheld from orchestrators): a compact finish-bias reinforcement plus a one-line reminder to route file/web work through the dedicated tools rather than `run_shell`, motivated by observed tool-routing thrash on the same harness. **Kimi** intentionally has no residual yet — `detectModelFamily` already resolves the family so callers can branch on it, but the prompt seam is left unfilled pending eval characterization of Kimi's behavior, mirroring the provisional (permissive-default) policy in `model-family-policy.ts`. @@ -373,7 +373,7 @@ tool call - **classify** — Read-only tools (`read_file`, `search_files`, `grep`, `list_dir`) are tier `allow`; everything else is tier `ask`. Builds approval requests: shell yields one request for the full command the model asked to run (security still splits under the gate); file tools keyed on the target path; other tools keyed on tool name. - **command** — Splits chained commands for security classification and derives command-shape approval scopes. Multi-segment chains only offer an exact-command persist pattern (a prefix like `npm *` must not cover `npm i && rm -rf /` later). - **auto-shell-policy** — Constrains `run_shell` even when auto mode would otherwise rubber-stamp it. Before matching, `expandShellSubjects` peels `bash`/`sh`/`zsh -c`, `xargs` utility tails, and transparent prefixes (`env`, `nice`, `timeout`, …) so rules see the real payload; an unparseable wrapper (variable expansion or command substitution) sets an opaque flag that forces `ask`. Effects: `deny` blocks outright (file mutations through ad-hoc tooling — output redirection, `tee`, `sed -i`/`perl -i`, interpreter inline programs or heredocs — which must instead go through `write_file`/`edit_file`); `ask` declines to auto-allow and falls through to the operator prompt (recursive `rm`, dependency installs and remote runners: npm/yarn/pnpm/bun, pip, cargo, go, brew, npx/bunx, …, force or uncontained `git worktree` ops, shell that references a sensitive path such as `.env` or a private key, and opaque wrappers). Contained non-force `git worktree add`/`remove`/`prune` and read-only `list` auto-allow (sibling destinations like `../corbits-dispatch-wts/…` included; absolute outside, `~`, globs, and credential basenames still ask). Deny beats ask when multiple subjects match. Quoted spans are stripped before pattern matching so a quoted `>` or install word in an argument is not flagged, and program names are matched only in command position. Adding a table category is a one-line rule append in `AUTO_SHELL_RULES`. -- **gate** — Evaluates a call: `skipPermissions` allows everything; `allow`-tier passes; for `ask`-tier, checks persisted approvals, otherwise requests operator approval. Shell security classifies each chain segment (`||` / `&&` / `|` / `;` / newlines), but the operator is prompted once for the full command block — any unapproved segment fails the whole block, and execution always runs the unsplit original. Safe pipeline tails and pure shell no-ops (`true` / `false` / `:` and bare control-flow keywords stranded by chain-splitting) skip without a prompt. In a non-interactive run an unresolved `ask` becomes a denial. In auto mode: non-shell built-ins in `AUTO_ALLOWED_TOOLS` (writes/edits/deletes, `manage_tasks`, `task`, …) auto-allow when not path-restricted; for `run_shell` the gate consults the auto-shell policy — a `deny` rule fails the call, an `ask` rule skips the auto-allow shortcut and proceeds to the normal approval flow, and anything unmatched is auto-allowed. Paths outside the workspace and writes under the session state root (`~/.corbits/projects/...` and legacy `.agent-state`) still ask under auto mode. Under `--dangerously-skip-permissions` (forces this process) or `/yolo` (persists as the user-global default via `setSkipPermissions`), the gate auto-allows those same cases, and pre-gate sandboxes (path-escape, shell session cwd retention, `list_dir` / `delete_file` workspace bounds) honor `getSkipPermissions()` live so outside-workspace access is not hard-denied after the gate already allowed it — without rebuilding the plugin stack. Secret-guard path denies and authorization hard blocks still apply. Mutating MCP and unknown built-ins are not blanket-allowed outside skip. Newly granted scopes are appended in memory and persisted. +- **gate** — Evaluates a call: `skipPermissions` allows everything; `allow`-tier passes; for `ask`-tier, checks persisted approvals, otherwise requests operator approval. Shell security classifies each chain segment (`||` / `&&` / `|` / `;` / newlines), but the operator is prompted once for the full command block — any unapproved segment fails the whole block, and execution always runs the unsplit original. Safe pipeline tails and pure shell no-ops (`true` / `false` / `:` and bare control-flow keywords stranded by chain-splitting) skip without a prompt. In a non-interactive run an unresolved `ask` becomes a denial. In auto mode: non-shell built-ins in `AUTO_ALLOWED_TOOLS` (writes/edits/deletes, `manage_tasks`, `spawn_agent`, `wait_agents`, …) auto-allow when not path-restricted; for `run_shell` the gate consults the auto-shell policy — a `deny` rule fails the call, an `ask` rule skips the auto-allow shortcut and proceeds to the normal approval flow, and anything unmatched is auto-allowed. Paths outside the workspace and writes under the session state root (`~/.corbits/projects/...` and legacy `.agent-state`) still ask under auto mode. Under `--dangerously-skip-permissions` (forces this process) or `/yolo` (persists as the user-global default via `setSkipPermissions`), the gate auto-allows those same cases, and pre-gate sandboxes (path-escape, shell session cwd retention, `list_dir` / `delete_file` workspace bounds) honor `getSkipPermissions()` live so outside-workspace access is not hard-denied after the gate already allowed it — without rebuilding the plugin stack. Secret-guard path denies and authorization hard blocks still apply. Mutating MCP and unknown built-ins are not blanket-allowed outside skip. Newly granted scopes are appended in memory and persisted. - **matcher** — Approval pattern matching via `@intx/authz` `matchPattern` (`*` wildcards). Exact-command grants store a backslash before each metacharacter; those patterns match by equality after unescape (the package has no escape syntax). - **authz-grants** — Maps stored approvals into `@intx/authz` `GrantRule`s and evaluates them with `evaluateGrants` (allow-only; Corbits cwd/provider-model filters applied first). Exact-escaped grants bypass the package path and use equality. @@ -383,7 +383,7 @@ tool call **Approval log** (`src/permission/approval-log.ts`, CL-5666): every consequential decision the gate makes — auto-mode allow/deny or an interactive prompt's allow-once/allow-with-scope/deny/timeout/abort — is appended as one JSONL record to `approvals.jsonl` in the session dir, carrying the classifier/auto-shell rule name that fired (the existing `auto-shell-policy.ts`/`classify.ts` rule names, plus a small closed set of additional fixed literals the log itself defines for decisions those modules don't otherwise name — `auto-allowed-tool`, `non-interactive`, `mega-chain` — never model- or user-authored text), whether the decision was `auto` or `interactive`, a shell chain's segment count, and queued/displayed/settled timestamps. `displayedAt` is set by `PermissionRequest.markDisplayed`, called from `gate-wire.ts`'s `open()` the moment a request actually reaches the overlay host — distinct from when it was raised, so the gap it exposes is the CL-5664 signal (a queued gate arming its timeout before the operator could see it). No command text, file content, path, credential, or other free text is ever recorded — only tool name, rule, mode, segment count, and timing; a sub-agent's free-text dispatch label is deliberately left out, even though it would enable a per-agent breakdown, because nothing constrains what a model puts in it. A hard size cap on the serialized line is defense in depth against a future field reintroducing free text. Writes are fire-and-forget and swallow their own errors; the log defaults to a no-op so nothing depends on it being wired. `scripts/approval-forensics.ts` aggregates across local sessions the same way `intervention-forensics.ts` does for stop/nudge events: per-tool counts by outcome and mode, duration/display-delay percentiles, mega-chain counts, and a duplicate-rate proxy (sessions that hit the same rule more than once). -**Tool wall-clock budget vs. permission prompts.** Each tool `run()` is wrapped by an outer execution watchdog (`src/tui/tool-execution-watchdog.ts`). The watchdog arms only when Settings set `tools.timeoutMs` / `tools.maxTimeoutMs`, or when `run_shell` passes a positive timeout (requested plus slack, so this layer cannot beat shell-guard). The `task` tool is always exempt, regardless of Settings: the generic per-tool budget never aborts a sub-agent run. That exemption is unconditional, not because the leaf is otherwise bounded — there is no turn budget; `deadlineMs` is opt-in, and there is no no-progress or thrash stop. A stuck leaf that never trips stall or deadline runs until the parent cancels it or, in eval mode, `--agent-timeout-ms` bounds it. By default (`tools.waitForApproval`, Settings → Tools, **On**), an armed budget freezes while the operator is deciding on a permission prompt, so a late approve still runs the tool and the agent waits for the decision instead of timing out under the modal. When **Off**, the budget keeps ticking during the prompt; if it expires first the tool is skipped and the permission modal is dismissed via the budget AbortSignal (auto-deny with a timeout message). The TUI permission queue (`src/tui/gate-wire.ts`, backed by `src/permission/queue.ts`) attaches that signal so ghost prompts cannot outlive an already-aborted tool. +**Tool wall-clock budget vs. permission prompts.** Each tool `run()` is wrapped by an outer execution watchdog (`src/tui/tool-execution-watchdog.ts`). The watchdog arms only when Settings set `tools.timeoutMs` / `tools.maxTimeoutMs`, or when `run_shell` passes a positive timeout (requested plus slack, so this layer cannot beat shell-guard). Fleet wait tools are exempt, regardless of Settings: the generic per-tool budget never aborts a sub-agent run while the parent waits for it. That exemption is unconditional, not because the leaf is otherwise bounded — there is no turn budget; `deadlineMs` is opt-in, and there is no no-progress or thrash stop. A stuck leaf that never trips stall or deadline runs until the parent cancels it or, in eval mode, `--agent-timeout-ms` bounds it. By default (`tools.waitForApproval`, Settings → Tools, **On**), an armed budget freezes while the operator is deciding on a permission prompt, so a late approve still runs the tool and the agent waits for the decision instead of timing out under the modal. When **Off**, the budget keeps ticking during the prompt; if it expires first the tool is skipped and the permission modal is dismissed via the budget AbortSignal (auto-deny with a timeout message). The TUI permission queue (`src/tui/gate-wire.ts`, backed by `src/permission/queue.ts`) attaches that signal so ghost prompts cannot outlive an already-aborted tool. `mcp__*` tool calls are the exception to "arms only when Settings set it": they arm unconditionally with a 5-minute default (`DEFAULT_MCP_TOOL_TIMEOUT_MS`), overridable via `mcp.timeoutMs` and still capped by `tools.maxTimeoutMs` (CL-6895). Nothing else bounds an MCP call — the stall watchdog treats an in-flight tool as activity by design, so a wedged MCP server previously hung a tool call, and the turn, forever. On expiry the call returns a normal tool-error result ("MCP tool `` timed out after ``s — the server may be wedged; retry or continue without it"); the turn is never aborted. The MCP client itself (`src/mcp/client.ts`, wrapping `@modelcontextprotocol/sdk`) multiplexes concurrent requests over one connection by JSON-RPC message id with no serial queue or mutex in our code or in the vendored SDK's `Protocol.request()` — so concurrent calls to the same server are not expected to deadlock each other. Live forensics for CL-6895 showed multi-minute MCP calls that eventually completed successfully, consistent with a slow server response rather than a client-side deadlock. @@ -412,7 +412,7 @@ Corbits Code **ships a bundled catalog** as the first-party data-only plugin `pl `discoverRepoPlugins` locates `plugins/` next to the source root, at `dist/plugins`, or at `dirname(execPath)/plugins`. It never scans the session cwd for the bundled catalog. -Primary is Skywalker. Bundled skill bodies are **how-to playbooks** (steps, done-definition) — not director personas and not fleet routers. Identity and who-does-what live on director system prompts. Default slashes: `/implement`, `/plan`, `/refactor`, `/review`, `/pull-request-review`, `/create-issue`, `/scribe`, `/interview`, `/ast-grep`. `/review` is how to review a branch (base, scope, signal); `/pull-request-review` is worktree checkout then the review skill; `/scribe` is how to maintain PRODUCT / ARCHITECTURE / IMPLEMENTATION; `/implement` is the per-commit greybeard → implement → gate → critic loop; `/plan` authors an eng change plan and does not implement or file tickets; `/create-issue` remains the tracker command — Linear MCP when available, otherwise `ask_operator` for the platform and persists `Preferred issue tracker` in `.corbits/MEMORY.md`. There is no first-party dispatch skill — Skywalker orchestrates natively. Draper and emil are closed directors via `task(agent=…)`, not slashes. There is no catch-all worker. The operator types the slash; the primary follows the playbook. +Primary is Skywalker. Bundled skill bodies are **how-to playbooks** (steps, done-definition) — not director personas and not fleet routers. Identity and who-does-what live on director system prompts. Default slashes: `/implement`, `/plan`, `/refactor`, `/review`, `/pull-request-review`, `/create-issue`, `/scribe`, `/interview`, `/ast-grep`. `/review` is how to review a branch (base, scope, signal); `/pull-request-review` is worktree checkout then the review skill; `/scribe` is how to maintain PRODUCT / ARCHITECTURE / IMPLEMENTATION; `/implement` is the per-commit greybeard → implement → gate → critic loop; `/plan` authors an eng change plan and does not implement or file tickets; `/create-issue` remains the tracker command — Linear MCP when available, otherwise `ask_operator` for the platform and persists `Preferred issue tracker` in `.corbits/MEMORY.md`. There is no first-party dispatch skill — Skywalker orchestrates natively. Draper and emil are closed directors via `spawn_agent(agent=…)`, not slashes. There is no catch-all worker. The operator types the slash; the primary follows the playbook. #### Discovery and precedence diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 0a9881894..5ed821ff0 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -87,8 +87,8 @@ src/ stream-consumer.ts Async stream consumer with error handling hooks.ts Lifecycle hooks: discovery, turn collector, run summary subagent/ - index.ts Sub-agent spawn + SubAgentDirector - task-tool.ts task() — fused spawn+wait; resolveDirector first + index.ts Sub-agent run exports + SubAgentDirector + agent-fleet.ts spawn_agent / wait_agents fleet dispatch and mailbox tools session-store.ts Retained child session transcripts for observe UI identity-context.ts ALS: worker description + cwd for gate attribution config/ @@ -153,18 +153,18 @@ docs/ Sixteen packages under `src/agent/directors//` register in `DIRECTOR_REGISTRY` (`registry.ts`). Wire path: -1. `spawn_agent(agent=…)` / `task(agent=…)` / `task(intent=…)` → `resolveDirector` in `task-tool.ts` before tools and system prompt are built. Bare `task` (neither field) and `intent=general` fail closed. +1. `spawn_agent(agent=…)` / `spawn_agent(intent=…)` → `resolveDirector` in `agent-fleet.ts` before tools and system prompt are built. Bare `spawn_agent` (neither field) and `intent=general` fail closed. 2. `packageToProfile` maps envelope (`tools.allow`/`deny`) to `AgentProfile.capabilities` and `spawn.maySpawn` → `orchestrator`. System prompts are prefixed with a stable identity block (`formatDirectorSystemPrompt`: agent id, model role, optional skills). -3. Nested spawn: packages with `spawn.allowlist` forward that list into nested `task` (`spawnAllowlist` on nestedDispatch). Off-list `agent` is refused. `task(agent=skywalker)` is refused (primary is not a spawned worker). Primary omits the list so plugin profiles stay reachable. +3. Nested spawn: packages with `spawn.allowlist` forward that list into nested `spawn_agent` (`spawnAllowlist` on nestedDispatch). Off-list `agent` is refused. `spawn_agent(agent=skywalker)` is refused (primary is not a spawned worker). Primary omits the list so plugin profiles stay reachable. 4. `directorProfiles()` is the spawn catalog (`default-agents.ts`) — closed set minus skywalker. Plugin and local `.agents/agents/` profiles still load, but closed `DIRECTOR_IDS` cannot be overridden or aliased. 5. Primary chat role is Skywalker: `buildChatRole()` → `createSkywalkerSystemPrompt()`. Product mutation tools (`write_file` / `edit_file` / `delete_file`) live in CORE (and `SKYWALKER_TOOLS`) so they are advertised on the primary without a `tool_search` round-trip. DIY tiny/bounded edits on the parent; spawn builder/docs directors for substantial work — a prompt judgment call, not a toolset strip. `PRIMARY_DENIED_PRODUCT_TOOLS` is gone. Shell file-writes stay denied; MCP tools are not re-filtered by a product-write deny list. There is no static per-profile write-path lock (CL-6952). **Codex tool proxies.** When the active provider is Codex (`isCodexProviderName`), `createAgentToolset` and `runSubAgent` mount `apply_patch`, `shell`, and `update_plan` stringTools from `createCodexToolProxies`, all forwarding through the same posix `ToolRunner` seam (`runTool`) so permission plugins still apply. `apply_patch` parses the Codex envelope and forwards each op (`write_file` / `delete_file` / `read_file`). `shell` — the native Codex name is `shell`, not `exec_command`, per the pinned base-instructions text quoted in `codex-responses-adapter.ts`'s bridge message — normalizes Codex's `command` (string or `["bash","-lc",script]`-style argv array), `workdir`, and `timeout_ms` onto `run_shell`'s `{command, cwd?, timeout?}` and is gated by `allowShellFromCapabilities` (mirrors `allowDeleteFromCapabilities` against `run_shell`). `update_plan` maps Codex's `plan: [{step, status}]` onto `manage_tasks(action: "create")`; `pending`/`in_progress`/`completed` map to `todo`/`doing`/`done` — `manage_tasks`'s `cancelled` status has no Codex equivalent and is never produced by this proxy. Primary strips `apply_patch` after mount (Corbits DIY stays on `write_file` / `edit_file` / `delete_file`); `shell` and `update_plan` stay on primary (same classification as `run_shell` / `manage_tasks`). Build and docs leaf allowlists (`BUILD_TOOLS` / `DOCS_TOOLS`) include `apply_patch` so Codex workers keep the proxy after the capability filter. `CORE_TOOL_NAMES` does not list it. -6. There is no static write-path declaration on packages or profiles (CL-6952 removed it — no shipped director ever set one). Instead, `task-tool.ts` tracks each running dispatch by cwd; a new dispatch that lands on the same cwd as a still-running lane records a `concurrent-lane-overlap` entry in `intervention-log.ts` (class `conflict`). This is advisory only — it never blocks the spawn, since cwd overlap does not prove the two lanes touch the same files. +6. There is no static write-path declaration on packages or profiles (CL-6952 removed it — no shipped director ever set one). Instead, `agent-fleet.ts` tracks each running dispatch by cwd; a new dispatch that lands on the same cwd as a still-running lane records a `concurrent-lane-overlap` entry in `intervention-log.ts` (class `conflict`). This is advisory only — it never blocks the spawn, since cwd overlap does not prove the two lanes touch the same files. 7. Spawn effort: pin > package `modelRole` default (`defaultEffortForDirector`; intern=low; plan/review/orchestrator=high; implement/explore/docs/test=medium) > orchestrator/worker binary > parent inheritance. Optional skills are listed in the identity header for awareness; workers do not mount `use_skill` (guidance is baked into package system prompts). Primary mounts `use_skill` for its own skill list. -Intent defaults: `intent=implement` → director `builder`; `explore` → `explorer`; `plan` → `counsel`; `review` → `critic`; general → error. Spawn: skywalker full fleet; greybeard intern/explorer/critic only; all other directors no `task`. Live `` injects cwd, platform, arch, runtime, date, and git status on every chat and worker prompt. +Intent defaults: `intent=implement` → director `builder`; `explore` → `explorer`; `plan` → `counsel`; `review` → `critic`; general → error. Spawn: skywalker full fleet; greybeard intern/explorer/critic only; all other directors mount no fleet tools. Live `` injects cwd, platform, arch, runtime, date, and git status on every chat and worker prompt. ### Auto Mode @@ -187,7 +187,7 @@ Unmatched shell auto-allows, including contained non-force `git worktree add`/`r `ChatInputProps` carries `isProcessing?: boolean` and `onInterrupt?: (message: string) => void`. When `isProcessing` is true, drain timing is **parent-idle** vs **session-idle**: -- **Enter** soft-steers while the parent is busy — enqueues kind `"steer"` and delivers at the next **parent** `tool.boundary` (the parent tool finishing, not a child). Does not interrupt. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; a long parent `run_shell` or awaiting `task()` is parent-busy and holds steers. +- **Enter** soft-steers while the parent is busy — enqueues kind `"steer"` and delivers at the next **parent** `tool.boundary` (the parent tool finishing, not a child). Does not interrupt. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; a long parent `run_shell` or awaiting `wait_agents` is parent-busy and holds steers. - **Alt+Enter** queues a follow-up (kind `"queue"`) delivered only on **session-idle** — parent-idle **and** no live fleet lanes (`run` goes idle). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent turn can settle while workers keep running. The runner emits a `fleet` event carrying the live-lane count; the bridge holds the run busy on that count, so mid-hold Enter upgrades to a new primary turn (sent immediately) instead of queueing a steer, follow-ups keep waiting for true session-idle, and any steer left pending at the hold's engagement delivers immediately — the parent it was steering has already stopped. @@ -232,7 +232,7 @@ Provider and model configuration lives in JSON settings files. The global file h } ``` - - `timeoutMs` / `maxTimeoutMs` — outer execution watchdog around each tool `run()`. Unset leaves the watchdog unarmed; set these to arm it. `maxTimeoutMs` clamps non-shell tools when set and does not cap a longer requested `run_shell`. The `task` tool is always exempt: a dispatched sub-agent is bounded by stall, opt-in `deadlineMs`, and operator cancel, not the generic per-tool budget. + - `timeoutMs` / `maxTimeoutMs` — outer execution watchdog around each tool `run()`. Unset leaves the watchdog unarmed; set these to arm it. `maxTimeoutMs` clamps non-shell tools when set and does not cap a longer requested `run_shell`. Fleet wait tools are exempt: a dispatched sub-agent is bounded by stall, opt-in `deadlineMs`, and operator cancel, not the generic per-tool budget. - `waitForApproval` (default **true** when unset) — freeze that budget while a permission prompt is open so a late approve still runs the tool. **Settings → Tools** toggles this live for the next tool call and persists it here. When **false**, the budget keeps ticking during the prompt; on expiry the tool is skipped and the modal is auto-dismissed. The freeze is bounded: after **30 minutes** with the prompt still unanswered the budget resumes ticking on its own, so a prompt that never becomes visible (overlay open, UI gone) cannot hang a tool run indefinitely. Optional `mcp` block bounds MCP tool calls (`mcp__*` names) specifically — unlike `tools.*`, this arms **unconditionally** even with no settings at all, defaulting to **5 minutes**, since a wedged MCP server otherwise hangs a call forever with nothing to bound it (CL-6895): diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 3848302e6..f4f986473 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -135,7 +135,7 @@ export type PluginManifest = { Workflow recipe names are **not** registered as top-level `/scope` slashes; an integration plugin owns the command prefix (e.g. a `kind: "workflow"` plugin → `/mywf scope`) and contributes workflow definitions beside the plugin under `plugins//src/workflows/`. Types live in `src/workflows/definition.ts`. -`agent` plugins contribute dispatchable profiles rather than commands. A command or workflow can still fan out to one subagent or a fleet through the normal `task` surface. +`agent` plugins contribute dispatchable profiles rather than commands. A command or workflow can still fan out to one subagent or a fleet through `spawn_agent` and `wait_agents`. The kind-specific export is the implementation hook: @@ -145,7 +145,7 @@ The kind-specific export is the implementation hook: | `command` | `commandPlugin` | slash-command registry | slash commands | | `workflow` | `workflowPlugin` + optional `commandPlugin` | workflow registry + slash-command registry | named workflow recipes behind an integration command prefix | | `tool` | `toolPlugin` (factory) | posix toolset | add new agent tools (highest trust) | -| `agent` | `agentPlugin` | sub-agent profiles | contribute `task`-dispatchable agent profiles | +| `agent` | `agentPlugin` | sub-agent profiles | contribute `spawn_agent`-dispatchable agent profiles | A module with no valid manifest is ignored (not silently half-loaded). @@ -246,8 +246,8 @@ shape. ### Agent plugins -- `agent` plugins (`agentPlugin` export) contribute `AgentProfile`s that the - `task` tool can dispatch to, resolved in `src/plugins/agent-plugins.ts` and +- `agent` plugins (`agentPlugin` export) contribute `AgentProfile`s that + `spawn_agent(agent=...)` can dispatch to, resolved in `src/plugins/agent-plugins.ts` and merged into the profile registry alongside local `.agents/agents/` profiles. - **Data-only agent plugins** — a directory with `agents/*.md` (or flat `*.md`) and optional `skills//SKILL.md` needs no `index.ts`; `loadDataOnlyAgentPlugin` diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 0adf991da..938662deb 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -35,14 +35,14 @@ The evidence is in how the product fails today: the personas already produce exc ## Key Value Propositions 1. **Deterministic progress** — Every turn must produce a tool call. No idle thinking; the director aborts a stalled run rather than spinning. -2. **Task tracking** — The agent can maintain a `manage_tasks` checklist for multi-step work; non-interactive `submit_output` is blocked while checklist items remain open. (A "task" here is a work item, not a child agent — spawning uses the separate `task` tool / sub-agent surface.) +2. **Task tracking** — The agent can maintain a `manage_tasks` checklist for multi-step work; non-interactive `submit_output` is blocked while checklist items remain open. (A "task" here is a work item, not a child agent — spawning uses the separate `spawn_agent` / `wait_agents` sub-agent surface.) 3. **Stall detection** — The director detects idle cycles and intervenes. 4. **Safe by default** — Consequential actions (writes, edits, shell) pass a permission gate; secret files and catastrophic commands are denied outright, regardless of intent. 5. **Resume capability** — Runs persist to a git-backed store and resume from the last point after interruption. 6. **Legible loop** — A live event log, working-tree diff panel, plan tracker, and real-time cost meter show what happened, when, and why. 7. **Operator-in-the-loop** — The agent can call `ask_operator` to pause and ask a clarifying question; the operator answers from a modal (TUI) or via stdin when the product agent runs under `corbits exec`. -8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` or an awaiting `wait_agents` / `task()` is parent-busy, so Enter is a queued steer, not a new turn. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` stop). -9. **Orchestrator-only (TUI + exec)** — The primary session is always the orchestrator: it can act directly and delegates via `spawn_agent` / `wait_agents` / `search_agents` (`task` remains a fused spawn-plus-wait wrapper). Long jobs belong on workers — a parent that runs them itself stays parent-busy and holds Enter steers. Single-agent session mode, the first-run mode picker, and Settings → Session are gone (CL-5814). Legacy `sessionMode` values on disk are ignored. +8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` or an awaiting `wait_agents` is parent-busy, so Enter is a queued steer, not a new turn. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` stop). +9. **Orchestrator-only (TUI + exec)** — The primary session is always the orchestrator: it can act directly and delegates via `spawn_agent` / `wait_agents` / `search_agents`. Long jobs belong on workers — a parent that runs them itself stays parent-busy and holds Enter steers. Single-agent session mode, the first-run mode picker, and Settings → Session are gone (CL-5814). Legacy `sessionMode` values on disk are ignored. ## User Experience @@ -105,7 +105,7 @@ recovery line instead of dumping the file path and parse details. The TUI has an extensible slash-command framework. Built-ins include `/help` (shortcut + command overlay), `/model` (models-only picker for connected accounts; **Alt+A** or `/connect` adds a provider), `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, `/mcp`, and `/yolo` (persists as the user-global skip-permissions default; `--dangerously-skip-permissions` still forces this process; secret-guard and authz still apply; `/yolo [on|off|toggle]`, bare `/yolo` toggles), plus a `/` command per available workflow. When a session starts with the persisted default already on, the TUI shows a startup notice ("Permission prompts are disabled by your saved default…") so the silent machine-wide default is never invisible; `corbits exec` prints the equivalent warning to stderr. Plugins can register additional commands. -**Default skills** exist out of the gate as first-party slash **actions**, not director names: `/implement`, `/plan`, `/refactor`, `/review`, `/pull-request-review`, `/create-issue`, `/scribe`, `/interview`, `/ast-grep`. Each one is a how-to playbook — the slash sends the skill body to the primary, which follows the steps. Skills do not assign identity or route the fleet; that stays on director system prompts. `/review` is how to review a branch; `/scribe` is how to maintain PRODUCT / ARCHITECTURE / IMPLEMENTATION; `/implement` is the per-commit review/build/critique loop; `/plan` authors an eng change plan (files, AC, non-goals, risks, ordered steps) and does not implement. `/create-issue` remains the tracker command: Linear MCP when available; otherwise it `ask_operator`s for the platform (GitHub etc.) and persists `Preferred issue tracker` in `.corbits/MEMORY.md` (GitHub via `gh issue create`). There is no first-party dispatch skill — Skywalker orchestrates natively. `git-rebase`, `linear-issue-workflow`, `style`, `philosophy`, `typescript`, and `opsh` stay `use_skill` only (`user-invocable: false`). Draper and emil are not slashes; they remain closed directors via `task(agent=…)`. There is no catch-all worker. Slash names are also available to the model via `use_skill`. Disable the catalog in `/plugins` (`corbits-skills`) if you want them gone. +**Default skills** exist out of the gate as first-party slash **actions**, not director names: `/implement`, `/plan`, `/refactor`, `/review`, `/pull-request-review`, `/create-issue`, `/scribe`, `/interview`, `/ast-grep`. Each one is a how-to playbook — the slash sends the skill body to the primary, which follows the steps. Skills do not assign identity or route the fleet; that stays on director system prompts. `/review` is how to review a branch; `/scribe` is how to maintain PRODUCT / ARCHITECTURE / IMPLEMENTATION; `/implement` is the per-commit review/build/critique loop; `/plan` authors an eng change plan (files, AC, non-goals, risks, ordered steps) and does not implement. `/create-issue` remains the tracker command: Linear MCP when available; otherwise it `ask_operator`s for the platform (GitHub etc.) and persists `Preferred issue tracker` in `.corbits/MEMORY.md` (GitHub via `gh issue create`). There is no first-party dispatch skill — Skywalker orchestrates natively. `git-rebase`, `linear-issue-workflow`, `style`, `philosophy`, `typescript`, and `opsh` stay `use_skill` only (`user-invocable: false`). Draper and emil are not slashes; they remain closed directors via `spawn_agent(agent=…)`. There is no catch-all worker. Slash names are also available to the model via `use_skill`. Disable the catalog in `/plugins` (`corbits-skills`) if you want them gone. Providers are **models-first**: there is no standalone `/login` command. `/model` opens a **models-only list** (Recent, Favorites, then connected provider/model rows) — type-to-filter owns printable keys, so Connect is never a bare letter. **Alt+A** or `/connect` opens a dedicated add-provider selector over every first-class kind (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Ollama, Custom), each annotated with its live account count and never filtered out for “already connected.” **Alt+F** toggles favorite on the highlighted model. **Alt+D** persists the highlighted pair as the default without switching the live session. Advanced provider drill-down (edit/delete/tiers) stays on the advanced surface, not a bare printable key while the model list is filtering. OAuth providers open their existing browser login with a named account step so multiple accounts per kind coexist (`codex/work`, …). API-key providers use the same named-instance step before the key (auth-only form: instance name + key + fixed catalog base URL), so personal and team keys land as distinct catalog rows (`openai/default`, `anthropic/work`, …); reusing a name re-keys that instance after confirm. Custom remains a free-form single endpoint (full manual form). Successful connect refreshes the catalog and reopens the model list focused on the new account’s default model. OpenCode Go routes each model by its protocol metadata (chat completions, OpenAI responses, or Anthropic messages) and can show subscription usage in the status bar when active (rolling 5h / weekly / monthly windows when the usage API responds; omitted on auth or network failure). When Go returns a quota or rate-limit error — including some HTTP 400 responses that carry limit payloads — Corbits classifies them so quota aborts cleanly and short provider rate limits remain retryable. On a free-tier or subscription quota hit, wait for the window to reset or use OpenCode Zen free models. @@ -162,16 +162,15 @@ The primary session is always **orchestrator** (single-agent mode is gone). Its | Design | draper, emil, rand | | Docs / QA | shakespeare, testsmith, tester | -There is **no catch-all worker**. `task` requires `agent=…` or a non-general `intent` (implement/explore/plan/review→critic); bare dispatch and `intent=general` are refused. Named `task(agent=…)` selects a director package without requiring a plugin profile, except `skywalker` which is the primary session identity and is refused as a spawned worker. Nested spawn is runtime-enforced: only skywalker (full fleet allowlist) and greybeard (intern/explorer/critic) may spawn; other workers have no `task`. Primary omits an allowlist so plugin profiles remain reachable from the main session. +There is **no catch-all worker**. `spawn_agent` requires `agent=…` or a non-general `intent` (implement/explore/plan/review→critic); bare dispatch and `intent=general` are refused. Named `spawn_agent(agent=…)` selects a director package without requiring a plugin profile, except `skywalker` which is the primary session identity and is refused as a spawned worker. Nested spawn is runtime-enforced: only skywalker (full fleet allowlist) and greybeard (intern/explorer/critic) may spawn; other workers have no fleet tools. Primary omits an allowlist so plugin profiles remain reachable from the main session. Corbits Code fans work out to short-lived **sub-agents** — child agents with their own loop, tools, and checklist — while the primary session stays focused. - **Agents** are runtime entities (primary session or child). - **Tasks** are checklist items owned by one agent via `manage_tasks`. -- **Sub-agents** are spawned with `spawn_agent` / `wait_agents` (`task` remains a fused spawn-plus-wait wrapper). +- **Sub-agents** are spawned with `spawn_agent` / `wait_agents`. Dispatch uses a structured brief (context / goal / optional goals seed) and returns a structured report. The TUI Agents strip and fleet board show who is running; live tool progress updates the status bar without dumping the child transcript into the parent chat. There is no turn budget. A tool-less final turn completes only with the four-heading report envelope; without it, one nudge is given and a second tool-less turn without the envelope salvages as `incomplete-report-stop`. A silent worker (no activity for `stallTimeoutMs`, opt-in) gets one continuation nudge, then salvages as `stalled` if a second consecutive check finds no activity. An opt-in `deadlineMs`, or an operator cancel, can also end a run early. Each of these returns a salvage report so a runaway or idle child cannot quietly burn a large token budget or look done after prose alone. -The parent tracks same-brief dispatch counts for the session (`src/subagent/brief-dispatch.ts`) but never refuses a re-dispatch. Deadline salvage prepends an advisory hint to continue from Findings with a longer deadline only if more wall-clock time is warranted; cancelled salvage suggests continuing from Findings rather than redoing completed work. A successful complete resets the same-brief dispatch count. ## Roadmap (planned, not yet shipped) diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index e833799c0..a61c07c5a 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -20,8 +20,8 @@ Each event carries a small set of properties: | `slash_command` | A slash command is dispatched (shared product-event path) | `command_name` | | `skill_used` | `use_skill` loads a skill that resolved | (none beyond common properties) | | `plugin_loaded` | First successful load of a plugin identity in this process | `origin` | -| `subagent_start` | A `task` / fleet dispatch begins | `agent_name` | -| `subagent_end` | A `task` / fleet dispatch finishes | `agent_name`, `status`, `duration_ms`, `model`, `turn_count`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `tool_call_count`, `tool_error_count`, `stop_reason`, `parent_trace_id` | +| `subagent_start` | A `spawn_agent` dispatch begins | `agent_name` | +| `subagent_end` | A `spawn_agent` dispatch finishes | `agent_name`, `status`, `duration_ms`, `model`, `turn_count`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `tool_call_count`, `tool_error_count`, `stop_reason`, `parent_trace_id` | | `permission_prompt` | An approval prompt is answered (or abandoned) | `decision`, `permission_kind` | | `compaction` | The compactor actually folds turns away | `mode`, `duration_ms`, `turns_before`, `turns_after` | | `crash` | A fatal error reaches the process-level handler | `kind`, `error_class` | diff --git a/docs/TUI.md b/docs/TUI.md index d86951e7a..030e873e5 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -41,7 +41,7 @@ Horizontally, every surface sits inside one shared gutter as a single column of content rather than stacked panes. `resolveGeometry` always returns `layoutMode: "stack"` — full-width y-stack, no dual-column rail. Live workers paint in the agents strip -above the prompt; transcript `● Task …` rows remain spawn/final/fail +above the prompt; transcript `spawn_agent` rows remain spawn/final/fail anchors (see Live agents chrome below). The side gutter is one column per side at every width that can afford it, and zero below `MARGIN_MIN_COLUMNS` (40), where every column belongs to @@ -72,7 +72,7 @@ prose into a hard-capped inset paragraph (`LIVE_THINKING_MAX_LINES`, currently 1 (assistant text, a tool call, or settle), the row collapses to its opening clause with the rest behind expand. Mid-turn thinking bursts fold onto that same one row per turn (`reasoning-fold`); `inference.text.delta` grows the -open assistant streaming row in place. Sub-agent Task-row thinking is a +open assistant streaming row in place. Sub-agent spawn_agent-row thinking is a separate path and is unchanged by this preview. The prompt box's border carries the metadata that would otherwise cost a @@ -203,7 +203,7 @@ Because `formatChromeZones` parks task auto-paint, Alt+T does not surface a live `manage_tasks` list; it can still show preformatted task rows that tests or demos push via `setChromeZones`. -The task tool writes state through `ChatDirectorImpl` (`src/agent/director.ts`), +The `manage_tasks` tool writes state through `ChatDirectorImpl` (`src/agent/director.ts`), which calls `onTasksChange` on every `manage_tasks` tool call and on session hydrate. `manage_tasks` calls paint no transcript rows; with the checklist parked, that list has no standing chrome surface until rebuild. @@ -228,9 +228,9 @@ poll uses needed it **does not** call `bridge.syncAgentProgress` — chrome owns the live clocks. -### Transcript Task rows (history anchors) +### Transcript spawn_agent rows (history anchors) -`runtime-bridge` still paints each `task` call as a transcript stream row for +`runtime-bridge` paints each `spawn_agent` call as a transcript stream row for **spawn / final / fail anchors**. While the agents strip is sticky, sticky-poll `syncAgentProgress` rewrites are gated off so the transcript is not a dual live rail. Ordinary in-flight tool rows keep their own elapsed clock @@ -239,7 +239,7 @@ rail. Ordinary in-flight tool rows keep their own elapsed clock ### Unprompted fleet reports Parent prose owns success narratives. Transcript fleet notices exist only for -attention live Task rows cannot keep: a lane **failed** while other work is +attention live spawn_agent rows cannot keep: a lane **failed** while other work is still running, and **one** dry-fleet line when the last lane finishes (`N done · nothing running`). Per-lane `done — summary` walls and live `dispatched` re-announcements are never printed. @@ -475,7 +475,7 @@ Two mid-run gestures, two delivery times (CL-6290): - **Enter, mid-run** — soft steer: enqueues kind `"steer"` and delivers at the next **parent** `tool.boundary` (the parent tool finishing, not a child) via `Agent.deliver` into the live reactor, not a new `send`. A - long parent `run_shell` or an awaiting `task()` is parent-busy and holds + long parent `run_shell` or an awaiting `wait_agents` is parent-busy and holds steers. The transcript row says `[will steer next]` while pending and `[steering]` once delivered (`submitPrompt`, `drainSteersAtBoundary` in `runtime-bridge.ts`). @@ -513,16 +513,14 @@ left waiting on an idle event the stop may never produce (`interrupt` in **Sub-agent lanes on redirect.** Soft steer (Enter mid-run) and follow-up (queued drain) leave running workers alone — they never call `runner.ts`'s `interrupt()`, so the parent's operation signal stays live and -in-flight `task` dispatches keep running. Ctrl+C is the explicit fleet -teardown: `doInterrupt` → `port.interrupt()` → `currentAgent.close()` aborts -the shared operation signal the `task` tool was given, which the tool -forwards to the child agent's own controller, so an in-flight sub-agent -dispatch is aborted along with the parent's turn and reports back as -cancelled by the operator rather than being left to finish silently detached -(`src/subagent/task-tool.ts`). `/clear` and session exit still call -`subAgentSessions.cancelAll` for an explicit session-wide cancel; that path -is separate from interrupt and must stay off the soft-steer / follow-up -gestures. +spawned workers keep running. Ctrl+C is the explicit fleet teardown: +`doInterrupt` → `port.interrupt()` → `currentAgent.close()` aborts the shared +operation signal and routes cancellation through the fleet/session-store +teardown path, so in-flight sub-agent dispatch reports back as cancelled by +the operator rather than being left to finish silently detached. `/clear` and +session exit still call `subAgentSessions.cancelAll` for an explicit +session-wide cancel; that path is separate from interrupt and must stay off +the soft-steer / follow-up gestures. Up/Down are caret motion first inside a multi-line buffer. History recall only fires when the caret is already at the first or last wrapped row of the diff --git a/evals/capability/README.md b/evals/capability/README.md index 446b5ef50..3dfcfdee3 100644 --- a/evals/capability/README.md +++ b/evals/capability/README.md @@ -38,7 +38,7 @@ Tool-discipline baits (shell editing, env prefixes, curl instead of `web_fetch`, search loops, skipped dispatch) used to be separate cases. They validate _behavior_, not capability, so they now ride on the tier cases as `requireBehaviors` bounds — the same mechanism that lets a case demand -`taskToolCallCount >= 1`: +`spawnAgentToolCallCount >= 1`: ```json "requireBehaviors": [ @@ -108,7 +108,7 @@ shell parser. | `maxChainSegmentsPerCommand` | largest chain in one command | lower is better | | `networkCommandCount` | segments invoking curl/wget/nc/... | lower is better | | `webFetchToolCallCount` | `web_fetch` tool calls (0 when the tool is absent or unused) | informational | -| `taskToolCallCount` | `task` tool calls (0 when the tool is absent or unused) | informational | +| `spawnAgentToolCallCount` | `spawn_agent` tool calls (0 when the tool is absent or unused) | informational | | `editViaShellCount` | sed/perl/awk `-i` edits or heredoc writes | lower is better | | `repeatedSearchCount` | tool calls repeating an earlier call's name with normalized-equal arguments | lower is better | | `longestToolOnlyStreak` | longest run of assistant turns with tool calls and no text | lower is better | @@ -183,21 +183,21 @@ change that justifies it. Flags: -| Flag | Meaning | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `--case ` | Case id or `all` (default) | -| `--provider ` / `--model ` | Required unless `--matrix`. Single-variant via `loadConfig`. Not inferred from local settings | -| `--matrix ` | Alternative to `--provider`/`--model`. Multi-variant: `p:m,p2:m2` or `label=p:m` (comma-separated). Every cell must include both sides | -| `--config ` | Settings file override (CI injection) | -| `--out ` | Write machine-readable results JSON | -| `--baseline ` | Compare this run to a prior results file (improve/regress + metric deltas) | -| `--ask-permissions` | Do **not** pass `--dangerously-skip-permissions` | -| `--agent-timeout-ms ` | Wall-clock limit for `runExec` (default `1200000`, env `CORBITS_EVAL_AGENT_TIMEOUT_MS`) | -| `--verify-timeout-ms ` | Wall-clock limit for `verify.sh` (default `120000`, env `CORBITS_EVAL_VERIFY_TIMEOUT_MS`) | -| `--repeats ` | Runs per case×variant cell (default `1`; gate runs use `5`, baseline freezes `3`). Results record every repeat plus per-cell aggregates | -| `--concurrency ` | Independent case×variant×repeat cells in parallel (default `1`, env `CORBITS_EVAL_CONCURRENCY`). Each cell still uses its own temp workdir. Use `--concurrency 4` (or similar) to run a live matrix faster | -| `--dry-run` | Load cases × variants and print plan; no inference. Still requires `--provider`/`--model` or `--matrix` | -| `--director ` | Exec overlay: run the product `corbits exec` path with this director's system prompt and initially-advertised tool set (default: skywalker). Eval/CI override, not single-agent mode. Directors that cannot spawn (for example `build`) do not mount `task`. | +| Flag | Meaning | +| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--case ` | Case id or `all` (default) | +| `--provider ` / `--model ` | Required unless `--matrix`. Single-variant via `loadConfig`. Not inferred from local settings | +| `--matrix ` | Alternative to `--provider`/`--model`. Multi-variant: `p:m,p2:m2` or `label=p:m` (comma-separated). Every cell must include both sides | +| `--config ` | Settings file override (CI injection) | +| `--out ` | Write machine-readable results JSON | +| `--baseline ` | Compare this run to a prior results file (improve/regress + metric deltas) | +| `--ask-permissions` | Do **not** pass `--dangerously-skip-permissions` | +| `--agent-timeout-ms ` | Wall-clock limit for `runExec` (default `1200000`, env `CORBITS_EVAL_AGENT_TIMEOUT_MS`) | +| `--verify-timeout-ms ` | Wall-clock limit for `verify.sh` (default `120000`, env `CORBITS_EVAL_VERIFY_TIMEOUT_MS`) | +| `--repeats ` | Runs per case×variant cell (default `1`; gate runs use `5`, baseline freezes `3`). Results record every repeat plus per-cell aggregates | +| `--concurrency ` | Independent case×variant×repeat cells in parallel (default `1`, env `CORBITS_EVAL_CONCURRENCY`). Each cell still uses its own temp workdir. Use `--concurrency 4` (or similar) to run a live matrix faster | +| `--dry-run` | Load cases × variants and print plan; no inference. Still requires `--provider`/`--model` or `--matrix` | +| `--director ` | Exec overlay: run the product `corbits exec` path with this director's system prompt and initially-advertised tool set (default: skywalker). Eval/CI override, not single-agent mode. Directors that cannot spawn (for example `build`) do not mount `spawn_agent` / `wait_agents`. | ## Case format @@ -287,7 +287,7 @@ verify.sh # objective grader (exit 0 = pass) "maxChainSegmentsPerCommand": 2, "networkCommandCount": 0, "webFetchToolCallCount": 0, - "taskToolCallCount": 0, + "spawnAgentToolCallCount": 0, "editViaShellCount": 0, "repeatedSearchCount": 0, "longestToolOnlyStreak": 2, diff --git a/evals/capability/behaviors.test.ts b/evals/capability/behaviors.test.ts index 88cac087f..0f50aba37 100644 --- a/evals/capability/behaviors.test.ts +++ b/evals/capability/behaviors.test.ts @@ -153,22 +153,24 @@ describe("deriveBehaviorMetrics", () => { expect(metrics.webFetchToolCallCount).toBe(0); }); - test("counts task tool calls separately", () => { + test("counts spawn_agent tool calls separately", () => { const metrics = deriveBehaviorMetrics( summary([ turn({ - toolCalls: [{ name: "task", arguments: { intent: "implement", prompt: "add /readyz" } }], + toolCalls: [ + { name: "spawn_agent", arguments: { intent: "implement", prompt: "add /readyz" } }, + ], }), turn({ toolCalls: [{ name: "web_fetch", arguments: { url: "http://x" } }] }), ]), ); - expect(metrics.taskToolCallCount).toBe(1); + expect(metrics.spawnAgentToolCallCount).toBe(1); expect(metrics.webFetchToolCallCount).toBe(1); }); - test("task count is 0 when the tool is never called", () => { + test("spawn_agent count is 0 when the tool is never called", () => { const metrics = deriveBehaviorMetrics(summary([shellTurn("ls")])); - expect(metrics.taskToolCallCount).toBe(0); + expect(metrics.spawnAgentToolCallCount).toBe(0); }); test("does not collide distinct name+argument fingerprints", () => { @@ -234,7 +236,7 @@ describe("deriveBehaviorMetrics", () => { const metrics = deriveBehaviorMetrics(summary([])); expect(metrics.shellCommandCount).toBe(0); expect(metrics.webFetchToolCallCount).toBe(0); - expect(metrics.taskToolCallCount).toBe(0); + expect(metrics.spawnAgentToolCallCount).toBe(0); expect(metrics.longestToolOnlyStreak).toBe(0); expect(metrics.toolCallsByName).toEqual({}); }); @@ -270,17 +272,25 @@ describe("parseBehaviorMetrics", () => { expect(parseBehaviorMetrics({ shellCommandCount: "many" })).toBeNull(); }); - test("defaults missing taskToolCallCount from the per-name map", () => { + test("defaults missing spawnAgentToolCallCount from the per-name map", () => { const metrics = deriveBehaviorMetrics( - summary([turn({ toolCalls: [{ name: "task", arguments: {} }] })]), + summary([turn({ toolCalls: [{ name: "spawn_agent", arguments: {} }] })]), ); - const { taskToolCallCount: _dropped, ...legacy } = metrics; - expect(parseBehaviorMetrics(legacy)?.taskToolCallCount).toBe(1); + const { spawnAgentToolCallCount: _dropped, ...legacy } = metrics; + expect(parseBehaviorMetrics(legacy)?.spawnAgentToolCallCount).toBe(1); }); - test("defaults missing taskToolCallCount to 0 when task was never called", () => { + test("defaults missing spawnAgentToolCallCount to 0 when spawn_agent was never called", () => { const metrics = deriveBehaviorMetrics(summary([shellTurn("ls")])); - const { taskToolCallCount: _dropped, ...legacy } = metrics; - expect(parseBehaviorMetrics(legacy)?.taskToolCallCount).toBe(0); + const { spawnAgentToolCallCount: _dropped, ...legacy } = metrics; + expect(parseBehaviorMetrics(legacy)?.spawnAgentToolCallCount).toBe(0); + }); + + test("accepts legacy taskToolCallCount reports", () => { + const metrics = deriveBehaviorMetrics(summary([shellTurn("ls")])); + const { spawnAgentToolCallCount: _dropped, ...legacy } = metrics; + expect(parseBehaviorMetrics({ ...legacy, taskToolCallCount: 2 })?.spawnAgentToolCallCount).toBe( + 2, + ); }); }); diff --git a/evals/capability/behaviors.ts b/evals/capability/behaviors.ts index ef0f636a7..add00a0e7 100644 --- a/evals/capability/behaviors.ts +++ b/evals/capability/behaviors.ts @@ -49,8 +49,8 @@ export interface BehaviorMetrics { networkCommandCount: number; /** web_fetch tool calls (0 when the tool is absent or unused). */ webFetchToolCallCount: number; - /** task tool calls (0 when the tool is absent or unused). */ - taskToolCallCount: number; + /** spawn_agent tool calls (0 when the tool is absent or unused). */ + spawnAgentToolCallCount: number; /** Segments editing files via sed/perl/awk in-place or heredoc redirection. */ editViaShellCount: number; /** Tool calls repeating an earlier call's name with normalized-equal arguments. */ @@ -71,7 +71,7 @@ export const NUMERIC_BEHAVIOR_METRICS = [ "maxChainSegmentsPerCommand", "networkCommandCount", "webFetchToolCallCount", - "taskToolCallCount", + "spawnAgentToolCallCount", "editViaShellCount", "repeatedSearchCount", "longestToolOnlyStreak", @@ -96,7 +96,7 @@ export const BEHAVIOR_METRIC_DIRECTIONS: Record = {}): BehaviorMetrics { maxChainSegmentsPerCommand: 0, networkCommandCount: 0, webFetchToolCallCount: 0, - taskToolCallCount: 0, + spawnAgentToolCallCount: 0, editViaShellCount: 0, repeatedSearchCount: 0, longestToolOnlyStreak: 0, diff --git a/plugins/corbits-skills/skills/git-rebase/SKILL.md b/plugins/corbits-skills/skills/git-rebase/SKILL.md index 1c4dbeddb..0c9370d6c 100644 --- a/plugins/corbits-skills/skills/git-rebase/SKILL.md +++ b/plugins/corbits-skills/skills/git-rebase/SKILL.md @@ -15,7 +15,7 @@ How to reshape a branch's commit history without interactive prompts — squashi 1. Read the techniques below. Identify the surgery (drop, squash, split, reword, edit-in-place, validate). 2. If a step needs judgment (what to squash, which commits to drop, how to split, which message), `ask_operator` first. Do not guess. 3. Copy the exact sequenced commands from this skill into an intern brief. -4. Spawn `task(agent="intern")` with that sequenced command list. Intern executes via `run_shell`. Intern drives every editor via `GIT_SEQUENCE_EDITOR` / `-c sequence.editor` / `-c core.editor` inline in the git command — do not tell intern to `write_file` an editor script. Intern runs git and resolves mechanical conflicts as the brief specifies. +4. Spawn `spawn_agent(agent="intern")` with that sequenced command list, then collect the result with `wait_agents`. Intern executes via `run_shell`. Intern drives every editor via `GIT_SEQUENCE_EDITOR` / `-c sequence.editor` / `-c core.editor` inline in the git command — do not tell intern to `write_file` an editor script. Intern runs git and resolves mechanical conflicts as the brief specifies. 5. If intern hits a judgment call mid-rebase, `ask_operator` then re-dispatch intern with the decision. Plan and synthesize. Intern mutates git. diff --git a/plugins/corbits-skills/skills/implement/SKILL.md b/plugins/corbits-skills/skills/implement/SKILL.md index 1f046dd41..a8d3dfd84 100644 --- a/plugins/corbits-skills/skills/implement/SKILL.md +++ b/plugins/corbits-skills/skills/implement/SKILL.md @@ -73,7 +73,7 @@ Before writing any code, describe your implementation approach to Greybeard and - You don't need to agree with every suggestion, but you need a reason to disagree - Once you're aligned on approach, move to Step 2 -Use `task(agent="greybeard")` for this step. +Use `spawn_agent(agent="greybeard")`, then `wait_agents`, for this step. ### Step 2: Implement and Test @@ -124,7 +124,7 @@ Ask critic to review the committed change. **How to run:** -1. Spawn `task(agent="critic")` and ask it to review the output of `git show HEAD`. Include the intent from Step 1 (what the change is meant to accomplish and the approach agreed with Greybeard) so Critique can evaluate whether the implementation matches the plan, not just surface-level quality. Tell critic to limit its findings to the scope of the current commit -- pre-existing issues in touched files are out of scope. +1. Spawn `spawn_agent(agent="critic")`, then `wait_agents`, and ask it to review the output of `git show HEAD`. Include the intent from Step 1 (what the change is meant to accomplish and the approach agreed with Greybeard) so Critique can evaluate whether the implementation matches the plan, not just surface-level quality. Tell critic to limit its findings to the scope of the current commit -- pre-existing issues in touched files are out of scope. 2. Read its findings 3. For each issue marked VERIFIED or HIGH confidence: fix it 4. Re-run the build gate (Step 3) to verify fixes diff --git a/plugins/corbits-skills/skills/opsh/SKILL.md b/plugins/corbits-skills/skills/opsh/SKILL.md index 09c6c667c..bc75af489 100644 --- a/plugins/corbits-skills/skills/opsh/SKILL.md +++ b/plugins/corbits-skills/skills/opsh/SKILL.md @@ -6,7 +6,7 @@ description: Write scripts using opsh and its built-in libraries. Tiny scripts: # opsh Scripting -How to write scripts with opsh and its libraries. Tiny / single-file scripts: DIY with write_file/edit_file using these rules. Substantial script work: spawn `task(agent="builder")` with these rules copied into the brief (workers do not mount `use_skill`). For a review, spawn `task(agent="critic")` (or `task(agent="neckbeard")` for hygiene-only) with the same rules copied in. +How to write scripts with opsh and its libraries. Tiny / single-file scripts: DIY with write_file/edit_file using these rules. Substantial script work: spawn `spawn_agent(agent="builder")` with these rules copied into the brief, then collect with `wait_agents` (workers do not mount `use_skill`). For a review, spawn `spawn_agent(agent="critic")` (or `spawn_agent(agent="neckbeard")` for hygiene-only) with the same rules copied in, then `wait_agents`. Shell for agent commands is `run_shell`. Bash-the-language in the examples below stays — opsh scripts are bash. diff --git a/src/agent/agent-search.test.ts b/src/agent/agent-search.test.ts index a9f41fc23..b24331746 100644 --- a/src/agent/agent-search.test.ts +++ b/src/agent/agent-search.test.ts @@ -41,10 +41,10 @@ describe("createAgentIndex", () => { }); describe("formatAgentSearchResults", () => { - test("includes task hint and ids", () => { + test("includes spawn hint and ids", () => { const text = formatAgentSearchResults([fixtures[1]!]); expect(text).toContain("critique"); - expect(text).toContain("task(agent="); + expect(text).toContain("spawn_agent(agent="); }); test("includes source label when present", () => { diff --git a/src/agent/agent-search.ts b/src/agent/agent-search.ts index 362f29986..093e6c3ec 100644 --- a/src/agent/agent-search.ts +++ b/src/agent/agent-search.ts @@ -88,11 +88,11 @@ export function formatAgentSearchResults(profiles: readonly AgentProfile[]): str // contain secret-shaped substrings). return scrubSecretShapedToolResultContent( [ - "Matching agent profiles (pass id to task(agent=...)). Full system prompt / body is included so you do not need read_file on plugin roots outside the workspace:", + "Matching agent profiles (pass id to spawn_agent(agent=...)). Full system prompt / body is included so you do not need read_file on plugin roots outside the workspace:", "", ...entries.flatMap((entry, i) => (i === 0 ? [entry] : ["", entry])), "", - "Spawn with task(description, prompt, agent=). For a team, call task once per member (parallel in one turn when independent).", + "Spawn with spawn_agent(description, prompt, agent=). For a team, call spawn_agent once per member (parallel in one turn when independent), then collect with wait_agents.", ].join("\n"), ); } @@ -100,7 +100,7 @@ export function formatAgentSearchResults(profiles: readonly AgentProfile[]): str export const searchAgentsDefinition: ToolDefinition = { name: "search_agents", description: - "Find task-dispatchable agent profiles by capability, role, or team name (e.g. 'review', 'review team', 'architect', 'security'). Returns profile ids, descriptions, and the full loaded system prompt / body for each match so you can inspect plugin or Claude marketplace agents without reading files outside the workspace. Use the id in task(agent=...). Call this when the user asks to spin up specialists or a team without naming exact ids.", + "Find spawnable agent profiles by capability, role, or team name (e.g. 'review', 'review team', 'architect', 'security'). Returns profile ids, descriptions, and the full loaded system prompt / body for each match so you can inspect plugin or Claude marketplace agents without reading files outside the workspace. Use the id in spawn_agent(agent=...). Call this when the user asks to spin up specialists or a team without naming exact ids.", inputSchema: { type: "object", properties: { diff --git a/src/agent/codex-tool-proxies.ts b/src/agent/codex-tool-proxies.ts index 56451072f..0908fa5fc 100644 --- a/src/agent/codex-tool-proxies.ts +++ b/src/agent/codex-tool-proxies.ts @@ -438,7 +438,7 @@ function createUpdatePlanProxy(runManageTasks: CodexRunManageTasks): AgentTool { // (pending/in_progress/completed) — this proxy never produces it, so a // Codex model cannot cancel a step through update_plan. That is a // lossy-but-safe narrowing (dropped, not misrepresented), not a bug fix - // for the underlying task tool, which stays out of scope here. + // for the underlying manage_tasks tool, which stays out of scope here. const tasks = parsed.plan.map((item, i) => ({ id: `p${i + 1}`, title: item.step, diff --git a/src/agent/directors/greybeard/package.test.ts b/src/agent/directors/greybeard/package.test.ts index 447d3b506..1bddf1829 100644 --- a/src/agent/directors/greybeard/package.test.ts +++ b/src/agent/directors/greybeard/package.test.ts @@ -90,7 +90,9 @@ describe("greybeardPackage", () => { test("tools.allow is orchestrator surface with product writes but without fleet discovery", () => { const allow = greybeardPackage.tools?.allow ?? []; - expect(allow).toContain("task"); + expect(allow).not.toContain("task"); + expect(allow).toContain("spawn_agent"); + expect(allow).toContain("wait_agents"); // CL-7051: search_agents is Skywalker-only — nested directors spawn from allowlist. expect(allow).not.toContain("search_agents"); expect(allow).toContain("write_file"); diff --git a/src/agent/directors/identity.test.ts b/src/agent/directors/identity.test.ts index 0357f6746..a7449feea 100644 --- a/src/agent/directors/identity.test.ts +++ b/src/agent/directors/identity.test.ts @@ -19,7 +19,7 @@ describe("formatDirectorSystemPrompt", () => { test("prefixes agent id, model role, and optional skills", () => { const text = formatDirectorSystemPrompt(DIRECTOR_REGISTRY.builder); expect(text.startsWith("Identity: agent id `builder`")).toBe(true); - expect(text).toContain('task(agent="builder")'); + expect(text).toContain('spawn_agent(agent="builder")'); expect(text).toContain("Model role: implement."); expect(text).toContain("style, philosophy, typescript"); expect(text).toContain(DIRECTOR_REGISTRY.builder.systemPrompt); diff --git a/src/agent/directors/identity.ts b/src/agent/directors/identity.ts index bf134eaa3..ed0a4da2f 100644 --- a/src/agent/directors/identity.ts +++ b/src/agent/directors/identity.ts @@ -35,7 +35,7 @@ export function formatDirectorSystemPrompt(pkg: DirectorPackage): string { } const header = [ - `Identity: agent id \`${pkg.id}\` — spawn as task(agent="${pkg.id}").`, + `Identity: agent id \`${pkg.id}\` — spawn as spawn_agent(agent="${pkg.id}").`, `Model role: ${pkg.modelRole}.`, ...(skillsLine !== null ? [skillsLine] : []), ].join("\n"); diff --git a/src/agent/directors/intern/package.test.ts b/src/agent/directors/intern/package.test.ts index 1998c7fa9..4e1571b86 100644 --- a/src/agent/directors/intern/package.test.ts +++ b/src/agent/directors/intern/package.test.ts @@ -38,7 +38,7 @@ describe("internPackage", () => { expect(allow).toContain("write_file"); expect(allow).toContain("edit_file"); expect(allow).toContain("delete_file"); - for (const name of ["grep", "search_files", "task", "apply_patch"]) { + for (const name of ["grep", "search_files", "spawn_agent", "wait_agents", "apply_patch"]) { expect(allow).not.toContain(name); } }); diff --git a/src/agent/directors/registry.test.ts b/src/agent/directors/registry.test.ts index 8ed9e38c2..d2199ea02 100644 --- a/src/agent/directors/registry.test.ts +++ b/src/agent/directors/registry.test.ts @@ -175,7 +175,9 @@ describe("director registry", () => { expect(s.systemPrompt).toContain("DIY tiny/single-file/one-route"); expect(s.systemPrompt).toContain("You are Skywalker"); expect(s.systemPrompt).toMatch(/No catch-all worker/i); - expect(s.tools?.allow).toContain("task"); + expect(s.tools?.allow).not.toContain("task"); + expect(s.tools?.allow).toContain("spawn_agent"); + expect(s.tools?.allow).toContain("wait_agents"); expect(s.tools?.allow).toContain("write_file"); expect(s.tools?.allow).toContain("edit_file"); expect(s.tools?.allow).toContain("delete_file"); @@ -200,7 +202,7 @@ describe("director registry", () => { for (const id of DIRECTOR_IDS) { const profile = packageToProfile(DIRECTOR_REGISTRY[id]); expect(profile.systemPromptRole).toContain(`agent id \`${id}\``); - expect(profile.systemPromptRole).toContain(`task(agent="${id}")`); + expect(profile.systemPromptRole).toContain(`spawn_agent(agent="${id}")`); expect(profile.description).toContain(`agent id: ${id}`); } }); diff --git a/src/agent/directors/registry.ts b/src/agent/directors/registry.ts index 61422c518..131043148 100644 --- a/src/agent/directors/registry.ts +++ b/src/agent/directors/registry.ts @@ -26,7 +26,7 @@ import { type TaskIntent, } from "./types.js"; -/** Intent → default director when `task(agent=…)` is omitted. No general director. */ +/** Intent -> default director when `spawn_agent(agent=...)` is omitted. No general director. */ export const INTENT_DEFAULT_DIRECTOR: Readonly, DirectorId>> = { implement: "builder", @@ -94,7 +94,7 @@ export function resolveDirector(input: ResolveDirectorInput): ResolveDirectorRes return { ok: false, error: "No director selected.", - hint: "Pass task(agent=…) for a named director, or task(intent=implement|explore|plan|review).", + hint: "Pass spawn_agent(agent=...) for a named director, or spawn_agent(intent=implement|explore|plan|review).", }; } if (intent === "general") { @@ -128,7 +128,7 @@ export function packageToProfile(pkg: DirectorPackage): AgentProfile { id: pkg.id, description: `${pkg.description} (agent id: ${pkg.id})`, systemPromptRole: formatDirectorSystemPrompt(pkg), - // Nested spawn is still gated by allowOrchestrator on the parent task tool. + // Nested spawn is still gated by allowOrchestrator on the parent fleet tools. // Greybeard/skywalker maySpawn marks intent; leaves stay non-orchestrator. orchestrator: pkg.spawn.maySpawn, ...(capabilities !== undefined ? { capabilities } : {}), diff --git a/src/agent/directors/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts index d8736701a..f733ee4bf 100644 --- a/src/agent/directors/skywalker/package.test.ts +++ b/src/agent/directors/skywalker/package.test.ts @@ -44,7 +44,9 @@ describe("skywalkerPackage", () => { test("tools.allow mounts orchestrator surface plus product writes for DIY", () => { const allow = skywalkerPackage.tools?.allow ?? []; - expect(allow).toContain("task"); + expect(allow).not.toContain("task"); + expect(allow).toContain("spawn_agent"); + expect(allow).toContain("wait_agents"); expect(allow).toContain("search_agents"); expect(allow).toContain("write_file"); expect(allow).toContain("edit_file"); @@ -96,7 +98,7 @@ describe("skywalkerPackage", () => { expect(p).toContain("spawn_agent"); expect(p).toContain("wait_agents"); expect(p).toContain("Idle-orchestrator"); - expect(p).toContain("deprecated fused spawn+wait"); + expect(p).not.toContain("task()"); expect(p).toContain('mode="all"'); expect(p).toContain("uncollected spawns"); expect(p).not.toContain("Present the plan when the change is large or ambiguous"); diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index 351a5c8a0..eb3cf7f14 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -6,7 +6,7 @@ import { SKYWALKER_TOOLS } from "../tool-sets.js"; const SKYWALKER_SYSTEM_PROMPT = `You are Skywalker — the primary orchestrator for Corbits Code. When asked your name, answer: Skywalker. -Agent id: skywalker (primary session; not a spawned worker). Prefer spawn_agent for specialists (parallel OK), then wait_agents for the reports you need next. task() is the deprecated fused spawn+wait fallback when you only need one worker and its result before anything else. +Agent id: skywalker (primary session; not a spawned worker). Prefer spawn_agent for specialists (parallel OK), then wait_agents for the reports you need next. PRIMARY INTENT: run the workflow. Classify every request. DIY tiny/single-file/one-route product edits. Delegate substantial work. Chain specialists into a sequence of actions. Track who is running. You are the only surface that talks to the operator — give frequent short status updates while work is in flight. Synthesize for the operator. Do not become the reviewer or explorer by default. @@ -16,7 +16,7 @@ You do not do the specialists' jobs by default. For tiny bounded product edits, Do not run long-blocking jobs on the parent (evals, full test suites, long installs, long-running implementation). Dispatch intern (mechanical shell), tester (suite / repro), or builder (substantial code). Path tools (write_file/edit_file/delete_file) are the DIY surface; shell file-writes stay denied. -Idle-orchestrator: fire one or more spawn_agent calls in a turn — each returns immediately with an agent_id and does not hold the parent. Then **reply to the operator** with who is running and what happens next before you block. Prefer ending that turn (or calling wait_agents with a short timeout_ms) so Enter can land; do not immediately fuse into a long wait_agents / task() right after spawn. wait_agents later on the targets you need (or omit targets to wait on this session's own uncollected spawns — never a sibling's). list_agents shows that same fleet without blocking. Use mode="all" when you need every target to finish; interrupt_agent unblocks wait_agents immediately. A timeout means still running — do not tight-loop wait_agents hoping for a different answer. task() still fuses spawn+wait and holds the parent until that one worker finishes. Enter mid-run delivers at the next parent tool.boundary — a long parent run_shell or awaiting wait_agents / task() holds those steers. A bare spawn_agent does not. +Idle-orchestrator: fire one or more spawn_agent calls in a turn — each returns immediately with an agent_id and does not hold the parent. Then **reply to the operator** with who is running and what happens next before you block. Prefer ending that turn (or calling wait_agents with a short timeout_ms) so Enter can land; do not immediately fuse into a long wait_agents right after spawn. wait_agents later on the targets you need (or omit targets to wait on this session's own uncollected spawns — never a sibling's). list_agents shows that same fleet without blocking. Use mode="all" when you need every target to finish; interrupt_agent unblocks wait_agents immediately. A timeout means still running — do not tight-loop wait_agents hoping for a different answer. Enter mid-run delivers at the next parent tool.boundary — a long parent run_shell or awaiting wait_agents holds those steers. A bare spawn_agent does not. # Operator updates (mandatory while fleet is live) @@ -74,12 +74,12 @@ Prefer synthesizing early returns over launching a second wave. # Anti-cascade (stall / dig / diagnose) Do **not** turn a "why is this stalled / why no thinking / spawn looks broken" dig into a fleet: -- Classify digs, screenshots of Task rows, and "why/how does X work" as COMMUNICATION first. +- Classify digs, screenshots of worker rows, and "why/how does X work" as COMMUNICATION first. - Answer from mounted tools + known architecture; at most **one** explorer worker if a single unknown path blocks the answer. - Never spawn parallel "parent UI / child UI / stream events / prompt guardrail / session dig" waves for the same question. - When workers stall, loop, or come back unfinished: synthesize what returned, report Blockers, and change approach — do **not** re-fan-out another diagnostic wave on the same topic. - Do **not** search the repo yourself after a worker stops without finishing. Change the brief (success_criteria / do_not / agent) or tell the operator. Then start the next worker if the job still needs doing. -- Permission asks and long run_shell clocks on Task rows are not a signal to spawn more diggers. +- Permission asks and long run_shell clocks on worker rows are not a signal to spawn more diggers. # Brief completeness diff --git a/src/agent/directors/tool-sets.test.ts b/src/agent/directors/tool-sets.test.ts index ae1369a0b..1a3962980 100644 --- a/src/agent/directors/tool-sets.test.ts +++ b/src/agent/directors/tool-sets.test.ts @@ -62,13 +62,17 @@ describe("DOCS_TOOLS", () => { }); describe("SKYWALKER_TOOLS / ORCHESTRATOR_TOOLS", () => { - test("both mount product writes and task", () => { + test("both mount product writes and split fleet tools", () => { for (const name of PRODUCT_WRITE_TOOLS) { expect(SKYWALKER_TOOLS as readonly string[]).toContain(name); expect(ORCHESTRATOR_TOOLS as readonly string[]).toContain(name); } - expect(SKYWALKER_TOOLS).toContain("task"); - expect(ORCHESTRATOR_TOOLS).toContain("task"); + for (const name of ["spawn_agent", "wait_agents"] as const) { + expect(SKYWALKER_TOOLS as readonly string[]).toContain(name); + expect(ORCHESTRATOR_TOOLS as readonly string[]).toContain(name); + } + expect(SKYWALKER_TOOLS as readonly string[]).not.toContain("task"); + expect(ORCHESTRATOR_TOOLS as readonly string[]).not.toContain("task"); }); // CL-7051: fleet discovery is Tier-1 only. @@ -86,11 +90,11 @@ describe("REVIEW_TOOLS / INTERN_TOOLS", () => { } }); - test("intern stays shell-first without grep/search/task", () => { + test("intern stays shell-first without grep/search/spawn", () => { expect(INTERN_TOOLS).toContain("run_shell"); expect(INTERN_TOOLS).toContain("read_file"); expect(INTERN_TOOLS).toContain("list_dir"); - for (const name of ["grep", "search_files", "task"] as const) { + for (const name of ["grep", "search_files", "spawn_agent", "wait_agents"] as const) { expect(INTERN_TOOLS as readonly string[]).not.toContain(name); } }); diff --git a/src/agent/directors/tool-sets.ts b/src/agent/directors/tool-sets.ts index bf4f72a88..bdc1a5224 100644 --- a/src/agent/directors/tool-sets.ts +++ b/src/agent/directors/tool-sets.ts @@ -62,7 +62,18 @@ export const REVIEW_TOOLS = [...READ_TOOLS, ...PRODUCT_WRITE_TOOLS] as const; export const INTERN_TOOLS = ["run_shell", "read_file", "list_dir", ...PRODUCT_WRITE_TOOLS] as const; /** Nested orchestrator surface (greybeard / package filter): dispatch + path writes. */ -export const ORCHESTRATOR_TOOLS = [...READ_TOOLS, ...PRODUCT_WRITE_TOOLS, "task"] as const; +export const ORCHESTRATOR_TOOLS = [ + ...READ_TOOLS, + ...PRODUCT_WRITE_TOOLS, + "spawn_agent", + "wait_agents", + "list_agents", + "close_agent", + "resume_agent", + "interrupt_agent", + "send_input", + "read_agent_trace", +] as const; /** Skywalker primary: orchestrator surface plus fleet discovery (Tier-1 only). */ export const SKYWALKER_TOOLS = [...ORCHESTRATOR_TOOLS, "search_agents"] as const; diff --git a/src/agent/directors/types.ts b/src/agent/directors/types.ts index 50b46d218..df29fe383 100644 --- a/src/agent/directors/types.ts +++ b/src/agent/directors/types.ts @@ -50,7 +50,7 @@ export interface ToolEnvelope { } export interface SpawnRights { - /** Whether this director may call `task`. */ + /** Whether this director may call fleet delegation tools. */ readonly maySpawn: boolean; /** When set, only these director ids may be spawned. */ readonly allowlist?: readonly DirectorId[]; diff --git a/src/agent/fleet-verbs-mount.test.ts b/src/agent/fleet-verbs-mount.test.ts index f40a54a40..b822cf01a 100644 --- a/src/agent/fleet-verbs-mount.test.ts +++ b/src/agent/fleet-verbs-mount.test.ts @@ -1,6 +1,6 @@ /** - * Primary createAgentToolset mounts the seven fleet verbs beside task / - * search_agents / read_agent_trace when subAgent (with the shared TUI + * Primary createAgentToolset mounts the seven fleet verbs beside search_agents / + * read_agent_trace when subAgent (with the shared TUI * sessions store) is wired. Leaves / no-subAgent toolsets stay without them. */ import { mkdtempSync } from "node:fs"; @@ -45,7 +45,7 @@ describe("primary fleet verb mount", () => { }, }); const names = toolset.dynamicRunner.currentDefinitions().map((d) => d.name); - expect(names).toContain("task"); + expect(names).not.toContain("task"); expect(names).toContain("read_agent_trace"); for (const name of FLEET_VERBS) { expect(names).toContain(name); diff --git a/src/agent/profile-types.ts b/src/agent/profile-types.ts index a3cdc0b8a..9ca91e478 100644 --- a/src/agent/profile-types.ts +++ b/src/agent/profile-types.ts @@ -62,11 +62,9 @@ export interface AgentProfile { // plugin-contributed profiles, or .agents/agents/ for local profiles. When both // systemPromptRole and systemPromptPath are set, systemPromptRole wins. systemPromptPath?: string; - // Orchestrator agents are an explicit exception to the "sub-agents do not - // recurse" rule. When true, the dispatch-time appendix grants this profile - // permission to call `task` to spawn other agents. Reserved for top-level - // coordinators (e.g. a planning agent that fans work out to specialists); - // leaf-task agents should leave this unset. + // Reserved for future profile-sourced orchestrator support. Today spawn_agent + // rejects profile orchestrators because only built-in director packages carry + // trusted fleet semantics. Leaf workers should leave this unset. orchestrator?: boolean; // Where the profile came from, for search_agents labeling (e.g. "claude", // "plugin:", "local"). Omitted for built-in defaults. diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index b4554c487..40b12987f 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -112,7 +112,7 @@ export function buildGuidelines( ...(subAgent ? [] : [ - "- Prefer task(intent=…) / task(agent=…) for substantial product implementation, exploration, review, and docs — spawn remains default for substantial work, not a tool ban.", + "- Prefer spawn_agent(agent=…) / wait_agents for substantial product implementation, exploration, review, and docs — spawn remains default for substantial work, not a tool ban.", ]), "- read_file for file contents; grep or search_files to locate code; lsp for symbols, types, references, or call flow before opening large files.", subAgent @@ -150,11 +150,11 @@ export function buildGuidelines( : [ "", "Orchestration:", - "- Break multi-step or parallel work into focused worker dispatches with distinct lenses; prefer `spawn_agent` (fire several in one turn when jobs are independent), then reply with who is running and end the turn — workers keep running while you are idle, and `wait_agents` / `list_agents` on a later turn collect their reports without holding this conversation blocked. `task` remains the deprecated fused spawn+wait fallback for a single blocking worker.", + "- Break multi-step or parallel work into focused worker dispatches with distinct lenses; prefer `spawn_agent` (fire several in one turn when jobs are independent), then reply with who is running and end the turn — workers keep running while you are idle, and `wait_agents` / `list_agents` on a later turn collect their reports without holding this conversation blocked.", "- Prefer the typed spawn contract on every worker: `intent`, `success_criteria` (done-when), `do_not` (scope fence), and `report_focus` so workers finish instead of thrashing. Free-form `prompt` alone is weaker.", "- After workers return, merge their Summary/Findings into a coherent answer for the operator; do not paste raw sub-agent dumps.", "- If a worker comes back without finishing, change the brief rather than repeating it: narrow the scope, name the files, or state the done-when more sharply.", - "- Use manage_tasks for your own coordination checklist; spawning workers is `spawn_agent` / `wait_agents` (or deprecated `task`), not manage_tasks.", + "- Use manage_tasks for your own coordination checklist; spawning workers is `spawn_agent` / `wait_agents`, not manage_tasks.", "- If context is compacted automatically, do not stop tasks early due to token fear; persist progress via manage_tasks and worker reports.", ]), ].join("\n"); @@ -215,9 +215,11 @@ const TOOL_SUMMARIES: Record = { lsp: "resolve symbols — goToDefinition, findReferences, hover (prefer before reading huge files)", web_search: "search the web (use instead of curl or wget)", web_fetch: "fetch the content of a URL", - task: "spawn a sub-agent for a self-contained job (not a checklist item); pass intent/success_criteria/do_not/report_focus when possible; when launching several task calls in one turn, give each a distinct lens so they do not duplicate work", + spawn_agent: + "start a worker agent and return immediately with agent_id; pass returned ids from search_agents as agent=...", + wait_agents: "wait for spawned workers by agent_id and collect their reports", search_agents: - "find agent profiles by role or team before spawning with task(agent=...); results include full system prompt / body so you need not read_file plugin roots outside the workspace", + "find agent profiles by role or team before spawning with spawn_agent(agent=...); results include full system prompt / body so you need not read_file plugin roots outside the workspace", manage_tasks: "maintain your work checklist — create/replace, update status, append, cancel", submit_output: "signal the task is complete, or complete a workflow step by passing its step id", ask_operator: @@ -327,26 +329,26 @@ export function buildChatSystemPrompt( } // Notes appended to every sub-agent's system prompt so corbitsdev-format -// agent definitions translate cleanly to Corbits Code: the `task` tool is the -// *spawn* surface (wire name kept for compatibility), tool names are -// Corbits Code-native, and the upstream `mode: primary` distinction collapses. +// agent definitions translate cleanly to Corbits Code: `spawn_agent` is the +// spawn surface, tool names are Corbits Code-native, and the upstream +// `mode: primary` distinction collapses. // // Vocabulary: an *agent* is a runtime entity; a *task* is a checklist item // owned via manage_tasks; a *sub-agent* is a short-lived child agent. Do not // conflate spawn with checklist. // // `orchestrator` flips the recursion rule: by default a sub-agent must NOT -// call `task` (no recursion past depth 1). An orchestrator profile is the -// documented exception — its purpose IS to fan work out to other agents — -// so the appendix grants permission and links the syntax. +// call `spawn_agent` (no recursion past depth 1). A built-in orchestrator +// director is the documented exception — its purpose IS to fan work out to +// other agents — so the appendix grants permission and links the syntax. export function buildSubAgentAppendix(opts: { orchestrator?: boolean } = {}): string { - // Workers must not be told both "spawn with task" and "do not call task". + // Workers must not be told both "you may spawn" and "do not spawn". // Orchestrators get the spawn instruction; everyone else gets the no-recursion // rule only. const recursionRule = opts.orchestrator === true - ? '- You are an orchestrator: you MAY call `task` to spawn other sub-agents (e.g. task(agent="greybeard", prompt="...")). This is an explicit exception to the no-recursion rule that applies to workers — use it to delegate specialist work, then synthesize their reports into your own. Prefer search_agents before naming a specialist. `task` spawns an agent; it is not a checklist item (use manage_tasks for your own checklist).' - : `- Only the primary ${PRODUCT_NAME} session (or an orchestrator profile) may call \`task\` to spawn sub-agents. You are a worker: return a concrete report to the caller instead of spawning further agents. Use manage_tasks for your own work checklist if the job is multi-step.`; + ? '- You are an orchestrator: you MAY call `spawn_agent` to spawn other sub-agents (e.g. spawn_agent(agent="greybeard", description="Review approach", prompt="...")). This is an explicit exception to the no-recursion rule that applies to workers — use it to delegate specialist work, then synthesize their reports into your own after `wait_agents`. `spawn_agent` spawns an agent; it is not a checklist item (use manage_tasks for your own checklist).' + : `- Only the primary ${PRODUCT_NAME} session (or a built-in orchestrator director) may call \`spawn_agent\` to spawn sub-agents. You are a worker: return a concrete report to the caller instead of spawning further agents. Use manage_tasks for your own work checklist if the job is multi-step.`; return [ `## ${PRODUCT_NAME} notes`, "", diff --git a/src/agent/tool-search.test.ts b/src/agent/tool-search.test.ts index 4ca0c237c..97740c230 100644 --- a/src/agent/tool-search.test.ts +++ b/src/agent/tool-search.test.ts @@ -76,11 +76,12 @@ describe("createToolIndex", () => { expect(index.search("read a file")).not.toContain("read_file"); }); - test("orchestrator mode advertises task and search_agents", () => { - expect(advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY)).toContain("task"); - expect(advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY)).toContain( - "search_agents", - ); + test("orchestrator mode advertises split fleet tools and search_agents", () => { + const advertised = advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY); + expect(advertised).not.toContain("task"); + expect(advertised).toContain("spawn_agent"); + expect(advertised).toContain("wait_agents"); + expect(advertised).toContain("search_agents"); }); test("orchestrator mode advertises the fleet verbs", () => { @@ -233,7 +234,7 @@ describe("advertisedTools", () => { test("orchestrator wire prefix names include multi-agent tools", () => { const prefix = advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY); - expect(prefix).toContain("task"); + expect(prefix).not.toContain("task"); expect(prefix).toContain("search_agents"); for (const name of [ "spawn_agent", diff --git a/src/agent/tool-search.ts b/src/agent/tool-search.ts index ec6681e63..1f469cb8b 100644 --- a/src/agent/tool-search.ts +++ b/src/agent/tool-search.ts @@ -35,10 +35,8 @@ export const CORE_TOOL_NAMES: readonly string[] = [ "use_skill", "search_agents", // Multi-agent dispatch is a first-class loop capability — always advertised so - // the model can call task immediately after search_agents without a tool_search - // round-trip. Catalog-only placement left the model discovering profiles then - // failing on an unloaded task tool. - "task", + // the model can call spawn_agent immediately after search_agents without a + // tool_search round-trip. // Fleet verbs (non-blocking spawn + lifecycle). Mounted on primary when // subAgent is wired; advertised here so the model does not tool_search for // them. Package allowlists (ORCHESTRATOR_TOOLS / SKYWALKER_TOOLS) are a @@ -54,7 +52,6 @@ export const CORE_TOOL_NAMES: readonly string[] = [ const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = [ "search_agents", - "task", "spawn_agent", "wait_agents", "list_agents", @@ -181,7 +178,7 @@ export function createActivatedToolTracker(): ActivatedToolTracker { export const toolSearchDefinition: ToolDefinition = { name: "tool_search", description: - "Discover callable tools by capability. Most tools — MCP servers, present, and other integrations — are dispatchable but not advertised in the tools list. Core tools (read_file, run_shell, web_fetch, web_search, task, …) are already on the wire — do not tool_search for them. Call this with a short description of what you need (e.g. 'issue tracker', 'render layout', 'granola notes') to get matching tools' names, descriptions, and input schemas. The returned tools are already callable — invoke them directly, no separate load step.", + "Discover callable tools by capability. Most tools — MCP servers, present, and other integrations — are dispatchable but not advertised in the tools list. Core tools (read_file, run_shell, web_fetch, web_search, spawn_agent, wait_agents, …) are already on the wire — do not tool_search for them. Call this with a short description of what you need (e.g. 'issue tracker', 'render layout', 'granola notes') to get matching tools' names, descriptions, and input schemas. The returned tools are already callable — invoke them directly, no separate load step.", inputSchema: { type: "object", properties: { diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 8ee8f8a2f..f146b98e9 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -44,7 +44,6 @@ import type { ProviderCatalogEntry } from "../config/index.js"; import type { AgentProfile } from "./profiles.js"; import type { WorkflowCompleteResult } from "../workflows/types.js"; import { - createTaskTool, runSubAgent, type SubAgentProvider, type SubAgentSessionStore, @@ -55,6 +54,7 @@ import { createWaitAgentsTool, createListAgentsTool, } from "../subagent/agent-fleet.js"; +import { DEFAULT_CLOSE_DEADLINE_MS } from "../subagent/dispose.js"; import { createCloseAgentTool, createResumeAgentTool, @@ -158,7 +158,7 @@ export interface AgentToolsetArgs { // Records skill loads and sub-agent dispatch. Omitted (tests, ad-hoc // toolsets) means those events are never emitted. telemetry?: Telemetry; - // When provided, the agent gets a `task` tool that delegates to autonomous + // When provided, the agent gets fleet tools that delegate to autonomous // sub-agents. Omitted in contexts that cannot spawn sub-agents (e.g. tests). subAgent?: { provider: SubAgentProvider | (() => SubAgentProvider); @@ -341,36 +341,14 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise inheritedMcpTools, - run: runSubAgent, - ...(shellTimeout !== undefined ? { shellTimeout } : {}), - ...(shellEnv !== undefined ? { shellEnv } : {}), - ...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}), - ...(sa.onEvent !== undefined ? { onEvent: sa.onEvent } : {}), - ...(sa.onProgress !== undefined ? { onProgress: sa.onProgress } : {}), - ...(sa.sessions !== undefined ? { sessions: sa.sessions } : {}), - ...(sa.settings !== undefined ? { settings: sa.settings } : {}), - ...(sa.catalog !== undefined ? { catalog: sa.catalog } : {}), - ...(sa.profiles !== undefined ? { profiles: sa.profiles } : {}), - ...(args.getBlobReader !== undefined ? { getBlobReader: args.getBlobReader } : {}), - ...(sa.useWorktree !== undefined ? { useWorktree: sa.useWorktree } : {}), - ...(args.telemetry !== undefined ? { telemetry: args.telemetry } : {}), - ...(fleetRecords !== undefined ? { fleetRecords } : {}), - }), - ); if (sa.profiles !== undefined) { orchestratorTools.push( createSearchAgentsTool(() => { @@ -390,6 +368,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise inheritedMcpTools, @@ -408,6 +387,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise { + const fleetSessions = fleetSessionsForDispose; + if (fleetSessions !== undefined) { + fleetSessions.cancelAll("parent session closed"); + for (const session of [...fleetSessions.list()].reverse()) { + await fleetSessions.closeOne(session.id, DEFAULT_CLOSE_DEADLINE_MS); + } + } await Promise.allSettled([...inFlightConnections.values()]); for (const client of connectedClients.values()) { permissionGate.unregisterMcpServer(client.serverName); diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 1dcc3d753..2a978a962 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -155,14 +155,14 @@ export async function disposeExecRuntime(args: { export interface ExecDirectorOverlay { /** Package system prompt; omitted on the skywalker default path. */ systemPrompt?: string; - /** `pkg.tools.allow` (task stripped when `maySpawn` is false). */ + /** `pkg.tools.allow` (fleet tools stripped when `maySpawn` is false). */ advertisedAllow?: readonly string[]; - mountTask: boolean; + mountFleet: boolean; } export function resolveExecDirectorOverlay(director: DirectorId | undefined): ExecDirectorOverlay { if (director === undefined || director === "skywalker") { - return { mountTask: true }; + return { mountFleet: true }; } const pkg = DIRECTOR_REGISTRY[director]; const allow = pkg.tools?.allow; @@ -170,12 +170,25 @@ export function resolveExecDirectorOverlay(director: DirectorId | undefined): Ex allow !== undefined && allow.length > 0 ? pkg.spawn.maySpawn ? [...allow] - : allow.filter((name) => name !== "task") + : allow.filter( + (name) => + ![ + "search_agents", + "spawn_agent", + "wait_agents", + "list_agents", + "close_agent", + "resume_agent", + "interrupt_agent", + "send_input", + "read_agent_trace", + ].includes(name), + ) : undefined; return { systemPrompt: formatDirectorSystemPrompt(pkg), ...(advertisedAllow !== undefined ? { advertisedAllow } : {}), - mountTask: pkg.spawn.maySpawn, + mountFleet: pkg.spawn.maySpawn, }; } @@ -476,7 +489,7 @@ export async function runExec(config: Config): Promise { ); return result.kind === "option" && result.index === 0; }, - ...(overlay.mountTask + ...(overlay.mountFleet ? { subAgent: { provider: () => liveSubAgentProvider.current, diff --git a/src/perf/permission-subagent-spans.test.ts b/src/perf/permission-subagent-spans.test.ts index 2216456e6..cd6490413 100644 --- a/src/perf/permission-subagent-spans.test.ts +++ b/src/perf/permission-subagent-spans.test.ts @@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import type { ReactorEmittedEvent } from "@intx/inference"; import { createPermissionGate } from "../permission/gate.js"; -import { createTaskTool } from "../subagent/task-tool.js"; import { clear, snapshot, type PerfSpan } from "./index.js"; import { createPerfReactorObserver, currentTurnId } from "./reactor-spans.js"; @@ -33,18 +32,6 @@ function event(type: string, data: unknown = {}): ReactorEmittedEvent { const shellCall = (command: string) => ({ id: "c1", name: "run_shell", arguments: { command } }) as const; -const provider = { - providerName: "test-provider", - baseURL: "http://localhost", - model: "test-model", -}; - -const skipGate = createPermissionGate({ - approvals: [], - interactive: false, - skipPermissions: true, -}); - describe("permission.wait spans", () => { test("records allow decision when operator approves a shell ask", async () => { const gate = createPermissionGate({ @@ -220,151 +207,3 @@ describe("permission.wait spans", () => { obs.reset(); }); }); - -describe("subagent spans", () => { - test("records a completed subagent span around run()", async () => { - let runEntered = false; - const tool = createTaskTool({ - permissionGate: skipGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - run: async () => { - runEntered = true; - // Span must still be open while the child runs. - const open = snapshot().filter((s) => s.name === "subagent" && s.endNs === undefined); - expect(open).toHaveLength(1); - expect(open[0]!.tags?.subagent_id).toBe("call-sa-1"); - return { report: "## Summary\n\nok\n" }; - }, - }); - if (tool.kind !== "full") throw new Error("expected full tool"); - - const result = await tool.handler( - { - id: "call-sa-1", - name: "task", - arguments: { description: "Job", prompt: "Do it", intent: "explore" }, - }, - new AbortController().signal, - ); - expect(runEntered).toBe(true); - expect(typeof result.content === "string" ? result.content : "").toContain("ok"); - - const agents = byName(completed(snapshot()), "subagent"); - expect(agents).toHaveLength(1); - expect(agents[0]!.tags?.subagent_id).toBe("call-sa-1"); - expect(agents[0]!.endNs).toBeDefined(); - expect(agents[0]!.endNs! >= agents[0]!.startNs).toBe(true); - }); - - test("nests under the open turn with turn_id tag for fanout rollup", async () => { - const obs = createPerfReactorObserver(); - obs.observe(event("inference.start", { model: "m" })); - obs.observe( - event("inference.done", { - turn: { - role: "assistant", - content: [{ type: "tool_call", id: "task-1", name: "task", arguments: {} }], - model: "m", - timestamp: 0, - }, - usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, thinking: 0 }, - source: { provider: "p", model: "m" }, - }), - ); - const turnId = obs.currentTurnId(); - expect(turnId).not.toBeNull(); - - const tool = createTaskTool({ - permissionGate: skipGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - run: async () => ({ report: "## Summary\n\nchild done\n" }), - }); - if (tool.kind !== "full") throw new Error("expected full tool"); - - await tool.handler( - { - id: "call-child", - name: "task", - arguments: { description: "Child", prompt: "Work", intent: "explore" }, - }, - new AbortController().signal, - ); - - const agent = byName(completed(snapshot()), "subagent")[0]!; - expect(agent.parentId).toBe(turnId!); - expect(agent.tags?.subagent_id).toBe("call-child"); - expect(agent.tags?.turn_id).toBe(turnId!); - - // Wall time under the child is attributable via parentId (fanout rollup). - const turn = byName(snapshot(), "turn").find((s) => s.id === turnId); - expect(turn).toBeDefined(); - expect(agent.startNs >= turn!.startNs).toBe(true); - - obs.reset(); - }); - - test("closes the span when run() rejects", async () => { - const tool = createTaskTool({ - permissionGate: skipGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - run: async () => { - throw new Error("boom"); - }, - }); - if (tool.kind !== "full") throw new Error("expected full tool"); - - const result = await tool.handler( - { - id: "call-fail", - name: "task", - arguments: { description: "Fail", prompt: "Work", intent: "explore" }, - }, - new AbortController().signal, - ); - expect(typeof result.content === "string" ? result.content : "").toContain("Error:"); - - const agents = byName(completed(snapshot()), "subagent"); - expect(agents).toHaveLength(1); - expect(agents[0]!.tags?.subagent_id).toBe("call-fail"); - expect(agents[0]!.endNs).toBeDefined(); - }); - - test("opens and closes subagent span when worktree setup fails before run", async () => { - let runEntered = false; - const tool = createTaskTool({ - permissionGate: skipGate, - // Not a git repo — createSubAgentWorktree fails before run. - cwd: "/tmp/not-a-git-repo-for-subagent-span", - getWorkdirBase: () => "/tmp/not-a-git-repo-for-subagent-span/.corbits", - provider, - useWorktree: true, - run: async () => { - runEntered = true; - return { report: "## Summary\n\nshould not run\n" }; - }, - }); - if (tool.kind !== "full") throw new Error("expected full tool"); - - const result = await tool.handler( - { - id: "call-wt-fail", - name: "task", - arguments: { description: "Worktree fail", prompt: "Work", intent: "explore" }, - }, - new AbortController().signal, - ); - expect(runEntered).toBe(false); - expect(typeof result.content === "string" ? result.content : "").toContain("Error:"); - - const agents = byName(completed(snapshot()), "subagent"); - expect(agents).toHaveLength(1); - expect(agents[0]!.tags?.subagent_id).toBe("call-wt-fail"); - expect(agents[0]!.endNs).toBeDefined(); - }); -}); diff --git a/src/permission/approval-log.test.ts b/src/permission/approval-log.test.ts index c41cf7fb4..82d0accdd 100644 --- a/src/permission/approval-log.test.ts +++ b/src/permission/approval-log.test.ts @@ -149,8 +149,8 @@ describe("approval-log wiring through the permission gate", () => { expect(record!.rule).toBe("non-interactive"); }); - // A sub-agent's `task` dispatch `description` is model-authored free text - // (see task-tool.ts) — it is only ever trimmed, never constrained to a + // A sub-agent's `spawn_agent` dispatch `description` is model-authored free text + // — it is only ever trimmed, never constrained to a // closed set. A prior version of this log carried it verbatim as // `agentLabel`. It must never reach the record: unlike `rule` (a fixed // taxonomy) and `segments` (a count), nothing stops a model from quoting a diff --git a/src/permission/gate.ts b/src/permission/gate.ts index 13f9f3856..ee5619002 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -234,7 +234,8 @@ const AUTO_ALLOWED_TOOLS = new Set([ "tool_search", "use_skill", "search_agents", - "task", + "spawn_agent", + "wait_agents", ]); export interface PermissionGateOptions { diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index 6154f3641..d1722bbd4 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -1099,7 +1099,14 @@ describe("createPermissionGate", () => { }); expect(editVerdict.allowed).toBe(true); // Benign built-ins a hands-off run should not stop for. - for (const name of ["present", "tool_search", "use_skill", "search_agents", "task"]) { + for (const name of [ + "present", + "tool_search", + "use_skill", + "search_agents", + "spawn_agent", + "wait_agents", + ]) { const verdict = await gate.evaluate({ id: "c", name, arguments: {} }); expect(verdict.allowed).toBe(true); } diff --git a/src/plugins/data-only-agent.test.ts b/src/plugins/data-only-agent.test.ts new file mode 100644 index 000000000..5657b0bf8 --- /dev/null +++ b/src/plugins/data-only-agent.test.ts @@ -0,0 +1,43 @@ +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { expect, test } from "bun:test"; + +import { loadDataOnlyAgentPlugin } from "./data-only-agent.js"; + +test("legacy Task tool alias grants a collectable fleet surface", async () => { + const root = await mkdtemp(join(tmpdir(), "data-only-agent-task-alias-")); + await mkdir(join(root, "agents"), { recursive: true }); + await writeFile( + join(root, "agents", "delegate.md"), + `---\nname: delegate\ndescription: delegate work\ntools:\n - Task\n---\nDelegate work.\n`, + ); + + const plugin = await loadDataOnlyAgentPlugin(root, { cwd: root }); + const agent = plugin?.agentPlugin.agents[0] as + { capabilities?: { mode: string; tools: string[] } } | undefined; + + expect(agent?.capabilities).toEqual({ + mode: "allow", + tools: ["spawn_agent", "wait_agents"], + }); +}); + +test("legacy subagent tool alias grants a collectable fleet surface", async () => { + const root = await mkdtemp(join(tmpdir(), "data-only-agent-subagent-alias-")); + await mkdir(join(root, "agents"), { recursive: true }); + await writeFile( + join(root, "agents", "delegate.md"), + `---\nname: delegate\ndescription: delegate work\ntools:\n subagent: true\n---\nDelegate work.\n`, + ); + + const plugin = await loadDataOnlyAgentPlugin(root, { cwd: root }); + const agent = plugin?.agentPlugin.agents[0] as + { capabilities?: { mode: string; tools: string[] } } | undefined; + + expect(agent?.capabilities).toEqual({ + mode: "allow", + tools: ["spawn_agent", "wait_agents"], + }); +}); diff --git a/src/plugins/data-only-agent.ts b/src/plugins/data-only-agent.ts index 8d32d712f..09dbbee83 100644 --- a/src/plugins/data-only-agent.ts +++ b/src/plugins/data-only-agent.ts @@ -56,28 +56,29 @@ const NativeCapabilitiesModeSchema = type("'allow' | 'exclude'"); // skills (frontmatter list, in addition to body `Load the X skill` lines). // Upstream tool-name aliases mapped to Corbits Code tool ids. Case-insensitive. -const TOOL_ALIASES: Record = { - read: "read_file", - write: "write_file", - edit: "edit_file", - bash: "run_shell", - shell: "run_shell", - glob: "search_files", - find: "search_files", - grep: "grep", - ls: "list_dir", - task: "task", - subagent: "task", - websearch: "web_search", - webfetch: "web_fetch", - fetch: "web_fetch", - lsp: "lsp", +const TOOL_ALIASES: Record = { + read: ["read_file"], + write: ["write_file"], + edit: ["edit_file"], + bash: ["run_shell"], + shell: ["run_shell"], + glob: ["search_files"], + find: ["search_files"], + grep: ["grep"], + ls: ["list_dir"], + task: ["spawn_agent", "wait_agents"], + subagent: ["spawn_agent", "wait_agents"], + websearch: ["web_search"], + webfetch: ["web_fetch"], + fetch: ["web_fetch"], + lsp: ["lsp"], }; -function aliasTool(raw: string): string { - const lower = raw.trim().toLowerCase(); - if (lower.length === 0) return raw; - return TOOL_ALIASES[lower] ?? raw; +function aliasTools(raw: string): string[] { + const trimmed = raw.trim(); + const lower = trimmed.toLowerCase(); + if (lower.length === 0) return [raw]; + return [...(TOOL_ALIASES[lower] ?? [trimmed])]; } function isReasoningEffort(v: unknown): v is ReasoningEffort { @@ -116,14 +117,14 @@ function normalizeCapabilities(fm: Record | null): CapabilityFi if (!(mode instanceof type.errors) && Array.isArray(cap.tools)) { return { mode, - tools: cap.tools.filter((t): t is string => typeof t === "string").map(aliasTool), + tools: cap.tools.filter((t): t is string => typeof t === "string").flatMap(aliasTools), }; } } // Claude Code: tools: [Read, Grep] (allowlist) if (Array.isArray(fm.tools) && fm.tools.length > 0 && fm.disallowedTools === undefined) { - const tools = fm.tools.filter((t): t is string => typeof t === "string").map(aliasTool); + const tools = fm.tools.filter((t): t is string => typeof t === "string").flatMap(aliasTools); if (tools.length > 0) return { mode: "allow", tools }; } @@ -131,7 +132,7 @@ function normalizeCapabilities(fm: Record | null): CapabilityFi if (Array.isArray(fm.disallowedTools) && fm.disallowedTools.length > 0) { const tools = fm.disallowedTools .filter((t): t is string => typeof t === "string") - .map(aliasTool); + .flatMap(aliasTools); if (tools.length > 0) return { mode: "exclude", tools }; } @@ -147,8 +148,8 @@ function normalizeCapabilities(fm: Record | null): CapabilityFi const allowed: string[] = []; const excluded: string[] = []; for (const [k, v] of Object.entries(map)) { - if (v === true) allowed.push(aliasTool(k)); - else if (v === false) excluded.push(aliasTool(k)); + if (v === true) allowed.push(...aliasTools(k)); + else if (v === false) excluded.push(...aliasTools(k)); } if (allowed.length > 0 && excluded.length === 0) return { mode: "allow", tools: allowed }; if (excluded.length > 0 && allowed.length === 0) return { mode: "exclude", tools: excluded }; @@ -203,11 +204,11 @@ function normalizePermission(perm: Record): CapabilityFilter | const denied: string[] = []; for (const [k, v] of Object.entries(flat)) { if (k === "*" || k === "**") continue; - if (v === "allow") allowed.push(aliasTool(k)); - else if (v === "deny") denied.push(aliasTool(k)); + if (v === "allow") allowed.push(...aliasTools(k)); + else if (v === "deny") denied.push(...aliasTools(k)); // "ask" is treated as allowed for v1 — the ask-vs-allow distinction needs // a permission UI that doesn't exist for sub-agents yet. - else if (v === "ask") allowed.push(aliasTool(k)); + else if (v === "ask") allowed.push(...aliasTools(k)); } if (hasWildcardDeny && allowed.length > 0) { @@ -431,10 +432,9 @@ export async function loadDataOnlyAgentPlugin( if (description !== undefined) profile.description = description; if (inference !== undefined) profile.inference = inference; if (capabilities !== undefined) profile.capabilities = capabilities; - // `orchestrator: true` opts the agent into the recursion exception. Stored - // as a boolean rather than inferred from `mode: primary` because primary - // also collapses to "inherit all tools" for permissions — conflating the - // two would force every primary-style agent to recurse, which is wrong. + // Preserve the declaration for schema/search visibility. Dispatch rejects + // profile orchestrators until profile-sourced tiers have authority semantics. + // Do not infer this from `mode: primary`; primary also means inherit tools. if (frontmatter.orchestrator === true) profile.orchestrator = true; profile.systemPromptRole = systemPromptRole; diff --git a/src/prompts.test.ts b/src/prompts.test.ts index 5870d3401..ce524027b 100644 --- a/src/prompts.test.ts +++ b/src/prompts.test.ts @@ -128,9 +128,11 @@ test("chat system prompt satisfies system prompt quality markers", () => { } }); -test("default session always lists task and search_agents", () => { +test("default session lists split fleet tools and search_agents", () => { const prompt = buildChatSystemPrompt(undefined, undefined, undefined, [], "orchestrator"); - expect(prompt).toContain("- task:"); + expect(prompt).not.toContain("- task:"); + expect(prompt).toContain("- spawn_agent:"); + expect(prompt).toContain("- wait_agents:"); expect(prompt).toContain("- search_agents:"); }); @@ -174,7 +176,8 @@ test("SYSTEM.md override still appends orchestrator harness rules", () => { expect(prompt).toContain(override); expect(prompt).toContain("## Session mode"); expect(prompt).toContain("Orchestration:"); - expect(prompt).toContain("- task:"); + expect(prompt).toContain("- spawn_agent:"); + expect(prompt).toContain("- wait_agents:"); }); test("an empty base override falls back to the default base", () => { @@ -323,25 +326,28 @@ test("sub-agent prompt always appends Corbits Code notes, even with a JS-plugin- test("default sub-agent prompt forbids recursion", () => { const prompt = buildSubAgentSystemPrompt(); expect(prompt).toContain( - "Only the primary Corbits Code session (or an orchestrator profile) may call `task`", + "Only the primary Corbits Code session (or a built-in orchestrator director) may call `spawn_agent`", ); expect(prompt).toContain("You are a worker"); }); -// Orchestrator profiles (frontmatter `orchestrator: true`) are the documented -// exception to the no-recursion rule — their purpose IS to fan work out to +// Built-in orchestrator directors are the documented exception to the +// no-recursion rule — their purpose IS to fan work out to // other agents. The appendix grants them permission and links the syntax. -test("orchestrator sub-agent prompt grants the task-tool recursion exception", () => { +test("orchestrator sub-agent prompt grants the spawn_agent recursion exception", () => { const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, { orchestrator: true, }); expect(prompt).toContain("You are an orchestrator"); - expect(prompt).toContain("MAY call `task`"); - expect(prompt).toContain('task(agent="'); + expect(prompt).toContain("MAY call `spawn_agent`"); + expect(prompt).toContain( + 'spawn_agent(agent="greybeard", description="Review approach", prompt="...")', + ); + expect(prompt).not.toContain("Prefer search_agents"); // Must NOT contain the default no-recursion line — that would contradict // the permission grant in the same appendix. expect(prompt).not.toContain( - "Only the primary Corbits Code session (or an orchestrator profile) may call `task`", + "Only the primary Corbits Code session (or a built-in orchestrator director) may call `spawn_agent`", ); }); diff --git a/src/provider/reasoning-effort.ts b/src/provider/reasoning-effort.ts index 3b5918537..2a7146acc 100644 --- a/src/provider/reasoning-effort.ts +++ b/src/provider/reasoning-effort.ts @@ -212,7 +212,7 @@ export function clampEffort( } export interface ResolveEffortForRoleOpts { - /** True when the spawn is an orchestrator profile (may call task). */ + /** True when the spawn is a built-in orchestrator director (may call fleet tools). */ orchestrator: boolean; /** Explicit profile inference leg or task-tier pin — highest precedence. */ pin?: ReasoningEffort; @@ -240,7 +240,7 @@ export interface ResolveEffortForRoleOpts { * * Pins are still highest precedence, but an unsupported pin is clamped so the * pure API owns the "never emit an unsupported effort" invariant (callers that - * want hard-fail on bad pins should validateEffort first, as task-tool does). + * want hard-fail on bad pins should validateEffort first, as spawn_agent does). */ export function pickEffortFromCascade(opts: { pin?: ReasoningEffort; diff --git a/src/subagent/agent-fleet.test.ts b/src/subagent/agent-fleet.test.ts index 05d9a79ee..bec99312e 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { createFleetMailbox, @@ -19,6 +22,7 @@ import { agentLaneIsLive, fleetProgress } from "../tui/agent-progress.js"; import { AGENTS_PANEL_LINGER_MS, formatAgentsPanel } from "../tui/chrome-state.js"; import { forcedStopReport } from "./stop-policy.js"; import type { RunSubAgentParams, RunSubAgentResult } from "./types.js"; +import { INTERVENTION_FILE } from "./intervention-log.js"; const testPermissionGate = createPermissionGate({ approvals: [], @@ -48,7 +52,10 @@ function deferred(): { function makeDeps( run: (params: RunSubAgentParams) => Promise, - opts: { cwd?: string; sessions?: ReturnType } = {}, + opts: { + cwd?: string; + sessions?: ReturnType; + } & Partial> = {}, ): AgentFleetDeps { const sessions = opts.sessions ?? createSubAgentSessionStore(); return { @@ -59,6 +66,9 @@ function makeDeps( run, sessions, fleetRecords: createFleetMailbox(sessions), + ...(opts.settings !== undefined ? { settings: opts.settings } : {}), + ...(opts.catalog !== undefined ? { catalog: opts.catalog } : {}), + ...(opts.profiles !== undefined ? { profiles: opts.profiles } : {}), }; } @@ -134,6 +144,67 @@ describe("spawn_agent", () => { gate.resolve({ report: "done" }); }); + + test("rejects unsupported profile orchestrators before starting a session", async () => { + let runCalled = false; + const deps = makeDeps( + async () => { + runCalled = true; + return { report: "done" }; + }, + { + profiles: [ + { + id: "profile-orchestrator", + orchestrator: true, + systemPromptRole: "You coordinate work.", + }, + ], + }, + ); + const spawn = createSpawnAgentTool(deps); + const result = await callToolRaw(spawn, { + description: "profile job", + prompt: "do it", + agent: "profile-orchestrator", + }); + + expect(result.isError).toBe(true); + expect(result.content).toContain("profile orchestrators are not supported"); + expect(runCalled).toBe(false); + expect(deps.sessions.list()).toEqual([]); + }); + + test("dispatches a local profile id returned by search_agents", async () => { + let captured: RunSubAgentParams | undefined; + const deps = makeDeps( + async (params) => { + captured = params; + return { report: "done" }; + }, + { + profiles: [ + { + id: "plugin-reviewer", + capabilities: { mode: "allow", tools: ["read_file"] }, + systemPromptRole: "You are the plugin reviewer.", + }, + ], + }, + ); + const spawn = createSpawnAgentTool(deps); + + const result = await callTool(spawn, { + description: "profile job", + prompt: "do it", + agent: "plugin-reviewer", + }); + + expect(result.status).toBe("running"); + expect(captured?.directorId).toBe("plugin-reviewer"); + expect(captured?.systemPromptRole).toBe("You are the plugin reviewer."); + expect(captured?.capabilities).toEqual({ mode: "allow", tools: ["read_file"] }); + }); }); describe("spawn_agent + wait_agents", () => { @@ -381,6 +452,46 @@ describe("spawn_agent same-cwd concurrency", () => { gates[0]!.resolve({ report: "one done" }); gates[1]!.resolve({ report: "two done" }); }); + + test("two concurrent shared-cwd spawn_agent lanes log concurrent-lane-overlap", async () => { + const dir = await mkdtemp(join(tmpdir(), "fleet-overlap-")); + const gates = [deferred(), deferred()]; + let callIndex = 0; + const deps = makeDeps(async () => gates[callIndex++]!.promise, { cwd: "/repo" }); + deps.getWorkdirBase = () => dir; + const spawn = createSpawnAgentTool(deps); + + await callTool(spawn, { + description: "build one", + prompt: "implement thing one", + intent: "implement", + }); + await callTool(spawn, { + description: "build two", + prompt: "implement thing two", + intent: "implement", + }); + + const path = join(dir, INTERVENTION_FILE); + let log = ""; + for (let i = 0; i < 50; i++) { + try { + log = await readFile(path, "utf8"); + if (log.includes("concurrent-lane-overlap")) break; + } catch { + // append is fire-and-forget + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + expect(log).toContain("concurrent-lane-overlap"); + expect(log).toContain("conflict"); + expect(log).toContain("/repo"); + expect(log).toContain("build one"); + expect(log).toContain("build two"); + + gates[0]!.resolve({ report: "one done" }); + gates[1]!.resolve({ report: "two done" }); + }); }); describe("wait mailbox session tombstone and pin", () => { @@ -569,6 +680,60 @@ describe("wait_agents caller scope", () => { gate.resolve({ report: "done" }); }); + test("explicit targets respect nested orchestrator subtree authority", async () => { + const sessions = createSubAgentSessionStore(); + const fleetRecords = createFleetMailbox(sessions); + const actor = sessions.start({ + id: "actor", + description: "actor", + agentId: "builder", + brief: "b", + }); + const child = sessions.start({ + id: "child", + description: "child", + agentId: "explorer", + brief: "b", + parentSessionId: actor.id, + }); + const sibling = sessions.start({ + id: "sibling", + description: "sibling", + agentId: "explorer", + brief: "b", + }); + for (const session of [child, sibling]) { + fleetRecords.register(session.id); + sessions.complete(session.id, `${session.id} done`); + } + const wait = createWaitAgentsTool({ + sessions, + fleetRecords, + authority: { + actorId: actor.id, + tier: "nested-orchestrator", + getNodes: () => sessions.list(), + }, + }); + + const own = await callTool(wait, { targets: [child.id], timeout_ms: 1000 }); + expect(own.timed_out).toBe(false); + const ownResults = own.results as { agent_id: string; status: string; report?: string }[]; + expect(ownResults[0]).toEqual({ agent_id: child.id, status: "done", report: "child done" }); + + if (wait.kind !== "full") throw new Error("expected full tool"); + const denied = await wait.handler( + { + id: "wait-denied", + name: "wait_agents", + arguments: { targets: [sibling.id], timeout_ms: 0 }, + }, + new AbortController().signal, + ); + expect(denied.isError).toBe(true); + expect(String(denied.content)).toContain("outside its subtree"); + }); + test("mode=all stays blocked until every target is terminal", async () => { const gates = [deferred(), deferred()]; let callIndex = 0; @@ -868,8 +1033,10 @@ describe("interrupt_agent unblocks wait_agents", () => { }); const id = spawned.agent_id as string; - // interruptOne is wait-terminal via session interrupted; collect freezes it. + // Soft interrupt leaves the run in flight; the mailbox overlay is what + // makes wait terminal (same path interrupt_agent takes). expect(deps.sessions.interruptOne(id).ok).toBe(true); + deps.fleetRecords.interrupt(id); expect(deps.fleetRecords.peek(id)?.status).toBe("interrupted"); const waited = await callTool(wait, { targets: [id], timeout_ms: 5000 }); @@ -951,6 +1118,10 @@ describe("interrupt_agent unblocks wait_agents", () => { fleetRecords.register(worker.id); sessions.registerInterrupt(worker.id, () => {}); sessions.interruptOne(worker.id); + // Mirror interrupt_agent: soft interrupt alone projects as running while + // in-flight, so the mailbox must flip for wait to see "interrupted". + fleetRecords.interrupt(worker.id); + fleetRecords.interrupt(worker.id); const wait = createWaitAgentsTool({ sessions, fleetRecords }); const waited = await callTool(wait, { targets: [worker.id], timeout_ms: 1000 }); @@ -1137,7 +1308,7 @@ describe("list_agents", () => { }); }); -describe("spawn_agent parity with task", () => { +describe("spawn_agent dispatch contracts", () => { test("uses the parent tool call id as the session id", async () => { const deps = makeDeps(async () => ({ report: "done" })); const spawn = createSpawnAgentTool(deps); diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index 26ea1e19e..f9dec6089 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -1,12 +1,9 @@ /** - * spawn_agent / wait_agents: the non-blocking half of fleet dispatch, - * split out of `task()`'s fused spawn+wait. + * spawn_agent / wait_agents: the split fleet dispatch surface. * - * `task()` (task-tool.ts) remains the deprecated fused spawn+wait fallback. * These two verbs are the supported fleet path: start several workers in one * turn (spawn_agent returns immediately) and later block on this caller's - * own workers (wait_agents), instead of one task() call per worker - * serializing the wait. + * own workers (wait_agents). * * Running state is the session store's `WorkerLifecycle`. wait_agents blocks * on that store's `subscribe` raced against a timeout timer, never polling. @@ -16,21 +13,17 @@ * * The store's finished-session retention is a TUI display cap (`maxCompleted`, * default 20): `complete()`/`fail()` evict the oldest finished session — - * report and all — once more than that many have finished. task() never hit - * this because it awaits its own single result before the tool call returns; - * here a caller can spawn far more workers than the cap in one turn and only + * report and all — once more than that many have finished. A caller can spawn + * far more workers than the cap in one turn and only * `wait_agents` them later, so an evicted report would otherwise vanish * silently. Mailbox `register` pins the session (honored by pruneCompleted * and pruneRetained) until collect unpins. Heavy payloads are still capped at * `MAX_FLEET_RECORDS`: past that, the oldest never-collected pin is compacted * to a tombstone (status only, plus a pointer at `read_agent_trace`). * - * Argument shape intentionally mirrors `task()`'s (description/prompt/ - * context/goals/intent/success_criteria/do_not/report_focus) so a - * caller can swap one for the other. Closed-director dispatch also carries - * task()'s isolation and spawn-matrix: worktree cwd, parent allowlist, - * maySpawn nestedDispatch, and deadline. Custom AgentProfile lookup and the - * re-dispatch ledger remain task()-only until task becomes a thin wrapper. + * Argument shape includes description/prompt/context/goals/intent/ + * success_criteria/do_not/report_focus. Dispatch supports both closed + * directors and local/plugin AgentProfile ids returned by search_agents. * */ @@ -44,7 +37,7 @@ import type { ReactorEmittedEvent } from "@intx/inference"; import { getLogger } from "@intx/log"; import { LOG_NAMESPACE_ROOT } from "../branding.js"; -import type { ProviderCatalogEntry } from "../config/index.js"; +import { runtimeSettingsWithCatalog, type ProviderCatalogEntry } from "../config/index.js"; import { generateSessionId } from "../session/index.js"; import { isDirectorId, @@ -56,7 +49,13 @@ import { formatDirectorSystemPrompt, } from "../agent/directors/identity.js"; import type { Settings } from "../config/settings.js"; -import { resolveEffortForRole } from "../provider/reasoning-effort.js"; +import { resolveInferenceWithPolicy } from "../config/settings.js"; +import { + resolveEffortForRole, + validateEffort, + type ReasoningEffort, +} from "../provider/reasoning-effort.js"; +import type { AgentProfile, CapabilityFilter } from "../agent/profiles.js"; import { isCodexProviderName } from "../config/codex-providers.js"; import { buildDispatchBrief, type TaskIntent } from "./report.js"; import { @@ -80,9 +79,16 @@ import { captureSubagentEnd } from "../telemetry/product-events.js"; import { getCurrentTurnTraceId } from "../telemetry/feedback.js"; import type { DirectorPackage } from "../agent/directors/types.js"; import { SPAWN_AGENT_TOOL_NAME } from "./tool-taxonomy.js"; +import { + assertCanTargetAgent, + FleetAuthorityError, + type FleetNode, + type SubagentTier, +} from "./authority.js"; -import { formatSubAgentTaskAuthFailureMessage } from "./inference-auth-failure.js"; +import { formatSubAgentSpawnAuthFailureMessage } from "./inference-auth-failure.js"; import { isSubAgentCancelError } from "./dispose.js"; +import { createInterventionLog, type InterventionSink } from "./intervention-log.js"; const log = getLogger([LOG_NAMESPACE_ROOT, "subagent", "agent-fleet"]); @@ -158,6 +164,11 @@ class FleetMailbox { this.enforceCap(); } + hasUncollectedTerminal(id: string): boolean { + const record = this.peek(id); + return record !== undefined && record.status !== "running" && record.collected !== true; + } + /** * Overlay wait-status override so wait unblocks while the session may still * be running (send_input interrupt:true followup, close_agent teardown). @@ -346,7 +357,7 @@ const SpawnAgentArgs = type({ export const spawnAgentToolDefinition: ToolDefinition = { name: SPAWN_AGENT_TOOL_NAME, description: - "Start a worker agent and return IMMEDIATELY with its agent_id — this never blocks on the worker's completion. Same brief fields as task() (description/prompt/context/goals/intent/success_criteria/do_not/report_focus); pass agent= a director id or intent= (one of explore|implement|review|plan|general). Fire several spawn_agent calls in one turn to start workers in parallel, then use wait_agents to collect them. task() is the deprecated fused spawn+wait fallback for a single blocking worker.", + "Start a worker agent and return IMMEDIATELY with its agent_id — this never blocks on the worker's completion. Pass agent= a director/profile id returned by search_agents, or intent= (one of explore|implement|review|plan|general). Fire several spawn_agent calls in one turn to start workers in parallel, then use wait_agents to collect them.", inputSchema: { type: "object", properties: { @@ -448,10 +459,11 @@ export type AgentFleetDeps = SubAgentSandboxDeps & { useWorktree?: boolean; /** Optional wall-clock budget (ms) forwarded to runSubAgent. */ deadlineMs?: number; - /** When false, tear the worker down on completion (task wrapper). Default true. */ + /** When false, tear the worker down on completion. Default true. */ persist?: boolean; settings?: Settings | (() => Settings | undefined); catalog?: readonly ProviderCatalogEntry[] | (() => readonly ProviderCatalogEntry[]); + profiles?: AgentProfile[] | (() => AgentProfile[]); onEvent?: (event: ReactorEmittedEvent) => void; onProgress?: (info: { description: string; toolName: string }) => void; telemetry?: Telemetry; @@ -466,7 +478,7 @@ function fleetResult(callId: string, content: string): ToolResult { return { callId, content, ...(isError ? { isError: true } : {}) }; } -/** Resolve agent=/intent= to a closed director. Mirrors task()'s director-only branch. */ +/** Resolve agent=/intent= to a closed director. */ export function resolveDirectorDispatch( agentId: string | undefined, intent: TaskIntent | undefined, @@ -519,8 +531,163 @@ export function resolveDirectorDispatch( }; } +interface ResolvedAgentDispatch { + directorId: string; + agentLabel: string; + systemPromptRole?: string; + capabilities?: CapabilityFilter; + roleDefault?: ReturnType; + pkg?: DirectorPackage; + orchestrator: boolean; + orchestratorTier?: DirectorPackage["tier"]; + nestedSpawnAllowlist?: readonly string[]; + effortPin?: ReasoningEffort; +} + +function resolveAgentDispatch(input: { + agentId: string | undefined; + intent: TaskIntent | undefined; + profiles: AgentProfile[] | undefined; + allowOrchestrator: boolean; + settings: Settings | undefined; + applyResolvedProvider: ( + resolved: { provider: string; model: string; reasoningEffort?: ReasoningEffort }, + label: string, + ) => string | null; +}): ResolvedAgentDispatch | { error: string } { + const { agentId, intent, profiles, allowOrchestrator, settings, applyResolvedProvider } = input; + if (agentId !== undefined && agentId.length > 0) { + if (isDirectorId(agentId)) { + const resolved = resolveDirector({ agentId }); + if (!resolved.ok) return { error: `Error: ${resolved.error} ${resolved.hint}` }; + const pkg = resolved.package; + const profile = profiles?.find((p) => p.id === agentId); + let effortPin: ReasoningEffort | undefined; + if (profile?.inference !== undefined && settings !== undefined) { + const outcome = resolveInferenceWithPolicy(profile.inference, settings); + if (outcome.kind === "unavailable") { + return { + error: `Error: agent "${agentId}" unavailable: ${outcome.reason}. Set agentModelFallback: "active" (or change the spec mode to "prefer") to fall back to the active session.`, + }; + } + if (outcome.kind === "resolved") { + const err = applyResolvedProvider(outcome.value, `agent "${agentId}"`); + if (err !== null) return { error: err }; + effortPin = outcome.value.reasoningEffort; + } + } + const capabilities = packageToCapabilities(pkg); + const orchestrator = pkg.spawn.maySpawn === true && allowOrchestrator; + return { + directorId: pkg.id, + agentLabel: pkg.id, + systemPromptRole: formatDirectorSystemPrompt(pkg), + ...(capabilities !== undefined ? { capabilities } : {}), + roleDefault: defaultEffortForDirector(pkg), + pkg, + orchestrator, + ...(orchestrator ? { orchestratorTier: pkg.tier } : {}), + ...(orchestrator && pkg.spawn.allowlist !== undefined && pkg.spawn.allowlist.length > 0 + ? { nestedSpawnAllowlist: pkg.spawn.allowlist } + : {}), + ...(effortPin !== undefined ? { effortPin } : {}), + }; + } + + if (profiles === undefined) { + return { + error: `Error: agent "${agentId}" requested but no agent profiles are loaded. Omit agent to use intent=, or ensure profiles are available.`, + }; + } + const profile = profiles.find((p) => p.id === agentId); + if (profile === undefined) { + const known = profiles.map((p) => p.id).sort(); + const hint = + known.length > 0 + ? ` Known profiles: ${known.join(", ")}. Call search_agents to discover more (results include full system prompt / body; do not read_file plugin paths outside the workspace).` + : " No profiles are currently loaded. Call search_agents to discover available agents (results include full system prompt / body)."; + return { error: `Error: unknown agent profile "${agentId}".${hint}` }; + } + let effortPin: ReasoningEffort | undefined; + if (profile.inference !== undefined && settings !== undefined) { + const outcome = resolveInferenceWithPolicy(profile.inference, settings); + if (outcome.kind === "unavailable") { + return { + error: `Error: agent "${agentId}" unavailable: ${outcome.reason}. Set agentModelFallback: "active" (or change the spec mode to "prefer") to fall back to the active session.`, + }; + } + if (outcome.kind === "resolved") { + const err = applyResolvedProvider(outcome.value, `agent "${agentId}"`); + if (err !== null) return { error: err }; + effortPin = outcome.value.reasoningEffort; + } + } + if (profile.orchestrator === true) { + return { + error: + `Error: agent profile "${agentId}" requests orchestrator=true, but profile ` + + "orchestrators are not supported for spawn_agent. Use a built-in orchestrator " + + "director or remove orchestrator from the profile.", + }; + } + return { + directorId: agentId, + agentLabel: agentId, + ...(profile.systemPromptRole !== undefined + ? { systemPromptRole: profile.systemPromptRole } + : {}), + ...(profile.capabilities !== undefined ? { capabilities: profile.capabilities } : {}), + orchestrator: false, + ...(effortPin !== undefined ? { effortPin } : {}), + }; + } + + if (intent !== undefined) { + const resolved = resolveDirector({ intent }); + if (!resolved.ok) return { error: `Error: ${resolved.error} ${resolved.hint}` }; + const pkg = resolved.package; + const capabilities = packageToCapabilities(pkg); + const orchestrator = pkg.spawn.maySpawn === true && allowOrchestrator; + return { + directorId: pkg.id, + agentLabel: pkg.id, + systemPromptRole: formatDirectorSystemPrompt(pkg), + ...(capabilities !== undefined ? { capabilities } : {}), + roleDefault: defaultEffortForDirector(pkg), + pkg, + orchestrator, + ...(orchestrator ? { orchestratorTier: pkg.tier } : {}), + ...(orchestrator && pkg.spawn.allowlist !== undefined && pkg.spawn.allowlist.length > 0 + ? { nestedSpawnAllowlist: pkg.spawn.allowlist } + : {}), + }; + } + + return { + error: + "Error: No director selected. Pass spawn_agent(agent=...) for a named director/profile, or spawn_agent(intent=implement|explore|plan|review).", + }; +} + export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { const telemetry = deps.telemetry ?? NOOP_TELEMETRY; + // Concurrent-lane overlap detection, replacing the static per-package + // writePaths lock. There is no field in the spawn_agent contract a caller + // uses to declare which files a dispatch will touch, so the only honestly + // knowable "intended scope" at spawn is the working directory the dispatch + // will run in — worktree-isolated lanes always get a fresh, disjoint path + // here, so this can only ever fire in the shared-cwd fallback, which is + // exactly where two lanes really can stomp each other's writes. + // Keyed by call.id so a completed lane (removed when the worker settles) + // is never mistaken for one still running: sequential dispatches to the + // same cwd are always clean. Tracking lasts the worker lifetime, not the + // immediate spawn_agent return. + const activeLanes = new Map(); + let conflictLog: InterventionSink | null = null; + const recordConflict = (event: Parameters[0]): void => { + conflictLog ??= createInterventionLog(deps.getWorkdirBase(), { role: "parent" }); + conflictLog(event); + }; return tool({ definition: spawnAgentToolDefinition, handler: async (call, _signal): Promise => { @@ -556,44 +723,85 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { const doNot = rawDoNot?.map((d) => d.trim()).filter((d) => d.length > 0) ?? []; const reportFocus = rawReportFocus?.trim(); - const resolved = resolveDirectorDispatch(agentId, intent); - if (!resolved.ok) return fleetResult(call.id, resolved.error); + let provider: SubAgentProvider = resolveDep(deps.provider); + const parentEffort = provider.reasoningEffort; + const diskSettings = deps.settings !== undefined ? resolveDep(deps.settings) : undefined; + const catalog = deps.catalog !== undefined ? resolveDep(deps.catalog) : undefined; + const settings = + catalog !== undefined ? runtimeSettingsWithCatalog(diskSettings, catalog) : diskSettings; + const profiles = deps.profiles !== undefined ? resolveDep(deps.profiles) : undefined; + const applyResolvedProvider = ( + resolved: { provider: string; model: string; reasoningEffort?: ReasoningEffort }, + label: string, + ): string | null => { + if (settings === undefined) { + return `Error: ${label} requires settings with configured providers.`; + } + if (resolved.reasoningEffort !== undefined) { + const verdict = validateEffort( + resolved.model, + resolved.reasoningEffort, + isCodexProviderName(resolved.provider), + ); + if (!verdict.ok) { + return `Error: ${label} has incompatible inference: ${verdict.error}`; + } + } + const providerSettings = settings.providers[resolved.provider]; + if (providerSettings === undefined) { + return `Error: ${label} resolved to provider "${resolved.provider}" which is not configured.`; + } + provider = { + providerName: resolved.provider, + baseURL: providerSettings.baseURL, + ...(providerSettings.keyless === true ? { keyless: true } : {}), + ...(providerSettings.bifrostVirtualKey === true ? { bifrostVirtualKey: true } : {}), + ...(providerSettings.apiKey !== undefined ? { apiKey: providerSettings.apiKey } : {}), + model: resolved.model, + }; + return null; + }; + + const resolved = resolveAgentDispatch({ + agentId, + intent, + profiles, + allowOrchestrator: deps.allowOrchestrator !== false, + settings, + applyResolvedProvider, + }); + if ("error" in resolved) return fleetResult(call.id, resolved.error); if (agentId === "skywalker" || resolved.directorId === "skywalker") { return fleetResult( call.id, - "Error: skywalker is the primary session identity, not a spawned worker. Pass spawn_agent(agent=…) for a specialist (builder, explorer, counsel, critic, …).", + "Error: skywalker is the primary session identity, not a spawned worker. Pass spawn_agent(agent=...) for a specialist (builder, explorer, counsel, critic, ...).", ); } if (deps.spawnAllowlist !== undefined && deps.spawnAllowlist.length > 0) { - if (!deps.spawnAllowlist.includes(resolved.directorId)) { + if (!deps.spawnAllowlist.includes(resolved.agentLabel)) { return fleetResult( call.id, - `Error: spawn of "${resolved.directorId}" is outside this director's allowlist. Allowed: ${deps.spawnAllowlist.join(", ")}.`, + `Error: spawn of "${resolved.agentLabel}" is outside this director's allowlist. Allowed: ${deps.spawnAllowlist.join(", ")}.`, ); } } - const settings = deps.settings !== undefined ? resolveDep(deps.settings) : undefined; - - const orchestrator = resolved.pkg.spawn.maySpawn === true && deps.allowOrchestrator !== false; - const nestedSpawnAllowlist = - orchestrator && - resolved.pkg.spawn.allowlist !== undefined && - resolved.pkg.spawn.allowlist.length > 0 - ? resolved.pkg.spawn.allowlist - : undefined; - - let provider: SubAgentProvider = resolveDep(deps.provider); + const orchestrator = resolved.orchestrator; + const nestedSpawnAllowlist = resolved.nestedSpawnAllowlist; const effort = resolveEffortForRole({ orchestrator, - roleDefault: resolved.roleDefault, - ...(provider.reasoningEffort !== undefined - ? { parentEffort: provider.reasoningEffort } - : {}), + ...(resolved.effortPin !== undefined ? { pin: resolved.effortPin } : {}), + ...(resolved.roleDefault !== undefined ? { roleDefault: resolved.roleDefault } : {}), + ...(parentEffort !== undefined ? { parentEffort } : {}), model: provider.model, isCodex: isCodexProviderName(provider.providerName), }); - provider = effort !== undefined ? { ...provider, reasoningEffort: effort } : provider; + if (effort !== undefined) { + provider = { ...provider, reasoningEffort: effort }; + } else { + const { reasoningEffort: _drop, ...rest } = provider; + provider = rest; + } const brief = buildDispatchBrief({ description, @@ -665,7 +873,6 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { deps.onEvent?.(event); }; - const catalog = deps.catalog !== undefined ? resolveDep(deps.catalog) : undefined; let worktreeCwd: string | undefined; let worktreeStashBaseline: readonly string[] | null = []; let worktreeHeadAtCreate: string | undefined; @@ -706,6 +913,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { sessions: deps.sessions, ...(settings !== undefined ? { settings } : {}), ...(catalog !== undefined ? { catalog } : {}), + ...(deps.profiles !== undefined ? { profiles: deps.profiles } : {}), parentSessionId: session.id, ...(deps.useWorktree !== undefined ? { useWorktree: deps.useWorktree } : {}), ...(nestedSpawnAllowlist !== undefined ? { spawnAllowlist: nestedSpawnAllowlist } : {}), @@ -732,10 +940,31 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { } }; + // Detect, don't lock: warn when another lane still running right now + // is already working in this same cwd. Worktree-isolated lanes never + // collide here (each gets its own directory); this only fires in the + // shared-cwd fallback, where two lanes genuinely can overwrite each + // other's writes. Never blocks the spawn — the least destructive + // response that still tells the operator something true, since a + // shared cwd does not by itself prove the two lanes touch the same + // files, only that they could. + const laneCwd = worktreeCwd ?? deps.cwd; + for (const [otherId, other] of activeLanes) { + if (other.cwd !== laneCwd) continue; + recordConflict({ + id: "concurrent-lane-overlap", + class: "conflict", + detail: + `"${description}" (${call.id}) and "${other.description}" (${otherId}) ` + + `are both running against ${laneCwd} at once`, + }); + } + activeLanes.set(call.id, { description, cwd: laneCwd }); + const params: RunSubAgentParams = { // Name the trace directory after the session-store id so the // descendant-scoping check behind read_agent_trace can resolve this - // worker's parent chain (matches task-tool.ts). + // worker's parent chain. id: session.id, permissionGate: deps.permissionGate, ...(deps.inheritMcpTools !== undefined ? { inheritMcpTools: deps.inheritMcpTools } : {}), @@ -763,18 +992,22 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { }, ...(deps.onProgress !== undefined ? { onProgress: deps.onProgress } : {}), ...(resolved.capabilities !== undefined ? { capabilities: resolved.capabilities } : {}), - systemPromptRole: resolved.systemPromptRole, + ...(resolved.systemPromptRole !== undefined + ? { systemPromptRole: resolved.systemPromptRole } + : {}), directorId: resolved.directorId, ...(orchestrator ? { orchestrator: true, - orchestratorTier: resolved.pkg.tier, + ...(resolved.orchestratorTier !== undefined + ? { orchestratorTier: resolved.orchestratorTier } + : {}), nestedDispatch: nestedDispatch!, } : {}), ...(deps.deadlineMs !== undefined ? { deadlineMs: deps.deadlineMs } : {}), - tier: resolved.pkg.tier, - ...(resolved.pkg.reportContract?.outputType !== undefined + ...(resolved.pkg !== undefined ? { tier: resolved.pkg.tier } : {}), + ...(resolved.pkg?.reportContract?.outputType !== undefined ? { reportType: resolved.pkg.reportContract.outputType } : {}), // Keep the session open after a clean completion, and hand the @@ -845,13 +1078,13 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { deps.sessions.settleRun(session.id); return; } - // Auth failures keep the actionable Re-authenticate wording that - // task()'s fused path surfaces via formatSubAgentTaskAuthFailureMessage. - const authMessage = formatSubAgentTaskAuthFailureMessage(description, err); + // Auth failures keep the actionable Re-authenticate wording. + const authMessage = formatSubAgentSpawnAuthFailureMessage(description, err); const failReason = authMessage ?? (err instanceof Error ? err.message : String(err)); deps.sessions.fail(session.id, failReason); }) .finally(() => { + activeLanes.delete(call.id); finalizeEnd(); if (!keepWorktreeAlive) void reclaimWorktree(); }); @@ -861,9 +1094,16 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { }); } +interface WaitAgentsAuthority { + actorId: string | undefined; + tier: SubagentTier; + getNodes: () => readonly FleetNode[]; +} + interface WaitAgentsDeps { sessions: SubAgentSessionStore; fleetRecords: FleetMailboxHandle; + authority?: WaitAgentsAuthority; } function isWaitTerminal(id: string, fleetRecords: FleetMailboxHandle): boolean { @@ -935,6 +1175,29 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool { return fleetResult(call.id, JSON.stringify({ results: [], timed_out: false })); } + if (deps.authority !== undefined) { + if (deps.authority.actorId === undefined) { + return fleetResult( + call.id, + "Error: wait_agents is unavailable for this worker (no resolvable session id to scope descendant access).", + ); + } + try { + for (const target of targets) { + assertCanTargetAgent( + { id: deps.authority.actorId, tier: deps.authority.tier }, + target, + deps.authority.getNodes(), + ); + } + } catch (cause) { + if (cause instanceof FleetAuthorityError) { + return fleetResult(call.id, `Error: ${cause.message}`); + } + throw cause; + } + } + const timedOut = await waitForTerminal( deps.sessions, deps.fleetRecords, @@ -958,7 +1221,9 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool { return { agent_id: id, status: taken.status, - ...(taken.report !== undefined ? { report: taken.report } : {}), + ...(taken.status !== "failed" && taken.report !== undefined + ? { report: taken.report } + : {}), ...(taken.error !== undefined ? { error: taken.error } : {}), ...(taken.hint !== undefined ? { hint: taken.hint } : {}), }; diff --git a/src/subagent/authority.test.ts b/src/subagent/authority.test.ts index 3bcf5b25e..0356dd9bf 100644 --- a/src/subagent/authority.test.ts +++ b/src/subagent/authority.test.ts @@ -8,7 +8,6 @@ import { describe("assertTierMayMountFleetVerb", () => { test("a Tier 3 leaf cannot obtain a fleet verb", () => { - expect(() => assertTierMayMountFleetVerb("leaf", "task")).toThrow(FleetAuthorityError); expect(() => assertTierMayMountFleetVerb("leaf", "search_agents")).toThrow(FleetAuthorityError); expect(() => assertTierMayMountFleetVerb("leaf", "spawn_agent")).toThrow(FleetAuthorityError); // The reusable-session verbs are gated the same way. @@ -26,13 +25,13 @@ describe("assertTierMayMountFleetVerb", () => { }); test("Tier 1 and Tier 2 may mount spawn/control fleet verbs", () => { - expect(() => assertTierMayMountFleetVerb("orchestrator", "task")).not.toThrow(); - expect(() => assertTierMayMountFleetVerb("nested-orchestrator", "task")).not.toThrow(); + expect(() => assertTierMayMountFleetVerb("orchestrator", "spawn_agent")).not.toThrow(); expect(() => assertTierMayMountFleetVerb("nested-orchestrator", "spawn_agent")).not.toThrow(); + expect(() => assertTierMayMountFleetVerb("nested-orchestrator", "wait_agents")).not.toThrow(); }); // CL-7051: fleet discovery is Skywalker (Tier 1) only — nested directors keep - // task/spawn allowlists but must not discover the full fleet. + // spawn allowlists but must not discover the full fleet. test("Tier 2 nested orchestrator cannot mount search_agents but may list its own fleet", () => { expect(() => assertTierMayMountFleetVerb("nested-orchestrator", "search_agents")).toThrow( FleetAuthorityError, @@ -46,7 +45,8 @@ describe("assertTierMayMountFleetVerb", () => { }); test("isFleetVerb matches the same set used for the gate", () => { - expect(isFleetVerb("task")).toBe(true); + expect(isFleetVerb("spawn_agent")).toBe(true); + expect(isFleetVerb("task")).toBe(false); expect(isFleetVerb("write_file")).toBe(false); }); }); diff --git a/src/subagent/authority.ts b/src/subagent/authority.ts index 4cec891e1..c9f162f7f 100644 --- a/src/subagent/authority.ts +++ b/src/subagent/authority.ts @@ -5,7 +5,7 @@ * in a prompt. This module owns two checks: * * - assertTierMayMountFleetVerb: a Tier 3 leaf may never mount a fleet verb - * (task, spawn_agent, wait_agents, list_agents, interrupt_agent, close_agent, + * (spawn_agent, wait_agents, list_agents, interrupt_agent, close_agent, * resume_agent, send_input, read_agent_trace, search_agents). * Fleet *discovery* of the director catalog (search_agents) is Tier 1 only * (CL-7051). list_agents is not catalog discovery — it lists this install's @@ -28,7 +28,6 @@ export type { SubagentTier } from "../agent/directors/types.js"; * observe). Tier 3 leaves may mount none of these — ever. */ export const FLEET_VERBS = new Set([ - "task", "search_agents", "spawn_agent", "wait_agents", @@ -112,8 +111,8 @@ function isDescendant( * fleet verbs at all and can never reach this check with a real call, so it * always fails closed here too. * - * Production call sites: `read_agent_trace`, `send_input`, `interrupt_agent`, - * `close_agent`, and `resume_agent` (nested mounts pass + * Production call sites: `read_agent_trace`, `wait_agents` explicit targets, + * `send_input`, `interrupt_agent`, `close_agent`, and `resume_agent` (nested mounts pass * authority from run.ts; Tier-1 primary omits it and stays unrestricted). */ export function assertCanTargetAgent( diff --git a/src/subagent/brief-dispatch.ts b/src/subagent/brief-dispatch.ts deleted file mode 100644 index 5fdf698af..000000000 --- a/src/subagent/brief-dispatch.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Parent-side re-dispatch bookkeeping for task briefs. - * - * This module tracks how often the *parent* re-spawns the same brief so - * salvage outcomes can be classified per-fingerprint (successful completes - * reset the counter). - * - * Session-scoped: one ledger per createTaskTool instance (parent chat tool). - */ - -import type { TaskIntent } from "./report.js"; -import type { ForcedStopReason } from "./stop-policy.js"; - -// Every forced-stop reason a leaf can report maps 1:1 onto a salvage kind -// the parent ledger cares about. -export type BriefSalvageKind = ForcedStopReason; - -export interface TaskBriefFingerprintInput { - prompt: string; - agent?: string; - intent?: TaskIntent; - successCriteria?: readonly string[]; - doNot?: readonly string[]; -} - -export interface BriefDispatchRecord { - /** How many times this fingerprint has been accepted for run (including first). */ - dispatchCount: number; -} - -/** - * Classify a completed dispatch as a salvage kind the parent ledger cares - * about, from the structured stop reason the run reported directly — never - * by matching the report body's prose. `wasCancelled` (observed independently, - * e.g. via the parent's own abort signal) takes precedence since a parent - * cancel can race a run that never got to report its own reason. - */ -export function classifyBriefSalvage(input: { - stopReason?: ForcedStopReason; - wasCancelled: boolean; -}): BriefSalvageKind | null { - if (input.wasCancelled) return "cancelled"; - return input.stopReason ?? null; -} - -/** - * Stable fingerprint for a task brief. Covers the typed spawn contract fields - * that define the job (prompt + agent + intent + success_criteria + do_not). - * Description, context, goals, report_focus, and tier are intentionally - * omitted so cosmetic label tweaks cannot bypass the cap. - */ -export function fingerprintTaskBrief(input: TaskBriefFingerprintInput): string { - const parts = [ - "v1", - normalizeText(input.prompt), - normalizeText(input.agent ?? ""), - input.intent ?? "", - serializeList(input.successCriteria), - serializeList(input.doNot), - ]; - return parts.join("\n"); -} - -function normalizeText(s: string): string { - return s.trim().replace(/\s+/g, " "); -} - -function serializeList(items: readonly string[] | undefined): string { - if (items === undefined || items.length === 0) return ""; - return items.map((item) => normalizeText(item)).join("\0"); -} - -export interface BriefDispatchLedger { - get: (fingerprint: string) => BriefDispatchRecord | undefined; - /** Pre-run gate. Always admits, returning the 1-based dispatch count that will be used. */ - admit: (fingerprint: string) => { dispatchCount: number }; - /** Record the outcome of an admitted run (salvage kind or null on success). */ - recordOutcome: (fingerprint: string, salvage: BriefSalvageKind | null) => void; - /** - * Undo a prior admit when the run never produced a salvage or success body - * (throw / auth fail). Prevents burning re-dispatch bookkeeping on crashes. - */ - release: (fingerprint: string) => void; -} - -export function createBriefDispatchLedger(): BriefDispatchLedger { - const byFingerprint = new Map(); - - return { - get(fingerprint) { - return byFingerprint.get(fingerprint); - }, - - admit(fingerprint) { - const existing = byFingerprint.get(fingerprint); - const nextCount = (existing?.dispatchCount ?? 0) + 1; - byFingerprint.set(fingerprint, { dispatchCount: nextCount }); - return { dispatchCount: nextCount }; - }, - - recordOutcome(fingerprint, salvage) { - if (salvage === null) { - // A successful complete resets the same-brief retry budget. - byFingerprint.set(fingerprint, { dispatchCount: 0 }); - return; - } - const existing = byFingerprint.get(fingerprint); - byFingerprint.set(fingerprint, { dispatchCount: existing?.dispatchCount ?? 1 }); - }, - - release(fingerprint) { - const existing = byFingerprint.get(fingerprint); - if (existing === undefined) return; - if (existing.dispatchCount <= 1) { - byFingerprint.delete(fingerprint); - return; - } - byFingerprint.set(fingerprint, { dispatchCount: existing.dispatchCount - 1 }); - }, - }; -} diff --git a/src/subagent/index.test.ts b/src/subagent/index.test.ts index bc23403d6..c8bded6a1 100644 --- a/src/subagent/index.test.ts +++ b/src/subagent/index.test.ts @@ -1,14 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { CodexAuthError } from "../auth/codex/session.js"; -import { createPermissionGate } from "../permission/gate.js"; -import { createDynamicToolRunner } from "../tui/dynamic-tool-runner.js"; import { buildDispatchBrief, - coreSubAgentWebTools, - createTaskTool, createSubAgentRunController, - createSubAgentSessionStore, createSubAgentSpawnRegistryPlugin, disposeSubAgentSession, evaluateSubAgentStop, @@ -16,9 +10,6 @@ import { formatSubAgentReport, parseSubAgentReport, appendSubAgentParentHints, - createBriefDispatchLedger, - fingerprintTaskBrief, - classifyBriefSalvage, EMPTY_THRASH_STATE, nextThrashState, salvagePathsFromThrash, @@ -33,11 +24,7 @@ import { SUBAGENT_DEADLINE_MARGIN_MS, SUBAGENT_PLUGIN_SPAWN_TEARDOWN_LIMITS, SubAgentDirector, - TaskToolArgs, - type RunSubAgentParams, } from "./index.js"; - -import { type } from "arktype"; import type { ReactorAction, ReactorCapabilities, @@ -45,29 +32,6 @@ import type { ReactorState, } from "@intx/types/runtime"; -const testPermissionGate = createPermissionGate({ - approvals: [], - interactive: false, - skipPermissions: true, -}); - -const provider = { - providerName: "test-provider", - baseURL: "http://localhost", - model: "test-model", -}; - -async function callTask( - tool: ReturnType, - args: Record, - signal: AbortSignal = new AbortController().signal, -): Promise { - // createTaskTool returns a full-handler AgentTool (call + signal → ToolResult). - if (tool.kind !== "full") throw new Error(`expected full tool, got ${tool.kind}`); - const result = await tool.handler({ id: "call-1", name: "task", arguments: args }, signal); - return typeof result.content === "string" ? result.content : JSON.stringify(result.content); -} - describe("sub-agent teardown", () => { test("disposeSubAgentSession closes agent, awaits stream, and disposes posix tools once", async () => { let closeCount = 0; @@ -810,541 +774,6 @@ describe("SubAgentDirector stall management", () => { }); }); -describe("createTaskTool", () => { - test("handler does not resolve until run() resolves; result includes the full report", async () => { - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - const report = "## Summary\nThe work is done."; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - profiles: [{ id: "leaf" }], - run: async () => { - await gate; - return { report }; - }, - }); - - const pending = callTask(tool, { - description: "Investigate", - prompt: "Do the work", - agent: "leaf", - }); - let settled = false; - void pending.then(() => { - settled = true; - }); - await Promise.resolve(); - expect(settled).toBe(false); - - release(); - const result = await pending; - expect(settled).toBe(true); - expect(result).toContain('Sub-agent "'); - expect(result).toContain(report); - expect(result).toContain("## Summary"); - }); - - test("profile inference rebuilds provider from settings", async () => { - let captured: RunSubAgentParams | undefined; - const settings = { - providers: { - "profile-p": { - baseURL: "http://profile", - apiKey: "k", - models: ["profile-model", "pinned-model"], - }, - }, - }; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - settings, - profiles: [ - { - id: "deep", - inference: { order: [{ provider: "profile-p", model: "pinned-model" }] }, - }, - ], - run: async (params) => { - captured = params; - return { report: "done" }; - }, - }); - await callTask(tool, { - description: "profile-inference", - prompt: "x", - agent: "deep", - }); - expect(captured?.provider.providerName).toBe("profile-p"); - expect(captured?.provider.model).toBe("pinned-model"); - }); - - test("profile inference targeting OAuth provider resolves via live catalog", async () => { - let captured: RunSubAgentParams | undefined; - const diskSettings = { - providers: {}, - }; - const catalog = [ - { - name: "xai/work", - baseURL: "https://api.x.ai/v1", - apiKey: "xai-token", - models: ["grok-4"], - xaiProfile: "work", - }, - ]; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - settings: diskSettings, - catalog, - profiles: [ - { - id: "deep", - inference: { order: [{ provider: "xai/work", model: "grok-4" }] }, - }, - ], - run: async (params) => { - captured = params; - return { report: "done" }; - }, - }); - await callTask(tool, { description: "oauth-profile-inference", prompt: "x", agent: "deep" }); - expect(captured?.provider.providerName).toBe("xai/work"); - expect(captured?.provider.model).toBe("grok-4"); - expect(captured?.provider.apiKey).toBe("xai-token"); - }); - - test("profile inference fails closed when pinned and unavailable", async () => { - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - settings: { - providers: { - "api-only": { baseURL: "http://api", apiKey: "k", models: ["m"] }, - }, - }, - profiles: [ - { - id: "deep", - inference: { mode: "pin", order: [{ provider: "xai/missing", model: "grok-4" }] }, - }, - ], - run: async () => ({ report: "done" }), - }); - const out = await callTask(tool, { - description: "missing-oauth", - prompt: "x", - agent: "deep", - }); - expect(out).toContain("Error:"); - expect(out).toContain("unavailable"); - }); - - test("forwards sandbox deps (permission gate and inherited MCP tools) to runSubAgent", async () => { - const inherited = [ - { - definition: { name: "mcp__srv__tool", description: "Test MCP tool", inputSchema: {} }, - kind: "string" as const, - handler: async () => "ok", - }, - ]; - let captured: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - inheritMcpTools: () => inherited, - run: async (params) => { - captured = params; - return { report: "done" }; - }, - }); - - await callTask(tool, { description: "MCP parity", prompt: "check tools", intent: "explore" }); - - expect(captured?.permissionGate).toBe(testPermissionGate); - expect(captured?.inheritMcpTools?.()).toEqual(inherited); - }); - - test("forwards shellEnv to runSubAgent so worker shell spawns get project env", async () => { - let captured: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - shellEnv: { FOO: "bar" }, - run: async (params) => { - captured = params; - return { report: "done" }; - }, - }); - - await callTask(tool, { description: "Env parity", prompt: "check env", intent: "explore" }); - - expect(captured?.shellEnv).toEqual({ FOO: "bar" }); - }); - - test("sub-agent toolset includes web_fetch and web_search", () => { - const names = coreSubAgentWebTools().map((t) => t.definition.name); - expect(names).toEqual(["web_fetch", "web_search"]); - }); - - test("forwards a dedicated child abort signal linked to the parent tool signal", async () => { - let captured: RunSubAgentParams | undefined; - let linkedAbort = false; - const parent = new AbortController(); - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - run: async (params) => { - captured = params; - expect(params.signal).toBeDefined(); - expect(params.signal).not.toBe(parent.signal); - expect(params.signal?.aborted).toBe(false); - // Abort while the run is in-flight so the parent→child link is still live. - await new Promise((resolve) => { - params.signal!.addEventListener( - "abort", - () => { - linkedAbort = true; - resolve(); - }, - { once: true }, - ); - parent.abort(); - }); - // Injected run returns salvage after cancel-with-progress; task must keep it. - return { - report: forcedStopReport("cancelled", "partial from tools"), - stopReason: "cancelled", - }; - }, - }); - const out = await callTask( - tool, - { description: "signal", prompt: "x", intent: "explore" }, - parent.signal, - ); - expect(linkedAbort).toBe(true); - expect(captured?.signal?.aborted).toBe(true); - expect(out).toContain("cancelled"); - expect(out).toContain("partial from tools"); - expect(out).toContain("## Summary"); - }); - - test("keeps a returned result when strip cancel races after run resolves", async () => { - const sessions = createSubAgentSessionStore(); - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - sessions, - run: async () => { - const row = sessions.list().find((s) => s.description === "race"); - if (row !== undefined) sessions.cancel(row.id, "Cancelled by operator"); - return { report: forcedStopReport("cancelled", "salvaged work"), stopReason: "cancelled" }; - }, - }); - const out = await callTask(tool, { description: "race", prompt: "x", intent: "explore" }); - expect(out).toContain("salvaged work"); - expect(out).toContain("## Summary"); - expect(out).not.toBe('Sub-agent "race" cancelled by operator.'); - const row = sessions.list().find((s) => s.description === "race"); - expect(row?.status).toBe("cancelled"); - }); - - test("pre-progress cancel surfaces the recorded cancel reason to the parent", async () => { - const sessions = createSubAgentSessionStore(); - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - sessions, - run: async () => { - const row = sessions.list().find((s) => s.description === "reasoned"); - if (row !== undefined) sessions.cancel(row.id, "Session closed"); - const err = new Error("aborted"); - err.name = "AbortError"; - throw err; - }, - }); - const out = await callTask(tool, { description: "reasoned", prompt: "x", intent: "explore" }); - expect(out).toContain("Stopped: cancelled — Session closed"); - const row = sessions.list().find((s) => s.description === "reasoned"); - expect(row?.status).toBe("cancelled"); - expect(row?.stopReason).toBe("cancelled — Session closed"); - }); - - test("pre-progress AbortError still surfaces as bare cancel", async () => { - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - run: async () => { - const err = new Error("aborted"); - err.name = "AbortError"; - throw err; - }, - }); - const out = await callTask(tool, { - description: "pre-progress", - prompt: "x", - intent: "explore", - }); - expect(out).toContain("cancelled by operator"); - expect(out).not.toContain("## Summary"); - }); - - test("injected cancel salvage is reported to the parent with Summary/Findings", async () => { - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - run: async () => ({ - report: forcedStopReport("cancelled", "Found path in gate.ts"), - stopReason: "cancelled", - }), - }); - const out = await callTask(tool, { description: "salvage", prompt: "x", intent: "explore" }); - expect(out).toContain("## Summary"); - expect(out).toContain("## Findings"); - expect(out).toContain("gate.ts"); - expect(out).toContain("cancelled"); - }); - - test("inference auth failure marks tool error and fails the sub-agent session", async () => { - const sessions = createSubAgentSessionStore(); - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - sessions, - run: async () => { - throw new CodexAuthError("work", "refresh-failed", "401 Unauthorized"); - }, - }); - if (tool.kind !== "full") throw new Error(`expected full tool, got ${tool.kind}`); - const result = await tool.handler( - { - id: "auth-call", - name: "task", - arguments: { description: "auth probe", prompt: "x", intent: "explore" }, - }, - new AbortController().signal, - ); - expect(result.isError).toBe(true); - const toolText = String(result.content); - expect(toolText).toContain("Re-authenticate"); - // Exactly one Error: prefix on the tool result (formatter is bare). - expect(toolText.startsWith("Error: ")).toBe(true); - expect(toolText.includes("Error: Error:")).toBe(false); - const row = sessions.list().find((s) => s.description === "auth probe"); - expect(row?.status).toBe("failed"); - // session.error is bare; report entry is prefixed once by SessionStore.fail. - expect(row?.error?.startsWith("Error:")).toBe(false); - expect(row?.error).toContain("Re-authenticate"); - const report = row?.entries.find((e) => e.kind === "report"); - expect(report?.content.startsWith("Error: ")).toBe(true); - expect(report?.content.includes("Error: Error:")).toBe(false); - }); - - test("forwards deadlineMs to run and appends parent hint on deadline salvage", async () => { - let captured: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - deadlineMs: 45_000, - run: async (params) => { - captured = params; - return { - report: forcedStopReport("deadline", "partial before wall clock"), - stopReason: "deadline", - }; - }, - }); - const out = await callTask(tool, { description: "deadline", prompt: "x", intent: "explore" }); - expect(captured?.deadlineMs).toBe(45_000); - expect(out).toContain("## Summary"); - expect(out).toContain("deadline"); - expect(out).toContain("partial before wall clock"); - expect(out).toContain("explicit wall-clock deadline"); - }); - - test("dynamic runner + task: parent cancel keeps salvage body, not task aborted", async () => { - const parent = new AbortController(); - const task = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - run: async (params) => { - // Wait for linked cancel, then return structured salvage. - await new Promise((resolve) => { - if (params.signal?.aborted) { - resolve(); - return; - } - params.signal?.addEventListener("abort", () => resolve(), { once: true }); - }); - await new Promise((r) => setTimeout(r, 10)); - return { - report: forcedStopReport("cancelled", "Found path in gate.ts"), - stopReason: "cancelled", - }; - }, - }); - const runner = createDynamicToolRunner([task], { defaultMs: 10_000 }); - const pending = runner.run( - { - id: "int-1", - name: "task", - arguments: { description: "race", prompt: "x", intent: "explore" }, - }, - parent.signal, - ); - await new Promise((r) => setTimeout(r, 15)); - parent.abort(); - const result = await pending; - expect(result.isError).not.toBe(true); - expect(String(result.content)).toContain("## Summary"); - expect(String(result.content)).toContain("gate.ts"); - expect(String(result.content)).not.toBe("task aborted"); - expect(String(result.content)).not.toContain("task aborted"); - }); - - test("forwards intent, success_criteria, do_not, and report_focus to run", async () => { - let captured: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - run: async (params) => { - captured = params; - return { report: "done" }; - }, - }); - - await callTask(tool, { - description: "typed-contract", - prompt: "Implement the feature", - intent: "implement", - success_criteria: ["tests pass", "typecheck green"], - do_not: ["commit", "refactor unrelated"], - report_focus: "files changed and test counts", - goals: ["seed step one"], - }); - - expect(captured?.intent).toBe("implement"); - expect(captured?.successCriteria).toEqual(["tests pass", "typecheck green"]); - expect(captured?.doNot).toEqual(["commit", "refactor unrelated"]); - expect(captured?.reportFocus).toBe("files changed and test counts"); - expect(captured?.goals).toEqual(["seed step one"]); - }); - - test("omits typed spawn fields when not provided (back-compat)", async () => { - let captured: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - run: async (params) => { - captured = params; - return { report: "done" }; - }, - }); - - await callTask(tool, { description: "legacy", prompt: "Do the work", intent: "explore" }); - - // Intent is required to select a director; other typed spawn fields stay optional. - expect(captured?.intent).toBe("explore"); - expect(captured?.successCriteria).toBeUndefined(); - expect(captured?.doNot).toBeUndefined(); - expect(captured?.reportFocus).toBeUndefined(); - expect(captured?.goals).toBeUndefined(); - }); - - test("rejects invalid intent via schema", async () => { - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - run: async () => ({ report: "done" }), - }); - const out = await callTask(tool, { - description: "bad-intent", - prompt: "x", - intent: "ship-it", - }); - expect(out).toContain("Error:"); - }); -}); - -describe("TaskToolArgs schema", () => { - test("accepts optional typed spawn fields", () => { - const parsed = TaskToolArgs({ - description: "job", - prompt: "do it", - intent: "explore", - success_criteria: ["mapped callers"], - do_not: ["edit files"], - report_focus: "call graph", - }); - expect(parsed instanceof type.errors).toBe(false); - if (parsed instanceof type.errors) throw new Error(parsed.summary); - expect(parsed.intent).toBe("explore"); - expect(parsed.success_criteria).toEqual(["mapped callers"]); - expect(parsed.do_not).toEqual(["edit files"]); - expect(parsed.report_focus).toBe("call graph"); - }); - - test("accepts legacy description+prompt only", () => { - const parsed = TaskToolArgs({ description: "job", prompt: "do it" }); - expect(parsed instanceof type.errors).toBe(false); - }); - - test("rejects unknown intent", () => { - const parsed = TaskToolArgs({ - description: "job", - prompt: "do it", - intent: "ship-it", - }); - expect(parsed instanceof type.errors).toBe(true); - }); - - test("accepts every intent enum value", () => { - for (const intent of ["explore", "implement", "review", "plan", "general"] as const) { - const parsed = TaskToolArgs({ description: "j", prompt: "p", intent }); - expect(parsed instanceof type.errors).toBe(false); - } - }); -}); - describe("buildDispatchBrief typed spawn contract", () => { test("renders Intent, Success criteria, Do not, and report_focus only when set", () => { const full = buildDispatchBrief({ @@ -1403,113 +832,3 @@ describe("buildDispatchBrief typed spawn contract", () => { expect(both).toContain("1. manage_tasks seed"); }); }); - -describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => { - test("fingerprint ignores whitespace and omits description", () => { - const a = fingerprintTaskBrief({ - prompt: " map callers of X ", - intent: "explore", - successCriteria: ["list callers"], - doNot: ["edit code"], - }); - const b = fingerprintTaskBrief({ - prompt: "map callers of X", - intent: "explore", - successCriteria: ["list callers"], - doNot: ["edit code"], - }); - expect(a).toBe(b); - const changed = fingerprintTaskBrief({ - prompt: "map callers of X", - intent: "implement", - successCriteria: ["list callers"], - doNot: ["edit code"], - }); - expect(changed).not.toBe(a); - }); - - test("admit always succeeds, even after a repeated salvage on the same fingerprint", () => { - const ledger = createBriefDispatchLedger(); - const fp = fingerprintTaskBrief({ prompt: "fix a job", intent: "implement" }); - expect(ledger.admit(fp).dispatchCount).toBe(1); - ledger.recordOutcome(fp, "deadline"); - expect(ledger.admit(fp).dispatchCount).toBe(2); - ledger.recordOutcome(fp, "deadline"); - expect(ledger.admit(fp).dispatchCount).toBe(3); - }); - - test("dispatch count advances across repeated same-brief admits", () => { - const ledger = createBriefDispatchLedger(); - const fp = fingerprintTaskBrief({ prompt: "budget job" }); - expect(ledger.admit(fp).dispatchCount).toBe(1); - ledger.recordOutcome(fp, "deadline"); - const second = ledger.admit(fp); - expect(second.dispatchCount).toBe(2); - }); - - test("successful complete resets retry budget", () => { - const ledger = createBriefDispatchLedger(); - const fp = fingerprintTaskBrief({ prompt: "ok job" }); - expect(ledger.admit(fp).dispatchCount).toBe(1); - ledger.recordOutcome(fp, "deadline"); - expect(ledger.admit(fp).dispatchCount).toBe(2); - // Success zeros dispatchCount so the next admit is 1. - ledger.recordOutcome(fp, null); - const afterSuccess = ledger.admit(fp); - expect(afterSuccess.dispatchCount).toBe(1); - }); - - test("release undoes admit when run never produces a body", () => { - const ledger = createBriefDispatchLedger(); - const fp = fingerprintTaskBrief({ prompt: "crash job" }); - expect(ledger.admit(fp).dispatchCount).toBe(1); - ledger.release(fp); - const again = ledger.admit(fp); - expect(again.dispatchCount).toBe(1); - }); - - test("classifyBriefSalvage decides purely from the structured stop reason, never from report prose", () => { - // classifyBriefSalvage takes no report text at all — only the structured - // stopReason and an independently-observed wasCancelled flag. - expect(classifyBriefSalvage({ wasCancelled: false })).toBeNull(); - expect(classifyBriefSalvage({ stopReason: "deadline", wasCancelled: false })).toBe("deadline"); - // An operator cancel wins even when the run's own reason disagrees. - expect(classifyBriefSalvage({ stopReason: "deadline", wasCancelled: true })).toBe("cancelled"); - }); - - test("createTaskTool always re-dispatches an identical brief after a forced-stop salvage", async () => { - const thrash = { - report: forcedStopReport("deadline", "Repeated the same call"), - stopReason: "deadline" as const, - }; - let runs = 0; - const sessions = createSubAgentSessionStore(); - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - sessions, - run: async () => { - runs += 1; - return thrash; - }, - }); - const args = { - description: "Thrash job", - prompt: "do the thrashy work", - intent: "implement", - }; - const first = await callTask(tool, args); - expect(first).toContain("deadline"); - expect(runs).toBe(1); - expect(sessions.list().filter((s) => s.status === "running")).toHaveLength(0); - - // Re-dispatching the identical brief is admitted, not refused. - const second = await callTask(tool, args); - expect(second).not.toContain("refused re-dispatch"); - expect(runs).toBe(2); - expect(sessions.list().filter((s) => s.status === "running")).toHaveLength(0); - expect(sessions.list().filter((s) => s.description === "Thrash job")).toHaveLength(1); - }); -}); diff --git a/src/subagent/index.ts b/src/subagent/index.ts index 24d96378e..bcdc1b861 100644 --- a/src/subagent/index.ts +++ b/src/subagent/index.ts @@ -59,16 +59,6 @@ export { type ToolLessNarrationSpiral, } from "./stop-policy.js"; -export { - classifyBriefSalvage, - createBriefDispatchLedger, - fingerprintTaskBrief, - type BriefDispatchLedger, - type BriefDispatchRecord, - type BriefSalvageKind, - type TaskBriefFingerprintInput, -} from "./brief-dispatch.js"; - export { SubAgentDirector } from "./nudge-director.js"; export { @@ -100,13 +90,6 @@ export { type SubAgentRunController, } from "./run.js"; -export { - TaskToolArgs, - createTaskTool, - taskToolDefinition, - type TaskToolDeps, -} from "./task-tool.js"; - export { cleanupSubAgentWorktree, createSubAgentWorktree, diff --git a/src/subagent/inference-auth-failure.test.ts b/src/subagent/inference-auth-failure.test.ts index 4b9ba8117..de89aa70b 100644 --- a/src/subagent/inference-auth-failure.test.ts +++ b/src/subagent/inference-auth-failure.test.ts @@ -3,7 +3,7 @@ import { CodexAuthError } from "../auth/codex/session.js"; import { XaiAuthError } from "../auth/xai/session.js"; import { classifySubAgentInferenceAuthFailure, - formatSubAgentTaskAuthFailureMessage, + formatSubAgentSpawnAuthFailureMessage, } from "./inference-auth-failure.js"; describe("sub-agent inference auth failures", () => { @@ -17,8 +17,8 @@ describe("sub-agent inference auth failures", () => { expect(classifySubAgentInferenceAuthFailure(new Error("nope"))).toBeNull(); }); - test("formats actionable task error with profile and re-login hint", () => { - const msg = formatSubAgentTaskAuthFailureMessage( + test("formats actionable spawn_agent error with profile and re-login hint", () => { + const msg = formatSubAgentSpawnAuthFailureMessage( "explore auth", new CodexAuthError("work", "refresh-failed", "Token refresh failed"), ); diff --git a/src/subagent/inference-auth-failure.ts b/src/subagent/inference-auth-failure.ts index f5589172b..8bb17635e 100644 --- a/src/subagent/inference-auth-failure.ts +++ b/src/subagent/inference-auth-failure.ts @@ -9,8 +9,8 @@ export function classifySubAgentInferenceAuthFailure(err: unknown): SubAgentAuth return null; } -/** Actionable task-tool error when OAuth refresh or inference auth fails for a sub-agent. */ -export function formatSubAgentTaskAuthFailureMessage( +/** Actionable spawn_agent error when OAuth refresh or inference auth fails for a sub-agent. */ +export function formatSubAgentSpawnAuthFailureMessage( description: string, err: unknown, ): string | null { @@ -24,6 +24,6 @@ export function formatSubAgentTaskAuthFailureMessage( // No "Error:" prefix — SessionStore.fail and tool-result surfaces add their own. return ( `sub-agent "${description}" could not run inference (${providerLabel} profile "${profile}"). ` + - `${detailSentence} Re-authenticate the profile from /model (Alt+A to Connect) and retry the task.` + `${detailSentence} Re-authenticate the profile from /model (Alt+A to Connect) and retry spawn_agent.` ); } diff --git a/src/subagent/intervention-log.ts b/src/subagent/intervention-log.ts index b6eb6990c..1eaa750c5 100644 --- a/src/subagent/intervention-log.ts +++ b/src/subagent/intervention-log.ts @@ -66,7 +66,7 @@ export interface InterventionRecord { model?: string; /** Model family the policy resolved, e.g. "grok" | "default". */ family?: string; - /** task() intent when the run had one. */ + /** spawn_agent intent when the run had one. */ intent?: string; measurement?: InterventionMeasurement; /** Present on `class: "outcome"` records only. */ diff --git a/src/subagent/lifecycle-tools.test.ts b/src/subagent/lifecycle-tools.test.ts index e62cec992..eeb161ac2 100644 --- a/src/subagent/lifecycle-tools.test.ts +++ b/src/subagent/lifecycle-tools.test.ts @@ -5,9 +5,10 @@ import { createResumeAgentTool, createInterruptAgentTool, createSendInputTool, + resumeAgentToolDefinition, } from "./lifecycle-tools.js"; import { createFleetMailbox, createWaitAgentsTool } from "./agent-fleet.js"; -import { createSubAgentSessionStore } from "./session-store.js"; +import { createSubAgentSessionStore, DEFAULT_MAX_ENTRY_CHARS } from "./session-store.js"; async function callTool( tool: @@ -256,6 +257,38 @@ describe("resume_agent", () => { finish("done"); }); + test("rejects resume before an uncollected prior terminal fleet result is delivered", async () => { + const sessions = createSubAgentSessionStore(); + const fleetRecords = createFleetMailbox(sessions); + const worker = sessions.start({ + description: "worker", + agentId: "a", + brief: "b", + retained: true, + }); + sessions.registerFollowup(worker.id, async () => "second report"); + sessions.complete(worker.id, "first report"); + fleetRecords.register(worker.id); + + const resumeAgent = createResumeAgentTool({ sessions, fleetRecords }); + if (resumeAgent.kind !== "full") throw new Error("expected full tool"); + const result = await resumeAgent.handler( + { + id: "resume-before-collect", + name: "resume_agent", + arguments: { target: worker.id, message: "next" }, + }, + new AbortController().signal, + ); + + expect(result.isError).toBe(true); + expect(String(result.content)).toContain("prior result is collected"); + const wait = createWaitAgentsTool({ sessions, fleetRecords }); + const collected = await callTool(wait, { targets: [worker.id], timeout_ms: 1000 }); + const results = collected.results as { agent_id: string; status: string; report?: string }[]; + expect(results[0]).toEqual({ agent_id: worker.id, status: "done", report: "first report" }); + }); + test("wait_agents collects the resumed turn after resume_agent returns", async () => { const sessions = createSubAgentSessionStore(); const fleetRecords = createFleetMailbox(sessions); @@ -332,6 +365,93 @@ describe("resume_agent", () => { expect(sessions.get(worker.id)?.lifecycle.state).toBe("failed"); expect(closeCalls).toBe(1); }); + + test("wait_agents collects a failed resumed turn instead of hanging", async () => { + const sessions = createSubAgentSessionStore(); + const fleetRecords = createFleetMailbox(sessions); + const worker = sessions.start({ + description: "worker", + agentId: "a", + brief: "b", + retained: true, + }); + sessions.registerFollowup(worker.id, async () => { + throw new Error("resumed turn failed"); + }); + sessions.complete(worker.id, "first report"); + + const resumeAgent = createResumeAgentTool({ sessions, fleetRecords }); + const wait = createWaitAgentsTool({ sessions, fleetRecords }); + + const resumed = await callTool(resumeAgent, { target: worker.id, message: "second turn" }); + expect(resumed.status).toBe("running"); + const collected = await callTool(wait, { targets: [worker.id], timeout_ms: 1000 }); + + expect(collected.timed_out).toBe(false); + const results = collected.results as { agent_id: string; status: string; error?: string }[]; + expect(results[0]).toEqual({ + agent_id: worker.id, + status: "failed", + error: "resumed turn failed", + }); + }); + + test("rejects missing, empty, and oversize messages without starting a turn", async () => { + const sessions = createSubAgentSessionStore(); + const fleetRecords = createFleetMailbox(sessions); + const worker = sessions.start({ + description: "worker", + agentId: "a", + brief: "b", + retained: true, + }); + let starts = 0; + sessions.registerFollowup(worker.id, async () => { + starts++; + return "should not run"; + }); + sessions.complete(worker.id, "first report"); + + const resumeAgent = createResumeAgentTool({ sessions, fleetRecords }); + if (resumeAgent.kind !== "full") throw new Error("expected full tool"); + + const missing = await resumeAgent.handler( + { id: "missing-message", name: "resume_agent", arguments: { target: worker.id } }, + new AbortController().signal, + ); + expect(missing.isError).toBe(true); + expect(String(missing.content)).toContain("message"); + + const empty = await resumeAgent.handler( + { + id: "empty-message", + name: "resume_agent", + arguments: { target: worker.id, message: " " }, + }, + new AbortController().signal, + ); + expect(empty.isError).toBe(true); + expect(String(empty.content)).toContain("non-empty message"); + + const oversize = await resumeAgent.handler( + { + id: "oversize-message", + name: "resume_agent", + arguments: { target: worker.id, message: "x".repeat(DEFAULT_MAX_ENTRY_CHARS + 1) }, + }, + new AbortController().signal, + ); + expect(oversize.isError).toBe(true); + expect(String(oversize.content)).toContain(`exceeds ${DEFAULT_MAX_ENTRY_CHARS} characters`); + expect(starts).toBe(0); + expect(sessions.get(worker.id)?.lifecycleStatus).toBe("completed"); + }); + + test("schema requires message and exposes no followup_task alias", () => { + expect(resumeAgentToolDefinition.name).toBe("resume_agent"); + expect(resumeAgentToolDefinition.inputSchema.required).toEqual(["target", "message"]); + expect(JSON.stringify(resumeAgentToolDefinition)).not.toContain("followup_task"); + }); }); describe("interrupt_agent", () => { diff --git a/src/subagent/lifecycle-tools.ts b/src/subagent/lifecycle-tools.ts index ac787b6e2..ab22107f1 100644 --- a/src/subagent/lifecycle-tools.ts +++ b/src/subagent/lifecycle-tools.ts @@ -121,7 +121,7 @@ export type CloseAgentToolDeps = LifecycleToolDeps & { fleetRecords: FleetMailboxHandle; }; -/** interrupt_agent stamps session interrupted; wait JSON projects that lifecycle. */ +/** interrupt_agent stamps session interrupted and flips the wait mailbox overlay. */ export type InterruptAgentToolDeps = LifecycleToolDeps & { fleetRecords: FleetMailboxHandle; }; @@ -226,6 +226,12 @@ export function createResumeAgentTool(deps: ResumeAgentToolDeps): AgentTool { `(got ${message.length}).`, ); } + if (deps.fleetRecords.hasUncollectedTerminal(target)) { + return lifecycleResult( + call.id, + `Error: cannot resume "${target}" before its prior result is collected. Call wait_agents for this agent_id first.`, + ); + } const outcome = deps.sessions.resumeOne(target, message, { onStart: () => { deps.fleetRecords.register(target); @@ -293,6 +299,10 @@ export function createInterruptAgentTool(deps: InterruptAgentToolDeps): AgentToo `Error: cannot interrupt "${target}" (status: ${outcome.status}).`, ); } + // Soft interrupt leaves the run in flight; projectWaitStatus treats + // interrupted+inFlight as running so resume cannot collect a stale stamp. + // Flip the wait mailbox overlay here (same as send_input interrupt:true). + deps.fleetRecords.interrupt(target); return lifecycleResult( call.id, JSON.stringify({ agent_id: target, status: "interrupted" satisfies AgentLifecycleStatus }), diff --git a/src/subagent/lifecycle.test.ts b/src/subagent/lifecycle.test.ts index dd6ca8b13..f6dc7cb37 100644 --- a/src/subagent/lifecycle.test.ts +++ b/src/subagent/lifecycle.test.ts @@ -6,6 +6,7 @@ import { isResumableLifecycle, projectLifecycleStatus, projectStripStatus, + projectWaitStatus, type StripStatus, type WorkerLifecycle, } from "./lifecycle.js"; @@ -65,4 +66,12 @@ describe("WorkerLifecycle projections", () => { expect(isAlreadyClosed({ state: "completed", report: "ok" })).toBe(false); expect(isAlreadyClosed({ state: "cancelled" })).toBe(false); }); + + test("in-flight followup is wait-running over a prior completed or interrupted stamp", () => { + expect(projectWaitStatus({ state: "completed", report: "first" }, true)).toBe("running"); + expect(projectWaitStatus({ state: "interrupted" }, true)).toBe("running"); + expect(projectWaitStatus({ state: "cancelled" }, true)).toBe("running"); + expect(projectWaitStatus({ state: "completed", report: "first" }, false)).toBe("done"); + expect(projectWaitStatus({ state: "failed", error: "boom" }, true)).toBe("failed"); + }); }); diff --git a/src/subagent/lifecycle.ts b/src/subagent/lifecycle.ts index 0ea29786b..a8faae526 100644 --- a/src/subagent/lifecycle.ts +++ b/src/subagent/lifecycle.ts @@ -81,11 +81,21 @@ export type WaitJSONStatus = "running" | "done" | "failed" | "interrupted"; /** * Wait JSON projection of stored lifecycle. Operator cancel (`cancelled`) is * wait-running while a run/followup is still in flight so the first collect - * can still attach salvage. `interrupted` and `shutdown` are immediately - * terminal. Never leaks `cancelled` into wait JSON. + * can still attach salvage. A followup that has been queued (`inFlight`) must + * not collect the prior `completed` / `interrupted` stamp — resume_agent + * flips the session to running on the next mutate, but `runInFlight` is set + * first. `interrupted` and `shutdown` are immediately terminal once the run + * has settled. Never leaks `cancelled` into wait JSON. */ export function projectWaitStatus(lifecycle: WorkerLifecycle, inFlight: boolean): WaitJSONStatus { - if (lifecycle.state === "cancelled" && inFlight) return "running"; + if ( + inFlight && + (lifecycle.state === "cancelled" || + lifecycle.state === "completed" || + lifecycle.state === "interrupted") + ) { + return "running"; + } switch (lifecycle.state) { case "completed": return "done"; diff --git a/src/subagent/report.ts b/src/subagent/report.ts index 7270b2092..c7b0948a7 100644 --- a/src/subagent/report.ts +++ b/src/subagent/report.ts @@ -7,7 +7,7 @@ import type { ReactorEmittedEvent } from "@intx/inference"; -/** Typed spawn intent — optional on `task`; omit Intent section when unset. */ +/** Typed spawn intent — optional on `spawn_agent`; omit Intent section when unset. */ export type TaskIntent = "explore" | "implement" | "review" | "plan" | "general"; // Extract the tool name from a sub-agent stream event. tool.start carries the diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 19ff475cd..cfc2e260c 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -114,7 +114,6 @@ import { isSubAgentCancelError, DEFAULT_CLOSE_DEADLINE_MS, } from "./dispose.js"; -import { createTaskTool } from "./task-tool.js"; import { createFleetMailbox, createSpawnAgentTool, @@ -360,7 +359,7 @@ export function createCodexProxyRunTool(posixTools: CodexProxyToolRunner): Codex // Spin up an isolated, autonomous agent loop, hand it one task, and return // its final report. `params.cwd` is either the dispatcher's own cwd (shared // mode) or a worktree snapshotted from the dispatcher's last commit -// (isolated mode, see task-tool.ts's useWorktree) — either way this loop +// (isolated mode, see agent-fleet.ts's useWorktree) — either way this loop // gets its own posix tool instances and its own git-backed context store so // the two loops never trample each other's state. export async function runSubAgent(params: RunSubAgentParams): Promise { @@ -467,7 +466,7 @@ async function runSubAgentInner( // deadline so a leaf that hits the deadline can still return a salvage // report. When deadlineMs is omitted, no timer is armed — cancel remains // the only bound. Declared before try so finally can dispose. - // The task tool is exempt from the generic per-tool watchdog (see + // spawn_agent is exempt from the generic per-tool watchdog (see // resolveToolExecutionTimeoutMs), so there is no outer budget to clamp under. const resolvedDeadlineMs = params.deadlineMs !== undefined @@ -567,19 +566,18 @@ async function runSubAgentInner( ]; } - // Orchestrators need task installed, not just mentioned in the prompt. + // Orchestrators need fleet tools installed, not just mentioned in the prompt. // Nested dispatch always forbids further orchestration so the tree // bottoms out after one hop. Fleet discovery (search_agents) is Tier-1 - // only (CL-7051) — nested directors keep task/spawn allowlists. + // only (CL-7051) — nested directors keep spawn allowlists. if (params.orchestrator === true) { // Tier enforcement at the mount point, not the prompt, fails closed: // an unresolved tier defaults to "leaf" rather than skipping the check, // so an AgentProfile outside the closed director set cannot mount - // task/search_agents just by setting orchestrator: true. + // spawn_agent/search_agents just by setting orchestrator: true. const tier = params.orchestratorTier ?? "leaf"; const mayDiscoverFleet = tier === "orchestrator"; for (const verb of [ - "task", ...(mayDiscoverFleet ? (["search_agents"] as const) : []), "read_agent_trace", "spawn_agent", @@ -594,7 +592,7 @@ async function runSubAgentInner( } if (params.nestedDispatch === undefined) { throw new Error( - "runSubAgent: orchestrator=true requires nestedDispatch so the task tool can be installed", + "runSubAgent: orchestrator=true requires nestedDispatch so fleet tools can be installed", ); } const nd = params.nestedDispatch; @@ -602,34 +600,6 @@ async function runSubAgentInner( const fleetRecords = createFleetMailbox(fleetSessions); tools = [ ...tools, - createTaskTool({ - permissionGate: nd.permissionGate, - ...(nd.inheritMcpTools !== undefined ? { inheritMcpTools: nd.inheritMcpTools } : {}), - ...(nd.shellTimeout !== undefined ? { shellTimeout: nd.shellTimeout } : {}), - ...(nd.shellEnv !== undefined ? { shellEnv: nd.shellEnv } : {}), - ...(nd.extraToolPlugins !== undefined ? { extraToolPlugins: nd.extraToolPlugins } : {}), - cwd: params.cwd, - getWorkdirBase: nd.getWorkdirBase, - provider: nd.provider, - allowOrchestrator: false, - // Nested workers inherit this composite so they can re-read both the - // orchestrator's spills and the original parent's. - getBlobReader: () => sessionBlobReader, - // Pass the public entry so nested workers still go through the outer - // slot/refresh path; avoids task-tool importing runSubAgent (cycle). - run: runSubAgent, - telemetry: liveTelemetry, - ...(nd.onEvent !== undefined ? { onEvent: nd.onEvent } : {}), - ...(nd.onProgress !== undefined ? { onProgress: nd.onProgress } : {}), - sessions: fleetSessions, - fleetRecords, - ...(nd.settings !== undefined ? { settings: nd.settings } : {}), - ...(nd.catalog !== undefined ? { catalog: nd.catalog } : {}), - ...(nd.profiles !== undefined ? { profiles: nd.profiles } : {}), - ...(nd.parentSessionId !== undefined ? { parentSessionId: nd.parentSessionId } : {}), - ...(nd.useWorktree !== undefined ? { useWorktree: nd.useWorktree } : {}), - ...(nd.spawnAllowlist !== undefined ? { spawnAllowlist: nd.spawnAllowlist } : {}), - }), ...(mayDiscoverFleet && nd.profiles !== undefined ? [ createSearchAgentsTool(() => { @@ -679,11 +649,16 @@ async function runSubAgentInner( ...(nd.onProgress !== undefined ? { onProgress: nd.onProgress } : {}), ...(nd.settings !== undefined ? { settings: nd.settings } : {}), ...(nd.catalog !== undefined ? { catalog: nd.catalog } : {}), + ...(nd.profiles !== undefined ? { profiles: nd.profiles } : {}), }; tools = [ ...tools, createSpawnAgentTool(fleetDeps), - createWaitAgentsTool({ sessions: fleetSessions, fleetRecords }), + createWaitAgentsTool({ + sessions: fleetSessions, + fleetRecords, + authority: lifecycleAuthority, + }), createListAgentsTool({ sessions: fleetSessions, fleetRecords }), createCloseAgentTool({ sessions: fleetSessions, diff --git a/src/subagent/session-store.test.ts b/src/subagent/session-store.test.ts index c7abc6d39..01ed732b0 100644 --- a/src/subagent/session-store.test.ts +++ b/src/subagent/session-store.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { createSubAgentSessionStore } from "./session-store.js"; +import { createSubAgentSessionStore, DEFAULT_MAX_ENTRY_CHARS } from "./session-store.js"; import { forcedStopReport } from "./stop-policy.js"; import { agentLaneIsLive, fleetProgress } from "../tui/agent-progress.js"; import { formatAgentsPanel } from "../tui/chrome-state.js"; @@ -426,6 +426,32 @@ describe("CL-6943 reusable worker sessions", () => { expect(store.resumeOne(session.id, "more")).toEqual({ ok: false, status: "completed" }); }); + test("resume_agent validates the message before starting a retained turn", () => { + const store = createSubAgentSessionStore(); + const session = store.start({ description: "d", agentId: "a", brief: "b", retained: true }); + let starts = 0; + store.registerFollowup(session.id, async () => { + starts++; + return "should not run"; + }); + store.complete(session.id, "## Summary\nDone."); + + expect(store.resumeOne(session.id, " ")).toEqual({ + ok: false, + status: "completed", + hint: "resume_agent requires a non-empty message.", + }); + expect(store.resumeOne(session.id, "x".repeat(DEFAULT_MAX_ENTRY_CHARS + 1))).toEqual({ + ok: false, + status: "completed", + hint: + `resume_agent message exceeds ${DEFAULT_MAX_ENTRY_CHARS} characters ` + + `(got ${DEFAULT_MAX_ENTRY_CHARS + 1}).`, + }); + expect(starts).toBe(0); + expect(store.get(session.id)?.lifecycleStatus).toBe("completed"); + }); + test("resume_agent fails on an unknown id with not_found", () => { const store = createSubAgentSessionStore(); expect(store.resumeOne("missing", "more")).toEqual({ ok: false, status: "not_found" }); diff --git a/src/subagent/session-store.ts b/src/subagent/session-store.ts index 750009359..644f0c5f9 100644 --- a/src/subagent/session-store.ts +++ b/src/subagent/session-store.ts @@ -104,7 +104,7 @@ export interface SubAgentSession { stopReason?: string; // Session id of the orchestrator that dispatched this worker, when this is // a nested (one-hop) dispatch. Undefined for top-level sessions started - // directly from the primary session's task tool. + // directly from the primary session's spawn_agent tool. parentSessionId?: string; /** * Projection of `lifecycle` for close/resume/interrupt JSON. Maps cancelled → @@ -946,8 +946,8 @@ export function createSubAgentSessionStore( // "still open, resumable" when the caller says the agent genuinely // survived this turn. // Defaults true: complete() historically meant "clean completion," and - // task-tool.ts / tests call it that way with no opts at all. Only - // agent-fleet's spawn_agent path ever has a salvage to report, and it + // tests call it that way with no opts at all. Only agent-fleet's + // spawn_agent path ever has a salvage to report, and it // always passes this flag explicitly (see its call site). const agentRetained = opts?.agentRetained ?? true; mutate(id, (session) => { @@ -1196,6 +1196,22 @@ export function createSubAgentSessionStore( if (!isResumableLifecycle(session.retained, session.lifecycle)) { return { ok: false, status: projectLifecycleStatus(session.lifecycle) }; } + if (message.trim().length === 0) { + return { + ok: false, + status: projectLifecycleStatus(session.lifecycle), + hint: "resume_agent requires a non-empty message.", + }; + } + if (message.length > maxEntryChars) { + return { + ok: false, + status: projectLifecycleStatus(session.lifecycle), + hint: + `resume_agent message exceeds ${maxEntryChars} characters ` + + `(got ${message.length}).`, + }; + } const followup = followupHandles.get(id); if (followup === undefined) { return { ok: false, status: projectLifecycleStatus(session.lifecycle) }; diff --git a/src/subagent/stop-policy.ts b/src/subagent/stop-policy.ts index b6e46f3e0..7d89c67ac 100644 --- a/src/subagent/stop-policy.ts +++ b/src/subagent/stop-policy.ts @@ -57,7 +57,7 @@ export type SubAgentCatchOutcome = "salvage-deadline" | "salvage-cancelled" | "r * An opt-in deadline firing must always produce a salvage report — even with * zero tool calls and zero partial text — so the parent gets a graceful report * instead of a bare AbortError racing the outer tool-execution watchdog. A - * genuine pre-progress operator cancel still rethrows so the task tool's + * genuine pre-progress operator cancel still rethrows so the spawn_agent tool's * cancel path stays a bare abort; mid-run cancel with progress salvages. */ export function resolveSubAgentCatchOutcome(input: { @@ -193,7 +193,7 @@ export interface ForcedStopReportOptions { // Exact Summary text rendered for each forced-stop reason. Human-facing only — // forcedStopReport is the sole reader; the parent classifies outcomes from the -// structured ForcedStopReason value itself (see run.ts/task-tool.ts), never by +// structured ForcedStopReason value itself (see run.ts/agent-fleet.ts), never by // parsing this text back out of the report. const FORCED_STOP_SUMMARIES: Record = { cancelled: "Stopped: cancelled by operator before finishing.", diff --git a/src/subagent/task-tool-worktree.test.ts b/src/subagent/task-tool-worktree.test.ts deleted file mode 100644 index 3bb27fd04..000000000 --- a/src/subagent/task-tool-worktree.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { execFile } from "node:child_process"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { promisify } from "node:util"; - -import { createTaskTool } from "./task-tool.js"; -import type { RunSubAgentParams } from "./types.js"; -import { createPermissionGate } from "../permission/gate.js"; -import type { Telemetry } from "../telemetry/index.js"; -import { initTemporaryGitRepo } from "../../tests/helpers/temporary-git-repo.js"; - -const run = promisify(execFile); - -const testPermissionGate = createPermissionGate({ - approvals: [], - interactive: false, - skipPermissions: true, -}); - -const provider = { - providerName: "test-provider", - baseURL: "http://localhost", - model: "test-model", -}; - -function telemetryCapture() { - const events: { event: string; properties: Record }[] = []; - const telemetry: Telemetry = { - enabled: true, - installationId: "test", - capture: (event, properties = {}) => events.push({ event, properties }), - captureIntentional: () => false, - flush: async () => {}, - discard: () => {}, - }; - return { telemetry, events }; -} - -async function callTask( - tool: ReturnType, - args: Record, -): Promise { - if (tool.kind !== "full") throw new Error(`expected full tool, got ${tool.kind}`); - const result = await tool.handler( - { id: "call-1", name: "task", arguments: args }, - new AbortController().signal, - ); - return typeof result.content === "string" ? result.content : JSON.stringify(result.content); -} - -async function makeRepo(): Promise { - const dir = await mkdtemp(join(tmpdir(), "corbits-worktree-")); - initTemporaryGitRepo(dir); - await writeFile(join(dir, "seed.txt"), "seed"); - await run("git", ["add", "."], { cwd: dir }); - await run("git", ["commit", "-m", "seed"], { cwd: dir }); - return dir; -} - -const tempDirs: string[] = []; - -afterEach(async () => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop()!; - await rm(dir, { recursive: true, force: true }); - } -}); - -describe("createTaskTool worktree isolation", () => { - test("propagates a fresh worktree path as the sub-agent's cwd, cleaned up when unchanged", async () => { - const repo = await makeRepo(); - tempDirs.push(repo); - const workdirBase = await mkdtemp(join(tmpdir(), "corbits-workdir-")); - tempDirs.push(workdirBase); - - let captured: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: repo, - getWorkdirBase: () => workdirBase, - provider, - useWorktree: true, - run: async (params) => { - captured = params; - return { report: "done" }; - }, - }); - - const result = await callTask(tool, { - description: "Isolated job", - prompt: "Do the work", - intent: "explore", - }); - - expect(result).toContain("done"); - expect(captured?.cwd).toBeDefined(); - expect(captured?.cwd).not.toBe(repo); - expect(captured?.cwd?.startsWith(workdirBase)).toBe(true); - - // Unchanged worktree is removed automatically: `git worktree list` no - // longer reports it as a registered worktree of the repo. - const { stdout } = await run("git", ["worktree", "list"], { cwd: repo }); - expect(stdout).not.toContain(captured!.cwd); - }); - - test("shares the dispatcher cwd when worktree isolation is not requested", async () => { - const repo = await makeRepo(); - tempDirs.push(repo); - const workdirBase = await mkdtemp(join(tmpdir(), "corbits-workdir-")); - tempDirs.push(workdirBase); - - let captured: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: repo, - getWorkdirBase: () => workdirBase, - provider, - run: async (params) => { - captured = params; - return { report: "done" }; - }, - }); - - await callTask(tool, { description: "Shared job", prompt: "Do the work", intent: "explore" }); - - expect(captured?.cwd).toBe(repo); - }); - - test("fails closed and never dispatches when the dispatcher cwd is not a git repository", async () => { - const notARepo = await mkdtemp(join(tmpdir(), "corbits-not-a-repo-")); - tempDirs.push(notARepo); - const workdirBase = await mkdtemp(join(tmpdir(), "corbits-workdir-")); - tempDirs.push(workdirBase); - - let ran = false; - const { telemetry, events } = telemetryCapture(); - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: notARepo, - getWorkdirBase: () => workdirBase, - provider, - useWorktree: true, - telemetry, - run: async () => { - ran = true; - return { report: "done" }; - }, - }); - - const result = await callTask(tool, { - description: "Blocked job", - prompt: "Do the work", - intent: "explore", - }); - - expect(result).toContain("Error:"); - expect(result).toContain("not inside a git repository"); - expect(ran).toBe(false); - expect(events.filter((event) => event.event === "subagent_start")).toHaveLength(1); - const ends = events.filter((event) => event.event === "subagent_end"); - expect(ends).toHaveLength(1); - expect(ends[0]?.properties).toMatchObject({ - status: "failed", - stop_reason: "setup_error", - model: "test-model", - turn_count: 0, - input_tokens: 0, - output_tokens: 0, - cache_read_tokens: 0, - cache_write_tokens: 0, - reasoning_tokens: 0, - tool_call_count: 0, - tool_error_count: 0, - }); - expect(typeof ends[0]?.properties.duration_ms).toBe("number"); - }); - - test("pairs pre-progress cancellation with a cancelled terminal event", async () => { - const repo = await makeRepo(); - tempDirs.push(repo); - const { telemetry, events } = telemetryCapture(); - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: repo, - getWorkdirBase: () => repo, - provider, - telemetry, - run: async (params) => { - params.onRunSettled?.({ - turn_count: 0, - input_tokens: 0, - output_tokens: 0, - cache_read_tokens: 0, - cache_write_tokens: 0, - reasoning_tokens: 0, - tool_call_count: 0, - tool_error_count: 0, - error_count: 1, - duration_ms: 1, - model: "test-model", - terminal_reason: "cancelled", - }); - const error = new Error("aborted"); - error.name = "AbortError"; - throw error; - }, - }); - - const result = await callTask(tool, { - description: "cancelled job", - prompt: "Do the work", - intent: "explore", - }); - - expect(result).toContain("cancelled by operator"); - expect(events.filter((event) => event.event === "subagent_start")).toHaveLength(1); - const ends = events.filter((event) => event.event === "subagent_end"); - expect(ends).toHaveLength(1); - expect(ends[0]?.properties).toMatchObject({ - status: "cancelled", - stop_reason: "cancelled", - }); - }); - - test("preserves a worktree the sub-agent left dirty, with a notice in the report", async () => { - const repo = await makeRepo(); - tempDirs.push(repo); - const workdirBase = await mkdtemp(join(tmpdir(), "corbits-workdir-")); - tempDirs.push(workdirBase); - - let worktreePath: string | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: repo, - getWorkdirBase: () => workdirBase, - provider, - useWorktree: true, - run: async (params) => { - worktreePath = params.cwd; - // Simulate the sub-agent leaving uncommitted work behind. - await writeFile(join(params.cwd, "new-file.txt"), "unfinished work"); - return { report: "done" }; - }, - }); - - const result = await callTask(tool, { - description: "Dirty job", - prompt: "Do the work", - intent: "explore", - }); - - expect(result).toContain("done"); - expect(result).toContain("uncommitted changes and was left in place"); - expect(worktreePath).toBeDefined(); - const contents = await readFile(join(worktreePath!, "new-file.txt"), "utf8"); - expect(contents).toBe("unfinished work"); - - const { stdout } = await run("git", ["worktree", "list"], { cwd: repo }); - expect(stdout).toContain(worktreePath!); - }); - - test("preserves a worktree the sub-agent left stashed, with a notice naming the stash", async () => { - const repo = await makeRepo(); - tempDirs.push(repo); - const workdirBase = await mkdtemp(join(tmpdir(), "corbits-workdir-")); - tempDirs.push(workdirBase); - - let worktreePath: string | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: repo, - getWorkdirBase: () => workdirBase, - provider, - useWorktree: true, - run: async (params) => { - worktreePath = params.cwd; - // Simulate the sub-agent stashing mid-task: `git status` reports - // clean afterward even though the work is not actually gone — it is - // parked in the repo's shared refs/stash. - await writeFile(join(params.cwd, "wip.txt"), "half-finished change"); - await run("git", ["add", "."], { cwd: params.cwd }); - await run("git", ["stash"], { cwd: params.cwd }); - return { report: "done" }; - }, - }); - - const result = await callTask(tool, { - description: "Stashing job", - prompt: "Do the work", - intent: "explore", - }); - - expect(result).toContain("done"); - expect(result).toContain("stash"); - expect(worktreePath).toBeDefined(); - - // The worktree itself is preserved rather than silently removed — - // `git status` alone would have called this clean. - const { stdout } = await run("git", ["worktree", "list"], { cwd: repo }); - expect(stdout).toContain(worktreePath!); - - // The stash entry the sub-agent created is still recoverable. - const { stdout: stashList } = await run("git", ["stash", "list"], { cwd: repo }); - expect(stashList).toContain("stash@{0}"); - }); -}); diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts deleted file mode 100644 index bd4bdca0a..000000000 --- a/src/subagent/task-tool.ts +++ /dev/null @@ -1,1169 +0,0 @@ -/** - * Task tool: spawn a sub-agent for one self-contained job. - */ - -import { tool } from "@intx/agent"; -import type { AgentTool } from "@intx/agent"; -import { type } from "arktype"; -import type { ReactorEmittedEvent } from "@intx/inference"; -import { getLogger } from "@intx/log"; -import type { ToolDefinition, ToolResult } from "@intx/types/runtime"; - -import { LOG_NAMESPACE_ROOT } from "../branding.js"; - -import { runtimeSettingsWithCatalog, type ProviderCatalogEntry } from "../config/index.js"; -import { formatSubAgentTaskAuthFailureMessage } from "./inference-auth-failure.js"; -import type { CapabilityFilter, AgentProfile } from "../agent/profiles.js"; -import { - isDirectorId, - packageToCapabilities, - resolveDirector, -} from "../agent/directors/registry.js"; -import { - defaultEffortForDirector, - formatDirectorSystemPrompt, -} from "../agent/directors/identity.js"; -import type { DirectorPackage, SubagentTier } from "../agent/directors/types.js"; -import type { Settings } from "../config/settings.js"; -import { resolveInferenceWithPolicy } from "../config/settings.js"; -import { - resolveEffortForRole, - validateEffort, - type ReasoningEffort, -} from "../provider/reasoning-effort.js"; -import { isCodexProviderName } from "../config/codex-providers.js"; -import { DEFAULT_CANCEL_REASON, type SubAgentSessionStore } from "./session-store.js"; -import { - createFleetMailbox, - createSpawnAgentTool, - createWaitAgentsTool, - MAX_WAIT_TIMEOUT_MS, - type AgentFleetDeps, - type FleetMailboxHandle, -} from "./agent-fleet.js"; -import { buildDispatchBrief, type TaskIntent } from "./report.js"; -import { appendSubAgentParentHints, type ForcedStopReason } from "./stop-policy.js"; -import { - classifyBriefSalvage, - createBriefDispatchLedger, - fingerprintTaskBrief, -} from "./brief-dispatch.js"; -import { createInterventionLog, type InterventionSink } from "./intervention-log.js"; -import { detectModelFamily } from "./provider-family.js"; -import { isSubAgentCancelError } from "./dispose.js"; -import { cleanupSubAgentWorktree, createSubAgentWorktree, WorktreeError } from "./worktree.js"; -import { generateSessionId } from "../session/index.js"; -import { end, start } from "../perf/index.js"; -import { currentTurnId } from "../perf/reactor-spans.js"; -import { classifyAgentName } from "../telemetry/classify.js"; -import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js"; -import { captureSubagentEnd } from "../telemetry/product-events.js"; -import { getCurrentTurnTraceId } from "../telemetry/feedback.js"; -import { SPAWN_AGENT_TOOL_NAME, TASK_TOOL_NAME } from "./tool-taxonomy.js"; - -import { join } from "node:path"; -import type { - NestedDispatchDeps, - RunSubAgentParams, - RunSubAgentResult, - SubAgentProvider, - SubAgentSandboxDeps, - SubAgentTelemetryRollup, - SubAgentTerminalReason, -} from "./types.js"; - -const log = getLogger([LOG_NAMESPACE_ROOT, "subagent", "task-tool"]); - -export const TaskToolArgs = type({ - description: "string", - prompt: "string", - "context?": "string", - "agent?": "string", - "goals?": "string[]", - "intent?": "'explore' | 'implement' | 'review' | 'plan' | 'general'", - "success_criteria?": "string[]", - "do_not?": "string[]", - "report_focus?": "string", -}); - -// Deprecated: task() is the fused, blocking spawn+wait primitive. -// Prefer spawn_agent + wait_agents for new call sites — spawn_agent returns -// immediately and wait_agents blocks on whichever workers you need next, so -// multiple workers do not serialize behind one call. task() is not removed — -// much still routes through it — but new work should reach for the split -// verbs first. -export const taskToolDefinition: ToolDefinition = { - name: TASK_TOOL_NAME, - description: - 'Deprecated: prefer spawn_agent + wait_agents for new call sites (this fused blocking form is kept for compatibility). Spawn a sub-agent (a short-lived child agent) for one self-contained job. This is not a checklist item — use manage_tasks for your own work list. The sub-agent has the full file, search, and shell toolset, uses this session\'s permission gate (saved grants and auto mode when eligible; you may be prompted for other consequential actions), and returns a structured report (Summary / Findings / Blockers / Paths). Use it to parallelize exploration ("map every caller of X") or hand off a well-scoped implementation so your own context stays focused. Fire several task calls in one turn to run sub-agents in parallel. When launching multiple agents with the same profile, assign each a distinct lens in description and prompt so they do not duplicate work. The sub-agent cannot ask you questions. Depending on dispatch configuration it either shares your working tree directly, or runs isolated in its own git worktree snapshotted from your last commit — in the isolated case, any uncommitted or untracked changes in your working tree are excluded. Write a clear brief: context = durable background; prompt = actionable goal; goals = optional manage_tasks seeds. Prefer the typed spawn contract so workers finish without thrashing: intent (explore|implement|review|plan|general), success_criteria (done-when checklist), do_not (scope fence), report_focus (what Findings must cover).', - inputSchema: { - type: "object", - properties: { - description: { - type: "string", - description: - "A short label for the sub-agent job (a few words), shown in the Agents strip.", - }, - context: { - type: "string", - description: - "Optional durable background (codebase structure, conventions, constraints). Separate from the actionable goal.", - }, - prompt: { - type: "string", - description: - "The actionable goal: what the sub-agent must accomplish and what to put in its report.", - }, - goals: { - type: "array", - items: { type: "string" }, - description: - "Optional ordered checklist seeds for the child's own manage_tasks list. Does not affect your manage_tasks list.", - }, - intent: { - type: "string", - enum: ["explore", "implement", "review", "plan", "general"], - description: - "Optional spawn intent (explore | implement | review | plan | general). Rendered in the dispatch brief when set; omit for max back-compat.", - }, - success_criteria: { - type: "array", - items: { type: "string" }, - description: - "Optional concrete done checks. Preferred over free-form prompt alone as the worker's completion gate.", - }, - do_not: { - type: "array", - items: { type: "string" }, - description: "Optional explicit out-of-scope or forbidden actions for the worker.", - }, - report_focus: { - type: "string", - description: "Optional hint for what the parent most needs in Findings.", - }, - agent: { - type: "string", - description: - "Optional agent profile id from search_agents (or .agents/agents/). Profiles specify capability restrictions and role. Role drives reasoning-effort defaults (orchestrator high, worker medium) unless the profile pins inference.reasoningEffort; parent session effort is inheritance only when the role default is unsupported on the model.", - }, - }, - required: ["description", "prompt"], - }, -}; - -function resolveDep(value: T | (() => T)): T { - return typeof value === "function" ? (value as () => T)() : value; -} - -export type TaskToolDeps = SubAgentSandboxDeps & { - cwd: string; - getWorkdirBase: () => string; - // A getter so a live /agent provider/model/effort switch reaches subagents - // spawned after the change, not just the value captured at startup. A plain - // value is also accepted for callers with no live switching. - provider: SubAgentProvider | (() => SubAgentProvider); - // Required runner — inject runSubAgent in production, a mock in tests. - // Keeping this required (no default import of run) breaks the run↔task-tool cycle. - run: (params: RunSubAgentParams) => Promise; - onEvent?: (event: ReactorEmittedEvent) => void; - onProgress?: (info: { description: string; toolName: string }) => void; - // When set, each spawn is recorded as an inspectable session (identity, - // brief, transcript, status) for the TUI enter-session surface. Events are - // written here only — they are not forwarded into the parent chat transcript. - sessions?: SubAgentSessionStore; - settings?: Settings | (() => Settings | undefined); - catalog?: readonly ProviderCatalogEntry[] | (() => readonly ProviderCatalogEntry[]); - profiles?: AgentProfile[] | (() => AgentProfile[]); - // When false, profile.orchestrator is ignored so nested workers cannot - // themselves become orchestrators. Defaults to true for the primary session. - allowOrchestrator?: boolean; - // Set on the nested task tool installed inside an orchestrator sub-agent: - // the orchestrator's own session id, so workers it spawns record as nested - // sessions the Agents strip can indent under it. - parentSessionId?: string; - /** - * When set, only these agent/director ids may be spawned. Nested directors - * (greybeard) pass their package spawn.allowlist; primary omits this so - * plugin profiles remain reachable. - */ - spawnAllowlist?: readonly string[]; - /** - * Optional wall-clock budget (ms) for each worker this tool spawns. Opt-in - * only — there is no default leaf death clock. The task tool is exempt from - * the generic tool-execution watchdog, so this deadline is the only - * wall-clock bound on a worker. - */ - deadlineMs?: number; - - /** - * Opt-in: isolate each spawn in its own git worktree branched from the - * dispatcher's HEAD instead of sharing deps.cwd. Fails closed (see - * worktree.ts) when deps.cwd is not a git repository or worktree creation - * fails. Omit (default) to keep today's shared-cwd dispatch. - */ - useWorktree?: boolean; - // Records sub-agent starts and outcomes. Injected so the tool has no - // process-wide dependency; omitting it makes dispatch silent. - telemetry?: Telemetry; - /** Shared with spawn_agent/wait_agents when this task tool is fleet-backed. */ - fleetRecords?: FleetMailboxHandle; -}; - -function taskToolResult( - callId: string, - content: string, - stopReason?: ForcedStopReason, -): ToolResult { - const isError = content.startsWith("Error:") || content.startsWith("Error "); - return { - callId, - content, - ...(isError ? { isError: true } : {}), - // Structured stop-reason side channel: the parent chat director - // classifies salvage outcomes from this, not from `content`. - ...(stopReason !== undefined ? { detail: { stopReason } } : {}), - }; -} - -type RequiredTaskField = "description" | "prompt"; - -const REQUIRED_TASK_FIELD_HINTS: Record = { - description: "a short label for the sub-agent job", - prompt: "the actionable goal for the worker", -}; - -/** Truncated echo of a received value so the rejection shows what arrived. */ -function receivedFieldPreview(value: string): string { - const trimmed = value.trim(); - return JSON.stringify(trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed); -} - -/** - * Rejection naming only the actually-bad required fields, echoing the valid - * one back. A generic "requires description and prompt" hid which field was - * missing, so models retried the identical call verbatim. - */ -function requiredTaskFieldsError( - args: Record, - bad: readonly RequiredTaskField[], -): string { - const parts = bad.map((name) => { - const value = args[name]; - const hint = REQUIRED_TASK_FIELD_HINTS[name]; - if (value === undefined) return `is missing ${name} (string): ${hint}`; - if (typeof value !== "string") return `has invalid ${name} (must be a string): ${hint}`; - return `requires a non-empty ${name}: ${hint}`; - }); - let message = `Error: task ${parts.join(" and ")}.`; - const good = (Object.keys(REQUIRED_TASK_FIELD_HINTS) as RequiredTaskField[]).filter( - (name) => - !bad.includes(name) && - typeof args[name] === "string" && - (args[name] as string).trim().length > 0, - ); - if (good.length > 0) { - const echo = good - .map((name) => `${name} ${receivedFieldPreview(args[name] as string)}`) - .join(" and "); - message += ` Received ${echo} — keep it and add ${bad.join(" and ")}.`; - } - return message; -} - -async function runTaskViaFleet(input: { - callId: string; - signal: AbortSignal; - description: string; - prompt: string; - context: string | undefined; - agentId: string | undefined; - goals: string[]; - intent: TaskIntent | undefined; - successCriteria: string[]; - doNot: string[]; - reportFocus: string | undefined; - deps: TaskToolDeps; - sessions: SubAgentSessionStore; - fleetRecords: FleetMailboxHandle; -}): Promise { - const fleetDeps: AgentFleetDeps = { - permissionGate: input.deps.permissionGate, - ...(input.deps.inheritMcpTools !== undefined - ? { inheritMcpTools: input.deps.inheritMcpTools } - : {}), - ...(input.deps.shellTimeout !== undefined ? { shellTimeout: input.deps.shellTimeout } : {}), - ...(input.deps.shellEnv !== undefined ? { shellEnv: input.deps.shellEnv } : {}), - ...(input.deps.extraToolPlugins !== undefined - ? { extraToolPlugins: input.deps.extraToolPlugins } - : {}), - ...(input.deps.getBlobReader !== undefined ? { getBlobReader: input.deps.getBlobReader } : {}), - cwd: input.deps.cwd, - getWorkdirBase: input.deps.getWorkdirBase, - provider: input.deps.provider, - run: input.deps.run, - sessions: input.sessions, - fleetRecords: input.fleetRecords, - persist: false, - ...(input.deps.parentSessionId !== undefined - ? { parentSessionId: input.deps.parentSessionId } - : {}), - ...(input.deps.spawnAllowlist !== undefined - ? { spawnAllowlist: input.deps.spawnAllowlist } - : {}), - ...(input.deps.allowOrchestrator !== undefined - ? { allowOrchestrator: input.deps.allowOrchestrator } - : {}), - ...(input.deps.useWorktree !== undefined ? { useWorktree: input.deps.useWorktree } : {}), - ...(input.deps.deadlineMs !== undefined ? { deadlineMs: input.deps.deadlineMs } : {}), - ...(input.deps.settings !== undefined ? { settings: input.deps.settings } : {}), - ...(input.deps.catalog !== undefined ? { catalog: input.deps.catalog } : {}), - ...(input.deps.onEvent !== undefined ? { onEvent: input.deps.onEvent } : {}), - ...(input.deps.onProgress !== undefined ? { onProgress: input.deps.onProgress } : {}), - ...(input.deps.telemetry !== undefined ? { telemetry: input.deps.telemetry } : {}), - }; - const spawn = createSpawnAgentTool(fleetDeps); - const wait = createWaitAgentsTool({ - sessions: input.sessions, - fleetRecords: input.fleetRecords, - }); - if (spawn.kind !== "full" || wait.kind !== "full") { - return taskToolResult(input.callId, "Error: fleet tools are unavailable."); - } - const started = await spawn.handler( - { - id: input.callId, - name: SPAWN_AGENT_TOOL_NAME, - arguments: { - description: input.description, - prompt: input.prompt, - ...(input.context !== undefined ? { context: input.context } : {}), - ...(input.agentId !== undefined ? { agent: input.agentId } : {}), - ...(input.goals.length > 0 ? { goals: input.goals } : {}), - ...(input.intent !== undefined ? { intent: input.intent } : {}), - ...(input.successCriteria.length > 0 ? { success_criteria: input.successCriteria } : {}), - ...(input.doNot.length > 0 ? { do_not: input.doNot } : {}), - ...(input.reportFocus !== undefined ? { report_focus: input.reportFocus } : {}), - }, - }, - input.signal, - ); - const startedText = - typeof started.content === "string" ? started.content : JSON.stringify(started.content); - if (started.isError === true || startedText.startsWith("Error:")) { - return taskToolResult(input.callId, startedText); - } - let agentId: string; - try { - const parsed = JSON.parse(startedText) as { agent_id?: unknown }; - if (typeof parsed.agent_id !== "string" || parsed.agent_id.length === 0) { - return taskToolResult(input.callId, "Error: spawn_agent returned no agent_id."); - } - agentId = parsed.agent_id; - } catch (err) { - log.error("spawn_agent payload was not JSON: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - return taskToolResult( - input.callId, - `Error: spawn_agent returned invalid payload: ${startedText}`, - ); - } - - while (!input.signal.aborted) { - const waited = await wait.handler( - { - id: `${input.callId}-wait`, - name: "wait_agents", - arguments: { targets: [agentId], mode: "all", timeout_ms: MAX_WAIT_TIMEOUT_MS }, - }, - input.signal, - ); - const waitedText = - typeof waited.content === "string" ? waited.content : JSON.stringify(waited.content); - if (waited.isError === true || waitedText.startsWith("Error:")) { - return taskToolResult(input.callId, waitedText); - } - let payload: { - timed_out?: boolean; - results?: { status?: string; report?: string; error?: string }[]; - }; - try { - payload = JSON.parse(waitedText) as typeof payload; - } catch (err) { - log.error("wait_agents payload was not JSON: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - return taskToolResult( - input.callId, - `Error: wait_agents returned invalid payload: ${waitedText}`, - ); - } - if (payload.timed_out === true) continue; - const result = payload.results?.[0]; - if (result === undefined) { - return taskToolResult(input.callId, `Error: wait_agents returned no result for ${agentId}.`); - } - if (result.status === "interrupted") { - if (typeof result.report === "string" && result.report.length > 0) { - return taskToolResult( - input.callId, - `Sub-agent "${input.description}" reported:\n\n${result.report}`, - ); - } - const session = input.sessions.get(agentId); - return taskToolResult( - input.callId, - cancelledSubAgentMessage(input.description, session?.error), - ); - } - if (result.status === "failed") { - const errText = result.error ?? "unknown error"; - // Auth failures already carry the actionable Re-authenticate wording - // from formatSubAgentTaskAuthFailureMessage (baked in spawn catch). - if (errText.includes("Re-authenticate")) { - return taskToolResult(input.callId, `Error: ${errText}`); - } - return taskToolResult( - input.callId, - `Error: sub-agent "${input.description}" failed: ${errText}`, - ); - } - const report = result.report ?? ""; - return taskToolResult(input.callId, `Sub-agent "${input.description}" reported:\n\n${report}`); - } - // Parent tool abort must cancel the child — wait_agents itself has no - // abort side effects (workers stay waitable), so task()'s fused contract - // owns the cancel here. - if (input.sessions.get(agentId)?.status === "running") { - input.sessions.cancel(agentId); - } - const cancelled = input.sessions.get(agentId); - return taskToolResult( - input.callId, - cancelledSubAgentMessage(input.description, cancelled?.error), - ); -} - -export function createTaskTool(deps: TaskToolDeps): AgentTool { - const run = deps.run; - const telemetry = deps.telemetry ?? NOOP_TELEMETRY; - // Session-scoped re-dispatch ledger: one per parent task tool instance. - const briefLedger = createBriefDispatchLedger(); - const fleetSessions = deps.sessions; - const fleetRecords = - deps.fleetRecords ?? - (fleetSessions !== undefined ? createFleetMailbox(fleetSessions) : undefined); - // Every completed dispatch gets an outcome record — the log otherwise - // carries shape and run state but never what the run actually produced. - // Tagged with the dispatched child's provider/model/family so - // per-model intervention counts finally have a denominator: the same - // provider/model this dispatch actually ran under, taken after profile/ - // agent inference resolution — never a name the parent merely intended. - let outcomeLog: InterventionSink | null = null; - const recordOutcome = ( - kind: string, - dispatchCount: number, - identity: { provider: string; model: string }, - ): void => { - outcomeLog ??= createInterventionLog(deps.getWorkdirBase(), { role: "parent" }); - const family = detectModelFamily({ providerName: identity.provider, model: identity.model }); - outcomeLog({ - id: "dispatch-outcome", - class: "outcome", - outcome: { kind, dispatchCount }, - provider: identity.provider, - model: identity.model, - family, - }); - }; - // Concurrent-lane overlap detection, replacing the static - // per-package writePaths lock. There is no field in the task() contract a - // caller uses to declare which files a dispatch will touch, so the only - // honestly knowable "intended scope" at spawn is the working directory the - // dispatch will run in — worktree-isolated lanes always get a fresh, - // disjoint path here, so this can only ever fire in the shared-cwd fallback, - // which is exactly where two lanes really can stomp each other's writes. - // Keyed by call.id so a completed lane (removed in the outer finally below) - // is never mistaken for one still running: sequential dispatches to the - // same cwd are always clean. - const activeLanes = new Map(); - let conflictLog: InterventionSink | null = null; - const recordConflict = (event: Parameters[0]): void => { - conflictLog ??= createInterventionLog(deps.getWorkdirBase(), { role: "parent" }); - conflictLog(event); - }; - return tool({ - definition: taskToolDefinition, - handler: async (call, signal): Promise => { - const args = call.arguments; - const parsed = TaskToolArgs(args); - if (parsed instanceof type.errors) { - const bad = (["description", "prompt"] as const).filter( - (name) => typeof args[name] !== "string", - ); - if (bad.length === 0) { - return taskToolResult(call.id, `Error: task arguments invalid: ${parsed.summary}`); - } - return taskToolResult(call.id, requiredTaskFieldsError(args, bad)); - } - const { - description: rawDesc, - context: rawCtx, - prompt: rawPrompt, - agent: agentId, - goals: rawGoals, - intent: rawIntent, - success_criteria: rawSuccessCriteria, - do_not: rawDoNot, - report_focus: rawReportFocus, - } = parsed; - const description = rawDesc.trim(); - const context = rawCtx?.trim(); - const prompt = rawPrompt.trim(); - const goals = rawGoals?.map((g) => g.trim()).filter((g) => g.length > 0) ?? []; - const intent = rawIntent as TaskIntent | undefined; - const successCriteria = - rawSuccessCriteria?.map((c) => c.trim()).filter((c) => c.length > 0) ?? []; - const doNot = rawDoNot?.map((d) => d.trim()).filter((d) => d.length > 0) ?? []; - const reportFocus = rawReportFocus?.trim(); - if (description.length === 0 || prompt.length === 0) { - const empty = (["description", "prompt"] as const).filter( - (name) => (name === "description" ? description : prompt).length === 0, - ); - return taskToolResult(call.id, requiredTaskFieldsError(args, empty)); - } - - // Closed-director task() is spawn_agent + wait_agents. Custom profiles - // still use the legacy await-run path until spawn grows profile lookup. - const agentForFleet = typeof args.agent === "string" ? args.agent : undefined; - const canUseFleet = - fleetSessions !== undefined && - fleetRecords !== undefined && - (agentForFleet === undefined || agentForFleet.length === 0 || isDirectorId(agentForFleet)); - if (canUseFleet) { - return await runTaskViaFleet({ - callId: call.id, - signal, - description, - prompt, - context, - agentId, - goals, - intent, - successCriteria, - doNot, - reportFocus, - deps, - sessions: fleetSessions, - fleetRecords, - }); - } - - let provider: SubAgentProvider = - typeof deps.provider === "function" ? deps.provider() : deps.provider; - // Snapshot parent effort before profile-inference rebuilds so role-default - // resolution can fall back to inheritance without reading a mutated provider. - const parentEffort = provider.reasoningEffort; - // Explicit profile inference pin (if any). Distinct from the parent - // snapshot so resolveEffortForRole can apply pin > role > parent. - let effortPin: ReasoningEffort | undefined; - let capabilities: CapabilityFilter | undefined; - let systemPromptRole: string | undefined; - let orchestrator = false; - /** - * Fleet authority tier for this dispatch — forwarded to - * runSubAgent, which fails closed (denies task/search_agents) when - * orchestrator is true and this is left undefined or resolves to - * "leaf". Set alongside `orchestrator = true` in every branch below; - * never left to default once orchestrator is true. - */ - let orchestratorTier: SubagentTier | undefined; - let resolvedDirectorId: string | undefined; - let resolvedPackage: DirectorPackage | undefined; - /** Child-package spawn allowlist to forward into nested task (if this worker may spawn). */ - let nestedSpawnAllowlist: readonly string[] | undefined; - const diskSettings = deps.settings !== undefined ? resolveDep(deps.settings) : undefined; - - const catalog = deps.catalog !== undefined ? resolveDep(deps.catalog) : undefined; - // OAuth providers live in the live catalog, not settings.json. Overlay so - // inference resolution can target Codex/xAI the same way the TUI does. - const settings = - catalog !== undefined ? runtimeSettingsWithCatalog(diskSettings, catalog) : diskSettings; - const profiles = deps.profiles !== undefined ? resolveDep(deps.profiles) : undefined; - - // Rebuild provider from a resolved provider/model assignment. Shared by - // profile.inference so fail-closed effort validation and settings - // lookup stay consistent. - const applyResolvedProvider = ( - resolved: { - provider: string; - model: string; - reasoningEffort?: ReasoningEffort; - }, - label: string, - ): string | null => { - if (settings === undefined) { - return `Error: ${label} requires settings with configured providers.`; - } - if (resolved.reasoningEffort !== undefined) { - const verdict = validateEffort( - resolved.model, - resolved.reasoningEffort, - isCodexProviderName(resolved.provider), - ); - if (!verdict.ok) { - return `Error: ${label} has incompatible inference: ${verdict.error}`; - } - // Pin is recorded here; final effort is applied after role resolution - // so a leg without reasoningEffort still gets the role default. - effortPin = resolved.reasoningEffort; - } - const providerSettings = settings.providers[resolved.provider]; - if (providerSettings === undefined) { - return `Error: ${label} resolved to provider "${resolved.provider}" which is not configured.`; - } - // Provider/model swap only — effort is finalized once via - // resolveEffortForRole (pin > role default > parent) below. - provider = { - providerName: resolved.provider, - baseURL: providerSettings.baseURL, - ...(providerSettings.keyless === true ? { keyless: true } : {}), - ...(providerSettings.bifrostVirtualKey === true ? { bifrostVirtualKey: true } : {}), - ...(providerSettings.apiKey !== undefined ? { apiKey: providerSettings.apiKey } : {}), - model: resolved.model, - }; - return null; - }; - - if (agentId !== undefined && agentId.length > 0) { - // Closed director fleet: resolve package even when profiles - // are not loaded; profiles may still pin inference for the same id. - if (isDirectorId(agentId)) { - const resolved = resolveDirector({ agentId }); - if (!resolved.ok) { - return taskToolResult(call.id, `Error: ${resolved.error} ${resolved.hint}`); - } - const pkg = resolved.package; - resolvedPackage = pkg; - resolvedDirectorId = pkg.id; - systemPromptRole = formatDirectorSystemPrompt(pkg); - const caps = packageToCapabilities(pkg); - if (caps !== undefined) capabilities = caps; - if (pkg.spawn.maySpawn && deps.allowOrchestrator !== false) { - orchestrator = true; - orchestratorTier = pkg.tier; - if (pkg.spawn.allowlist !== undefined && pkg.spawn.allowlist.length > 0) { - nestedSpawnAllowlist = pkg.spawn.allowlist; - } - } - const profile = profiles?.find((p) => p.id === agentId); - - if (profile?.inference !== undefined && settings !== undefined) { - const outcome = resolveInferenceWithPolicy(profile.inference, settings); - if (outcome.kind === "unavailable") { - return taskToolResult( - call.id, - `Error: agent "${agentId}" unavailable: ${outcome.reason}. Set agentModelFallback: "active" (or change the spec mode to "prefer") to fall back to the active session.`, - ); - } - if (outcome.kind === "resolved") { - const err = applyResolvedProvider(outcome.value, `agent "${agentId}"`); - if (err !== null) return taskToolResult(call.id, err); - } - } - } else { - // Fail closed: an explicit agent= that cannot be resolved is an error, - // not a silent fall-through to a generic worker. Silent fall-through - // made typos and stale ids look like successful generic dispatches. - if (profiles === undefined) { - return taskToolResult( - call.id, - `Error: agent "${agentId}" requested but no agent profiles are loaded. Omit agent to use a generic sub-agent, or ensure profiles are available.`, - ); - } - const profile = profiles.find((p) => p.id === agentId); - if (profile === undefined) { - const known = profiles.map((p) => p.id).sort(); - // Point at search_agents (which injects full system prompt bodies) rather - // than read_file on plugin roots — path-escape blocks those paths by design. - const hint = - known.length > 0 - ? ` Known profiles: ${known.join(", ")}. Call search_agents to discover more (results include full system prompt / body; do not read_file plugin paths outside the workspace).` - : " No profiles are currently loaded. Call search_agents to discover available agents (results include full system prompt / body)."; - return taskToolResult(call.id, `Error: unknown agent profile "${agentId}".${hint}`); - } - if (profile.capabilities !== undefined) { - capabilities = profile.capabilities; - } - if (profile.systemPromptRole !== undefined) { - systemPromptRole = profile.systemPromptRole; - } - // Nested workers (allowOrchestrator: false) cannot re-enter orchestration - // even if their profile is marked orchestrator — recursion bottoms out. - if (profile.orchestrator === true && deps.allowOrchestrator !== false) { - orchestrator = true; - // Fail closed: a profile is outside the closed director - // set, so orchestrator: true alone does not grant a tier. No - // profile field opts in; orchestratorTier stays undefined, which - // runSubAgent treats as "leaf" and denies task/search_agents. - } - // Per-agent pinned inference (provider/model/effort), if declared. - // Resolution uses policy (mode: pin / agentModelFallback: none) so a - // forbidden fallback surfaces as a dispatch error rather than - // silently running on the parent's provider. - if (profile.inference !== undefined && settings !== undefined) { - const outcome = resolveInferenceWithPolicy(profile.inference, settings); - if (outcome.kind === "unavailable") { - return taskToolResult( - call.id, - `Error: agent "${agentId}" unavailable: ${outcome.reason}. Set agentModelFallback: "active" (or change the spec mode to "prefer") to fall back to the active session.`, - ); - } - if (outcome.kind === "resolved") { - const err = applyResolvedProvider(outcome.value, `agent "${agentId}"`); - if (err !== null) return taskToolResult(call.id, err); - } - } - } - } else if (intent !== undefined) { - // intent-only dispatch maps to closed directors (no catch-all worker). - const resolved = resolveDirector({ intent }); - if (!resolved.ok) { - return taskToolResult(call.id, `Error: ${resolved.error} ${resolved.hint}`); - } - const pkg = resolved.package; - resolvedPackage = pkg; - resolvedDirectorId = pkg.id; - systemPromptRole = formatDirectorSystemPrompt(pkg); - const caps = packageToCapabilities(pkg); - if (caps !== undefined) capabilities = caps; - if (pkg.spawn.maySpawn && deps.allowOrchestrator !== false) { - orchestrator = true; - orchestratorTier = pkg.tier; - if (pkg.spawn.allowlist !== undefined && pkg.spawn.allowlist.length > 0) { - nestedSpawnAllowlist = pkg.spawn.allowlist; - } - } - } else { - // No catch-all worker: bare task (no agent, no intent) is refused. Reclassify. - return taskToolResult( - call.id, - 'Error: No director selected. Pass task(agent=…) for a named director, or task(intent=implement|explore|plan|review). Intent "general" is not a director.', - ); - } - - // Skywalker is the primary session identity, not a spawned worker. - if (agentId === "skywalker" || resolvedDirectorId === "skywalker") { - return taskToolResult( - call.id, - "Error: skywalker is the primary session identity, not a spawned worker. Pass task(agent=…) for a specialist (builder, explorer, counsel, critic, …).", - ); - } - - // Parent director spawn matrix (e.g. greybeard → intern/explorer/critic only). - if (deps.spawnAllowlist !== undefined && deps.spawnAllowlist.length > 0) { - const childId = - agentId !== undefined && agentId.length > 0 ? agentId : (resolvedDirectorId ?? ""); - if (childId.length === 0 || !deps.spawnAllowlist.includes(childId)) { - const allowed = deps.spawnAllowlist.join(", "); - return taskToolResult( - call.id, - `Error: spawn of "${childId.length > 0 ? childId : "(unresolved)"}" is outside this director's allowlist. Allowed: ${allowed}.`, - ); - } - } - - // Role-based effort: pin > package modelRole default > orchestrator/worker > parent. - // Leaves default to medium (intern: low) so a primary on high/sol does not - // multiply the latency cliff across every spawned worker. - { - const roleDefault = - resolvedPackage !== undefined ? defaultEffortForDirector(resolvedPackage) : undefined; - const effort = resolveEffortForRole({ - orchestrator, - ...(effortPin !== undefined ? { pin: effortPin } : {}), - ...(roleDefault !== undefined ? { roleDefault } : {}), - ...(parentEffort !== undefined ? { parentEffort } : {}), - model: provider.model, - isCodex: isCodexProviderName(provider.providerName), - }); - if (effort !== undefined) { - provider = { ...provider, reasoningEffort: effort }; - } else { - const { reasoningEffort: _drop, ...rest } = provider; - provider = rest; - } - } - - const brief = buildDispatchBrief({ - description, - prompt, - ...(context !== undefined && context.length > 0 ? { context } : {}), - ...(goals.length > 0 ? { goals } : {}), - ...(intent !== undefined ? { intent } : {}), - ...(successCriteria.length > 0 ? { successCriteria } : {}), - ...(doNot.length > 0 ? { doNot } : {}), - ...(reportFocus !== undefined && reportFocus.length > 0 ? { reportFocus } : {}), - }); - const fingerprint = fingerprintTaskBrief({ - prompt, - ...(agentId !== undefined && agentId.length > 0 ? { agent: agentId } : {}), - ...(intent !== undefined ? { intent } : {}), - ...(successCriteria.length > 0 ? { successCriteria } : {}), - ...(doNot.length > 0 ? { doNot } : {}), - }); - const dispatchCount = briefLedger.admit(fingerprint).dispatchCount; - - const agentLabel = - agentId !== undefined && agentId.length > 0 ? agentId : (resolvedDirectorId ?? "worker"); - const session = - deps.sessions !== undefined - ? deps.sessions.start({ - id: call.id, - description, - agentId: agentLabel, - brief, - ...(deps.parentSessionId !== undefined - ? { parentSessionId: deps.parentSessionId } - : {}), - }) - : undefined; - const recordEvent = - session !== undefined && deps.sessions !== undefined - ? (event: ReactorEmittedEvent): void => { - deps.sessions!.appendEvent(session.id, event); - deps.onEvent?.(event); - } - : deps.onEvent; - - // A dispatch can fail over to a different configured provider/model - // mid-run (source priority list, `resolveInferenceWithPolicy` builds the - // primary; the reactor retries the next source on error) — so the - // provider/model this call resolved before dispatch is only what the - // parent *intended*. `inference.done` carries the source that actually - // served each cycle; track the last one seen so the outcome record can - // prefer it over the resolved-but-possibly-superseded `provider` value. - let lastCycleSource: { provider: string; model: string } | undefined; - const onEvent = (event: ReactorEmittedEvent): void => { - if (event.type === "inference.done") { - const source = (event.data as { source?: { provider?: string; model?: string } }).source; - if ( - source !== undefined && - typeof source.provider === "string" && - typeof source.model === "string" - ) { - lastCycleSource = { provider: source.provider, model: source.model }; - } - } - recordEvent?.(event); - }; - - const sandbox: SubAgentSandboxDeps = { - permissionGate: deps.permissionGate, - ...(deps.inheritMcpTools !== undefined ? { inheritMcpTools: deps.inheritMcpTools } : {}), - ...(deps.shellTimeout !== undefined ? { shellTimeout: deps.shellTimeout } : {}), - ...(deps.shellEnv !== undefined ? { shellEnv: deps.shellEnv } : {}), - ...(deps.extraToolPlugins !== undefined ? { extraToolPlugins: deps.extraToolPlugins } : {}), - ...(deps.getBlobReader !== undefined ? { getBlobReader: deps.getBlobReader } : {}), - }; - const nestedDispatch: NestedDispatchDeps | undefined = orchestrator - ? { - ...sandbox, - getWorkdirBase: deps.getWorkdirBase, - provider: deps.provider, - // Forward the external sink, not this session's recorder: nested - // workers record into their own sessions (deps.sessions below), - // so chaining recordEvent here would replay each grandchild event - // into the orchestrator's transcript as well. - ...(deps.onEvent !== undefined ? { onEvent: deps.onEvent } : {}), - ...(deps.onProgress !== undefined ? { onProgress: deps.onProgress } : {}), - // Nested workers share the same session store so their transcripts - // are enterable too; allowOrchestrator is false so they cannot - // re-orchestrate indefinitely. - ...(deps.sessions !== undefined ? { sessions: deps.sessions } : {}), - ...(deps.settings !== undefined ? { settings: deps.settings } : {}), - ...(deps.catalog !== undefined ? { catalog: deps.catalog } : {}), - ...(deps.profiles !== undefined ? { profiles: deps.profiles } : {}), - ...(session !== undefined ? { parentSessionId: session.id } : {}), - ...(deps.useWorktree !== undefined ? { useWorktree: deps.useWorktree } : {}), - ...(nestedSpawnAllowlist !== undefined ? { spawnAllowlist: nestedSpawnAllowlist } : {}), - } - : undefined; - // Per-spawn controller so strip cancel and parent stop share one abort - // path. Parent tool signal links into this controller; registerCancel - // lets the session store abort without holding the agent handle. - const childCtl = new AbortController(); - const onParentAbort = (): void => { - if (!childCtl.signal.aborted) childCtl.abort(signal.reason); - }; - if (signal.aborted) { - childCtl.abort(signal.reason); - } else { - signal.addEventListener("abort", onParentAbort, { once: true }); - } - if (session !== undefined) { - deps.sessions?.registerCancel(session.id, () => { - if (!childCtl.signal.aborted) childCtl.abort(); - }); - } - - let worktreeCwd: string | undefined; - let worktreeStashBaseline: readonly string[] | null = []; - let worktreeHeadAtCreate: string | undefined; - // Full child wall: worktree setup → run → teardown. - const turnId = currentTurnId(); - const subagentSpanId = start("subagent", { - ...(turnId !== null && turnId.length > 0 ? { parentId: turnId } : {}), - tags: { - subagent_id: call.id, - ...(turnId !== null && turnId.length > 0 ? { turn_id: turnId } : {}), - }, - }); - const subagentStartedAt = Date.now(); - // Profile / director ids are classified: first-party DIRECTOR_IDS (and - // legacy "worker") report by name; project/plugin profiles become custom. - // Capture the in-flight parent turn trace at dispatch — getLastTurnTraceId - // would be the previous completed turn while this tool still runs. - const agentName = classifyAgentName(agentLabel); - const parentTraceId = getCurrentTurnTraceId(); - telemetry.capture("subagent_start", { agent_name: agentName }); - let subagentStatus: "completed" | "cancelled" | "failed" = "completed"; - let endRollup: SubAgentTelemetryRollup | undefined; - let endStopReason: SubAgentTerminalReason | "setup_error" | undefined; - let endModel: string | undefined; - - try { - if (deps.useWorktree === true) { - const worktreePath = join(deps.getWorkdirBase(), "worktrees", generateSessionId()); - try { - const worktree = await createSubAgentWorktree(deps.cwd, worktreePath); - worktreeCwd = worktree.path; - worktreeStashBaseline = worktree.stashBaseline; - worktreeHeadAtCreate = worktree.headAtCreate; - } catch (err) { - // Admit already happened and the strip session may be "running" — - // release the ledger slot and fail the session so a worktree setup - // error never burns re-dispatch bookkeeping or leaves a ghost row. - const message = - err instanceof WorktreeError - ? err.message - : `sub-agent worktree setup failed: ${err instanceof Error ? err.message : String(err)}`; - subagentStatus = "failed"; - endStopReason = "setup_error"; - endRollup = { - turn_count: 0, - input_tokens: 0, - output_tokens: 0, - cache_read_tokens: 0, - cache_write_tokens: 0, - reasoning_tokens: 0, - tool_call_count: 0, - tool_error_count: 0, - }; - briefLedger.release(fingerprint); - if (session !== undefined) deps.sessions?.fail(session.id, message); - signal.removeEventListener("abort", onParentAbort); - return taskToolResult(call.id, `Error: ${message}`); - } - } - // Detect, don't lock: warn when another lane still running right now - // is already working in this same cwd. Worktree-isolated lanes never - // collide here (each gets its own directory); this only fires in the - // shared-cwd fallback, where two lanes genuinely can overwrite each - // other's writes. Never blocks the spawn — the least destructive - // response that still tells the operator something true, since a - // shared cwd does not by itself prove the two lanes touch the same - // files, only that they could. - const laneCwd = worktreeCwd ?? deps.cwd; - for (const [otherId, other] of activeLanes) { - if (other.cwd !== laneCwd) continue; - recordConflict({ - id: "concurrent-lane-overlap", - class: "conflict", - detail: - `"${description}" (${call.id}) and "${other.description}" (${otherId}) ` + - `are both running against ${laneCwd} at once`, - }); - } - activeLanes.set(call.id, { description, cwd: laneCwd }); - // Cleanup runs once the sub-agent's report is ready, regardless of - // outcome, so a cancelled or failed run's worktree is still reclaimed - // (or preserved with a notice) rather than leaked. - const finishWithWorktree = async (result: ToolResult): Promise => { - if (worktreeCwd === undefined) return result; - const cleanup = await cleanupSubAgentWorktree(deps.cwd, worktreeCwd, { - stashBaseline: worktreeStashBaseline, - ...(worktreeHeadAtCreate !== undefined ? { headAtCreate: worktreeHeadAtCreate } : {}), - }); - if (cleanup.status === "preserved") { - return { ...result, content: `${result.content}\n\n${cleanup.notice}` }; - } - return result; - }; - - try { - const params: RunSubAgentParams = { - ...sandbox, - cwd: worktreeCwd ?? deps.cwd, - workdirBase: deps.getWorkdirBase(), - // Same id as the SubAgentSessionStore record so read_agent_trace's - // descendant check (authority.ts assertCanTargetAgent) can reuse the - // store's parentSessionId chain instead of a second identity scheme. - ...(session !== undefined ? { id: session.id } : {}), - provider, - ...(settings !== undefined ? { settings } : {}), - ...(catalog !== undefined ? { catalog } : {}), - description, - ...(context !== undefined && context.length > 0 ? { context } : {}), - prompt, - ...(goals.length > 0 ? { goals } : {}), - ...(intent !== undefined ? { intent } : {}), - ...(successCriteria.length > 0 ? { successCriteria } : {}), - ...(doNot.length > 0 ? { doNot } : {}), - ...(reportFocus !== undefined && reportFocus.length > 0 ? { reportFocus } : {}), - signal: childCtl.signal, - onEvent, - onRunSettled: (summary) => { - endRollup = summary; - endStopReason = summary.terminal_reason; - endModel = summary.model; - }, - ...(deps.onProgress !== undefined ? { onProgress: deps.onProgress } : {}), - ...(capabilities !== undefined ? { capabilities } : {}), - ...(systemPromptRole !== undefined ? { systemPromptRole } : {}), - ...(resolvedDirectorId !== undefined ? { directorId: resolvedDirectorId } : {}), - ...(orchestrator - ? { - orchestrator: true, - ...(orchestratorTier !== undefined ? { orchestratorTier } : {}), - nestedDispatch: nestedDispatch!, - } - : {}), - ...(deps.deadlineMs !== undefined ? { deadlineMs: deps.deadlineMs } : {}), - // submit_result mount gate: only a resolved Tier 3 leaf - // director gets tier here, and only if it declared an outputType. - ...(resolvedPackage !== undefined ? { tier: resolvedPackage.tier } : {}), - ...(resolvedPackage?.reportContract?.outputType !== undefined - ? { reportType: resolvedPackage.reportContract.outputType } - : {}), - }; - const result = await run(params); - // Operator cancel may race after run resolves. Keep strip status cancelled - // when requested, but never discard a returned body (including salvage). - - const wasCancelled = - childCtl.signal.aborted || - (session !== undefined && deps.sessions?.get(session.id)?.status === "cancelled"); - const salvage = classifyBriefSalvage({ - ...(result.stopReason !== undefined ? { stopReason: result.stopReason } : {}), - wasCancelled, - }); - briefLedger.recordOutcome(fingerprint, salvage); - // Prefer the last provider/model that actually served inference - // (captured off inference.done above) over the pre-dispatch - // `provider` this call resolved — a mid-run failover to a backup - // source means the two can diverge, and the outcome record should - // describe what the child ran under, not what the parent intended. - recordOutcome(salvage ?? "clean-complete", dispatchCount, { - provider: lastCycleSource?.provider ?? provider.providerName, - model: lastCycleSource?.model ?? provider.model, - }); - const hintOptions = { dispatchCount }; - if (wasCancelled) { - subagentStatus = "cancelled"; - if (session !== undefined && deps.sessions?.get(session.id)?.status === "running") { - deps.sessions.cancel(session.id, cancelReason(childCtl.signal)); - } - const reported = appendSubAgentParentHints( - result.report, - result.stopReason, - hintOptions, - ); - return await finishWithWorktree( - taskToolResult( - call.id, - `Sub-agent "${description}" reported:\n\n${reported}`, - salvage ?? undefined, - ), - ); - } - if (session !== undefined) - deps.sessions?.complete(session.id, result.report, { - ...(result.stopReason !== undefined ? { stopReason: result.stopReason } : {}), - }); - - const reported = appendSubAgentParentHints(result.report, result.stopReason, hintOptions); - return await finishWithWorktree( - taskToolResult( - call.id, - `Sub-agent "${description}" reported:\n\n${reported}`, - salvage ?? undefined, - ), - ); - } catch (err) { - if ( - isSubAgentCancelError(err, childCtl.signal) || - (session !== undefined && deps.sessions?.get(session.id)?.status === "cancelled") - ) { - subagentStatus = "cancelled"; - briefLedger.recordOutcome(fingerprint, "cancelled"); - if (session !== undefined && deps.sessions?.get(session.id)?.status === "running") { - deps.sessions.cancel(session.id, cancelReason(childCtl.signal)); - } - // Prefer the store's recorded reason (strip cancel writes it there - // before aborting); fall back to the abort signal's reason. - const reason = - (session !== undefined ? deps.sessions?.get(session.id)?.error : undefined) ?? - cancelReason(childCtl.signal); - return await finishWithWorktree( - taskToolResult(call.id, cancelledSubAgentMessage(description, reason)), - ); - } - subagentStatus = "failed"; - // Run never produced a body — undo the admit so re-dispatch bookkeeping - // is not burned by auth/provider crashes. - briefLedger.release(fingerprint); - const authMessage = formatSubAgentTaskAuthFailureMessage(description, err); - const message = - authMessage !== null - ? `Error: ${authMessage}` - : `Error: sub-agent "${description}" failed: ${err instanceof Error ? err.message : String(err)}`; - const sessionError = err instanceof Error ? err.message : String(err); - // fail() prefixes "Error:" on the transcript report entry — pass bare text. - const failReason = authMessage ?? sessionError; - if (session !== undefined) deps.sessions?.fail(session.id, failReason); - return await finishWithWorktree(taskToolResult(call.id, message)); - } finally { - signal.removeEventListener("abort", onParentAbort); - } - } finally { - activeLanes.delete(call.id); - end(subagentSpanId); - captureSubagentEnd(telemetry, { - agentName, - status: subagentStatus, - durationMs: Date.now() - subagentStartedAt, - ...(endModel !== undefined ? { model: endModel } : { model: provider.model }), - ...(endStopReason !== undefined ? { stopReason: endStopReason } : {}), - ...(endRollup !== undefined ? { rollup: endRollup } : {}), - ...(parentTraceId !== undefined ? { parentTraceId } : {}), - }); - } - }, - }); -} - -function cancelReason(signal: AbortSignal): string { - const reason = signal.reason; - if (typeof reason === "string" && reason.length > 0) return reason; - if (reason instanceof Error && reason.message.length > 0) return reason.message; - return DEFAULT_CANCEL_REASON; -} - -function cancelledSubAgentMessage(description: string, reason?: string): string { - const base = `Sub-agent "${description}" cancelled by operator.`; - // Only a non-default reason adds signal ("Session cleared", a stop cause…). - return reason !== undefined && reason !== DEFAULT_CANCEL_REASON - ? `${base} Stopped: cancelled — ${reason}` - : base; -} diff --git a/src/subagent/task-via-fleet.test.ts b/src/subagent/task-via-fleet.test.ts deleted file mode 100644 index e16bf0e98..000000000 --- a/src/subagent/task-via-fleet.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createTaskTool } from "./task-tool.js"; -import { createFleetMailbox } from "./agent-fleet.js"; -import { createSubAgentSessionStore } from "./session-store.js"; -import { createPermissionGate } from "../permission/gate.js"; - -const testPermissionGate = createPermissionGate({ - approvals: [], - interactive: false, - skipPermissions: true, -}); - -const provider = { - providerName: "test-provider", - baseURL: "http://localhost", - model: "test-model", -}; - -describe("task via spawn_agent + wait_agents", () => { - test("a director task with a session store returns the worker report", async () => { - const sessions = createSubAgentSessionStore(); - const fleetRecords = createFleetMailbox(sessions); - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/tmp", - getWorkdirBase: () => "/tmp/workdir", - provider, - sessions, - fleetRecords, - run: async () => ({ - report: "## Summary\nshipped\n## Findings\nok\n## Blockers\n\n## Paths\n", - }), - }); - if (tool.kind !== "full") throw new Error("expected full tool"); - const result = await tool.handler( - { - id: "t1", - name: "task", - arguments: { description: "ship", prompt: "do it", intent: "explore" }, - }, - new AbortController().signal, - ); - const content = typeof result.content === "string" ? result.content : ""; - expect(content).toContain('Sub-agent "ship" reported'); - expect(content).toContain("shipped"); - }); -}); diff --git a/src/subagent/tool-preview.test.ts b/src/subagent/tool-preview.test.ts index 012b7bf00..8d4c60602 100644 --- a/src/subagent/tool-preview.test.ts +++ b/src/subagent/tool-preview.test.ts @@ -29,10 +29,10 @@ describe("toolCallPreview", () => { ).toBe("currentToolPreview"); }); - test("task prefers description over prompt", () => { + test("spawn_agent prefers description over prompt", () => { expect( toolCallPreview( - "task", + "spawn_agent", JSON.stringify({ description: "map callers", prompt: "Find every call site of leaveObserve.", diff --git a/src/subagent/tool-preview.ts b/src/subagent/tool-preview.ts index bb75ded63..c2e64653a 100644 --- a/src/subagent/tool-preview.ts +++ b/src/subagent/tool-preview.ts @@ -81,7 +81,7 @@ function extractSubject(name: string, rawArgs: string): string | null { return stringField(args, "query") ?? stringField(args, "url"); } - if (tool === "task") { + if (tool === "spawn_agent") { return stringField(args, "description") ?? stringField(args, "prompt"); } diff --git a/src/subagent/tool-taxonomy.ts b/src/subagent/tool-taxonomy.ts index 5cb399574..3fd3d4cfe 100644 --- a/src/subagent/tool-taxonomy.ts +++ b/src/subagent/tool-taxonomy.ts @@ -1,7 +1,6 @@ -export const TASK_TOOL_NAME = "task"; export const SPAWN_AGENT_TOOL_NAME = "spawn_agent"; -const SUBAGENT_TOOL_NAMES = new Set([TASK_TOOL_NAME, SPAWN_AGENT_TOOL_NAME]); +const SUBAGENT_TOOL_NAMES = new Set([SPAWN_AGENT_TOOL_NAME]); export function isSubagentToolName(toolName: string): boolean { return SUBAGENT_TOOL_NAMES.has(toolName); diff --git a/src/subagent/types.ts b/src/subagent/types.ts index 0167d1881..90cc2e226 100644 --- a/src/subagent/types.ts +++ b/src/subagent/types.ts @@ -1,6 +1,6 @@ /** - * Shared sub-agent types used by both run.ts and task-tool.ts. - * Kept separate so task-tool does not import run (breaks the ESM cycle). + * Shared sub-agent types used by both run.ts and agent-fleet.ts. + * Kept separate so agent-fleet does not import run (breaks the ESM cycle). */ import type { AgentTool } from "@intx/agent"; @@ -37,7 +37,7 @@ export interface SubAgentProvider { } // Dependencies an orchestrator sub-agent needs to spawn further workers via -// `task`. Nested dispatch always sets allowOrchestrator: false so the +// `spawn_agent`. Nested dispatch always sets allowOrchestrator: false so the // recursion bottoms out at one hop of orchestration. export interface SubAgentSandboxDeps { permissionGate: PermissionGate; @@ -64,23 +64,23 @@ export type NestedDispatchDeps = SubAgentSandboxDeps & { // The orchestrator's own session id, so workers it dispatches record as // nested (one-hop) sessions the Agents strip can indent under it. parentSessionId?: string; - // Forwarded from the outer TaskToolDeps so nested workers get the same + // Forwarded from the outer fleet deps so nested workers get the same // worktree-isolation behavior as their orchestrator. useWorktree?: boolean; /** - * When set (e.g. greybeard → intern/explorer/critic), nested `task` may only - * spawn these director/profile ids. Omitted = no allowlist filter (primary). + * When set (e.g. greybeard -> intern/explorer/critic), nested `spawn_agent` + * may only spawn these director/profile ids. Omitted = no allowlist filter (primary). */ spawnAllowlist?: readonly string[]; }; -/** Typed spawn intent — optional on `task`; omit Intent section when unset. */ +/** Typed spawn intent — optional on `spawn_agent`; omit Intent section when unset. */ export type RunSubAgentParams = { cwd: string; workdirBase: string; /** * Stable id for this worker's on-disk trace directory (subagents/). - * Callers that track a session store (task-tool.ts) pass the same id as + * Callers that track a session store (agent-fleet.ts) pass the same id as * the SubAgentSessionStore record so read_agent_trace's descendant check * can reuse the store's existing parentSessionId chain instead of a * second identity scheme. Falls back to a fresh generated id when unset @@ -97,7 +97,7 @@ export type RunSubAgentParams = { // the dispatch brief as a suggested manage_tasks seed — the child's list is // still its own; the parent does not share a checklist. goals?: readonly string[]; - /** Spawn intent for the brief (no tool filtering here — that is a later task). */ + /** Spawn intent for the brief; tool filtering is owned by the dispatcher. */ intent?: TaskIntent; /** Concrete done checks preferred over free-form prompt alone. */ successCriteria?: readonly string[]; @@ -114,22 +114,22 @@ export type RunSubAgentParams = { /** Resolved closed-director id (e.g. "critic") when the worker is one. Structured gate key — prefer over persona-string matching in systemPromptRole. */ directorId?: string; // When true, the assembled system prompt grants this sub-agent permission - // to call `task` to spawn further agents (orchestrator exception to the - // no-recursion rule). Set from AgentProfile.orchestrator at dispatch time. - // Requires nestedDispatch so the task tool can actually be installed — - // advertising permission without the tool is a hard break. + // to call `spawn_agent` to spawn further agents (orchestrator exception to + // the no-recursion rule). Set only for built-in director packages. + // Requires nestedDispatch so fleet tools can actually be installed — + // advertising permission without the tools is a hard break. orchestrator?: boolean; /** * Fleet authority tier for this dispatch, resolved by the caller - * (task-tool.ts) from either the closed DirectorPackage.tier or an explicit - * AgentProfile.tier opt-in. Required whenever orchestrator is true: - * runSubAgent fails closed (denies task/search_agents) when orchestrator is - * true and this is undefined or "leaf" — an unrecognized or unresolved tier + * (agent-fleet.ts) from the closed DirectorPackage.tier. Required whenever + * orchestrator is true: + * runSubAgent fails closed (denies fleet tools) when orchestrator is true + * and this is undefined or "leaf" — an unrecognized or unresolved tier * must never mount a fleet verb. See src/subagent/authority.ts. */ orchestratorTier?: SubagentTier; - // Present only when orchestrator is true. Installs task + search_agents so - // the orchestrator can actually dispatch workers. + // Present only when orchestrator is true. Installs fleet tools so the + // orchestrator can actually dispatch workers. nestedDispatch?: NestedDispatchDeps; /** * Optional wall-clock budget for this worker's whole run (ms). Opt-in only — @@ -139,7 +139,7 @@ export type RunSubAgentParams = { deadlineMs?: number; /** * Resolved director tier, independent of `orchestratorTier` (which - * is only ever set when `orchestrator` is true). Set by task-tool.ts from + * is only ever set when `orchestrator` is true). Set by agent-fleet.ts from * `DirectorPackage.tier`. runSubAgent mounts `submit_result` only when this * is `"leaf"` — the existing tier machinery (authority.ts / directors/types.ts) * gates it, not a new mechanism. diff --git a/src/telemetry/ai-observability.test.ts b/src/telemetry/ai-observability.test.ts index f9c338370..c119b175c 100644 --- a/src/telemetry/ai-observability.test.ts +++ b/src/telemetry/ai-observability.test.ts @@ -15,7 +15,7 @@ import { } from "./ai-observability.js"; import { getCurrentTurnTraceId, resetFeedbackStateForTests } from "./feedback.js"; -const SUBAGENT_TOOL_NAME = "task"; +const SUBAGENT_TOOL_NAME = "spawn_agent"; const SESSION_ID = "0199-parent-session"; afterEach(() => { @@ -94,8 +94,7 @@ describe("secondsFromMs", () => { }); describe("classifySpanKind", () => { - test("classifies both subagent dispatch tools as subagent_call", () => { - expect(classifySpanKind("task")).toBe("subagent_call"); + test("classifies spawn_agent as a subagent_call", () => { expect(classifySpanKind("spawn_agent")).toBe("subagent_call"); }); diff --git a/src/telemetry/ai-observability.ts b/src/telemetry/ai-observability.ts index d8707ac3b..d0e45e03b 100644 --- a/src/telemetry/ai-observability.ts +++ b/src/telemetry/ai-observability.ts @@ -42,8 +42,7 @@ export function turnTraceId(sessionId: string, turnIndex: number): string { } // Maps a tool call to a fixed span kind. Takes the tool's canonical name -// (e.g. the subagent task tool's registered name) rather than reaching into -// subagent internals, so this module has no dependency on tool +// rather than reaching into subagent internals, so this module has no dependency on tool // implementations beyond the one identifier it needs to classify. export function classifySpanKind(toolName: string): AiSpanKind { return isSubagentToolName(toolName) ? "subagent_call" : "tool_call"; diff --git a/src/telemetry/classify.ts b/src/telemetry/classify.ts index 00b58fe3c..325b07c4a 100644 --- a/src/telemetry/classify.ts +++ b/src/telemetry/classify.ts @@ -32,7 +32,8 @@ const BUILT_IN_TOOL_NAMES: ReadonlySet = new Set([ "run_shell", "search_agents", "search_files", - "task", + "spawn_agent", + "wait_agents", "tool_search", "use_skill", "web_fetch", diff --git a/src/telemetry/feedback.ts b/src/telemetry/feedback.ts index c52900cd7..3ecbf385f 100644 --- a/src/telemetry/feedback.ts +++ b/src/telemetry/feedback.ts @@ -173,7 +173,7 @@ export function getLastTurnTraceId(): string | undefined { /** * Remember the in-flight turn's `$ai_trace_id` so `subagent_end` can link to the - * turn that is still running when `task` / `spawn_agent` dispatch (not the + * turn that is still running when `spawn_agent` dispatch (not the * previous completed turn). */ export function noteCurrentTurnTraceId(traceId: string): void { diff --git a/src/tui/agent-progress.test.ts b/src/tui/agent-progress.test.ts index 465ea0796..ebaf0257e 100644 --- a/src/tui/agent-progress.test.ts +++ b/src/tui/agent-progress.test.ts @@ -153,7 +153,7 @@ describe("agentProgress", () => { test("default stall window tolerates a multi-minute Grok think gap", () => { // DEFAULT_STALL_MS is 300s — 180s of quiet with no tool outstanding must - // still read working, or Task rows false-stall on healthy Responses thinks. + // still read working, or worker/spawn_agent rows false-stall on healthy Responses thinks. const progress = agentProgress({ ...base, currentToolName: null, lastActivityAt: 0 }, 180_000); expect(progress?.state).toBe("working"); expect(progress?.stalled).toBe(false); diff --git a/src/tui/agent-progress.ts b/src/tui/agent-progress.ts index 4e5f8df13..21fa38998 100644 --- a/src/tui/agent-progress.ts +++ b/src/tui/agent-progress.ts @@ -2,11 +2,13 @@ * Live progress for a dispatched sub-agent's pending row in the transcript, * and the fleet-level roll-up of those same lanes. * - * A "task" tool call renders as one row for its whole lifetime (see - * `runtime-bridge.ts`'s `syncAgentProgress`). While the call is outstanding - * this fills in what a bare pending mark cannot say: how long the worker has - * been running, what it is doing right now, and whether it has gone quiet - * long enough to look hung rather than merely slow. + * A `spawn_agent` row is tracked for the worker lifetime (see + * `runtime-bridge.ts`'s `syncAgentProgress`), not only while the spawn_agent + * tool call is in flight. The immediate `{status:running}` result must not + * drop live clocks. While the worker is running this fills in what a bare + * pending mark cannot say: how long the worker has been running, what it is + * doing right now, and whether it has gone quiet long enough to look hung + * rather than merely slow. * * Lane state and the fleet roll-up live in this one file on purpose. "Stalled" * has exactly one definition — `laneState` below — and the fleet summary @@ -65,7 +67,7 @@ export interface AgentProgress { * Grok on the Responses path routinely sits 60–120s (sometimes longer) between * tool cycles with only sparse reasoning-summary deltas — billing thinking * tokens the whole time. A 2-minute bar painted those healthy gaps as stalled - * Task rows and drove dig/cascade thrash. Align with the 5-minute sub-agent + * worker/spawn_agent rows and drove dig/cascade thrash. Align with the 5-minute sub-agent * stall nudge so UI and salvage agree on what "quiet too long" means. */ export const DEFAULT_STALL_MS = 300_000; diff --git a/src/tui/chrome-state.ts b/src/tui/chrome-state.ts index af88b07e6..5ad179a4e 100644 --- a/src/tui/chrome-state.ts +++ b/src/tui/chrome-state.ts @@ -9,8 +9,8 @@ * `formatChromeZones` paints the agents zone from `formatAgentsPanel` and keeps * the task checklist parked (`task: null`). Live fleet status is a flat strip * above the prompt (label / status / current tool) — same shape as transcript - * `● Task …` anchors, without a FLEET header board. Transcript Task rows remain - * as spawn/final/fail anchors; live progress clocks belong to chrome only + * `spawn_agent` anchors, without a FLEET header board. Transcript spawn_agent + * rows remain as spawn/final/fail anchors; live progress clocks belong to chrome only * (product-host gates `syncAgentProgress` while this strip needs a tick). * * ## Product host push contract @@ -78,7 +78,7 @@ export interface ChromeAgentSession { readonly finishedAt?: number; } -/** Lightweight task row: title + status, as written by the task tool. */ +/** Lightweight task row: title + status, as written by manage_tasks. */ export interface ChromeTaskRow { readonly title: string; readonly status: "todo" | "doing" | "done" | "cancelled"; @@ -100,8 +100,8 @@ export interface TaskPanelRow { */ export interface ChromeLiveState { /** - * Task list: the structured rows the task tool writes. Distinct from - * `agents` — a task is a unit of work with a status, not an executor. + * Task list: the structured rows manage_tasks writes. Distinct from + * `agents` — a task is a checklist item with a status, not an executor. */ readonly task?: readonly ChromeTaskRow[] | null; /** Subagent sessions for the strip summary (running preferred). */ diff --git a/src/tui/diff-rows.test.ts b/src/tui/diff-rows.test.ts index 5bb930f58..308838e73 100644 --- a/src/tui/diff-rows.test.ts +++ b/src/tui/diff-rows.test.ts @@ -115,18 +115,17 @@ describe("diff transcript rows", () => { }, WIDE); }); - test("a task/dispatch call paints a sentence, never the full spawn JSON (CL-5762)", async () => { + test("a spawn_agent dispatch call paints a sentence, never the full spawn JSON (CL-5762)", async () => { const brief = { agent: "explorer", description: "map callers of leaveObserve", prompt: "Find every call site of leaveObserve.\nReport paths and line numbers.", intent: "explore", - maxTurns: 40, success_criteria: ["list call sites", "note tests"], do_not: ["edit code", "open PRs"], }; const args = JSON.stringify(brief); - const row = toolCallRow({ name: "task", arguments: args }); + const row = toolCallRow({ name: "spawn_agent", arguments: args }); // Structural: summary set, not raw args; detail expands with real newlines. expect(row.summary).toBe("map callers of leaveObserve"); @@ -156,7 +155,7 @@ describe("diff transcript rows", () => { }, WIDE); }); - test("a task without description still collapses — falls back to prompt, not raw JSON", () => { + test("a spawn_agent call without description still collapses — falls back to prompt, not raw JSON", () => { const prompt = "Find every call site of leaveObserve and report them."; const args = JSON.stringify({ agent: "explorer", @@ -164,7 +163,7 @@ describe("diff transcript rows", () => { intent: "explore", success_criteria: ["list sites"], }); - const row = toolCallRow({ name: "task", arguments: args }); + const row = toolCallRow({ name: "spawn_agent", arguments: args }); expect(row.summary).toBeDefined(); expect(row.summary!.length).toBeGreaterThan(0); expect(row.summary).not.toContain("success_criteria"); diff --git a/src/tui/history-hydrate.test.ts b/src/tui/history-hydrate.test.ts index cc307f0f9..c5703f2af 100644 --- a/src/tui/history-hydrate.test.ts +++ b/src/tui/history-hydrate.test.ts @@ -197,17 +197,32 @@ describe("hydrateHistoryRows", () => { ]); }); - // CL-5562: a resumed transcript with three parallel `task` dispatches has - // three tool_call blocks that all share name "task" — the callId each + // CL-5562: a resumed transcript with three parallel `spawn_agent` dispatches has + // three tool_call blocks that all share name "spawn_agent" — the callId each // block carries is what tells them apart on replay. test("resolves parallel same-name tool_call/tool_result pairs by callId", () => { const rows = hydrateHistoryRows([ - { type: "tool_call", name: "task", arguments: '{"description":"Fix CL-5559"}', callId: "c1" }, - { type: "tool_call", name: "task", arguments: '{"description":"Fix CL-5560"}', callId: "c2" }, - { type: "tool_call", name: "task", arguments: '{"description":"Fix CL-5561"}', callId: "c3" }, - { type: "tool_result", name: "task", content: "done c2", callId: "c2" }, - { type: "tool_result", name: "task", content: "done c1", callId: "c1" }, - { type: "tool_result", name: "task", content: "done c3", callId: "c3" }, + { + type: "tool_call", + name: "spawn_agent", + arguments: '{"description":"Fix CL-5559"}', + callId: "c1", + }, + { + type: "tool_call", + name: "spawn_agent", + arguments: '{"description":"Fix CL-5560"}', + callId: "c2", + }, + { + type: "tool_call", + name: "spawn_agent", + arguments: '{"description":"Fix CL-5561"}', + callId: "c3", + }, + { type: "tool_result", name: "spawn_agent", content: "done c2", callId: "c2" }, + { type: "tool_result", name: "spawn_agent", content: "done c1", callId: "c1" }, + { type: "tool_result", name: "spawn_agent", content: "done c3", callId: "c3" }, ]); expect(rows.length).toBe(3); expect(rows.every((r) => r.pending !== true)).toBe(true); diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index ea8b3d270..d72f42d44 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -162,7 +162,7 @@ export interface ProductHostConfig { readonly onObserveRequest?: PaletteOnObserveRequest; /** * Live sub-agent sessions read on the chrome poll cadence to refresh - * outstanding `task` rows with elapsed time, current tool, and stall state. + * outstanding `spawn_agent` rows with elapsed time, current tool, and stall state. * Omitted hosts (tests, the demo shell) simply paint bare pending rows. */ readonly subAgentSessions?: () => readonly TaskProgressSession[]; diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index 601dddf57..0572da1cf 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -170,7 +170,7 @@ describe("mountRunnerHost chrome wiring", () => { // CL-5731: subscribeChrome must stay wired end-to-end. formatChromeZones // now parks both chrome strips (always null), so a tasks push must not // paint the checklist — this test asserts the notify path still runs and - // leaves the task panel empty (rebuild later; live work is ● Task rows). + // leaves the task panel empty (rebuild later; live work is spawn_agent rows). test("a live chrome push (subscribeChrome notify) does not auto-paint the task panel", async () => { const harness = await createHarness({ width: 80, height: 24 }); let liveTasks: readonly { title: string; status: "todo" | "doing" | "done" | "cancelled" }[] = diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 7f31de88a..641184d7e 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -1868,7 +1868,7 @@ export async function runTUI(initialConfig: Config): Promise { // and follow-up (queued drain / deliver) must never call this — those paths // leave in-flight workers running. Closing the agent is the only thing that // aborts the reactor mid-inference (the send signal only rejects the send - // promise); that close cascades: operationController.abort → task-tool parent + // promise); that close cascades: operationController.abort → wait_agents parent // signal → child abort. Do not add cancelAll here — fleet cancelAll is // reserved for /clear (newSession) and shutdown. // Close it, drain the old stream, and rebuild a fresh agent so the next send diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 60e4e4ab5..1b9cb3c6a 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -1147,7 +1147,7 @@ describe("parallel sub-agent dispatch on the live session bridge", () => { // cannot silently reintroduce CL-5562's misattribution on the parent // transcript specifically (the observe overlay and resumed history are // covered separately in tool-rows.test.ts / history-hydrate.test.ts). - test("three parallel task calls resolve to three rows, each with its own result", async () => { + test("three parallel spawn_agent calls resolve to three rows, each with its own result", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -1161,32 +1161,44 @@ describe("parallel sub-agent dispatch on the live session bridge", () => { { type: "inference.start", data: {} }, { type: "inference.tool_call.end", - data: { name: "task", callId: "c1", arguments: { description: "Fix CL-5559" } }, + data: { + name: "spawn_agent", + callId: "c1", + arguments: { description: "Fix CL-5559" }, + }, }, { type: "inference.tool_call.end", - data: { name: "task", callId: "c2", arguments: { description: "Fix CL-5560" } }, + data: { + name: "spawn_agent", + callId: "c2", + arguments: { description: "Fix CL-5560" }, + }, }, { type: "inference.tool_call.end", - data: { name: "task", callId: "c3", arguments: { description: "Fix CL-5561" } }, + data: { + name: "spawn_agent", + callId: "c3", + arguments: { description: "Fix CL-5561" }, + }, }, { type: "inference.done", data: {} }, - { type: "tool.start", data: { call: { id: "c1", name: "task" } } }, - { type: "tool.start", data: { call: { id: "c2", name: "task" } } }, - { type: "tool.start", data: { call: { id: "c3", name: "task" } } }, + { type: "tool.start", data: { call: { id: "c1", name: "spawn_agent" } } }, + { type: "tool.start", data: { call: { id: "c2", name: "spawn_agent" } } }, + { type: "tool.start", data: { call: { id: "c3", name: "spawn_agent" } } }, // Completion order does not follow dispatch order. { type: "tool.done", - data: { result: { callId: "c2", name: "task", content: "done c2" } }, + data: { result: { callId: "c2", name: "spawn_agent", content: "done c2" } }, }, { type: "tool.done", - data: { result: { callId: "c1", name: "task", content: "done c1" } }, + data: { result: { callId: "c1", name: "spawn_agent", content: "done c1" } }, }, { type: "tool.done", - data: { result: { callId: "c3", name: "task", content: "done c3" } }, + data: { result: { callId: "c3", name: "spawn_agent", content: "done c3" } }, }, { type: "reactor.done", data: {} }, ] as const; @@ -1419,7 +1431,7 @@ describe("syncAgentProgress", () => { bridge.handle({ type: "inference.tool_call.end", data: { - name: "task", + name: "spawn_agent", callId: "task-1", arguments: { description: "Review permission gate" }, }, @@ -1473,14 +1485,16 @@ describe("syncAgentProgress", () => { bridge.handle({ type: "inference.tool_call.end", data: { - name: "task", + name: "spawn_agent", callId: "task-1", arguments: { description: "Review mouse/paste" }, }, }); bridge.handle({ type: "tool.done", - data: { result: { callId: "task-1", name: "task", content: "done", isError: false } }, + data: { + result: { callId: "task-1", name: "spawn_agent", content: "done", isError: false }, + }, }); const index = shell.streamLog.length - 1; bridge.syncAgentProgress([taskSession({ status: "done" })]); @@ -1494,6 +1508,53 @@ describe("syncAgentProgress", () => { { width: 80, height: 24 }, ); }); + + test("live progress continues after spawn_agent's immediate running result", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }); + let nowMs = 0; + const bridge = attachSessionBridge(shell, createRecordingPort(), { + now: () => nowMs, + }); + try { + bridge.handle({ + type: "inference.tool_call.end", + data: { + name: "spawn_agent", + callId: "task-1", + arguments: { description: "Review permission gate" }, + }, + }); + bridge.handle({ + type: "tool.done", + data: { + result: { + callId: "task-1", + name: "spawn_agent", + content: JSON.stringify({ agent_id: "task-1", status: "running" }), + isError: false, + }, + }, + }); + const index = shell.streamLog.length - 1; + nowMs = 42_000; + bridge.syncAgentProgress([taskSession({ lastActivityAt: nowMs })]); + const row = shell.streamLog[index]!; + expect(row.agentWorking).toBe(true); + expect(row.stat).toContain("grep"); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); }); describe("in-flight tool row elapsed time", () => { diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index dc8e7f979..14baed6a5 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -72,7 +72,7 @@ import { } from "./agent-progress.js"; /** Tool name a sub-agent dispatch call carries — its row gets live progress. */ -const TASK_TOOL_NAME = "task"; +const SPAWN_AGENT_TOOL_NAME = "spawn_agent"; /** * Tool name the task checklist is written through. Its calls paint no @@ -202,8 +202,10 @@ export interface SessionBridge { readonly turn: TurnState; readonly shell: AppShell; /** - * Refresh outstanding `task` rows with each worker's live progress. The - * caller supplies the sessions (from `SubAgentSessionStore.listForStrip()` + * Refresh outstanding `spawn_agent` rows with each worker's live progress for + * the worker lifetime — not only while the spawn_agent tool call is in + * flight. The immediate `{status:running}` result must not drop tracking. + * The caller supplies the sessions (from `SubAgentSessionStore.listForStrip()` * or similar) on whatever cadence it already polls at. */ syncAgentProgress: (sessions: readonly TaskProgressSession[]) => void; @@ -370,11 +372,14 @@ interface BridgeBag { /** Row of the newest in-flight call, for results that carry no call id. */ lastToolRow: number; /** - * callIds of outstanding `task` calls — a subset of `toolRows`' keys. Kept - * separate so `syncAgentProgress` never has to walk every in-flight tool to - * find the handful that are sub-agent dispatches. + * callIds of `spawn_agent` rows tracked for the worker lifetime. Not a subset + * of in-flight `toolRows`: spawn_agent returns immediately with + * `{status:running}`, and live progress must continue after that result + * lands. Keyed by the same id as the sub-agent session (`call.id`). */ taskCallIds: Set; + /** Row index for each tracked spawn_agent call, kept after the tool_result. */ + spawnProgressRows: Map; /** * Last sub-agent session list the host synced. Retained rather than consumed * and dropped because the status ticker recomputes fleet state at paint time @@ -582,7 +587,7 @@ function applyToolCall( if (event.name === MANAGE_TASKS_TOOL_NAME) { // Remembered so the matching result is dropped too — suppressing only the // call would leave its result to land as an unpaired row. Checklist lives - // on the task panel; Task dispatches paint live transcript rows instead. + // on the task panel; spawn_agent dispatches paint live transcript rows instead. if (event.callId !== undefined) bag.panelOnlyCallIds.add(event.callId); return; } @@ -607,8 +612,9 @@ function applyToolCall( bag.toolCallStartedAt.set(event.callId, bag.now()); } } - if (event.callId !== undefined && event.name === TASK_TOOL_NAME) { + if (event.callId !== undefined && event.name === SPAWN_AGENT_TOOL_NAME) { bag.taskCallIds.add(event.callId); + bag.spawnProgressRows.set(event.callId, index); } bag.lastToolRow = index; shell.inFlightTool = { name: event.name, startedAt: bag.now() }; @@ -638,7 +644,13 @@ function applyToolResult( if (event.callId !== undefined) { bag.toolRows.delete(event.callId); bag.toolCallStartedAt.delete(event.callId); - bag.taskCallIds.delete(event.callId); + // spawn_agent's immediate running JSON is not the end of the worker — + // keep the row in taskCallIds / spawnProgressRows until the session + // leaves the running set (see syncAgentProgress). + if (event.name !== SPAWN_AGENT_TOOL_NAME) { + bag.taskCallIds.delete(event.callId); + bag.spawnProgressRows.delete(event.callId); + } } if (bag.toolRows.size === 0) shell.inFlightTool = null; const index = tracked ?? bag.lastToolRow; @@ -652,11 +664,12 @@ function applyToolResult( } /** - * Refresh every outstanding `task` call's row with its worker's live progress — - * elapsed time, current tool, and whether it has gone quiet. Rewrites each row - * in place through `replaceStreamRowAt`; a session that finished, or is missing - * from `sessions`, leaves its row untouched rather than reverting to a bare - * pending mark. + * Refresh every tracked `spawn_agent` row with its worker's live progress — + * elapsed time, current tool, and whether it has gone quiet. Tracking lasts + * the worker lifetime, not the immediate spawn_agent tool_result. Rewrites + * each row in place through `replaceStreamRowAt`; a session that finished, + * or is missing from `sessions`, leaves its row untouched rather than + * reverting to a bare pending mark. */ function syncAgentProgress( shell: AppShell, @@ -666,20 +679,26 @@ function syncAgentProgress( ): void { if (bag.taskCallIds.size === 0) return; for (const callId of bag.taskCallIds) { - const index = bag.toolRows.get(callId); + const index = bag.spawnProgressRows.get(callId) ?? bag.toolRows.get(callId); if (index === undefined) { bag.taskCallIds.delete(callId); + bag.spawnProgressRows.delete(callId); continue; } const row = streamRowAt(shell, index); - if (row === undefined || row.pending !== true) { + if (row === undefined) { bag.taskCallIds.delete(callId); + bag.spawnProgressRows.delete(callId); continue; } const session = sessions.find((s) => s.id === callId); if (session === undefined) continue; const progress = agentProgress(session, nowMs); - if (progress === null) continue; + if (progress === null) { + bag.taskCallIds.delete(callId); + bag.spawnProgressRows.delete(callId); + continue; + } if (row.stat === progress.stat && row.agentWorking === progress.working) continue; replaceStreamRowAt(shell, index, { ...row, @@ -697,7 +716,7 @@ function omitStat(row: StreamRow): StreamRow { /** * Refresh every plain in-flight tool call's row with how long it has been - * running. A `task` dispatch already gets this (and more) from + * running. A `spawn_agent` dispatch already gets this (and more) from * `syncAgentProgress`, so those calls are skipped here rather than double * painted. Without a live clock an ordinary call's row sits on a static * pending mark for however long the tool takes — indistinguishable from a @@ -753,6 +772,7 @@ function rollbackAttempt(shell: AppShell, bag: BridgeBag): void { bag.toolRows.delete(callId); bag.toolCallStartedAt.delete(callId); bag.taskCallIds.delete(callId); + bag.spawnProgressRows.delete(callId); } } if (bag.lastToolRow >= boundary) bag.lastToolRow = -1; @@ -949,6 +969,7 @@ export function attachSessionBridge( toolCallStartedAt: new Map(), lastToolRow: -1, taskCallIds: new Set(), + spawnProgressRows: new Map(), agentSessions: [], panelOnlyCallIds: new Set(), attemptRow: null, diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 95c424ef1..dc433dade 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -1791,7 +1791,7 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { syncTranscriptSpacer(shell); // Agents strip: full-width flex stack under the transcript when present. - // Live chrome keeps the zone empty (● Task transcript rows instead). + // Live chrome keeps the zone empty (spawn_agent transcript rows instead). shell.agentsBox.position = "relative"; shell.agentsBox.left = 0; shell.agentsBox.top = 0; @@ -2054,7 +2054,7 @@ interface ShellInternals { * Rendered task rows — empty when there is nothing to show OR the panel * is hidden by the operator toggle. `tasksRaw` holds the live data * independent of that toggle, so un-hiding shows the current list - * without waiting on the next task-tool write. + * without waiting on the next manage_tasks write. */ task: readonly TaskPanelRow[]; /** Last live task rows pushed via setChromeZones, regardless of hidden state. */ @@ -4725,7 +4725,7 @@ const PANEL_TOGGLE_FLASH_MS = 3000; /** * Toggle the task-list panel visible/hidden without touching the live task - * data underneath it — un-hiding shows whatever the task tool last wrote, + * data underneath it — un-hiding shows whatever manage_tasks last wrote, * not a stale snapshot from before the hide. The flag lives on the shell's * internals in memory for the shell's lifetime; nothing is written to * storage, so it does not survive a restart. diff --git a/src/tui/stall-watchdog.ts b/src/tui/stall-watchdog.ts index c1f8967c9..7eb1aca55 100644 --- a/src/tui/stall-watchdog.ts +++ b/src/tui/stall-watchdog.ts @@ -11,7 +11,7 @@ export const STALL_TIMEOUT_MS = 900_000; // break working runs to fix a wording problem. Grok-4.6 on the Responses path // streams only sparse reasoning *summaries* while billing tens of thousands of // thinking tokens, so 60–180s of true client silence mid-think is routine; -// the notice sits above that band and matches DEFAULT_STALL_MS on Task rows. +// the notice sits above that band and matches DEFAULT_STALL_MS on spawn_agent rows. export const STALL_NOTICE_MS = 300_000; export interface ShouldAbortForStallArgs { diff --git a/src/tui/stream.test.ts b/src/tui/stream.test.ts index 4039be13b..2ff671fca 100644 --- a/src/tui/stream.test.ts +++ b/src/tui/stream.test.ts @@ -409,7 +409,7 @@ describe("block labels", () => { describe("sub-agent dispatch row marks", () => { const dispatch = toolCallRow({ - name: "task", + name: "spawn_agent", arguments: JSON.stringify({ description: "Review permission gate" }), }); @@ -442,7 +442,7 @@ describe("sub-agent dispatch row marks", () => { }); test("a resolved dispatch drops back to the plain done mark", () => { - const result = toolResultRow({ name: "task", content: "8 lines", isError: false }); + const result = toolResultRow({ name: "spawn_agent", content: "8 lines", isError: false }); const merged = mergeToolRows({ ...dispatch, agentWorking: true }, result); expect(streamRowGutter(merged, SOLO).content).toContain("✓"); }); diff --git a/src/tui/stream.ts b/src/tui/stream.ts index 9ce880f73..87d2198ee 100644 --- a/src/tui/stream.ts +++ b/src/tui/stream.ts @@ -73,7 +73,7 @@ export interface StreamRow { * the exact row it resolves by this id first — the tool name alone is * ambiguous the moment two calls to the same tool are in flight at once, * which parallel sub-agent dispatch does on every turn that fires more - * than one `task` call. + * than one `spawn_agent` call. */ readonly callId?: string; /** @@ -138,7 +138,7 @@ export interface StreamRow { /** Diff stat or line range painted dim after the subject, e.g. "+1/-0". */ readonly stat?: string; /** - * A dispatched sub-agent's row while its `task` call is still pending: true + * A dispatched sub-agent's row while its worker is still running: true * once it has reported activity within the stall window, false once the * silence has run long enough to look hung rather than merely slow. Absent * for every row that is not a live sub-agent dispatch. diff --git a/src/tui/tool-execution-watchdog.test.ts b/src/tui/tool-execution-watchdog.test.ts index 84f20c71b..e1a610a54 100644 --- a/src/tui/tool-execution-watchdog.test.ts +++ b/src/tui/tool-execution-watchdog.test.ts @@ -34,26 +34,40 @@ describe("tool execution watchdog", () => { expect(resolveToolExecutionTimeoutMs({ defaultMs: 9_999_999, maxMs: 100 })).toBe(100); }); - test("task with no settings timeout is unbounded", () => { + test("spawn_agent with no settings timeout is unbounded", () => { expect( - resolveToolExecutionTimeoutMs(undefined, { id: "1", name: "task", arguments: {} }), + resolveToolExecutionTimeoutMs(undefined, { id: "1", name: "spawn_agent", arguments: {} }), ).toBeUndefined(); }); - test("task is exempt from the settings watchdog", () => { - // A sub-agent run ends on the model's own finish signal, an opt-in - // deadline, or an operator; the generic per-tool budget must not abort it. - const call = { id: "1", name: "task", arguments: {} }; + test("wait_agents with no settings timeout is unbounded", () => { + expect( + resolveToolExecutionTimeoutMs(undefined, { id: "1", name: "wait_agents", arguments: {} }), + ).toBeUndefined(); + }); + + test("spawn_agent is exempt from the settings watchdog", () => { + // Dispatch returns immediately; the generic per-tool budget must not abort it. + const call = { id: "1", name: "spawn_agent", arguments: {} }; + expect(resolveToolExecutionTimeoutMs({ defaultMs: 660_000 }, call)).toBeUndefined(); + expect( + resolveToolExecutionTimeoutMs({ defaultMs: 660_000, maxMs: 1_800_000 }, call), + ).toBeUndefined(); + }); + + test("wait_agents is exempt from the settings watchdog", () => { + // Collect can outlast settings.tools.timeoutMs while workers still run. + const call = { id: "1", name: "wait_agents", arguments: {} }; expect(resolveToolExecutionTimeoutMs({ defaultMs: 660_000 }, call)).toBeUndefined(); expect( resolveToolExecutionTimeoutMs({ defaultMs: 660_000, maxMs: 1_800_000 }, call), ).toBeUndefined(); }); - test("task run outlasting the generic budget completes with its own report", async () => { + test("wait_agents run outlasting the generic budget completes with its own report", async () => { const runner = createDynamicToolRunner( [ - stringTool("task", async () => { + stringTool("wait_agents", async () => { // Slow but progressing: runs well past the 30ms generic budget. await new Promise((r) => setTimeout(r, 120)); return "## Summary\nworker report"; @@ -62,7 +76,7 @@ describe("tool execution watchdog", () => { { defaultMs: 30 }, ); const result = await runner.run( - { id: "t", name: "task", arguments: {} }, + { id: "t", name: "wait_agents", arguments: {} }, new AbortController().signal, ); expect(result.isError).toBeUndefined(); @@ -256,7 +270,7 @@ describe("tool execution watchdog", () => { content: "## Summary\nPartial work salvaged\n\n## Findings\ngate.ts mapped", }; const pending = runWithToolExecutionWatchdog( - { id: "3", name: "task", arguments: {} }, + { id: "3", name: "wait_agents", arguments: {} }, parent.signal, 5_000, async (signal) => { @@ -289,7 +303,7 @@ describe("tool execution watchdog", () => { content: "## Summary\nDeadline salvage\n\n## Findings\npartial findings", }; const result = await runWithToolExecutionWatchdog( - { id: "4", name: "task", arguments: {} }, + { id: "4", name: "wait_agents", arguments: {} }, new AbortController().signal, 30, async (signal) => { @@ -340,7 +354,7 @@ describe("tool execution watchdog", () => { test("undefined timeout lets a 50ms tool complete", async () => { const result = await runWithToolExecutionWatchdog( - { id: "unbounded", name: "task", arguments: {} }, + { id: "unbounded", name: "wait_agents", arguments: {} }, new AbortController().signal, undefined, async () => { @@ -356,7 +370,7 @@ describe("tool execution watchdog", () => { test("undefined timeout still surfaces parent abort", async () => { const parent = new AbortController(); const pending = runWithToolExecutionWatchdog( - { id: "unbounded-hang", name: "task", arguments: {} }, + { id: "unbounded-hang", name: "wait_agents", arguments: {} }, parent.signal, undefined, async () => { @@ -376,7 +390,7 @@ describe("tool execution watchdog", () => { ), ]); expect(afterAbort.isError).toBe(true); - expect(afterAbort.content).toBe("task aborted"); + expect(afterAbort.content).toBe("wait_agents aborted"); }); test("isUsableToolExecuteResult rejects errors and empty bodies", () => { @@ -529,12 +543,12 @@ describe("tool execution watchdog", () => { }); test("nested watchdog pause freezes the enclosing budget too", async () => { - // task tool: outer watchdog wraps the parent `task` call; each child tool + // wait_agents: outer watchdog wraps the parent collect call; each child tool // call opens its own nested watchdog. A permission prompt during the child // captures the innermost budget — pausing it must also freeze the parent // budget, or the parent keeps ticking under the modal. const result = await runWithToolExecutionWatchdog( - { id: "outer", name: "task", arguments: {} }, + { id: "outer", name: "wait_agents", arguments: {} }, new AbortController().signal, 60, async (outerSignal) => { diff --git a/src/tui/tool-execution-watchdog.ts b/src/tui/tool-execution-watchdog.ts index 1b1f48b13..2c401a736 100644 --- a/src/tui/tool-execution-watchdog.ts +++ b/src/tui/tool-execution-watchdog.ts @@ -48,7 +48,7 @@ export const RUN_SHELL_WATCHDOG_SLACK_MS = 1_000; /** * After budget/parent abort wins the race, wait this long for the in-flight - * execute to settle with a usable (non-error) body — e.g. task-tool salvage — + * execute to settle with a usable (non-error) body — e.g. wait_agents salvage — * before returning the synthetic abort/timeout message. */ export const TOOL_EXECUTION_SALVAGE_GRACE_MS = 5_000; @@ -70,9 +70,11 @@ export const MAX_TOOL_APPROVAL_PAUSE_MS = 1_800_000; * layer cannot beat shell-guard). A requested run_shell timeout is not clamped * to MAX_TOOL_EXECUTION_TIMEOUT_MS or tools.maxTimeoutMs. * - * The task tool is exempt: it runs an entire sub-agent that carries its own - * bound (an opt-in deadline), so the generic per-tool budget would - * abort healthy long-running workers mid-run. + * spawn_agent returns immediately; wait_agents is the long block. Both are + * exempt: wait_agents can outlast settings.tools.timeoutMs while workers + * still run, and aborting collect would not stop those workers. spawn_agent + * stays exempt so the generic per-tool budget cannot abort a dispatch that + * should return at once (or a worker that carries its own bound). * * mcp__* tool calls are the opposite of exempt: they arm unconditionally (see * resolveMcpToolTimeoutMs) even when no Settings are configured, because an @@ -83,7 +85,7 @@ export function resolveToolExecutionTimeoutMs( config?: ToolWatchdogConfig, call?: ToolCall, ): number | undefined { - if (call?.name === "task") return undefined; + if (call?.name === "spawn_agent" || call?.name === "wait_agents") return undefined; if (call?.name === "run_shell") { const requested = requestedRunShellTimeoutMs(call); if (requested !== undefined) { @@ -271,7 +273,7 @@ export function withPauseableTimeout( } /** - * Composite pause token for a chained budget (task tool → child tool call): + * Composite pause token for a chained budget (wait_agents → child tool call): * one entry per budget in the enclosing chain, each keyed to that budget's * own generation. */ @@ -335,7 +337,7 @@ export async function settleWithGrace( } /** - * After budget abort, prefer a late non-error execute body (task salvage) when + * After budget abort, prefer a late non-error execute body (wait_agents salvage) when * it settles within grace. Errors / empty bodies / grace expiry → undefined so * the caller can emit the synthetic abort/timeout result. */ @@ -370,7 +372,7 @@ export interface ToolExecutionWatchdogOptions { * * When budget/parent abort wins the race, the signal is still aborted, but we * give the in-flight execute a short grace to return a usable non-error body - * (e.g. task-tool structured salvage) before synthesizing "aborted"/timeout. + * (e.g. wait_agents structured salvage) before synthesizing "aborted"/timeout. * This closes the CL-4611 race where salvage was discarded wholesale. */ export async function runWithToolExecutionWatchdog( @@ -392,9 +394,9 @@ export async function runWithToolExecutionWatchdog( pause: (): PauseToken => 0, resume: (_token: PauseToken) => {}, }; - // Nested runs (task tool → child tool call) shadow the parent store: the + // Nested runs (wait_agents → child tool call) shadow the parent store: the // gate captures the innermost budget, so pause/resume must chain outward or - // the parent `task` budget keeps ticking under the permission modal. + // the parent `wait_agents` budget keeps ticking under the permission modal. const enclosing = toolApprovalBudgetAls.getStore(); const approvalBudget: ToolApprovalBudget = { signal: budget.signal, diff --git a/src/tui/tool-formatter.test.ts b/src/tui/tool-formatter.test.ts index a17d407e5..d54977bf5 100644 --- a/src/tui/tool-formatter.test.ts +++ b/src/tui/tool-formatter.test.ts @@ -285,37 +285,37 @@ describe("isUserFacingJSON", () => { }); }); -describe("describeToolCall for task tool", () => { +describe("describeToolCall for spawn_agent", () => { test("named agent call uses agent name as display with description separate", () => { const args = JSON.stringify({ agent: "greybeard", description: "review the diff", prompt: "...", }); - const result = describeToolCall("task", args); + const result = describeToolCall("spawn_agent", args); expect(result.display).toBe("Greybeard"); expect(result.summary).toBe("review the diff"); expect(result.isShell).toBe(false); }); - test("task without agent uses generic Task display", () => { + test("spawn_agent without agent uses generic Worker display", () => { const args = JSON.stringify({ description: "map all callers", prompt: "..." }); - const result = describeToolCall("task", args); - expect(result.display).toBe("Task"); + const result = describeToolCall("spawn_agent", args); + expect(result.display).toBe("Worker"); expect(result.summary).toBe("map all callers"); }); - test("task with blank agent uses generic Task display", () => { + test("spawn_agent with blank agent uses generic Worker display", () => { const args = JSON.stringify({ agent: "", description: "map all callers", prompt: "..." }); - const result = describeToolCall("task", args); - expect(result.display).toBe("Task"); + const result = describeToolCall("spawn_agent", args); + expect(result.display).toBe("Worker"); expect(result.summary).toBe("map all callers"); }); - test("task without description falls back to the prompt subject", () => { + test("spawn_agent without description falls back to the prompt subject", () => { const prompt = "Find every call site of leaveObserve and report them."; const args = JSON.stringify({ agent: "explorer", prompt, intent: "explore" }); - const result = describeToolCall("task", args); + const result = describeToolCall("spawn_agent", args); expect(result.display).toBe("Explorer"); // ARG_VALUE_MAX = 48 with ellipsis when truncated expect(result.summary.length).toBeLessThanOrEqual(48); @@ -327,13 +327,13 @@ describe("describeToolCall for task tool", () => { test("long description is abbreviated", () => { const long = "a".repeat(100); const args = JSON.stringify({ agent: "critic", description: long, prompt: "..." }); - const result = describeToolCall("task", args); + const result = describeToolCall("spawn_agent", args); expect(result.summary.length).toBeLessThan(long.length + 20); expect(result.summary.length).toBe(48); // ARG_VALUE_MAX }); }); -describe("task activity transcript lines", () => { +describe("spawn_agent activity transcript lines", () => { const fullBrief = { agent: "explorer", description: "map callers of leaveObserve", @@ -363,7 +363,7 @@ describe("task activity transcript lines", () => { ].join("\n"); test("summarizeToolArgs keeps only the description, not the full spawn brief", () => { - const s = summarizeToolArgs("task", JSON.stringify(fullBrief)); + const s = summarizeToolArgs("spawn_agent", JSON.stringify(fullBrief)); expect(s.summary).toBe("map callers of leaveObserve"); expect(s.summary).not.toContain("prompt"); expect(s.summary).not.toContain("intent"); @@ -375,7 +375,7 @@ describe("task activity transcript lines", () => { test("summarizeToolArgs falls back to prompt when description is missing", () => { const prompt = "Find every call site of leaveObserve and report them with paths."; const s = summarizeToolArgs( - "task", + "spawn_agent", JSON.stringify({ agent: "explorer", prompt, intent: "explore", maxTurns: 40 }), ); expect(s.summary.length).toBeLessThanOrEqual(48); @@ -388,7 +388,7 @@ describe("task activity transcript lines", () => { test("describeToolCall full keeps the untrimmed description for Ctrl+O", () => { const long = "a".repeat(80); const d = describeToolCall( - "task", + "spawn_agent", JSON.stringify({ agent: "explorer", description: long, prompt: "secret brief" }), ); expect(d.summary.length).toBeLessThan(long.length); @@ -397,31 +397,65 @@ describe("task activity transcript lines", () => { expect(d.display).toBe("Explorer"); }); - test("describeToolCall task with empty description stays empty", () => { - const d = describeToolCall("task", JSON.stringify({ agent: "worker" })); + test("describeToolCall spawn_agent with empty description stays empty", () => { + const d = describeToolCall("spawn_agent", JSON.stringify({ agent: "worker" })); expect(d.summary).toBe(""); expect(d.full).toBe(""); expect(d.display).toBe("Worker"); }); - test("summarizeToolResult peels the report envelope to the summary line", () => { - const r = summarizeToolResult("task", reportBody); + test("summarizeToolResult formats spawn_agent running JSON as id/status", () => { + const r = summarizeToolResult( + "spawn_agent", + JSON.stringify({ agent_id: "call-abc", status: "running" }), + ); + expect(r.preview).toBe("running call-abc"); + expect(r.preview).not.toContain("## Summary"); + }); + + test("summarizeToolResult peels wait_agents report envelopes to the summary line", () => { + const r = summarizeToolResult( + "wait_agents", + JSON.stringify({ + results: [{ agent_id: "call-abc", status: "done", report: reportBody }], + timed_out: false, + }), + ); expect(r.preview).toBe("Found 3 call sites in app.tsx"); expect(r.preview).not.toContain("## Summary"); expect(r.preview).not.toContain("## Findings"); }); - test("summarizeToolResult marks a cancelled task without raw markdown", () => { + test("summarizeToolResult marks a cancelled historical task without raw markdown", () => { const r = summarizeToolResult("task", 'Sub-agent "map callers" cancelled by operator.'); expect(r.preview).toBe("cancelled"); expect(r.preview).not.toContain("##"); }); - test("mergedToolCollapsedPreview curates task call+result into one line", () => { - const line = mergedToolCollapsedPreview("task", JSON.stringify(fullBrief), reportBody, false); - expect(line).toBe("Explorer map callers of leaveObserve — Found 3 call sites in app.tsx"); + test("mergedToolCollapsedPreview curates spawn_agent call+running JSON into one line", () => { + const line = mergedToolCollapsedPreview( + "spawn_agent", + JSON.stringify(fullBrief), + JSON.stringify({ agent_id: "call-abc", status: "running" }), + false, + ); + expect(line).toBe("Explorer map callers of leaveObserve — running call-abc"); expect(line).not.toContain("prompt"); expect(line).not.toContain("maxTurns"); expect(line).not.toContain("## Summary"); }); + + test("mergedToolCollapsedPreview peels wait_agents Summary envelopes", () => { + const line = mergedToolCollapsedPreview( + "wait_agents", + JSON.stringify({ targets: ["call-abc"] }), + JSON.stringify({ + results: [{ agent_id: "call-abc", status: "done", report: reportBody }], + timed_out: false, + }), + false, + ); + expect(line).toContain("Found 3 call sites in app.tsx"); + expect(line).not.toContain("## Summary"); + }); }); diff --git a/src/tui/tool-formatter.ts b/src/tui/tool-formatter.ts index 6c72cad55..441667629 100644 --- a/src/tui/tool-formatter.ts +++ b/src/tui/tool-formatter.ts @@ -127,7 +127,7 @@ export function describeToolCall(toolName: string, rawArgs: string): ToolCallDes isShell: true, }; } - if (toolName === "task") { + if (toolName === "spawn_agent" || toolName === "task") { const taskParsed = TaskArgSchema(tryParseObject(rawArgs)); if (!(taskParsed instanceof type.errors)) { const agentName = taskParsed.agent?.trim(); @@ -139,7 +139,7 @@ export function describeToolCall(toolName: string, rawArgs: string): ToolCallDes const display = agentName !== undefined && agentName.length > 0 ? agentName[0]!.toUpperCase() + agentName.slice(1) - : "Task"; + : "Worker"; // Collapsed row uses the abbreviated subject; Alt+E expands to the full text. return { display, @@ -240,6 +240,7 @@ export function summarizeToolArgs(toolName: string, rawArgs: string): ToolArgSum } break; } + case "spawn_agent": case "task": { // Spawns carry a large structured brief (prompt, intent, criteria). The // transcript only needs a short subject — prefer description, then prompt — @@ -409,7 +410,7 @@ export function mergedToolCollapsedPreview( return outcomePreview; } - if (toolName === "task") { + if (toolName === "spawn_agent" || toolName === "task") { // describeToolCall already curates the spawn brief to a short description; // reusing it here keeps the collapsed row free of prompt/intent/criteria dumps. const { display, summary } = describeToolCall(toolName, rawArgs); @@ -441,7 +442,7 @@ function pathFromResult(_toolName: string, content: string): string | null { return null; } -// Task tool results are either "Sub-agent \"desc\" reported:\n\n## Summary\n..." +// Worker reports are either "Sub-agent \"desc\" reported:\n\n## Summary\n..." // or a cancel notice. Pull a one-line human preview without leaking markdown headers. function summarizeTaskResultPreview(content: string): string { const trimmed = content.trim(); @@ -466,6 +467,36 @@ function summarizeTaskResultPreview(content: string): string { return first.length > 0 ? abbreviate(first, 64) : "(no output)"; } +function summarizeSpawnAgentResultPreview(content: string): string { + const obj = tryParseObject(content); + if (obj !== null && typeof obj.status === "string") { + const id = typeof obj.agent_id === "string" ? obj.agent_id.trim() : ""; + return id.length > 0 ? `${obj.status} ${id}` : obj.status; + } + return summarizeTaskResultPreview(content); +} + +function summarizeWaitAgentsResultPreview(content: string): string { + const obj = tryParseObject(content); + if (obj === null) return summarizeTaskResultPreview(content); + const results = Array.isArray(obj.results) ? obj.results : []; + for (const item of results) { + if (typeof item !== "object" || item === null) continue; + const rec = item as Record; + if (typeof rec.report === "string" && rec.report.trim().length > 0) { + return summarizeTaskResultPreview(rec.report); + } + } + if (obj.timed_out === true) return "timed out"; + const statuses = results.flatMap((item) => { + if (typeof item !== "object" || item === null) return []; + const rec = item as Record; + return typeof rec.status === "string" ? [rec.status] : []; + }); + if (statuses.length > 0) return statuses.join(", "); + return abbreviate(content, 64) || "(no output)"; +} + const WebSearchItemSchema = type({ "title?": "string", "url?": "string", "snippet?": "string" }); const SEARCH_DISPLAY_LIMIT = 5; @@ -583,9 +614,21 @@ export function summarizeToolResult(toolName: string, rawResult: string): ToolRe } break; } + case "spawn_agent": { + // Live payload is `{"agent_id","status":"running"}`. Historical fused + // spawn+wait bodies still peel the report envelope. + preview = summarizeSpawnAgentResultPreview(content); + break; + } + case "wait_agents": { + // Collect returns `{results:[{report}], timed_out}`. Peel ## Summary from + // the first report so raw markdown headings never leak into the transcript. + preview = summarizeWaitAgentsResultPreview(content); + break; + } case "task": { - // Workers reply with a ## Summary / ## Findings envelope. Collapse to the - // summary first line so raw markdown headings never leak into the transcript. + // Resume of a retired fused spawn: format the old report envelope without + // remounting a callable `task` tool. preview = summarizeTaskResultPreview(content); break; } diff --git a/src/tui/tool-rows.test.ts b/src/tui/tool-rows.test.ts index 2fbb47366..a5708a6cb 100644 --- a/src/tui/tool-rows.test.ts +++ b/src/tui/tool-rows.test.ts @@ -89,12 +89,12 @@ describe("a call and its answer", () => { test("a resolved sub-agent dispatch drops its live elapsed-time trailer for the real answer", () => { const rows: StreamRow[] = []; pushToolCall(rows, { - name: "task", + name: "spawn_agent", arguments: JSON.stringify({ description: "Review mouse/paste" }), }); rows[0] = { ...rows[0]!, agentWorking: true, stat: "0:42 · bash" }; - pushToolResult(rows, { name: "task", content: "8 lines" }); + pushToolResult(rows, { name: "spawn_agent", content: "8 lines" }); expect(rows[0]?.pending).toBeUndefined(); expect(rows[0]?.stat).toBe("8 lines"); }); @@ -139,34 +139,34 @@ describe("a run of identical calls", () => { }); describe("parallel calls to the same tool", () => { - // CL-5562: three `task` calls dispatched in one turn all carry - // meta === "task" — name alone cannot tell them apart, so a result must - // find its own row by call id or it resolves whichever pending "task" row + // CL-5562: three `spawn_agent` calls dispatched in one turn all carry + // meta === "spawn_agent" — name alone cannot tell them apart, so a result must + // find its own row by call id or it resolves whichever pending "spawn_agent" row // happens to be newest, leaving the others stranded pending forever and // turning any later same-name result into an orphaned extra row. test("each result resolves its own call by id, not the newest pending call of that name", () => { const rows: StreamRow[] = []; pushToolCall(rows, { - name: "task", + name: "spawn_agent", arguments: JSON.stringify({ agent: "intern", description: "Fix CL-5559 heading shake" }), callId: "c1", }); pushToolCall(rows, { - name: "task", + name: "spawn_agent", arguments: JSON.stringify({ agent: "intern", description: "Fix CL-5560 approval UI" }), callId: "c2", }); pushToolCall(rows, { - name: "task", + name: "spawn_agent", arguments: JSON.stringify({ agent: "intern", description: "Fix CL-5561 scroll/history" }), callId: "c3", }); expect(rows.length).toBe(3); // Results land out of dispatch order, as real sub-agent completion does. - pushToolResult(rows, { name: "task", content: "done c2", callId: "c2" }); - pushToolResult(rows, { name: "task", content: "done c1", callId: "c1" }); - pushToolResult(rows, { name: "task", content: "done c3", callId: "c3" }); + pushToolResult(rows, { name: "spawn_agent", content: "done c2", callId: "c2" }); + pushToolResult(rows, { name: "spawn_agent", content: "done c1", callId: "c1" }); + pushToolResult(rows, { name: "spawn_agent", content: "done c3", callId: "c3" }); expect(rows.length).toBe(3); expect(rows.every((r) => r.pending !== true)).toBe(true); @@ -186,12 +186,12 @@ describe("parallel calls to the same tool", () => { // here means the id genuinely does not belong to anything on the log. test("an id that matches nothing on the log answers nothing, not the newest pending call", () => { const rows: StreamRow[] = [ - { role: "tool", text: "", meta: "task", pending: true, callId: "a1" }, - { role: "tool", text: "", meta: "task", pending: true, callId: "b1" }, + { role: "tool", text: "", meta: "spawn_agent", pending: true, callId: "a1" }, + { role: "tool", text: "", meta: "spawn_agent", pending: true, callId: "b1" }, ]; - expect(pendingCallIndex(rows, "task", "zzz-does-not-exist")).toBe(-1); + expect(pendingCallIndex(rows, "spawn_agent", "zzz-does-not-exist")).toBe(-1); - pushToolResult(rows, { name: "task", content: "orphan", callId: "zzz-does-not-exist" }); + pushToolResult(rows, { name: "spawn_agent", content: "orphan", callId: "zzz-does-not-exist" }); // Answers nothing on the log — appended as its own row rather than // resolving (and thereby corrupting) an unrelated in-flight call. expect(rows.length).toBe(3); @@ -207,12 +207,12 @@ describe("parallel calls to the same tool", () => { test("a failed call keeps its error text behind the expand arrow", () => { const rows: StreamRow[] = []; pushToolCall(rows, { - name: "task", + name: "spawn_agent", arguments: JSON.stringify({ agent: "intern", description: "Fix CL-5559 heading shake" }), callId: "c1", }); pushToolResult(rows, { - name: "task", + name: "spawn_agent", content: 'Error: sub-agent "Fix CL-5559 heading shake" failed: boom', isError: true, callId: "c1", diff --git a/src/tui/tool-rows.ts b/src/tui/tool-rows.ts index 80076d6c2..08fb975c8 100644 --- a/src/tui/tool-rows.ts +++ b/src/tui/tool-rows.ts @@ -154,8 +154,8 @@ export function coalesceCallRows(tail: StreamRow, next: StreamRow): StreamRow { * * A carried call id is exact and wins outright — it is the only thing that * tells two in-flight calls to the same tool apart, which parallel sub-agent - * dispatch produces on every turn that fires more than one `task` call (three - * dispatches all show `meta === "task"`; name alone cannot tell them apart). + * dispatch produces on every turn that fires more than one `spawn_agent` call + * (three dispatches all show `meta === "spawn_agent"`; name alone cannot tell them apart). * An id that matches nothing on the log still returns -1 rather than falling * through to the name scan below: every current caller (the live bridge's own * call map, `SubAgentTranscriptEntry`, `BridgeInboundEvent`) always carries an diff --git a/src/tui/turn-monitor.test.ts b/src/tui/turn-monitor.test.ts index ce94f3ad9..56a1a8d29 100644 --- a/src/tui/turn-monitor.test.ts +++ b/src/tui/turn-monitor.test.ts @@ -490,17 +490,17 @@ describe("stall watchdog", () => { }); // CL-5640: live sub-agent progress must keep the parent stream's silence - // exempt from abort even though the parent's own `task` call is the only - // thing in `activeToolCalls` — a future change to task-lifecycle handling + // exempt from abort even though the parent's own `wait_agents` call is the only + // thing in `activeToolCalls` — a future change to fleet-lifecycle handling // must not silently drop this exemption. - test("live sub-agent progress under an outstanding task call is never auto-aborted", async () => { + test("live sub-agent progress under an outstanding wait_agents call is never auto-aborted", async () => { await withTestRenderer(async (h) => { const t: Harness = await setup(h); try { t.bridge.submit("build it", "immediate"); t.bridge.handle({ type: "inference.tool_call.end", - data: { name: "task", callId: "c1" }, + data: { name: "wait_agents", callId: "c1" }, }); t.port.clear(); @@ -589,7 +589,7 @@ describe("stall watchdog", () => { t.bridge.submit("build it", "immediate"); t.bridge.handle({ type: "inference.tool_call.end", - data: { name: "task", callId: "c1" }, + data: { name: "spawn_agent", callId: "c1" }, }); t.bridge.gateOpened(); t.port.clear(); diff --git a/src/workflows/coordinator.ts b/src/workflows/coordinator.ts index 505b4f2f4..eec1c59bd 100644 --- a/src/workflows/coordinator.ts +++ b/src/workflows/coordinator.ts @@ -113,11 +113,13 @@ function guidanceFor(step: WorkflowStep): string[] { const agents = Array.isArray(step.agent) ? step.agent : [step.agent]; if (step.parallel === true && agents.length > 1) { out.push( - `Delegate this step to these sub-agents in parallel via the task tool: ${agents.join(", ")}.` + - ` Wait for all of them before advancing.`, + `Delegate this step to these sub-agents in parallel via spawn_agent: ${agents.join(", ")}.` + + ` Use wait_agents to collect all of them before advancing.`, ); } else { - out.push(`Delegate this step to the ${agents.join(", ")} sub-agent via the task tool.`); + out.push( + `Delegate this step to the ${agents.join(", ")} sub-agent via spawn_agent, then collect it with wait_agents.`, + ); } } if (step.type === "gate") { diff --git a/tests/fixtures/plugins/example-agent/README.md b/tests/fixtures/plugins/example-agent/README.md index 6f7b41d49..ee9b7ab5c 100644 --- a/tests/fixtures/plugins/example-agent/README.md +++ b/tests/fixtures/plugins/example-agent/README.md @@ -1,7 +1,7 @@ # Example Agent Plugin A minimal worked example of a `kind: "agent"` plugin. It contributes one -sub-agent profile, `scout`, that can be dispatched via the `task` tool with +sub-agent profile, `scout`, that can be dispatched via `spawn_agent` with `agent: "scout"`. ## What it shows @@ -15,5 +15,5 @@ sub-agent profile, `scout`, that can be dispatched via the `task` tool with ## Usage 1. Enable in `/plugins` (or add this directory via "add by path"). -2. In any session, the `task` tool accepts `agent: "scout"` to dispatch a +2. In any session, call `spawn_agent` with `agent: "scout"` to dispatch a sub-agent using this profile. diff --git a/tests/fixtures/plugins/example-agent/src/index.ts b/tests/fixtures/plugins/example-agent/src/index.ts index 75ccd1d21..c5ae0fd10 100644 --- a/tests/fixtures/plugins/example-agent/src/index.ts +++ b/tests/fixtures/plugins/example-agent/src/index.ts @@ -1,5 +1,5 @@ // A minimal worked example of a `kind: "agent"` plugin. It contributes one -// sub-agent profile, "scout", that can be dispatched via the `task` tool with +// sub-agent profile, "scout", that can be dispatched via `spawn_agent` with // `agent: "scout"`. The profile restricts the sub-agent to read-only tools and // assigns it to the "fast" tier (resolved via settings.tiers to a concrete // provider and model). Kept self-contained — it declares only the small slice diff --git a/tests/unit/corbits-skills-catalog.test.ts b/tests/unit/corbits-skills-catalog.test.ts index 3c693c283..c95510bb3 100644 --- a/tests/unit/corbits-skills-catalog.test.ts +++ b/tests/unit/corbits-skills-catalog.test.ts @@ -103,10 +103,10 @@ test("corbits-skills catalog lists 16 skills with name and description", async ( } }); -test("spawn-recipe skills contain task(agent=", async () => { +test("spawn-recipe skills contain spawn_agent(agent=", async () => { for (const name of SPAWN_RECIPE_SKILLS) { const skill = await Bun.file(join(pluginRoot, "skills", name, "SKILL.md")).text(); - expect(skill).toContain("task(agent="); + expect(skill).toContain("spawn_agent(agent="); } }); @@ -128,8 +128,8 @@ test("typescript skill guides TS quality without fake enforcement", async () => test("implement skill is a per-commit workflow without a false 4-cap", async () => { const skill = await Bun.file(join(pluginRoot, "skills/implement/SKILL.md")).text(); - expect(skill).toContain('task(agent="greybeard")'); - expect(skill).toContain('task(agent="critic")'); + expect(skill).toContain('spawn_agent(agent="greybeard")'); + expect(skill).toContain('spawn_agent(agent="critic")'); expect(skill).toContain("Do not invent a worker-count or fan-out ceiling"); expect(skill).toContain("Close the loop"); expect(skill).not.toContain("once or twice"); diff --git a/tests/unit/exec/runner.test.ts b/tests/unit/exec/runner.test.ts index 96c451f29..a2d9fe280 100644 --- a/tests/unit/exec/runner.test.ts +++ b/tests/unit/exec/runner.test.ts @@ -6,7 +6,7 @@ import { resolveExecDirectorOverlay, runExec, } from "../../../src/exec/runner.js"; -import { BUILD_TOOLS } from "../../../src/agent/directors/tool-sets.js"; +import { BUILD_TOOLS, SKYWALKER_TOOLS } from "../../../src/agent/directors/tool-sets.js"; import { createSubAgentSessionStore } from "../../../src/subagent/session-store.js"; function bareConfig(task: string): Config { @@ -87,20 +87,25 @@ describe("disposeExecRuntime", () => { }); describe("resolveExecDirectorOverlay", () => { - test("builder exec primary does not mount task", () => { + test("builder exec primary does not mount fleet", () => { const overlay = resolveExecDirectorOverlay("builder"); - expect(overlay.mountTask).toBe(false); + expect(overlay.mountFleet).toBe(false); expect(overlay.advertisedAllow).toBeDefined(); - expect(overlay.advertisedAllow).not.toContain("task"); expect(overlay.advertisedAllow).toEqual([...BUILD_TOOLS]); + const buildToolSet = new Set(BUILD_TOOLS); + const fleetVerbs = SKYWALKER_TOOLS.filter((name) => !buildToolSet.has(name)); + expect(fleetVerbs.length).toBeGreaterThan(0); + for (const verb of fleetVerbs) { + expect(overlay.advertisedAllow).not.toContain(verb); + } expect(overlay.systemPrompt).toContain("BuilderDirector"); }); - test("skywalker default still can mount task", () => { - expect(resolveExecDirectorOverlay(undefined).mountTask).toBe(true); + test("skywalker default still can mount fleet", () => { + expect(resolveExecDirectorOverlay(undefined).mountFleet).toBe(true); expect(resolveExecDirectorOverlay(undefined).systemPrompt).toBeUndefined(); expect(resolveExecDirectorOverlay(undefined).advertisedAllow).toBeUndefined(); - expect(resolveExecDirectorOverlay("skywalker").mountTask).toBe(true); + expect(resolveExecDirectorOverlay("skywalker").mountFleet).toBe(true); expect(resolveExecDirectorOverlay("skywalker").systemPrompt).toBeUndefined(); }); }); diff --git a/tests/unit/subagent-session-store.test.ts b/tests/unit/subagent-session-store.test.ts index 3c9cefaa3..b91e09a9b 100644 --- a/tests/unit/subagent-session-store.test.ts +++ b/tests/unit/subagent-session-store.test.ts @@ -2,14 +2,6 @@ import { describe, expect, test } from "bun:test"; import type { ReactorEmittedEvent } from "@intx/inference"; import { createSubAgentSessionStore } from "../../src/subagent/session-store.js"; -import { createTaskTool } from "../../src/subagent/index.js"; -import { createPermissionGate } from "../../src/permission/gate.js"; - -const testPermissionGate = createPermissionGate({ - approvals: [], - interactive: false, - skipPermissions: true, -}); function event(type: string, data: unknown): ReactorEmittedEvent { return { type, data } as ReactorEmittedEvent; @@ -324,175 +316,3 @@ describe("createSubAgentSessionStore", () => { expect(store.get(pinned.id)).toBeUndefined(); }); }); - -describe("createTaskTool session recording", () => { - const provider = { - providerName: "test", - baseURL: "http://localhost", - apiKey: "k", - model: "m", - }; - - async function call( - tool: ReturnType, - args: Record, - signal?: AbortSignal, - ): Promise { - const result = await tool.handler( - { - id: "task-call-test", - name: "task", - arguments: args, - }, - signal ?? new AbortController().signal, - ); - if (typeof result === "string") return result; - return typeof result.content === "string" ? result.content : JSON.stringify(result.content); - } - - test("records a session on spawn and completes it with the report", async () => { - const store = createSubAgentSessionStore(); - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: process.cwd(), - getWorkdirBase: () => "/tmp", - provider, - sessions: store, - run: async (params) => { - params.onEvent?.(event("inference.text.delta", { token: "working" })); - return { report: "## Summary\nDone." }; - }, - }); - const out = await call(tool, { - description: "inspect store", - prompt: "do the job", - context: "background", - intent: "explore", - }); - expect(out).toContain("## Summary\nDone."); - const sessions = store.list(); - expect(sessions).toHaveLength(1); - expect(sessions[0]?.status).toBe("done"); - expect(sessions[0]?.description).toBe("inspect store"); - expect(sessions[0]?.brief).toContain("## Goal"); - expect(sessions[0]?.brief).toContain("do the job"); - expect(sessions[0]?.entries.some((e) => e.kind === "text" && e.content === "working")).toBe( - true, - ); - expect(sessions[0]?.report).toBe("## Summary\nDone."); - }); - - test("records failure without throwing out of the tool handler", async () => { - const store = createSubAgentSessionStore(); - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: process.cwd(), - getWorkdirBase: () => "/tmp", - provider, - sessions: store, - run: async () => { - throw new Error("boom"); - }, - }); - const out = await call(tool, { description: "fail me", prompt: "x", intent: "explore" }); - expect(out).toContain("failed: boom"); - const session = store.list()[0]; - expect(session?.status).toBe("failed"); - expect(session?.error).toBe("boom"); - }); - - test("does not require a store — spawn still works", async () => { - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: process.cwd(), - getWorkdirBase: () => "/tmp", - provider, - run: async () => ({ report: "ok" }), - }); - const out = await call(tool, { description: "no store", prompt: "x", intent: "explore" }); - expect(out).toContain("ok"); - }); - - test("strip cancel aborts the child run and marks the session cancelled", async () => { - const store = createSubAgentSessionStore(); - let sawAbort = false; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: process.cwd(), - getWorkdirBase: () => "/tmp", - provider, - sessions: store, - run: async (params) => { - // Simulate a long-running child that only exits when the operator cancels. - await new Promise((_resolve, reject) => { - const signal = params.signal; - if (signal === undefined) { - reject(new Error("expected signal")); - return; - } - if (signal.aborted) { - sawAbort = true; - reject(Object.assign(new Error("aborted"), { name: "AbortError" })); - return; - } - signal.addEventListener( - "abort", - () => { - sawAbort = true; - reject(Object.assign(new Error("aborted"), { name: "AbortError" })); - }, - { once: true }, - ); - // Cancel from the strip after the run has registered its handle. - queueMicrotask(() => { - const id = store.list()[0]?.id; - expect(id).toBeDefined(); - store.cancel(id!, "Cancelled from Agents strip"); - }); - }); - return { report: "should not complete" }; - }, - }); - const out = await call(tool, { - description: "stuck looper", - prompt: "spin", - intent: "explore", - }); - expect(out).toContain("cancelled by operator"); - expect(sawAbort).toBe(true); - const session = store.list()[0]; - expect(session?.status).toBe("cancelled"); - expect(session?.error).toContain("Cancelled"); - }); - - test("parent tool signal abort cancels the session", async () => { - const store = createSubAgentSessionStore(); - const parent = new AbortController(); - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: process.cwd(), - getWorkdirBase: () => "/tmp", - provider, - sessions: store, - run: async (params) => { - await new Promise((_resolve, reject) => { - const signal = params.signal!; - signal.addEventListener( - "abort", - () => reject(Object.assign(new Error("aborted"), { name: "AbortError" })), - { once: true }, - ); - queueMicrotask(() => parent.abort()); - }); - return { report: "nope" }; - }, - }); - const out = await call( - tool, - { description: "parent stop child", prompt: "x", intent: "explore" }, - parent.signal, - ); - expect(out).toContain("cancelled by operator"); - expect(store.list()[0]?.status).toBe("cancelled"); - }); -}); diff --git a/tests/unit/subagent.test.ts b/tests/unit/subagent.test.ts deleted file mode 100644 index 9a2e7d2ea..000000000 --- a/tests/unit/subagent.test.ts +++ /dev/null @@ -1,980 +0,0 @@ -import { describe, test, expect } from "bun:test"; -import { - appendActivitySummary, - buildDispatchBrief, - buildSubAgentPrimarySource, - createTaskTool, - formatSubAgentReport, - parseSubAgentReport, - runSubAgent, - subAgentToolName, - taskToolDefinition, - type RunSubAgentParams, - type SubAgentProvider, -} from "../../src/subagent/index.js"; -import { createSubAgentSessionStore } from "../../src/subagent/session-store.js"; -import { buildSubAgentSystemPrompt } from "../../src/agent/prompts.js"; -import { createPermissionGate } from "../../src/permission/gate.js"; -import type { ReactorEmittedEvent } from "@intx/inference"; - -const testPermissionGate = createPermissionGate({ - approvals: [], - interactive: false, - skipPermissions: true, -}); - -const provider: SubAgentProvider = { - providerName: "test", - baseURL: "https://example.test/v1", - apiKey: "sk-test", - model: "test-model", -}; - -function callHandler( - tool: ReturnType, - args: Record, -): Promise { - // createTaskTool returns a full-handler AgentTool (call + signal → ToolResult). - if (tool.kind !== "full") throw new Error(`expected full tool, got ${tool.kind}`); - return tool - .handler({ id: "call-1", name: "task", arguments: args }, new AbortController().signal) - .then((result) => - typeof result.content === "string" ? result.content : JSON.stringify(result.content), - ); -} - -test("task tool definition requires description and prompt", () => { - expect(taskToolDefinition.name).toBe("task"); - expect(taskToolDefinition.inputSchema.required).toEqual(["description", "prompt"]); -}); - -test("handler rejects empty description or prompt, naming only the empty field", async () => { - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - run: async () => ({ report: "should not run" }), - }); - const emptyDesc = await callHandler(tool, { description: "", prompt: "do it" }); - expect(emptyDesc).toContain("Error: task requires a non-empty description"); - expect(emptyDesc).toContain('Received prompt "do it"'); - expect(emptyDesc).not.toContain("non-empty prompt"); - const emptyPrompt = await callHandler(tool, { description: "label", prompt: " " }); - expect(emptyPrompt).toContain("Error: task requires a non-empty prompt"); - expect(emptyPrompt).toContain('Received description "label" — keep it and add prompt.'); - expect(emptyPrompt).not.toContain("non-empty description"); -}); - -test("handler rejects missing required fields, naming only the missing ones", async () => { - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - run: async () => ({ report: "should not run" }), - }); - const missingPrompt = await callHandler(tool, { description: "Add GET /health route" }); - expect(missingPrompt).toContain( - "Error: task is missing prompt (string): the actionable goal for the worker.", - ); - expect(missingPrompt).toContain( - 'Received description "Add GET /health route" — keep it and add prompt.', - ); - expect(missingPrompt).not.toContain("missing description"); - const missingDesc = await callHandler(tool, { prompt: "do it" }); - expect(missingDesc).toContain("Error: task is missing description (string)"); - expect(missingDesc).toContain('Received prompt "do it" — keep it and add description.'); - expect(missingDesc).not.toContain("missing prompt"); - const missingBoth = await callHandler(tool, {}); - expect(missingBoth).toContain("Error: task is missing description (string)"); - expect(missingBoth).toContain("is missing prompt (string)"); - expect(missingBoth).not.toContain("Received"); -}); - -test("generic leaf gets role-default medium even when parent effort is high", async () => { - // CL-5162: leaves do not inherit primary high — that multiplies the sol+high - // latency cliff across every spawn. Role default (medium) wins over parent. - let receivedEffort: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider: { ...provider, reasoningEffort: "high" }, - run: async (params) => { - receivedEffort = params; - return { report: "done" }; - }, - }); - - await callHandler(tool, { description: "task", prompt: "do it", intent: "explore" }); - - expect(receivedEffort?.provider.reasoningEffort).toBe("medium"); -}); - -test("a provider getter is resolved at spawn time, so a live switch reaches subagents", async () => { - let received: RunSubAgentParams | undefined; - let current: SubAgentProvider = { ...provider, model: "model-a" }; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider: () => current, - run: async (params) => { - received = params; - return { report: "done" }; - }, - }); - - // Simulate a /agent switch after the tool was constructed. - current = { ...provider, model: "model-b", reasoningEffort: "high" }; - await callHandler(tool, { description: "task", prompt: "do it", intent: "explore" }); - - expect(received?.provider.model).toBe("model-b"); - // Live model switch is honored; effort still follows leaf role default. - expect(received?.provider.reasoningEffort).toBe("medium"); -}); - -test("handler forwards trimmed args to the runner and wraps the result", async () => { - let received: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - run: async (params) => { - received = params; - return { report: "found three callers in foo.ts" }; - }, - }); - - const result = await callHandler(tool, { - description: " map callers ", - prompt: " find every caller of X ", - intent: "explore", - }); - - expect(received?.description).toBe("map callers"); - expect(received?.prompt).toBe("find every caller of X"); - expect(received?.cwd).toBe("/repo"); - expect(result).toContain("map callers"); - expect(result).toContain("found three callers in foo.ts"); -}); - -test("handler reports runner failures without throwing", async () => { - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - run: async () => { - throw new Error("provider exploded"); - }, - }); - - const result = await callHandler(tool, { - description: "boom", - prompt: "trigger failure", - intent: "explore", - }); - expect(result).toContain("Error:"); - expect(result).toContain("provider exploded"); -}); - -test("sub-agent prompt is autonomous and forbids recursion for workers", () => { - const prompt = buildSubAgentSystemPrompt(); - expect(prompt).toContain("sub-agent"); - expect(prompt).toContain("permission policy as the parent session"); - expect(prompt).toContain("parent session's permission gate"); - // Workers must not be invited to spawn further agents. - expect(prompt).toContain("You are a worker"); - expect(prompt).not.toContain("MAY call `task`"); -}); - -test("unknown agent id fails closed instead of silent generic fall-through", async () => { - let ran = false; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - profiles: [{ id: "greybeard", systemPromptRole: "You are greybeard." }], - run: async () => { - ran = true; - return { report: "should not run" }; - }, - }); - const result = await callHandler(tool, { - description: "review", - prompt: "look at it", - agent: "no-such-agent", - }); - expect(result).toContain("Error:"); - expect(result).toContain("unknown agent profile"); - expect(result).toContain("greybeard"); - expect(result).toContain("search_agents"); - expect(result).toContain("full system prompt / body"); - expect(ran).toBe(false); -}); - -test("unknown agent id fails closed when no profiles are loaded", async () => { - let ran = false; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - run: async () => { - ran = true; - return { report: "should not run" }; - }, - }); - // Non-director ids still require profiles; directors resolve from the closed registry. - const result = await callHandler(tool, { - description: "review", - prompt: "look at it", - agent: "no-such-agent", - }); - expect(result).toContain("Error:"); - expect(result).toContain("no agent profiles are loaded"); - expect(ran).toBe(false); -}); - -test("closed director resolves without profiles loaded", async () => { - let received: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - run: async (params) => { - received = params; - return { report: "ok" }; - }, - }); - const result = await callHandler(tool, { - description: "ship", - prompt: "implement the fix", - agent: "builder", - }); - expect(result).toContain("ok"); - expect(received?.systemPromptRole).toBeDefined(); - expect(received?.systemPromptRole).toContain("PRIMARY INTENT"); -}); - -test("intent maps to closed director without profiles", async () => { - let received: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - run: async (params) => { - received = params; - return { report: "ok" }; - }, - }); - const result = await callHandler(tool, { - description: "map code", - prompt: "find callers of X", - intent: "explore", - }); - expect(result).toContain("ok"); - expect(received?.systemPromptRole).toContain("PRIMARY INTENT"); - expect(received?.capabilities?.mode).toBe("allow"); - expect(received?.capabilities?.tools).toContain("read_file"); - expect(received?.capabilities?.tools).toContain("write_file"); - expect(received?.capabilities?.tools).toContain("edit_file"); - expect(received?.capabilities?.tools).toContain("delete_file"); -}); - -test("intent general is refused (no general director)", async () => { - let ran = false; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - run: async () => { - ran = true; - return { report: "should not run" }; - }, - }); - const result = await callHandler(tool, { - description: "vague", - prompt: "do something", - intent: "general", - }); - expect(result).toContain("Error:"); - expect(result).toContain("general"); - expect(ran).toBe(false); -}); - -test("bare task without agent or intent is refused (no catch-all worker)", async () => { - let ran = false; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - run: async () => { - ran = true; - return { report: "should not run" }; - }, - }); - const result = await callHandler(tool, { - description: "vague", - prompt: "do something", - }); - expect(result).toContain("Error:"); - expect(result).toContain("No director selected"); - expect(ran).toBe(false); -}); - -test("spawnAllowlist rejects children outside the parent director matrix", async () => { - let ran = false; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - spawnAllowlist: ["intern", "explorer", "critic"], - run: async () => { - ran = true; - return { report: "should not run" }; - }, - }); - const denied = await callHandler(tool, { - description: "ship code", - prompt: "implement the feature", - agent: "builder", - }); - expect(denied).toContain("Error:"); - expect(denied).toContain("allowlist"); - expect(ran).toBe(false); - - const allowed = await callHandler(tool, { - description: "map", - prompt: "read the tree", - agent: "explorer", - }); - expect(allowed).not.toContain("Error:"); - expect(ran).toBe(true); -}); - -test("task refuses skywalker as a spawned worker", async () => { - let ran = false; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - run: async () => { - ran = true; - return { report: "should not run" }; - }, - }); - const result = await callHandler(tool, { - description: "orchestrate", - prompt: "fan out the fleet", - agent: "skywalker", - }); - expect(result).toContain("Error:"); - expect(result).toMatch(/primary session identity/i); - expect(result).not.toContain("allowlist"); - expect(ran).toBe(false); -}); - -test("greybeard nestedDispatch carries spawn allowlist into nested task", async () => { - let nestedAllow: readonly string[] | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - run: async (params) => { - nestedAllow = params.nestedDispatch?.spawnAllowlist; - return { report: "reviewed" }; - }, - }); - await callHandler(tool, { - description: "arch review", - prompt: "review approach", - agent: "greybeard", - }); - expect(nestedAllow).toEqual(["intern", "explorer", "critic"]); -}); - -test("orchestrator profile installs nestedDispatch so task can be re-dispatched", async () => { - let received: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - profiles: [ - { - id: "dispatch", - orchestrator: true, - systemPromptRole: "You coordinate specialists.", - }, - ], - run: async (params) => { - received = params; - return { report: "coordinated" }; - }, - }); - await callHandler(tool, { - description: "fan out", - prompt: "dispatch the team", - agent: "dispatch", - }); - expect(received?.orchestrator).toBe(true); - expect(received?.nestedDispatch).toBeDefined(); - expect(received?.systemPromptRole).toContain("coordinate"); -}); - -test("nested dispatch forwards the external sink, not the orchestrator recorder", async () => { - const store = createSubAgentSessionStore(); - const external: string[] = []; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - sessions: store, - onEvent: (event) => external.push(event.type), - profiles: [{ id: "dispatch", orchestrator: true }], - run: async (params) => { - // While the orchestrator session is still running, a grandchild event - // arrives on the nested sink. It must reach the external sink but not be - // recorded into the orchestrator's own transcript. - params.nestedDispatch?.onEvent?.({ - type: "inference.text.delta", - data: { token: "grandchild" }, - } as ReactorEmittedEvent); - return { report: "coordinated" }; - }, - }); - await callHandler(tool, { description: "fan out", prompt: "dispatch", agent: "dispatch" }); - - const orchestrator = store.list()[0]; - expect(orchestrator).toBeDefined(); - expect(orchestrator!.entries.some((e) => e.kind === "text")).toBe(false); - expect(external).toContain("inference.text.delta"); -}); - -test("allowOrchestrator false strips orchestrator even when the profile is marked", async () => { - let received: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - allowOrchestrator: false, - profiles: [{ id: "dispatch", orchestrator: true }], - run: async (params) => { - received = params; - return { report: "leaf" }; - }, - }); - await callHandler(tool, { - description: "work", - prompt: "do the work", - agent: "dispatch", - }); - expect(received?.orchestrator).toBeUndefined(); - expect(received?.nestedDispatch).toBeUndefined(); -}); - -test("appendActivitySummary counts tool names", () => { - expect(appendActivitySummary("done", [])).toBe("done"); - expect(appendActivitySummary("done", ["read_file", "read_file", "grep"])).toBe( - "done\n\n[tools: read_file×2, grep]", - ); -}); - -test("subAgentToolName reads tool.start call name", () => { - const event = { - type: "tool.start", - data: { call: { name: "grep" } }, - } as unknown as ReactorEmittedEvent; - expect(subAgentToolName(event)).toBe("grep"); - expect( - subAgentToolName({ type: "tool.done", data: {} } as unknown as ReactorEmittedEvent), - ).toBeNull(); -}); - -test("handler injects context and goals into runner params when provided", async () => { - let received: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - run: async (params) => { - received = params; - return { report: "task completed" }; - }, - }); - - const result = await callHandler(tool, { - description: "refactor utils", - context: "The codebase uses functional programming with no classes.", - prompt: "Extract duplicated validation logic into a shared function.", - goals: [" find duplicates ", "", " extract helper "], - intent: "implement", - }); - - expect(received?.context).toBe("The codebase uses functional programming with no classes."); - expect(received?.prompt).toBe("Extract duplicated validation logic into a shared function."); - expect(received?.goals).toEqual(["find duplicates", "extract helper"]); - expect(result).toContain("task completed"); -}); - -test("handler omits context and goals when empty", async () => { - let receivedNoContext: RunSubAgentParams | undefined; - let receivedEmptyContext: RunSubAgentParams | undefined; - - const toolNoContext = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - run: async (params) => { - receivedNoContext = params; - return { report: "done" }; - }, - }); - - const toolEmptyContext = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - run: async (params) => { - receivedEmptyContext = params; - return { report: "done" }; - }, - }); - - await callHandler(toolNoContext, { - description: "check code", - prompt: "Review the function signatures.", - intent: "explore", - }); - - await callHandler(toolEmptyContext, { - description: "check code", - context: " ", - prompt: "Review the function signatures.", - goals: [], - intent: "explore", - }); - - expect(receivedNoContext?.context).toBeUndefined(); - expect(receivedNoContext?.goals).toBeUndefined(); - expect(receivedEmptyContext?.context).toBeUndefined(); - expect(receivedEmptyContext?.goals).toBeUndefined(); -}); - -test("buildDispatchBrief separates context, goal, and checklist seeds", () => { - const brief = buildDispatchBrief({ - description: "map callers", - prompt: "find every caller of X", - context: "repo uses ES modules", - goals: ["search", "report"], - }); - expect(brief).toContain("# Dispatch brief: map callers"); - expect(brief).toContain("## Goal"); - expect(brief).toContain("find every caller of X"); - expect(brief).toContain("## Context"); - expect(brief).toContain("repo uses ES modules"); - expect(brief).toContain("## Suggested checklist"); - expect(brief).toContain("1. search"); - expect(brief).toContain("## Report shape"); -}); - -test("parseSubAgentReport and formatSubAgentReport normalize free-form and structured replies", () => { - const free = parseSubAgentReport("just some prose"); - expect(free.summary).toBe("just some prose"); - expect(formatSubAgentReport(free)).toContain("## Summary"); - expect(formatSubAgentReport(free)).toContain("just some prose"); - - const structured = parseSubAgentReport( - "## Summary\nDid the thing.\n\n## Findings\nFound three callers.\n\n## Paths\nsrc/a.ts\n", - ); - expect(structured.summary).toBe("Did the thing."); - expect(structured.findings).toBe("Found three callers."); - expect(structured.paths).toBe("src/a.ts"); - const formatted = formatSubAgentReport(structured); - expect(formatted).toContain("## Findings"); - expect(formatted).not.toContain("## Blockers"); -}); - -test("runSubAgent is wired as the default task runner", () => { - expect(typeof runSubAgent).toBe("function"); -}); - -describe("buildSubAgentPrimarySource", () => { - test("builds an openai-compatible source for plain providers", () => { - const bundle = buildSubAgentPrimarySource(provider); - expect(bundle.sources[0]?.provider).toBe("openai-compatible"); - expect(bundle.defaultSource).toBe("test"); - }); - - test("builds a bifrost source when the provider carries a virtual key", () => { - const bundle = buildSubAgentPrimarySource({ ...provider, bifrostVirtualKey: true }); - expect(bundle.sources[0]?.provider).toBe("bifrost"); - expect(bundle.sources[0]?.apiKey).toBe("sk-test"); - }); - - test("routes an xAI OAuth profile through the grok-responses adapter", () => { - const catalog = [ - { - name: "test", - baseURL: provider.baseURL, - apiKey: "sk-test", - models: ["grok-composer-2.5-fast"], - xaiProfile: "me@example.com", - }, - ]; - const bundle = buildSubAgentPrimarySource( - { ...provider, model: "grok-composer-2.5-fast" }, - catalog, - ); - expect(bundle.sources[0]?.provider).toBe("grok-responses"); - }); - - test("routes a Codex OAuth profile through the codex-responses adapter", () => { - const catalog = [ - { - name: "test", - baseURL: provider.baseURL, - apiKey: "sk-test", - models: ["gpt-5.1-codex"], - codexProfile: "me@example.com", - }, - ]; - const bundle = buildSubAgentPrimarySource({ ...provider, model: "gpt-5.1-codex" }, catalog); - expect(bundle.sources[0]?.provider).toBe("codex-responses"); - }); - - test("routes a catalog bifrost entry through the bifrost adapter", () => { - const catalog = [ - { - name: "test", - baseURL: provider.baseURL, - apiKey: "sk-bf-test", - models: ["gpt-5.1"], - bifrostVirtualKey: true, - }, - ]; - const bundle = buildSubAgentPrimarySource(provider, catalog); - expect(bundle.sources[0]?.provider).toBe("bifrost"); - }); - - test("falls back to the provider fields when the catalog lacks the provider", () => { - const bundle = buildSubAgentPrimarySource(provider, []); - expect(bundle.sources[0]?.provider).toBe("openai-compatible"); - expect(bundle.sources[0]?.baseURL).toContain("example.test"); - }); -}); - -test("a profile-resolved provider carries the bifrost virtual-key marker", async () => { - let received: RunSubAgentParams | undefined; - const settings = { - providers: { - gateway: { - name: "gateway", - baseURL: "https://gateway.test/v1", - apiKey: "sk-bf-test", - models: ["gpt-5.1"], - bifrostVirtualKey: true, - }, - }, - }; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - settings: settings as unknown as NonNullable[0]["settings"]>, - profiles: [ - { - id: "p", - inference: { mode: "pin", order: [{ provider: "gateway", model: "gpt-5.1" }] }, - }, - ], - run: async (params) => { - received = params; - return { report: "ran" }; - }, - }); - - await callHandler(tool, { description: "task", prompt: "do it", agent: "p" }); - - expect(received?.provider.providerName).toBe("gateway"); - expect(received?.provider.bifrostVirtualKey).toBe(true); -}); - -// Profile-driven dispatch: an agent frontmatter can pin inference -// (provider/model/reasoningEffort) with a mode that says whether to fall back -// to the active session when no leg is viable. These tests pin both branches -// of that decision and the pre-dispatch validateEffort check, so a regression -// in the resolver→dispatcher contract surfaces here rather than as a wrong- -// provider sub-agent run. -describe("createTaskTool profile resolution", () => { - const baseSettings = { - providers: { - anthropic: { - name: "Anthropic", - baseURL: "https://api.anthropic.com", - models: ["claude-sonnet-4", "claude-haiku-4"], - }, - }, - } as const; - - test("mode: pin agent with an unconfigured provider surfaces an unavailable error and never runs", async () => { - let runs = 0; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - settings: baseSettings as unknown as NonNullable< - Parameters[0]["settings"] - >, - profiles: [ - { - id: "p", - systemPromptRole: "You are p.", - inference: { - mode: "pin", - order: [{ provider: "openai", model: "gpt-5" }], - }, - }, - ], - run: async () => { - runs += 1; - return { report: "should-not-be-called" }; - }, - }); - - const result = await callHandler(tool, { - description: "task", - prompt: "do it", - agent: "p", - }); - - expect(runs).toBe(0); - expect(result).toContain('Error: agent "p" unavailable'); - expect(result).toContain("openai/gpt-5"); - // Actionable hint pointing the user at the remediation paths. - expect(result.toLowerCase()).toContain("agentmodelfallback"); - }); - - test("mode: prefer agent with an unconfigured provider falls back to the active session provider", async () => { - let received: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - settings: baseSettings as unknown as NonNullable< - Parameters[0]["settings"] - >, - profiles: [ - { - id: "p", - systemPromptRole: "You are p.", - inference: { - mode: "prefer", - order: [{ provider: "openai", model: "gpt-5" }], - }, - }, - ], - run: async (params) => { - received = params; - return { report: "ran" }; - }, - }); - - await callHandler(tool, { description: "task", prompt: "do it", agent: "p" }); - - // Falls through to the parent's provider (test/test-model from the - // module-level `provider` constant). - expect(received?.provider.providerName).toBe("test"); - expect(received?.provider.model).toBe("test-model"); - }); - - test("a pinned inference leg whose model is incompatible with its reasoningEffort fails before run", async () => { - let runs = 0; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - settings: baseSettings as unknown as NonNullable< - Parameters[0]["settings"] - >, - profiles: [ - { - id: "p", - systemPromptRole: "You are p.", - inference: { - mode: "pin", - order: [ - // haiku is unknown to the validator → only low/medium/high are - // accepted; xhigh is restricted to the gpt-5.1 family / codex. - { provider: "anthropic", model: "claude-haiku-4", reasoningEffort: "xhigh" }, - ], - }, - }, - ], - run: async () => { - runs += 1; - return { report: "should-not-be-called" }; - }, - }); - - const result = await callHandler(tool, { - description: "task", - prompt: "do it", - agent: "p", - }); - - expect(runs).toBe(0); - expect(result).toContain('Error: agent "p" has incompatible inference'); - }); - - test("leaf role default applies when the resolved leg does not pin effort", async () => { - // CL-5162: a profile that pins provider/model without reasoningEffort gets - // the leaf role default (medium), not the parent's high — so fleet fanout - // stays off the sol+high cliff unless the profile explicitly pins effort. - let received: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider: { ...provider, reasoningEffort: "high" }, - settings: baseSettings as unknown as NonNullable< - Parameters[0]["settings"] - >, - profiles: [ - { - id: "p", - systemPromptRole: "You are p.", - inference: { - mode: "pin", - order: [{ provider: "anthropic", model: "claude-sonnet-4" }], - }, - }, - ], - run: async (params) => { - received = params; - return { report: "ran" }; - }, - }); - - await callHandler(tool, { description: "task", prompt: "do it", agent: "p" }); - - expect(received?.provider.providerName).toBe("anthropic"); - expect(received?.provider.model).toBe("claude-sonnet-4"); - expect(received?.provider.reasoningEffort).toBe("medium"); - }); - - test("profile inference pin for effort wins over role default and parent", async () => { - let received: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider: { ...provider, reasoningEffort: "high" }, - settings: baseSettings as unknown as NonNullable< - Parameters[0]["settings"] - >, - profiles: [ - { - id: "p", - systemPromptRole: "You are p.", - inference: { - mode: "pin", - order: [{ provider: "anthropic", model: "claude-sonnet-4", reasoningEffort: "low" }], - }, - }, - ], - run: async (params) => { - received = params; - return { report: "ran" }; - }, - }); - - await callHandler(tool, { description: "task", prompt: "do it", agent: "p" }); - - expect(received?.provider.reasoningEffort).toBe("low"); - }); - - test("orchestrator profile gets high role default when effort is not pinned", async () => { - let received: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider: { ...provider, reasoningEffort: "low" }, - settings: baseSettings as unknown as NonNullable< - Parameters[0]["settings"] - >, - profiles: [ - { - id: "orch", - systemPromptRole: "You are orch.", - orchestrator: true, - inference: { - mode: "pin", - order: [{ provider: "anthropic", model: "claude-sonnet-4" }], - }, - }, - ], - run: async (params) => { - received = params; - return { report: "ran" }; - }, - }); - - await callHandler(tool, { description: "task", prompt: "do it", agent: "orch" }); - - expect(received?.orchestrator).toBe(true); - expect(received?.provider.reasoningEffort).toBe("high"); - }); - - test("orchestrator profile flag flows through to the runner params", async () => { - // Pins the dispatcher wiring for the orchestrator exception: a profile - // with `orchestrator: true` causes RunSubAgentParams.orchestrator to be - // set, which buildSubAgentSystemPrompt then uses to grant the recursion - // exception in the appendix (covered in src/prompts.test.ts). - let received: RunSubAgentParams | undefined; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - settings: baseSettings as unknown as NonNullable< - Parameters[0]["settings"] - >, - profiles: [{ id: "karen", systemPromptRole: "You are karen.", orchestrator: true }], - run: async (params) => { - received = params; - return { report: "ran" }; - }, - }); - - await callHandler(tool, { description: "task", prompt: "do it", agent: "karen" }); - - expect(received?.orchestrator).toBe(true); - // Fail-closed (CL-6941): no profile field opts a profile-sourced - // orchestrator into fleet verbs, so the tier stays unresolved and - // runSubAgent treats it as "leaf" — denied task/search_agents. - expect(received?.orchestratorTier).toBeUndefined(); - }); -}); diff --git a/tests/unit/telemetry-product-events.test.ts b/tests/unit/telemetry-product-events.test.ts index 06b43b1f7..d3b58cf1b 100644 --- a/tests/unit/telemetry-product-events.test.ts +++ b/tests/unit/telemetry-product-events.test.ts @@ -15,7 +15,13 @@ import type { Settings } from "../../src/config/settings.js"; import { createPermissionGate } from "../../src/permission/gate.js"; import { loadPluginEntry } from "../../src/plugins/loader.js"; import { createSessionPruningCompactor } from "../../src/session/runtime-assembly.js"; -import { createTaskTool } from "../../src/subagent/task-tool.js"; +import { + createFleetMailbox, + createSpawnAgentTool, + createWaitAgentsTool, +} from "../../src/subagent/agent-fleet.js"; + +import { createSubAgentSessionStore } from "../../src/subagent/session-store.js"; import { classifyAgentName, classifyErrorClass, @@ -244,7 +250,9 @@ test('subagent events bucket a project-defined profile id to "custom"', async () const cwd = await tempDir("corbits-agent-"); const gate = createPermissionGate({ approvals: [], interactive: false, skipPermissions: true }); - const tool = createTaskTool({ + const sessions = createSubAgentSessionStore(); + const fleetRecords = createFleetMailbox(sessions); + const tool = createSpawnAgentTool({ cwd, getWorkdirBase: () => cwd, permissionGate: gate, @@ -252,6 +260,8 @@ test('subagent events bucket a project-defined profile id to "custom"', async () profiles: [ { id: "acmecorp-release-captain", description: "release", systemPromptRole: "release" }, ], + sessions, + fleetRecords, run: async (params) => { params.onRunSettled?.({ turn_count: 2, @@ -272,14 +282,20 @@ test('subagent events bucket a project-defined profile id to "custom"', async () telemetry, }); if (tool.kind !== "full") throw new Error(`expected full tool, got ${tool.kind}`); + const wait = createWaitAgentsTool({ sessions, fleetRecords }); + if (wait.kind !== "full") throw new Error(`expected full tool, got ${wait.kind}`); await tool.handler( { id: "call-1", - name: "task", + name: "spawn_agent", arguments: { description: "Ship", prompt: "Ship it", agent: "acmecorp-release-captain" }, }, new AbortController().signal, ); + await wait.handler( + { id: "wait-1", name: "wait_agents", arguments: { mode: "all", timeout_ms: 5000 } }, + new AbortController().signal, + ); const captured = await events(); const names = captured.map((e) => e.event); @@ -311,7 +327,9 @@ test("subagent_end parent_trace_id is the in-flight turn at spawn, not the last noteLastTurnTraceId("sess:turn:0"); noteCurrentTurnTraceId("sess:turn:1"); - const tool = createTaskTool({ + const sessions = createSubAgentSessionStore(); + const fleetRecords = createFleetMailbox(sessions); + const tool = createSpawnAgentTool({ cwd, getWorkdirBase: () => cwd, permissionGate: gate, @@ -319,18 +337,26 @@ test("subagent_end parent_trace_id is the in-flight turn at spawn, not the last profiles: [ { id: "acmecorp-release-captain", description: "release", systemPromptRole: "release" }, ], + sessions, + fleetRecords, run: async () => ({ report: "done" }), telemetry, }); if (tool.kind !== "full") throw new Error(`expected full tool, got ${tool.kind}`); + const wait = createWaitAgentsTool({ sessions, fleetRecords }); + if (wait.kind !== "full") throw new Error(`expected full tool, got ${wait.kind}`); await tool.handler( { id: "call-1", - name: "task", + name: "spawn_agent", arguments: { description: "Ship", prompt: "Ship it", agent: "acmecorp-release-captain" }, }, new AbortController().signal, ); + await wait.handler( + { id: "wait-1", name: "wait_agents", arguments: { mode: "all", timeout_ms: 5000 } }, + new AbortController().signal, + ); const end = (await events()).find((e) => e.event === "subagent_end"); expect(end).toBeDefined(); diff --git a/tests/unit/tui/agent-tools.test.ts b/tests/unit/tui/agent-tools.test.ts index 520eba53d..6477c8482 100644 --- a/tests/unit/tui/agent-tools.test.ts +++ b/tests/unit/tui/agent-tools.test.ts @@ -2,6 +2,7 @@ import { test, expect, mock } from "bun:test"; import type { ToolDefinition, ToolCall } from "@intx/types/runtime"; import { TOOL_NAMES } from "@intx/tools-posix"; import { createPermissionGate } from "../../../src/permission/gate.js"; +import { createSubAgentSessionStore } from "../../../src/subagent/session-store.js"; import type { PermissionGate } from "../../../src/permission/gate.js"; import { mcpServerFingerprint } from "../../../src/trust/project-trust.js"; import { withMockedModule } from "../../helpers/mock-module.js"; @@ -327,10 +328,11 @@ const subAgentDeps = { model: "m", }), getWorkdirBase: () => "/tmp", + sessions: createSubAgentSessionStore(), profiles: () => [], }; -test("default session registers task and search_agents", async () => { +test("default session registers split fleet tools and search_agents", async () => { const toolset = await createAgentToolset({ cwd: "/fake", permissionGate: fakePermissionGate, @@ -339,7 +341,9 @@ test("default session registers task and search_agents", async () => { subAgent: subAgentDeps, }); const names = toolset.dynamicRunner.currentDefinitions().map((d) => d.name); - expect(names).toContain("task"); + expect(names).not.toContain("task"); + expect(names).toContain("spawn_agent"); + expect(names).toContain("wait_agents"); expect(names).toContain("search_agents"); }); @@ -493,6 +497,34 @@ test("startup connectMCP still fail-closes untrusted local servers", async () => await toolset.dispose(); }); +test("dispose closes retained sub-agent sessions", async () => { + const sessions = createSubAgentSessionStore(); + const worker = sessions.start({ + id: "worker-1", + description: "worker", + agentId: "builder", + brief: "b", + retained: true, + }); + let closed = false; + sessions.registerClose(worker.id, async () => { + closed = true; + }); + sessions.markRunning(worker.id); + + const toolset = await createAgentToolset({ + cwd: "/fake", + permissionGate: fakePermissionGate, + onOperatorGate: async () => ({ kind: "option", index: 0 }), + sessionMode: "orchestrator", + subAgent: { ...subAgentDeps, sessions }, + }); + + await toolset.dispose(); + expect(closed).toBe(true); + expect(sessions.get(worker.id)?.lifecycleStatus).toBe("shutdown"); +}); + test("dispose calls posixTools.dispose", async () => { mockDispose.mockClear();