From ce84f13a7d044ee061e886e400cbc655501a9c56 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 11 Sep 2026 22:26:27 -0700 Subject: [PATCH 1/4] Unmount wait_agents on TUI primary and nested orchestrators --- CHANGELOG.md | 5 ++ README.md | 4 +- docs/ARCHITECTURE.md | 22 ++++---- scripts/eval-capability.ts | 2 + src/agent/agent-search.ts | 2 +- src/agent/directors/greybeard/package.test.ts | 4 +- src/agent/directors/greybeard/package.ts | 2 +- src/agent/directors/registry.test.ts | 4 +- src/agent/directors/skywalker/package.test.ts | 8 ++- src/agent/directors/skywalker/package.ts | 6 +-- src/agent/directors/tool-sets.test.ts | 7 ++- src/agent/directors/tool-sets.ts | 8 ++- src/agent/fleet-verbs-mount.test.ts | 41 +++++++++++++-- src/agent/prompts.test.ts | 38 ++++++++++++++ src/agent/prompts.ts | 52 +++++++++++++++---- src/agent/tool-search.test.ts | 26 ++++++++++ src/agent/tool-search.ts | 8 ++- src/agent/tools.ts | 26 ++++++---- src/exec/runner.ts | 15 +++++- src/prompts.test.ts | 26 ++++++++-- src/subagent/agent-fleet.ts | 11 ++-- src/subagent/authority.ts | 2 +- src/subagent/run.ts | 7 --- src/tui/runner/session.ts | 11 +--- tests/unit/exec/runner.test.ts | 10 ++++ tests/unit/tui/agent-tools.test.ts | 4 +- 26 files changed, 272 insertions(+), 79 deletions(-) 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..31da2eaf5 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 in every chat turn) 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/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..4615eb7d0 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -6,17 +6,17 @@ 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) 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..27aebdfad 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. ` + @@ -1797,11 +1797,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/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/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"); }); From a11cd44073e34174b3a58626827f361b0914d816 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 11 Sep 2026 22:43:18 -0700 Subject: [PATCH 2/4] Scope remaining wait-path copy to the exec primary surface TUI and nested runs collect through mailbox mail, so every prompt, doc, and comment that still teaches the wait path as live now says which surface it applies to. Drops the readerless queued-steer yield flag whose comment cited the unmounted wait. --- docs/ARCHITECTURE.md | 2 +- docs/PRODUCT.md | 6 +-- src/agent/directors/skywalker/package.ts | 2 +- src/tui/runner/exit.ts | 4 +- src/tui/runner/state.ts | 2 - src/tui/runner/wiring.ts | 2 - src/workflows/coordinator.ts | 18 ++++++-- src/workflows/host.ts | 5 ++ tests/unit/workflows-runtime.test.ts | 58 +++++++++++++++++++++++- 9 files changed, 83 insertions(+), 16 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 31da2eaf5..91470ed90 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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`** (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. +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/`) 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/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index 4615eb7d0..562f9b2c8 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -23,7 +23,7 @@ Idle-orchestrator: fire one or more spawn_agent calls in a turn — each returns 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/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/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/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/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); From 2706f1736f5707d87a2f11a3af4503557e07b2b0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 11 Sep 2026 22:49:25 -0700 Subject: [PATCH 3/4] Reword leftover TUI wait-yield copy as exec-scoped session wake --- docs/IMPLEMENTATION.md | 2 +- docs/TUI.md | 6 +++--- src/tui/runtime-bridge.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 9852857a1..94a51b899 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` gets `shouldYieldWait` (queued steer or uncollected mail/ask) and finishes as a timeout without taking workers. `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/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/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; From 25719e2aa6f434314cbdd6e7cd1522e0ba4f1914 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 11 Sep 2026 22:51:58 -0700 Subject: [PATCH 4/4] Correct wait_agents yield copy to supported-but-unwired actuality --- docs/IMPLEMENTATION.md | 2 +- src/subagent/agent-fleet.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 94a51b899..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. Exec-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/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index 27aebdfad..2ee7cbe40 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -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