diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d37a3db7..00c949f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ### Changed +- `wait_agents` is now exec-primary opt-in (`mountWaitAgents` in + `createAgentToolset`; CL-7678). The TUI primary and nested orchestrators no + longer mount it and collect fleet results through mailbox mail instead: + spawn then idle, do not poll. Headless `corbits exec` keeps it mounted with + an advertised allow. - Headless `corbits exec` unmounts `ask_operator` when stdin/stdout are not TTYs instead of advertising a cancel stub. TUI Skywalker still mounts it; TTY exec still prompts on stdin. diff --git a/README.md b/README.md index 558d9e608..89eadfc36 100644 --- a/README.md +++ b/README.md @@ -154,8 +154,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` -then idle (mailbox mail inbound), `search_agents`, and optional `wait_agents` -for nested orchestrators. +then idle (mailbox mail inbound) and `search_agents`. `wait_agents` stays +mounted only on headless `corbits exec` primary runs. ``` CLI (src/index.ts) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5537241c8..91470ed90 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -122,7 +122,7 @@ Two directors, selected by role: 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). Assistant text that prints explicit `` markup is treated as attempted tool use, not narration: one **verbatim-tool-call** nudge asks the worker to re-issue a real `tool_call` and does not count toward the tool-less spiral. A missing envelope otherwise 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. `requirePlanSubstance` (counsel or `intent=plan`, not `modelRole === "plan"`) additionally requires Findings to contain files/paths, acceptance criteria, non-goals, risks, and ordered steps with a non-placeholder line each — four headings with stub Findings are incomplete-report, not an attachable plan. 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 worker runs until it completes, stalls, hits an opt-in wall-clock deadline, or is cancelled. - `spawn_agent` starts each worker and records it in the caller's fleet mailbox. On the TUI primary, mailbox mail is the collect path: occupancy takes uncollected terminals and re-enters the parent as system inbound. Nested orchestrators still collect with `wait_agents`. TUI-primary `wait_agents` may yield as a timeout (workers untouched, no take) so occupancy can deliver mail or a queued Enter steer. Already-collected waits return status without a second report or error body. Wait JSON includes `stop_reason` from the session when present so a salvage that is wait-`done` is not mistaken for a clean complete, and so parent-initiated interrupt (`interrupted`) is not mistaken for operator-cancel (`cancelled`). Deadline salvage prepends an advisory parent hint suggesting continuation plus a longer deadline if more wall-clock time is warranted. Failed and incomplete-report salvage tell the parent to diagnose from the report or error and MAY spawn one successor with a changed brief. A parent-initiated interrupt is a resumable pause: wait unblocks with `stop_reason: interrupted` (often while the session is still running and has no report); the parent should `resume_agent` or re-wait, and must not spawn a successor against a still-live worker. Successor only if that session is no longer resumable. Operator-cancelled salvage asks the parent to synthesize Findings and Paths and wait for the operator instead of auto-starting another specialist. Identical re-dispatch of the same brief stays refused at the prompt / spawn-handoff layer; there is no fingerprint-based re-dispatch hard-block. Deadline hints are advisory only — an identical re-dispatch is still admitted at runtime. Parent hints are prepended on salvage reports returned to the parent. The runtime does not auto-spawn successors. + `spawn_agent` starts each worker and records it in the caller's fleet mailbox. On the TUI primary, mailbox mail is the collect path: occupancy takes uncollected terminals and re-enters the parent as system inbound. Nested orchestrators collect through mailbox mail the same way. A mounted `wait_agents` (exec primary) collects worker reports directly instead. Already-collected waits return status without a second report or error body. Wait JSON includes `stop_reason` from the session when present so a salvage that is wait-`done` is not mistaken for a clean complete, and so parent-initiated interrupt (`interrupted`) is not mistaken for operator-cancel (`cancelled`). Deadline salvage prepends an advisory parent hint suggesting continuation plus a longer deadline if more wall-clock time is warranted. Failed and incomplete-report salvage tell the parent to diagnose from the report or error and MAY spawn one successor with a changed brief. A parent-initiated interrupt is a resumable pause: wait unblocks with `stop_reason: interrupted` (often while the session is still running and has no report); the parent should `resume_agent` or re-wait, and must not spawn a successor against a still-live worker. Successor only if that session is no longer resumable. Operator-cancelled salvage asks the parent to synthesize Findings and Paths and wait for the operator instead of auto-starting another specialist. Identical re-dispatch of the same brief stays refused at the prompt / spawn-handoff layer; there is no fingerprint-based re-dispatch hard-block. Deadline hints are advisory only — an identical re-dispatch is still admitted at runtime. Parent hints are prepended on salvage reports returned to the parent. The runtime does not auto-spawn successors. #### Model-family policy (`src/agent/model-family-policy.ts`) @@ -190,7 +190,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`, `skill_search` (catalog; callable without `tool_search`), **`spawn_agent`** / **`wait_agents`** (spawn and collect fleet agents), and **`search_agents`** when fleet-agent profiles are available — see Fleet agents below. +Core agent tools (advertised per surface; exec-only verbs noted) include `manage_tasks`, `tool_search`, `use_skill`, `skill_search` (catalog; callable without `tool_search`), **`spawn_agent`** (spawn fleet agents; collect through mailbox mail) and **`wait_agents`** (exec-primary collection verb; not mounted on TUI or nested runs), and **`search_agents`** when fleet-agent profiles are available — see Fleet agents below. ### Workflows (`src/workflows/`) @@ -209,21 +209,21 @@ 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 | -| **Fleet agent** | A short-lived worker for one self-contained job | Spawned with **`spawn_agent`**; primary mailbox mail arrives as inbound; nested orchestrators collect with **`wait_agents`** | +| 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 | +| **Fleet agent** | A short-lived worker for one self-contained job | Spawned with **`spawn_agent`**; mailbox mail arrives as inbound on TUI and nested runs | -The **`spawn_agent`** tool starts a fleet agent on a separate inference source (tier/profile resolved from settings) and returns immediately with an `agent_id`. On the TUI primary, Skywalker idles after spawn and occupancy delivers mailbox mail as system inbound when a worker finishes or fails (including while siblings still run). Nested orchestrators still collect with **`wait_agents`**. Declared fan-out is unlimited: excess dispatches enqueue rather than fail. `run()` is admitted by `src/subagent/admission.ts` (default burst window of 8 is race-avoidance so a 429 freeze can fire before a herd — not a declared-spawn cap). Occupancy is the whole first `run()`, including `wait_agents`. Nested children of an already-admitted parent bypass **capacity** so a nested orchestrator cannot deadlock while holding a slot; they still wait on a provider 429 pause. Drain is FIFO among currently admissible jobs (a paused provider is skipped, not head-of-line for every provider). Resume and followup inference re-enter the same queue. Queued workers report wait/list status `queued` (live, not failed). Lowering capacity never cancels in-flight work. Retryable provider 429s freeze new admits via the shared retry remapper in `createCorbitsRetryPolicy`; `quota_exhausted` does not freeze. `list_agents` remains mailbox-scoped. The dispatch brief separates durable `context`, actionable `prompt`, and optional `goals` (checklist seeds for the _child's_ own `manage_tasks` list). Implement/review dispatches (and their default directors) fail closed without non-empty `success_criteria`. 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 fleet agent on a separate inference source (tier/profile resolved from settings) and returns immediately with an `agent_id`. On the TUI primary, Skywalker idles after spawn and occupancy delivers mailbox mail as system inbound when a worker finishes or fails (including while siblings still run). Nested orchestrators collect through mailbox mail the same way. Declared fan-out is unlimited: excess dispatches enqueue rather than fail. `run()` is admitted by `src/subagent/admission.ts` (default burst window of 8 is race-avoidance so a 429 freeze can fire before a herd — not a declared-spawn cap). Occupancy is the whole first `run()`, including a mounted `wait_agents` (exec primary). Nested children of an already-admitted parent bypass **capacity** so a nested orchestrator cannot deadlock while holding a slot; they still wait on a provider 429 pause. Drain is FIFO among currently admissible jobs (a paused provider is skipped, not head-of-line for every provider). Resume and followup inference re-enter the same queue. Queued workers report wait/list status `queued` (live, not failed). Lowering capacity never cancels in-flight work. Retryable provider 429s freeze new admits via the shared retry remapper in `createCorbitsRetryPolicy`; `quota_exhausted` does not freeze. `list_agents` remains mailbox-scoped. The dispatch brief separates durable `context`, actionable `prompt`, and optional `goals` (checklist seeds for the _child's_ own `manage_tasks` list). Implement/review dispatches (and their default directors) fail closed without non-empty `success_criteria`. The child returns a structured report (`Summary` / `Findings` / `Blockers` / `Paths`) plus a tools-used footer. Parent and child never share a `manage_tasks` list. -Workers ask the spawning parent with **`ask_director`** (not the human). That parks a question while the worker stays `running`. On the TUI primary that arrives as an idle-send wake (and yields an in-flight `wait_agents` as a timeout without the question payload). Nested **`wait_agents`** still returns `awaiting_director` with a question payload — that is not terminal. Once a parked ask is surfaced (TUI wake, nested `wait_agents` with a question payload, or a successful `list_agents`), further `list_agents` calls fail closed until **`send_input`** answers or the ask is dropped — `list_agents` is not a poll. The parent answers with **`send_input`**, then continues. Escalate to the human with **`ask_operator`** only when the parent cannot resolve it. +Workers ask the spawning parent with **`ask_director`** (not the human). That parks a question while the worker stays `running`. On the TUI primary that arrives as an idle-send wake. On a nested orchestrator the question arrives as mailbox mail with an `awaiting_director` status — that is not terminal. Once a parked ask is surfaced (TUI wake, nested mailbox mail, or a successful `list_agents`), further `list_agents` calls fail closed until **`send_input`** answers or the ask is dropped — `list_agents` is not a poll. The parent answers with **`send_input`**, then continues. Escalate to the human with **`ask_operator`** only when the parent cannot resolve it. -When the parent TUI is not blocked in `wait_agents`, the runner publishes an authoritative snapshot of currently pending top-level questions on each store notification, including empty snapshots before fleet-count updates. During synchronous session rotation, a runner-owned barrier suppresses both publications before delivery-generation invalidation, transcript clearing, and worker cancellation; successful reset reconciles a fresh snapshot before resuming asynchronous backend rebuild. The bridge drops resolved, cancelled, replaced, terminal, and removed asks and delivers each session/question identity once while pending. A coalesced wake starts only when the parent is not processing and every operator gate is closed, including parent-idle fleet holds where the shell stays busy. Worker gates do not manufacture parent processing. Replies use `send_input`'s `target` field with the worker session ID, never its shared catalog ID. Synthetic wakes use `SessionPort.deliver` through queued-delivery's idle-send path without entering the user follow-up queue or composer `/feedback` capture. +When the parent TUI is not blocked in an in-flight tool call, the runner publishes an authoritative snapshot of currently pending top-level questions on each store notification, including empty snapshots before fleet-count updates. During synchronous session rotation, a runner-owned barrier suppresses both publications before delivery-generation invalidation, transcript clearing, and worker cancellation; successful reset reconciles a fresh snapshot before resuming asynchronous backend rebuild. The bridge drops resolved, cancelled, replaced, terminal, and removed asks and delivers each session/question identity once while pending. A coalesced wake starts only when the parent is not processing and every operator gate is closed, including parent-idle fleet holds where the shell stays busy. Worker gates do not manufacture parent processing. Replies use `send_input`'s `target` field with the worker session ID, never its shared catalog ID. Synthetic wakes use `SessionPort.deliver` through queued-delivery's idle-send path without entering the user follow-up queue or composer `/feedback` capture. 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=...)`. Default results are id, description, and spawn metadata (orchestrator flag, source). Pass `include_body=true` to include each match's loaded system prompt / body (truncated) 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. -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. +Built-in directors with `spawn.maySpawn` may themselves call `spawn_agent` (one hop only): nested dispatch installs the mailbox-scoped fleet verbs (`spawn_agent`, `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) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 9852857a1..d044950e9 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -201,7 +201,7 @@ Unmatched shell auto-allows, including contained non-force `git worktree add`/`r - **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. While the hold is up and the parent is not processing, occupancy flushes mailbox mail (`driveMailboxMail` + `buildMailboxMailMessage`) on store subscribe and idle-with-fleet settle — one child done while siblings run is enough. Skip that shot when a fleet-dry open-task continuation is latched. TUI-primary `wait_agents` gets `shouldYieldWait` (queued steer or uncollected mail/ask) and finishes as a timeout without taking workers. +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. While the hold is up and the parent is not processing, occupancy flushes mailbox mail (`driveMailboxMail` + `buildMailboxMailMessage`) on store subscribe and idle-with-fleet settle — one child done while siblings run is enough. Skip that shot when a fleet-dry open-task continuation is latched. Exec-primary `wait_agents` mounts with no yield predicate (`createWaitAgentsTool({ sessions, fleetRecords })`) and blocks to ready/timeout/abort; `shouldYieldWait` remains a supported-but-unwired tool option (no production mount passes it). `src/tui/stream-event-map.ts` maps reactor events onto the bridge's inbound events, and `src/tui/turn-state.ts` tracks the turn's status. `src/tui/turns-to-blocks.ts` hydrates a resumed session's stored turns into the same content blocks. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 3e472dc6d..1387030ee 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -41,8 +41,8 @@ The evidence is in how the product fails today: the personas already produce exc 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). Headless `corbits exec` unmounts `ask_operator` when stdin/stdout are not TTYs. TTY exec still reads a single line from stdin. -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. An in-flight TUI-primary `wait_agents` yields as a timeout when that steer is queued so occupancy can deliver it. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, mailbox mail arrives as inbound when a worker finishes or fails, 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` (then idle; mailbox mail inbound) / `search_agents`. Nested orchestrators still collect with `wait_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. +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` is parent-busy, so Enter is a queued steer, not a new turn. A queued steer delivers at the next parent `tool.boundary` so occupancy can pick it up. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, mailbox mail arrives as inbound when a worker finishes or fails, 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` (then idle; mailbox mail inbound) / `search_agents`. Nested orchestrators collect through mailbox mail the same way. 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 @@ -170,7 +170,7 @@ Corbits Code fans work out to short-lived **fleet agents** — workers with thei - **Agents** are runtime entities (primary session or child). - **Tasks** are checklist items owned by one agent via `manage_tasks`. -- **Fleet agents** are spawned with `spawn_agent`. On the TUI primary, mailbox mail arrives as inbound when a worker finishes or fails — do not poll `wait_agents`. Nested orchestrators still collect with `wait_agents`. Workers ask the parent with `ask_director`. That parks a question while the worker stays `running`. Nested `wait_agents` returns `awaiting_director` with a question payload — that is not terminal. Once a parked ask is surfaced, `list_agents` fails closed until `send_input` answers or the ask is dropped — do not poll `list_agents`. The parent answers with `send_input` (`target` = the worker's session id). When the parent TUI is not blocked in `wait_agents`, a parked question arrives as a synthetic idle-send wake. Escalate to the human only with `ask_operator`. +- **Fleet agents** are spawned with `spawn_agent`. On the TUI primary, mailbox mail arrives as inbound when a worker finishes or fails — spawn then idle; do not poll. Nested orchestrators collect through mailbox mail the same way. Workers ask the parent with `ask_director`. That parks a question while the worker stays `running`. On an exec-primary run, `wait_agents` returns `awaiting_director` with a question payload — that is not terminal. Once a parked ask is surfaced, `list_agents` fails closed until `send_input` answers or the ask is dropped — do not poll `list_agents`. The parent answers with `send_input` (`target` = the worker's session id). On the TUI primary, a parked question arrives as a synthetic idle-send wake. Escalate to the human only with `ask_operator`. 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. Printed `` markup in assistant text gets one corrective nudge to issue a real tool call and does not count as the wrap-up; without the envelope, one incomplete-report nudge is given and a second tool-less turn without the envelope salvages as `incomplete-report-stop`. Counsel and `intent=plan` also require plan substance in Findings (files/paths, acceptance criteria, non-goals, risks, ordered steps); headings-only or stub Findings salvage as `incomplete-report`, not an attachable plan. A silent worker (no activity for `stallTimeoutMs`, opt-in) gets one continuation nudge, then salvages as `stalled` only after a full `stallTimeoutMs` grace with still no activity — queued checks inside that window wait, they do not salvage. 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. diff --git a/docs/TUI.md b/docs/TUI.md index ef65e7077..73d18eeaa 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -628,9 +628,9 @@ 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 `wait_agents` is parent-busy and holds - steers. An in-flight TUI-primary `wait_agents` yields as a timeout when a - steer is queued so occupancy can deliver it. The transcript row says + long parent `run_shell` is parent-busy and holds steers. A queued steer + delivers at the next parent `tool.boundary` so occupancy can pick it up. + The transcript row says `[will steer next]` while pending and `[steering]` once delivered (`submitPrompt`, `drainSteersAtBoundary` in `runtime-bridge.ts`). If the captured target agent is already closed when diff --git a/scripts/eval-capability.ts b/scripts/eval-capability.ts index b38882226..b648cb234 100755 --- a/scripts/eval-capability.ts +++ b/scripts/eval-capability.ts @@ -686,6 +686,8 @@ export async function buildEvalDiagnostics( // Capability evals run through exec; they are non-TTY, so ask_operator // is unmounted the same way the runner does when interactive is false. operatorAvailable: false, + // ...and wait_agents stays mounted the way the exec runner mounts it. + waitAgentsMounted: true, }); return { advertisedTools, diff --git a/src/agent/agent-search.ts b/src/agent/agent-search.ts index 19a149ed8..4e059361d 100644 --- a/src/agent/agent-search.ts +++ b/src/agent/agent-search.ts @@ -111,7 +111,7 @@ export function formatAgentSearchResults( "", ...entries.flatMap((entry, i) => (i === 0 ? [entry] : ["", entry])), "", - "Spawn with spawn_agent(description, prompt, agent=). For a team, call spawn_agent once per member (parallel in one turn when independent), then reply and idle — mailbox mail arrives as inbound. Nested orchestrators still collect with wait_agents.", + "Spawn with spawn_agent(description, prompt, agent=). For a team, call spawn_agent once per member (parallel in one turn when independent), then reply and idle — mailbox mail arrives as inbound. wait_agents is mounted on exec-primary runs only.", ].join("\n"), ); } diff --git a/src/agent/directors/greybeard/package.test.ts b/src/agent/directors/greybeard/package.test.ts index 0077dd38b..05eb7fd56 100644 --- a/src/agent/directors/greybeard/package.test.ts +++ b/src/agent/directors/greybeard/package.test.ts @@ -104,7 +104,9 @@ describe("greybeardPackage", () => { const allow = greybeardPackage.tools?.allow ?? []; expect(allow).not.toContain("task"); expect(allow).toContain("spawn_agent"); - expect(allow).toContain("wait_agents"); + // CL-7678: nested orchestrators collect through mailbox mail; wait_agents + // is exec-primary opt-in, so it stays off the Greybeard allow. + expect(allow).not.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/greybeard/package.ts b/src/agent/directors/greybeard/package.ts index 15ef7b724..a9cac2438 100644 --- a/src/agent/directors/greybeard/package.ts +++ b/src/agent/directors/greybeard/package.ts @@ -33,7 +33,7 @@ Judge the approach: 4. Rank risks for long-term maintainability and backward compatibility. 5. Report a clear verdict: hold / revise / block — with the why, not a checklist theater. -Spawn only when a concrete unknown blocks that judgment. Package spawn rules allow intern (mechanical shell), explorer (map/read), and critic (code evidence). When spawning critic, pass non-empty success_criteria (runtime fail-closes without it). intern and explorer remain optional. Prefer doing the review yourself with mounted read/search tools. Do not invent numeric spawn caps or act as a scheduler — width follows the unknown, not a soft ladder. Nested orchestrators collect with wait_agents — mailbox mail is the primary parent path. +Spawn only when a concrete unknown blocks that judgment. Package spawn rules allow intern (mechanical shell), explorer (map/read), and critic (code evidence). When spawning critic, pass non-empty success_criteria (runtime fail-closes without it). intern and explorer remain optional. Prefer doing the review yourself with mounted read/search tools. Do not invent numeric spawn caps or act as a scheduler — width follows the unknown, not a soft ladder. Spawn then idle; reports arrive as mailbox mail — do not poll. Blinders: do not call search_agents to discover the fleet (even when nested). You already know the limited spawn set; stay inside it. Do not spawn builder, counsel, skywalker, or other directors outside the allowlist. diff --git a/src/agent/directors/registry.test.ts b/src/agent/directors/registry.test.ts index e3e7a05b1..475fbdc52 100644 --- a/src/agent/directors/registry.test.ts +++ b/src/agent/directors/registry.test.ts @@ -188,7 +188,9 @@ describe("director registry", () => { expect(s.systemPrompt).toMatch(/No catch-all worker/i); expect(s.tools?.allow).not.toContain("task"); expect(s.tools?.allow).toContain("spawn_agent"); - expect(s.tools?.allow).toContain("wait_agents"); + // CL-7678: wait_agents is exec-primary opt-in, off the Skywalker allow — + // TUI primary collects through mailbox mail. + expect(s.tools?.allow).not.toContain("wait_agents"); expect(s.tools?.allow).toContain("write_file"); expect(s.tools?.allow).toContain("edit_file"); expect(s.tools?.allow).toContain("delete_file"); diff --git a/src/agent/directors/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts index f65073e1b..d917eb682 100644 --- a/src/agent/directors/skywalker/package.test.ts +++ b/src/agent/directors/skywalker/package.test.ts @@ -52,7 +52,9 @@ describe("skywalkerPackage", () => { const allow = skywalkerPackage.tools?.allow ?? []; expect(allow).not.toContain("task"); expect(allow).toContain("spawn_agent"); - expect(allow).toContain("wait_agents"); + // CL-7678: TUI primary collects through mailbox mail; wait_agents is + // exec-primary opt-in, so it stays off the Skywalker allow. + expect(allow).not.toContain("wait_agents"); expect(allow).toContain("search_agents"); expect(allow).toContain("write_file"); expect(allow).toContain("edit_file"); @@ -123,7 +125,9 @@ describe("skywalkerPackage", () => { expect(p).toContain("wait_agents"); expect(p).toContain("Idle-orchestrator"); expect(p).not.toContain("task()"); - expect(p).toContain("do not poll wait_agents"); + expect(p).toContain("Spawn then idle; do not poll"); + expect(p).not.toContain("do not poll wait_agents"); + expect(p).toContain("wait_agents is mounted on exec-primary runs only"); expect(p).toContain("mailbox mail arrives as inbound"); expect(p).toContain( "When the fleet goes dry the runtime re-enters with collected reports", diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index bcce0d040..562f9b2c8 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -6,24 +6,24 @@ 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 idle. Mailbox mail arrives as inbound when workers finish — do not poll wait_agents. +Agent id: skywalker (primary session; not a spawned worker). Prefer spawn_agent for specialists (parallel OK), then idle. Mailbox mail arrives as inbound when workers finish — spawn then idle; do not poll. 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. -You do not do the specialists' jobs by default. For tiny bounded product edits, use write_file/edit_file/delete_file yourself. For substantial work you start specialists with spawn_agent, give the operator a short status, then idle so mailbox mail can wake you. Do not poll wait_agents. +You do not do the specialists' jobs by default. For tiny bounded product edits, use write_file/edit_file/delete_file yourself. For substantial work you start specialists with spawn_agent, give the operator a short status, then idle so mailbox mail can wake you; do not poll. # Parent tools 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 **end the turn**. Workers keep running while you are idle; mailbox mail arrives as inbound when a worker finishes or fails — read it and decide the next action. Do not poll wait_agents. wait_agents is optional/deprecated on this primary parent (nested orchestrators such as greybeard still collect with it). list_agents shows the fleet without blocking; do not poll list_agents. interrupt_agent unblocks an in-flight wait immediately. Enter mid-run delivers at the next parent tool.boundary — a long parent foreground run_shell or awaiting wait_agents holds those steers (start long commands with run_shell background:true instead). A bare spawn_agent does not. When the fleet goes dry the runtime re-enters with collected reports. +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 **end the turn**. Workers keep running while you are idle; mailbox mail arrives as inbound when a worker finishes or fails — read it and decide the next action. Spawn then idle; do not poll. wait_agents is mounted on exec-primary runs only. list_agents shows the fleet without blocking; do not poll list_agents. interrupt_agent unblocks an in-flight wait immediately. Enter mid-run delivers at the next parent tool.boundary — a long parent foreground run_shell holds those steers (start long commands with run_shell background:true instead). A bare spawn_agent does not. When the fleet goes dry the runtime re-enters with collected reports. # Operator updates (mandatory while fleet is live) You are the chat surface. Workers cannot ask_operator; they ask_director. A parked question arrives as an idle-send wake — answer with send_input using target = that worker's session id. Do not poll list_agents. Escalate with ask_operator only when you cannot resolve it. While any specialist is running: - After every spawn wave: short status (who, goal, what you are waiting on) then end the turn. - On mailbox mail or a finished report: short update — do not go silent. -- When the operator messages mid-run: answer them first (COMMUNICATION). Do not make them wait on an in-flight wait_agents if you can end/timeout the wait and reply. +- When the operator messages mid-run: answer them first (COMMUNICATION). Do not hold the reply on fleet collection — answer now and fold worker results in on the next turn. - Keep updates short; no wall of task dumps. manage_tasks is the checklist; chat is the narrative. Example chains: diff --git a/src/agent/directors/tool-sets.test.ts b/src/agent/directors/tool-sets.test.ts index 622a66a90..76ef43489 100644 --- a/src/agent/directors/tool-sets.test.ts +++ b/src/agent/directors/tool-sets.test.ts @@ -71,10 +71,15 @@ describe("SKYWALKER_TOOLS / ORCHESTRATOR_TOOLS", () => { expect(SKYWALKER_TOOLS as readonly string[]).toContain(name); expect(ORCHESTRATOR_TOOLS as readonly string[]).toContain(name); } - for (const name of ["spawn_agent", "wait_agents"] as const) { + for (const name of ["spawn_agent"] as const) { expect(SKYWALKER_TOOLS as readonly string[]).toContain(name); expect(ORCHESTRATOR_TOOLS as readonly string[]).toContain(name); } + // CL-7678: wait_agents is exec-primary opt-in (mountWaitAgents), not on the + // TUI/nested allowlists — those runs collect through mailbox mail. + for (const surface of [SKYWALKER_TOOLS, ORCHESTRATOR_TOOLS] as const) { + expect(surface as readonly string[]).not.toContain("wait_agents"); + } expect(SKYWALKER_TOOLS as readonly string[]).not.toContain("task"); expect(ORCHESTRATOR_TOOLS as readonly string[]).not.toContain("task"); }); diff --git a/src/agent/directors/tool-sets.ts b/src/agent/directors/tool-sets.ts index 39f18288c..75f86c088 100644 --- a/src/agent/directors/tool-sets.ts +++ b/src/agent/directors/tool-sets.ts @@ -71,12 +71,16 @@ export const INTERN_TOOLS = [ ...PRODUCT_WRITE_TOOLS, ] as const; -/** Nested orchestrator surface (greybeard / package filter): dispatch + path writes. */ +/** + * Nested orchestrator surface (greybeard / package filter): dispatch + path writes. + * wait_agents is NOT here: TUI primary and nested orchestrators collect through + * mailbox mail. Exec primary mounts it separately (mountWaitAgents) and extends + * its advertised allow in resolveExecDirectorOverlay. + */ export const ORCHESTRATOR_TOOLS = [ ...READ_TOOLS, ...PRODUCT_WRITE_TOOLS, "spawn_agent", - "wait_agents", "list_agents", "close_agent", "resume_agent", diff --git a/src/agent/fleet-verbs-mount.test.ts b/src/agent/fleet-verbs-mount.test.ts index 4c8762e11..fc8bb3c15 100644 --- a/src/agent/fleet-verbs-mount.test.ts +++ b/src/agent/fleet-verbs-mount.test.ts @@ -1,7 +1,8 @@ /** - * Primary createAgentToolset mounts the seven fleet verbs beside search_agents / + * Primary createAgentToolset mounts six 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. + * sessions store) is wired; wait_agents is exec-primary opt-in via + * mountWaitAgents (CL-7678). Leaves / no-subAgent toolsets stay without them. */ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -12,7 +13,6 @@ import { createSubAgentSessionStore } from "../subagent/session-store.js"; const FLEET_VERBS = [ "spawn_agent", - "wait_agents", "list_agents", "close_agent", "resume_agent", @@ -21,7 +21,7 @@ const FLEET_VERBS = [ ] as const; describe("primary fleet verb mount", () => { - test("createAgentToolset registers the seven fleet verbs when subAgent + sessions are set", async () => { + test("createAgentToolset registers six fleet verbs without wait_agents by default", async () => { const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-")); const { createAgentToolset } = await import("./tools.js"); const permissionGate = { @@ -50,6 +50,39 @@ describe("primary fleet verb mount", () => { for (const name of FLEET_VERBS) { expect(names).toContain(name); } + // TUI primary and nested orchestrators collect via mailbox mail. + expect(names).not.toContain("wait_agents"); + await toolset.dispose(); + }); + + test("createAgentToolset mounts wait_agents when mountWaitAgents is true (exec primary)", async () => { + const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-")); + const { createAgentToolset } = await import("./tools.js"); + const permissionGate = { + check: async () => ({ allowed: true }), + getSkipPermissions: () => false, + } as never; + const sessions = createSubAgentSessionStore(); + + const toolset = await createAgentToolset({ + cwd, + permissionGate, + onOperatorGate: async () => ({ kind: "option", index: 0 }), + mountWaitAgents: true, + subAgent: { + provider: { + providerName: "test", + baseURL: "http://127.0.0.1:0", + model: "test-model", + }, + getWorkdirBase: () => cwd, + sessions, + }, + }); + const names = toolset.dynamicRunner.currentDefinitions().map((d) => d.name); + for (const name of [...FLEET_VERBS, "wait_agents"] as const) { + expect(names).toContain(name); + } await toolset.dispose(); }); diff --git a/src/agent/prompts.test.ts b/src/agent/prompts.test.ts index 76a2073dd..f6eecd431 100644 --- a/src/agent/prompts.test.ts +++ b/src/agent/prompts.test.ts @@ -140,6 +140,44 @@ describe("sub-agent report contract", () => { }); }); +describe("wait_agents mount-gated prompt copy (CL-7678)", () => { + const TUI_AVAILABILITY = { + languageServerAvailable: false, + operatorAvailable: false, + }; + function chatPrompt( + toolAvailability: + | typeof TUI_AVAILABILITY + | (typeof TUI_AVAILABILITY & { waitAgentsMounted: boolean }), + ): string { + return buildChatSystemPrompt( + undefined, + undefined, + undefined, + [], + "orchestrator", + toolAvailability, + ); + } + + it("tells an unmounted primary to spawn then idle on mailbox mail", () => { + const prompt = chatPrompt(TUI_AVAILABILITY); + expect(prompt).toContain("mailbox mail arrives as inbound"); + // No wait_agents tool ad on an unmounted primary — only the exec-only note. + expect(prompt).not.toContain("- wait_agents:"); + expect(prompt).not.toContain("collect with wait_agents"); + expect(prompt).toContain( + "wait_agents is mounted on exec-primary runs only", + ); + }); + + it("keeps the wait_agents collect path on an exec-mounted primary", () => { + const prompt = chatPrompt({ ...TUI_AVAILABILITY, waitAgentsMounted: true }); + expect(prompt).toContain("- wait_agents:"); + expect(prompt).toContain("collect with wait_agents"); + }); +}); + describe("shared verification guidance", () => { it("requires evidence-carrying verification in worker prompts", () => { const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, { diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index 792c7b328..50582b56b 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -9,8 +9,9 @@ import { import { createSkywalkerSystemPrompt } from "./directors/skywalker/package.js"; // Advertise every gated core tool when the caller has no session-start facts -// (tests, ad-hoc prompt previews). Real sessions always pass their detected -// availability — see tui/runner.ts and exec/runner.ts. +// (tests, ad-hoc prompt previews) — except wait_agents, which is mount-gated: +// the default preview shows the unmounted (TUI/nested) surface. Real sessions +// always pass their detected availability — see tui/runner.ts and exec/runner.ts. const DEFAULT_TOOL_AVAILABILITY: ToolAvailability = { languageServerAvailable: true, }; @@ -112,10 +113,14 @@ export function buildGuidelines( subAgent?: boolean; sessionMode?: SessionMode; askDirector?: boolean; + // True where createAgentToolset mounted wait_agents (exec primary). + // Picks the collection-path copy: wait_agents vs mailbox mail. + waitAgentsMounted?: boolean; } = {}, ): string { const subAgent = opts.subAgent ?? false; const askDirector = opts.askDirector === true; + const waitAgentsMounted = opts.waitAgentsMounted === true; return [ "Guidelines:", "", @@ -129,7 +134,11 @@ export function buildGuidelines( ...(subAgent ? [] : [ - "- Prefer spawn_agent(agent=…) then idle for substantial product implementation, exploration, review, and docs — mailbox mail arrives as inbound; do not poll wait_agents. Spawn remains default for substantial work, not a tool ban.", + "- Prefer spawn_agent(agent=…) then idle for substantial product implementation, exploration, review, and docs — " + + (waitAgentsMounted + ? "collect with wait_agents; do not poll list_agents." + : "mailbox mail arrives as inbound; do not poll.") + + " 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 @@ -174,9 +183,17 @@ 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. Mailbox mail arrives as inbound when a worker finishes; read it and do not poll `wait_agents`. Nested orchestrators still collect with `wait_agents`. `list_agents` shows the fleet without blocking; after a parked ask is surfaced, answer with `send_input` and do not poll `list_agents`.", + "- 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. " + + (waitAgentsMounted + ? "This surface has no mailbox delivery: collect with `wait_agents`; do not poll `list_agents`." + : "Mailbox mail arrives as inbound when a worker finishes; read it and do not poll.") + + " `list_agents` shows the fleet without blocking; after a parked ask is surfaced, answer with `send_input` and do not poll `list_agents`.", "- Pass the typed spawn contract: `intent`, `success_criteria` (done-when; required for implement/review and their default directors), `do_not` (scope fence), and `report_focus`. Free-form `prompt` without `success_criteria` fail-closes for implement/review and their default directors.", - "- After workers return, classify fail / incomplete-report vs parent-initiated interrupt vs operator-cancel vs clean complete. Fail-path (`status: failed` or salvage `incomplete-report`): diagnose from the report or error and MAY spawn one successor with a changed brief. Parent-initiated interrupt (`interrupt_agent` / `send_input` with `interrupt:true` unblocks wait with `stop_reason: interrupted`): the worker is often still running and often has no report — `resume_agent` or re-wait; do not `spawn_agent` a successor against a still-live worker. Successor only if that session is no longer resumable. Operator-cancel (`stop_reason` cancelled): wait for the operator; do not auto-retry. Identical brief: refuse. Merge Summary/Findings into a coherent answer for the operator; do not paste raw fleet-agent dumps.", + "- After workers return, classify fail / incomplete-report vs parent-initiated interrupt vs operator-cancel vs clean complete. Fail-path (`status: failed` or salvage `incomplete-report`): diagnose from the report or error and MAY spawn one successor with a changed brief. Parent-initiated interrupt (`interrupt_agent` / `send_input` with `interrupt:true` unblocks wait with `stop_reason: interrupted`): the worker is often still running and often has no report — `resume_agent`" + + (waitAgentsMounted + ? " or re-wait" + : ", or idle for its mailbox mail") + + "; do not `spawn_agent` a successor against a still-live worker. Successor only if that session is no longer resumable. Operator-cancel (`stop_reason` cancelled): wait for the operator; do not auto-retry. Identical brief: refuse. Merge Summary/Findings into a coherent answer for the operator; do not paste raw fleet-agent dumps.", "- Use manage_tasks for your own coordination checklist; spawning workers is `spawn_agent`, 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.", ]), @@ -243,7 +260,7 @@ const TOOL_SUMMARIES: Record = { spawn_agent: "start a worker agent and return immediately with agent_id; pass returned ids from search_agents as agent=...", wait_agents: - "optional/deprecated on the primary parent — mailbox mail arrives as inbound; nested orchestrators still wait for spawned workers by agent_id; returns awaiting_director when a worker asks, without collecting that session", + "collect spawned workers by agent_id; mounted on exec-primary runs only — elsewhere mailbox mail arrives as inbound, so do not poll; returns awaiting_director when a worker asks, without collecting that session", list_agents: "list this session's spawn_agent workers without blocking; after a parked ask_director is surfaced, returns an error until send_input answers or the ask is dropped — do not poll", search_agents: @@ -339,6 +356,7 @@ function contextSection(env?: EnvironmentInfo): string { function baseSection( baseOverride: string | undefined, sessionMode: SessionMode, + waitAgentsMounted?: boolean, ): string { if (baseOverride !== undefined && baseOverride.trim().length > 0) { const custom = baseOverride.trim(); @@ -347,14 +365,20 @@ function baseSection( custom, "## Session mode", buildHarnessFacts({ sessionMode: "orchestrator" }), - buildGuidelines({ sessionMode: "orchestrator" }), + buildGuidelines({ + sessionMode: "orchestrator", + ...(waitAgentsMounted !== undefined ? { waitAgentsMounted } : {}), + }), buildPromptDisciplineBlock(), ]); } return joinSections([ buildChatRole(sessionMode), buildHarnessFacts({ sessionMode }), - buildGuidelines({ sessionMode }), + buildGuidelines({ + sessionMode, + ...(waitAgentsMounted !== undefined ? { waitAgentsMounted } : {}), + }), buildPromptDisciplineBlock(), ]); } @@ -377,7 +401,7 @@ export function buildChatSystemPrompt( toolAvailability: ToolAvailability = DEFAULT_TOOL_AVAILABILITY, ): string { const sections = [ - baseSection(baseOverride, sessionMode), + baseSection(baseOverride, sessionMode, toolAvailability.waitAgentsMounted), buildAvailableTools( coreToolNamesForSessionMode(sessionMode, toolAvailability), { advertiseArchive: true }, @@ -411,9 +435,17 @@ export function buildSubAgentAppendix( // Orchestrators get the spawn instruction; everyone else gets the no-recursion // rule only. const askDirector = opts.toolNames?.includes("ask_director") === true; + // wait_agents is mounted on exec-primary runs only; nested orchestrators + // get the live toolNames from runSubAgent, so the mount flag doubles as + // the collection-path copy switch with no call-site changes. + const waitAgentsMounted = opts.toolNames?.includes("wait_agents") === true; const recursionRule = opts.orchestrator === true - ? '- You are an orchestrator: you MAY call `spawn_agent` to spawn other fleet 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).' + ? '- You are an orchestrator: you MAY call `spawn_agent` to spawn other fleet 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 ' + + (waitAgentsMounted + ? "synthesize their reports into your own after `wait_agents`." + : "reply and idle — their reports arrive as mailbox mail; do not poll.") + + " `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 fleet 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 2b3f752ad..e3e2f600d 100644 --- a/src/agent/tool-search.test.ts +++ b/src/agent/tool-search.test.ts @@ -14,6 +14,8 @@ import { const FULL_AVAILABILITY: ToolAvailability = { languageServerAvailable: true, + // Exec-primary mount: wait_agents stays advertised here. + waitAgentsMounted: true, }; const NO_AVAILABILITY: ToolAvailability = { languageServerAvailable: false, @@ -106,6 +108,30 @@ describe("createToolIndex", () => { } }); + test("wait_agents is advertised only when mounted (exec primary)", () => { + for (const availability of [ + { languageServerAvailable: true, waitAgentsMounted: false }, + { languageServerAvailable: true }, + ] as const) { + const advertised = advertisedToolNamesForSessionMode( + "orchestrator", + availability, + ); + expect(advertised).not.toContain("wait_agents"); + // The rest of the fleet surface stays advertised on TUI/nested. + for (const name of [ + "spawn_agent", + "list_agents", + "send_input", + ] as const) { + expect(advertised).toContain(name); + } + } + expect( + advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY), + ).toContain("wait_agents"); + }); + test("manage_tasks is advertised regardless of availability", () => { expect( coreToolNamesForSessionMode("orchestrator", NO_AVAILABILITY), diff --git a/src/agent/tool-search.ts b/src/agent/tool-search.ts index e753afdf6..b391adfa9 100644 --- a/src/agent/tool-search.ts +++ b/src/agent/tool-search.ts @@ -75,6 +75,11 @@ export interface ToolAvailability { // default (mounted). False drops ask_operator from the advertised prefix // instead of leaving a cancel stub on the wire. operatorAvailable?: boolean; + // Whether createAgentToolset mounted the wait_agents collection verb. True + // only on exec primary; TUI primary and nested orchestrators omit it and + // collect via mailbox mail instead. Omit to keep the unmounted default — + // false/omitted filters wait_agents out of the core/advertised name sets. + waitAgentsMounted?: boolean; } export function coreToolNamesForSessionMode( @@ -88,6 +93,7 @@ export function coreToolNamesForSessionMode( if (name === "lsp") return availability.languageServerAvailable; if (name === "ask_operator") return availability.operatorAvailable !== false; + if (name === "wait_agents") return availability.waitAgentsMounted === true; return true; }); } @@ -203,7 +209,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 not advertised until this search promotes them onto the wire. 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. Matched tools are promoted and callable on return — invoke them directly, no separate load step.", + "Discover callable tools by capability. Most tools — MCP servers, present, and other integrations — are not advertised until this search promotes them onto the wire. Core tools (read_file, run_shell, web_fetch, web_search, spawn_agent, …) are already on the wire — do not tool_search for them. wait_agents is mounted on exec-primary runs only, so it is not on the wire elsewhere and this search cannot promote it there. 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. Matched tools are promoted and callable on return — invoke them directly, no separate load step.", inputSchema: { type: "object", properties: { diff --git a/src/agent/tools.ts b/src/agent/tools.ts index ed4f1e745..621de824a 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -256,11 +256,11 @@ export interface AgentToolsetArgs { */ isCodex?: boolean; /** - * TUI primary only. When true, wait_agents finishes as a timeout (workers - * untouched, no take) so occupancy can deliver mailbox mail or a queued - * operator steer. Nested mounts omit this. + * Opt-in: mount wait_agents beside the other fleet verbs. Exec-primary only + * (with an advertised allow) — TUI primary and nested orchestrators omit it + * and collect worker reports from mailbox mail instead. */ - shouldYieldWait?: () => boolean; + mountWaitAgents?: boolean; } // Per-server connection state surfaced to the TUI. @@ -552,19 +552,23 @@ export async function createAgentToolset( }; orchestratorTools.push( createSpawnAgentTool(fleetDeps), - createWaitAgentsTool({ - sessions: fleetSessions, - fleetRecords, - ...(args.shouldYieldWait !== undefined - ? { shouldYieldWait: args.shouldYieldWait } - : {}), - }), createListAgentsTool({ sessions: fleetSessions, fleetRecords }), createCloseAgentTool({ sessions: fleetSessions, fleetRecords }), createResumeAgentTool({ sessions: fleetSessions, fleetRecords }), createInterruptAgentTool({ sessions: fleetSessions, fleetRecords }), createSendInputTool({ sessions: fleetSessions, fleetRecords }), ); + // Exec-primary opt-in only: TUI primary and nested orchestrators collect + // via mailbox mail, so wait_agents stays unmounted (and unadvertised) + // there. See mountWaitAgents. + if (args.mountWaitAgents === true) { + orchestratorTools.push( + createWaitAgentsTool({ + sessions: fleetSessions, + fleetRecords, + }), + ); + } } } diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 71b47d94e..95febb052 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -270,7 +270,13 @@ export function resolveExecDirectorOverlay( const advertisedAllow = allow !== undefined && allow.length > 0 ? pkg.spawn.maySpawn - ? [...allow] + ? [ + ...allow, + // Exec mounts wait_agents beside the fleet verbs (mountWaitAgents), + // so it stays advertised here even though the package allow omits + // it for TUI/nested mailbox-mail collection. + ...(!allow.includes("wait_agents") ? ["wait_agents"] : []), + ] : allow.filter( (name) => ![ @@ -592,6 +598,9 @@ export async function runExec(config: Config): Promise { const toolAvailability: ToolAvailability = { languageServerAvailable: detectLanguageServerAvailable(config.cwd), operatorAvailable: interactive, + // Exec primary keeps wait_agents mounted: headless runs have no + // mailbox-mail flush, so wait_agents stays the collection path here. + waitAgentsMounted: true, }; let currentAgent: Agent | null = null; @@ -640,6 +649,10 @@ export async function runExec(config: Config): Promise { : {}), sessionMode, toolAvailability, + // Exec-primary keeps wait_agents mounted (with an advertised allow): + // headless runs have no mailbox-mail flush, so wait_agents stays the + // collection path here. TUI primary and nested orchestrators omit it. + mountWaitAgents: true, ...(config.mcpServers !== undefined ? { mcpServers: config.mcpServers } : {}), diff --git a/src/prompts.test.ts b/src/prompts.test.ts index 93342e01d..9b79286d5 100644 --- a/src/prompts.test.ts +++ b/src/prompts.test.ts @@ -164,7 +164,13 @@ test("orchestrator guidelines teach the typed task spawn contract", () => { expect(guidelines).toContain("report_focus"); expect(guidelines).toContain("intent"); expect(guidelines).toContain("spawn_agent"); - expect(guidelines).toContain("wait_agents"); + // CL-7678: the default (TUI/nested) surface is unmounted — spawn then idle + // on mailbox mail. The wait_agents collect path is exec-primary opt-in. + expect(guidelines).toContain("mailbox mail arrives as inbound"); + expect(guidelines).not.toContain("wait_agents"); + expect( + buildGuidelines({ sessionMode: "orchestrator", waitAgentsMounted: true }), + ).toContain("wait_agents"); expect(guidelines).toContain("required for implement/review"); expect(guidelines).toContain("and their default directors"); expect(guidelines).not.toContain("weaker"); @@ -216,8 +222,20 @@ test("default session lists split fleet tools and search_agents", () => { ); expect(prompt).not.toContain("- task:"); expect(prompt).toContain("- spawn_agent:"); - expect(prompt).toContain("- wait_agents:"); + // CL-7678: default (TUI/nested) session leaves wait_agents unmounted — + // collection is mailbox mail. Exec-primary mounts it via waitAgentsMounted. + expect(prompt).not.toContain("- wait_agents:"); expect(prompt).toContain("- search_agents:"); + const mounted = buildChatSystemPrompt( + undefined, + undefined, + undefined, + [], + "orchestrator", + { languageServerAvailable: true, waitAgentsMounted: true }, + ); + expect(mounted).toContain("- wait_agents:"); + expect(mounted).toContain("collect with wait_agents"); }); test("chat prompt advertises core tools but never enumerates MCP integrations", () => { @@ -280,7 +298,9 @@ test("SYSTEM.md override still appends orchestrator harness rules", () => { expect(prompt).toContain("## Session mode"); expect(prompt).toContain("Orchestration:"); expect(prompt).toContain("- spawn_agent:"); - expect(prompt).toContain("- wait_agents:"); + // CL-7678: SYSTEM.md override keeps the unmounted default — no wait_agents ad. + expect(prompt).not.toContain("- wait_agents:"); + expect(prompt).toContain("Mailbox mail arrives as inbound"); }); test("an empty base override falls back to the default base", () => { diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index b82a82f20..2ee7cbe40 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -507,7 +507,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. Pass agent= a director/profile id returned by search_agents, or intent= (one of explore|implement|review|plan|general). The child starts blank. success_criteria is required for implement/review (and their default directors). Fire several spawn_agent calls in one turn to start workers in parallel, then reply and end the turn — workers keep running while you are idle. wait_agents is optional/deprecated on the primary parent (mailbox mail arrives as inbound). Nested orchestrators still collect with wait_agents. Excess fan-out is queued rather than refused.", + "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). The child starts blank. success_criteria is required for implement/review (and their default directors). Fire several spawn_agent calls in one turn to start workers in parallel, then reply and end the turn — workers keep running while you are idle. Reports arrive as mailbox mail where mailbox delivery is mounted; where wait_agents is mounted (exec primary), collect with it instead. Do not poll. Excess fan-out is queued rather than refused.", inputSchema: { type: "object", properties: { @@ -569,7 +569,7 @@ export const MAX_WAIT_TIMEOUT_MS = 300_000; export const waitAgentsToolDefinition: ToolDefinition = { name: "wait_agents", description: - `Optional/deprecated on the primary parent: mailbox mail arrives as inbound when workers finish, so spawn then idle instead of polling. Nested orchestrators still collect with this tool. ` + + `Mounted on exec-primary runs only: elsewhere mailbox mail arrives as inbound when workers finish, so spawn then idle instead of polling. ` + `Block until the given agents reach a terminal state (done, failed, or interrupted), or a worker asks its director (awaiting_director), or timeout_ms elapses. ` + `Default mode is "any" (return when the first target finishes or asks). Pass mode="all" to wait until every target is ` + `terminal — except a pending ask_director unblocks immediately regardless of mode so the director can send_input. ` + @@ -1578,9 +1578,10 @@ interface WaitAgentsDeps { fleetRecords: FleetMailboxHandle; authority?: WaitAgentsAuthority; /** - * TUI primary only. When true, finish the wait as a timeout (workers - * untouched, no take) so occupancy can deliver mailbox mail or a queued - * operator steer. Nested mounts omit this. + * Supported but unwired in production: no current mount passes this + * (TUI does not mount wait_agents; exec mounts without the predicate). + * When true, finish the wait as a timeout (workers untouched, no take) + * so occupancy can deliver mailbox mail or a queued operator steer. */ shouldYieldWait?: () => boolean; } @@ -1592,7 +1593,8 @@ function isWaitTerminal(id: string, fleetRecords: FleetMailboxHandle): boolean { /** * Blocks until `mode` is satisfied for `targets`, or `timeoutMs` / abort - * elapses, or TUI-primary `shouldYieldWait` is true. Driven by the session + * elapses, or the optional (currently unwired in production) + * `shouldYieldWait` predicate is true. Driven by the session * store's mailbox (`subscribe`) raced against a timer and the parent tool * signal; never polls. Timeout, abort, and yield have no side effects: * workers keep running and remain waitable. Overlay writers wake this wait @@ -1797,11 +1799,10 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool { export const listAgentsToolDefinition: ToolDefinition = { name: "list_agents", description: - "List the workers this session started with spawn_agent — the same fleet wait_agents " + - "collects. Does not list siblings or another orchestrator's workers. Each entry is id, " + - "director, description, wait status, lifecycle, stop_reason when recorded, and whether wait_agents already collected it. " + + "List the workers this session started with spawn_agent. Does not list siblings or another orchestrator's workers. Each entry is id, " + + "director, description, wait status, lifecycle, stop_reason when recorded, and whether the fleet already collected it. " + "When status is awaiting_director, the entry also includes question and question_id. " + - "After parked ask_director questions are already surfaced (idle-send wake, wait_agents with a question payload, or a prior list), " + + "After parked ask_director questions are already surfaced (idle-send wake or a prior list), " + "list_agents returns an error until you answer with send_input (soft) or the ask is dropped. Do not poll list_agents.", inputSchema: { type: "object", diff --git a/src/subagent/authority.ts b/src/subagent/authority.ts index b14125540..c5a02c30b 100644 --- a/src/subagent/authority.ts +++ b/src/subagent/authority.ts @@ -9,7 +9,7 @@ * 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 - * own spawn_agent workers, the same scoped mailbox wait_agents uses, so + * own spawn_agent workers, the same scoped mailbox the fleet uses, so * nested orchestrators may mount it. * - assertCanTargetAgent: a Tier 2 nested orchestrator may act only on its * own descendants, never a sibling or anything above it in the tree. diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 69a861bf4..8bb47af84 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -161,7 +161,6 @@ import { import { createFleetMailbox, createSpawnAgentTool, - createWaitAgentsTool, createListAgentsTool, } from "./agent-fleet.js"; import { @@ -799,7 +798,6 @@ async function runSubAgentInner( ...(mayDiscoverFleet ? (["search_agents"] as const) : []), "read_agent_trace", "spawn_agent", - "wait_agents", "list_agents", "close_agent", "resume_agent", @@ -889,11 +887,6 @@ async function runSubAgentInner( tools = [ ...tools, createSpawnAgentTool(fleetDeps), - createWaitAgentsTool({ - sessions: fleetSessions, - fleetRecords, - authority: lifecycleAuthority, - }), createListAgentsTool({ sessions: fleetSessions, fleetRecords }), createCloseAgentTool({ sessions: fleetSessions, diff --git a/src/tui/runner/exit.ts b/src/tui/runner/exit.ts index 7a461a6bf..6c00d0ad2 100644 --- a/src/tui/runner/exit.ts +++ b/src/tui/runner/exit.ts @@ -512,8 +512,8 @@ export async function createRunLifecycle( // 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 → wait_agents parent - // signal → child abort. Do not add cancelAll here — fleet cancelAll is + // promise); that close cascades: operationController.abort → child + // parent-abort forwarding → 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 // works. diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index 82e63501d..05cd66d45 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -25,7 +25,6 @@ import { getProcessAdmissionQueue } from "../../subagent/admission.js"; import { createSubAgentSessionStore, liveFleetCount, - occupancyShouldYieldWait, } from "../../subagent/index.js"; import { buildPluginDescriptor, @@ -314,6 +313,8 @@ export async function assembleTUISession( const toolAvailability: ToolAvailability = { languageServerAvailable: detectLanguageServerAvailable(config.cwd), operatorAvailable: true, + // TUI primary does not mount wait_agents — mailbox mail is the collect path. + waitAgentsMounted: false, }; // The workflow host is built below, after the toolset; the holder lets // submit_output's handler complete the live workflow without a @@ -321,19 +322,12 @@ export async function assembleTUISession( const workflowHostHolder: { instance?: WorkflowHost } = {}; const evidenceArchiveHolder: { current?: CompactionArchive } = {}; - const toolsetHolder: { - current?: Awaited>; - } = {}; const toolset = await createAgentToolset({ cwd: config.cwd, permissionGate, skillDirs, telemetry: liveTelemetry, isCodex: isCodexProviderName(config.providerName), - shouldYieldWait: () => { - if (state.hasQueuedSteer?.() === true) return true; - return occupancyShouldYieldWait(toolsetHolder.current?.fleetRecords); - }, ...(shellTimeout !== undefined ? { shellTimeout } : {}), ...(localSettingsForEnv?.env !== undefined ? { shellEnv: localSettingsForEnv.env } @@ -430,7 +424,6 @@ export async function assembleTUISession( profiles: () => liveAgentProfiles, }, }); - toolsetHolder.current = toolset; const { systemPrompt } = await loadSessionChatPrompt({ cwd: config.cwd, diff --git a/src/tui/runner/state.ts b/src/tui/runner/state.ts index 735726ed5..49a7de7ec 100644 --- a/src/tui/runner/state.ts +++ b/src/tui/runner/state.ts @@ -295,8 +295,6 @@ export interface RunnerState { stopFleetReporting?: () => void; stopRunHeartbeat?: () => void; withFleetPublicationSuspended?: (reset: () => void) => void; - /** TUI primary: true when a queued Enter steer should yield in-flight wait_agents. */ - hasQueuedSteer?: () => boolean; } export function recordRunError(state: RunnerState, err: unknown): void { diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts index 9a972d0aa..aca682194 100644 --- a/src/tui/runner/wiring.ts +++ b/src/tui/runner/wiring.ts @@ -68,7 +68,6 @@ import { buildFleetDryContinuationMessage, buildMailboxMailMessage, } from "../../session/runtime-assembly.js"; -import { steerCount } from "../session-queue.js"; const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); @@ -283,7 +282,6 @@ export function wirePostStartup( sessionBridge.setWaitYieldWake(() => { services.subAgentSessions.wake(); }); - state.hasQueuedSteer = () => steerCount(hostOf(state).shell.session) > 0; const unsubscribeFleetReport = services.subAgentSessions.subscribe(() => { fleetWakePublisher.publish(); sessionBridge.flushMailboxMail(); diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 5a9cd0ff5..c16f46b8b 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -292,8 +292,8 @@ export interface SessionBridge { handler: ((asks: readonly PendingAskWake[]) => void) | undefined, ) => void; /** - * Wake in-flight wait_agents when the operator queues a steer. Timeout-shaped - * yield — workers are not interrupted. + * Wake the session/mailbox when the operator queues a steer, so occupancy + * can deliver it at the next parent tool.boundary. Workers are not interrupted. */ setWaitYieldWake: (wake: (() => void) | undefined) => void; /** @@ -501,7 +501,7 @@ export interface BridgeBag { mailboxMailDriver: (() => boolean) | undefined; /** After a pending ask wake is actually sent. Independent of deliveredAskWake. */ onAskWakeSent: ((asks: readonly PendingAskWake[]) => void) | undefined; - /** Wake in-flight wait_agents when a steer is queued (timeout-shaped yield). */ + /** Wake the session/mailbox when a steer is queued, so occupancy can deliver it. */ waitYieldWake: (() => void) | undefined; /** Last prompt actually sent — replay source for the quota auto-retry. */ lastSentMessage: string; diff --git a/src/workflows/coordinator.ts b/src/workflows/coordinator.ts index 8058b91db..fcfe9f46c 100644 --- a/src/workflows/coordinator.ts +++ b/src/workflows/coordinator.ts @@ -16,6 +16,10 @@ export class WorkflowCoordinator { // When true the workflow pauses after each step for user confirmation; the // directive tells the agent to gate via ask_operator before advancing. private readonly stepThrough = false, + // True where the live tool surface mounted wait_agents (exec primary). + // Picks the collection-path copy: wait_agents vs mailbox mail. Defaults + // to the unmounted surface so the directive never advertises a missing tool. + private readonly waitAgentsMounted = false, ) {} isActive(): boolean { @@ -61,7 +65,8 @@ export class WorkflowCoordinator { "", ]; if (step.prompt !== undefined) lines.push(step.prompt, ""); - for (const guidance of guidanceFor(step)) lines.push(guidance); + for (const guidance of guidanceFor(step, this.waitAgentsMounted)) + lines.push(guidance); if (this.stepThrough && step.type !== "gate") { lines.push( `Step-through mode is on: when this step is done, summarize it and call` + @@ -108,7 +113,7 @@ function stepIdOf(args: unknown): string | null { return typeof step === "string" && step.length > 0 ? step : null; } -function guidanceFor(step: WorkflowStep): string[] { +function guidanceFor(step: WorkflowStep, waitAgentsMounted: boolean): string[] { const out: string[] = []; if (step.skill !== undefined) { out.push(`First load the ${step.skill} skill, then follow this step.`); @@ -118,11 +123,16 @@ function guidanceFor(step: WorkflowStep): string[] { if (step.parallel === true && agents.length > 1) { out.push( `Delegate this step to these sub-agents in parallel via spawn_agent: ${agents.join(", ")}.` + - ` Use wait_agents to collect all of them before advancing.`, + (waitAgentsMounted + ? ` Use wait_agents to collect all of them before advancing.` + : ` Then idle: mailbox mail arrives as inbound as each worker finishes — read it before advancing.`), ); } else { out.push( - `Delegate this step to the ${agents.join(", ")} sub-agent via spawn_agent, then collect it with wait_agents.`, + `Delegate this step to the ${agents.join(", ")} sub-agent via spawn_agent, ` + + (waitAgentsMounted + ? `then collect it with wait_agents.` + : `then idle: mailbox mail arrives as inbound when it finishes — read it before advancing.`), ); } } diff --git a/src/workflows/host.ts b/src/workflows/host.ts index e9d2f79a8..4b2c30d8f 100644 --- a/src/workflows/host.ts +++ b/src/workflows/host.ts @@ -157,6 +157,11 @@ export class WorkflowHost { this.notify(); }, workflow.stepThrough === true, + // The directive's collect copy follows the live surface: wait_agents on + // exec primary, mailbox mail where it is unmounted (TUI, nested). + this.args + .getToolDefinitions() + .some((definition) => definition.name === "wait_agents"), ); this.listen(runtime); this.runtime = runtime; diff --git a/tests/unit/exec/runner.test.ts b/tests/unit/exec/runner.test.ts index a29f8b738..06b04d7d8 100644 --- a/tests/unit/exec/runner.test.ts +++ b/tests/unit/exec/runner.test.ts @@ -660,6 +660,16 @@ describe("resolveExecDirectorOverlay", () => { expect(overlay.systemPrompt).toContain("BuilderDirector"); }); + test("greybeard exec primary keeps wait_agents advertised (CL-7678)", () => { + const overlay = resolveExecDirectorOverlay("greybeard"); + expect(overlay.mountFleet).toBe(true); + expect(overlay.advertisedAllow).toBeDefined(); + // Exec mounts wait_agents beside the fleet verbs even though the package + // allow omits it for TUI/nested mailbox-mail collection. + expect(overlay.advertisedAllow).toContain("wait_agents"); + expect(overlay.advertisedAllow).toContain("spawn_agent"); + }); + test("skywalker default still can mount fleet", () => { expect(resolveExecDirectorOverlay(undefined).mountFleet).toBe(true); expect(resolveExecDirectorOverlay(undefined).systemPrompt).toBeUndefined(); diff --git a/tests/unit/tui/agent-tools.test.ts b/tests/unit/tui/agent-tools.test.ts index 61028f156..2e9d679a0 100644 --- a/tests/unit/tui/agent-tools.test.ts +++ b/tests/unit/tui/agent-tools.test.ts @@ -471,7 +471,9 @@ test("default session registers split fleet tools and search_agents", async () = const names = toolset.dynamicRunner.currentDefinitions().map((d) => d.name); expect(names).not.toContain("task"); expect(names).toContain("spawn_agent"); - expect(names).toContain("wait_agents"); + // CL-7678: default (TUI) session leaves wait_agents unmounted — mailbox mail + // is the collect path. Exec primary opts in via mountWaitAgents. + expect(names).not.toContain("wait_agents"); expect(names).toContain("search_agents"); }); diff --git a/tests/unit/workflows-runtime.test.ts b/tests/unit/workflows-runtime.test.ts index a0829757f..8b89de1b6 100644 --- a/tests/unit/workflows-runtime.test.ts +++ b/tests/unit/workflows-runtime.test.ts @@ -61,8 +61,30 @@ const parent: Workflow = { ], }; +const withAgentStep: Workflow = { + name: "agented", + description: "delegates a step", + steps: [{ id: "a", label: "A", prompt: "do a", agent: "builder" }], +}; + +const withParallelAgents: Workflow = { + name: "parallel-agents", + description: "delegates a step in parallel", + steps: [ + { + id: "a", + label: "A", + prompt: "do a", + agent: ["builder", "critic"], + parallel: true, + }, + ], +}; + function resolver(name: string): Workflow | undefined { - return [simple, withGatedStep, child, parent].find((w) => w.name === name); + return [simple, withGatedStep, child, parent, withAgentStep].find( + (w) => w.name === name, + ); } function collect(runtime: WorkflowRuntime): WorkflowEvent[] { @@ -71,6 +93,12 @@ function collect(runtime: WorkflowRuntime): WorkflowEvent[] { return events; } +function coordDirective(rt: WorkflowRuntime): string { + const directive = new WorkflowCoordinator(rt).directive(); + if (directive === null) throw new Error("expected an active directive"); + return directive; +} + test("start lands on the first executable step", () => { const rt = new WorkflowRuntime(empty, resolver); rt.start(simple); @@ -165,6 +193,34 @@ test("coordinator directive includes the ordinal, label, prompt, and completion expect(directive).not.toContain("advance_workflow"); }); +test("coordinator directive defaults to mailbox collect when wait_agents is unmounted", () => { + const rt = new WorkflowRuntime(empty, resolver); + rt.start(withAgentStep); + const directive = coordDirective(rt); + expect(directive).toContain("mailbox mail"); + expect(directive).not.toContain("wait_agents"); +}); + +test("coordinator directive keeps the wait_agents collect path when mounted", () => { + const rt = new WorkflowRuntime(empty, resolver); + rt.start(withAgentStep); + const coord = new WorkflowCoordinator(rt, () => undefined, false, true); + const directive = coord.directive(); + expect(directive).toContain("collect it with wait_agents"); +}); + +test("coordinator parallel-agent guidance is mount-gated", () => { + const parallelResolver = (n: string): Workflow | undefined => + n === "parallel-agents" ? withParallelAgents : undefined; + const unmounted = new WorkflowRuntime(empty, parallelResolver); + unmounted.start(withParallelAgents); + expect(coordDirective(unmounted)).not.toContain("wait_agents"); + const mounted = new WorkflowRuntime(empty, parallelResolver); + mounted.start(withParallelAgents); + const coord = new WorkflowCoordinator(mounted, () => undefined, false, true); + expect(coord.directive()).toContain("wait_agents"); +}); + test("runtime complete is a compare-and-advance against the current step", () => { const rt = new WorkflowRuntime(empty, resolver); rt.start(simple);