# Multi-Agent Orchestration

VeilCLI supports multiple agents working together. An **orchestrator** agent delegates work to **worker** agents by spawning sub-agent sessions and messaging them. Sub-agents are regular **chat sessions** of the target agent — there is no separate execution mode.

---

## Core Tools

| Tool | Mode | Description |
|------|------|-------------|
| `agent_spawn` | Sync, async, or no-turn | Start a **chat session** with an agent. Required: `agent`, `instance_name`, `objective`. Optional: `success_criteria`, `overrides: { reasoning? }`, `initial_message: { message, async_inform? }`. (Legacy `budget_override` was removed — use `agent_control update-session` to set budgets.) |
| `agent_message` | Sync (default) or async | Send a message to a **specific session** by `sessionId`. Pass `async_inform: true` for fire-and-forget; otherwise blocks until reply. The target agent is derived from the session row. |
| `agent_control` | — | Inspect (`get-state`, `get-summary`, `get-last-message`), update (`update-session` for reasoning / budget), or `stop` a subagent session. Auto-paired with `agent_spawn` — you get it for free if your mode lists `agent_spawn`. |

---

## Pattern 1: Simple Delegation (Sync)

The simplest pattern — orchestrator spawns a worker with an initial message and waits for the reply:

```
Orchestrator
  └► agent_spawn(
       agent="coder", instance_name="analyzer",
       objective="Analyse src/index.js",
       initial_message={ message: "Analyse src/index.js and summarize its structure." }
     )
        └► Coder runs a chat turn, returns its reply
  ◄─── { instance_name, sessionId, response }
```

**Agent config** (`orchestrator/agent.json`):
```json
{
  "name": "orchestrator",
  "modes": {
    "chat": {
      "enabled": true,
      "tools": ["agent_spawn", "agent_message", "log_write"]
    }
  }
}
```

**How it works**: `agent_spawn` with a plain `initial_message` blocks until the sub-agent's first turn finishes and returns the reply inline, plus a `sessionId` you can keep talking to.

---

## Pattern 2: Parallel Fan-Out (Async)

Spawn multiple workers simultaneously with `async_inform: true`, then collect replies as they arrive:

```
Orchestrator
  ├► agent_spawn(agent="researcher", instance_name="r1", objective="Find X",
  │              initial_message={ message: "...", async_inform: true })  → notice
  ├► agent_spawn(agent="researcher", instance_name="r2", objective="Find Y",
  │              initial_message={ message: "...", async_inform: true })  → notice
  └► agent_spawn(agent="researcher", instance_name="r3", objective="Find Z",
                 initial_message={ message: "...", async_inform: true })  → notice
        │
        ◄─ each researcher's reply arrives in the orchestrator's session
           as a new user message ([Message from researcher (session: …)])
        │
        └► Synthesise once all N replies have arrived
```

**Orchestrator AGENT.md instruction**:
```markdown
When given a research topic with multiple subtopics:
1. agent_spawn one researcher per subtopic with
   initial_message={ message: ..., async_inform: true }
2. Their replies will arrive as new messages in YOUR session —
   even if you have finished your turn (the runtime wakes you).
3. Track which instance_names have replied; once all have,
   synthesise the results into a final report.
```

Delivery is durable: if the orchestrator's turn has already ended when a reply lands, the runtime **wakes the idle orchestrator** with a fresh turn and the reply is processed as a normal user message (see the asymmetric-delivery rules below).

---

## Pattern 3: Hierarchical Delegation

Multi-level hierarchies — orchestrators can spawn sub-orchestrators:

```
Top-level Orchestrator
  ├─► Project Manager (sub-orchestrator)
  │     ├─► Coder agent
  │     └─► Reviewer agent
  └─► Quality Checker
        └─► Tester agent
```

The `max_spawn_depth` budget (set on the spawned agent's `budget.max_spawn_depth`, on the calling session via `agent_control update-session { budget_override }`, or globally via `settings.budget.max_spawn_depth`) limits how deep nesting can go. **Defaults are unlimited** — set a number to enforce. When a subagent hits the cap, `agent_spawn` returns a self-explanatory `BUDGET_EXCEEDED` error with `details.remediation: 'edit_agent_or_caller_budget'`.

---

## Pattern 4: Session-Based Messaging

For back-and-forth exchanges with an agent in the same conversation:

```
Orchestrator
  └► agent_spawn(
       agent="advisor",
       instance_name="advisor_1",
       objective="Prioritise the user's quarterly goals",
       success_criteria="Top 3 priorities ranked with one-sentence rationale",
       initial_message={ message: "What should I prioritise?" }
     ) → { instance_name, sessionId, response }
  └► agent_message(sessionId=sessionId, message="What about item B?")
             ◄─ "Item B should come second because..."
  └► agent_message(sessionId=sessionId, message="Thanks, moving on.", async_inform=true)
  └► agent_control(sessionId=sessionId, action="get-state")  // peek at progress
```

**`agent_spawn`** opens the session and gets the first response. Required: `agent`, `instance_name`, `objective`. Always required to obtain a `sessionId`. Use `success_criteria` to write down what "done" looks like — it's injected into the subagent's system prompt and dramatically reduces context-gap failures at the handoff boundary.

**No-LLM-turn variant:** omit `initial_message` to create a session without firing an LLM turn. Useful when you want to set up multiple subagents up front and address them later via `agent_message`.

**Async-fire-and-forget spawn:** pass `initial_message: { message, async_inform: true }`. You get back a `notice` immediately; the subagent's first response will arrive in your session later as a new user message.

**Seed wrapping (Meeting 010 Finding B).** Both the sync (`async_inform: false`) and async branches of `agent_spawn`'s `initial_message` wrap the seed identically to `agent_message`: `[Message from <agent> (session: <sid>)]: <body>`. The target's first user-row format matches every subsequent inter-agent message, so disambiguation logic that depends on the wrapping prefix works the same regardless of whether the message arrived via spawn-seed or follow-up.

**`agent_message`** routes a message to the exact session.
- Default (`async_inform: false`) is synchronous — waits for a reply (default timeout 600s).
  - If the session is idle → triggers a new turn directly (same as a user message)
  - If the session is mid-processing → injects at the next tool-call checkpoint
  - Automatically falls back if the message is missed at the last iteration
  - Pass `full_steps: true` to receive a per-line log of assistant text + tool calls instead of just the final reply.
- `async_inform: true` is fire-and-forget — useful for notifications, follow-up nudges, or status updates without blocking. **Asymmetric delivery (Meeting 008.1):**
  - The FIRST `async_inform` to a target (the **seed**, msg#1) gets the dispatcher's **final** reply at runChat-end.
  - Any SUBSEQUENT `async_inform` to the same target while the seed's runChat is still running (msg#2+, possibly from a different caller) gets the **first mid-run text** the LLM produces after that message is drained at an iteration boundary — delivered immediately, not waiting for the runChat to finish.
  - **Mid-run replies are prefixed with a `<system-reminder>`** noting that the response is mid-run and the sub-agent is still running. This lets the receiving agent distinguish auto-deliveries (sub-agent still executing) from msg#1's final delivery (sub-agent done).
  - `full_steps` is silently ignored on msg#2+ replies.
  - **§3.3.1 coincide rule.** If a msg#2+ message is drained on the runChat's **last** iteration (the same iteration whose text becomes msg#1's final reply), msg#2+'s separate auto-delivery is suppressed — it would byte-identically duplicate msg#1's reply. The corresponding `agent_messages` row is marked `delivered` with audit text `(coincided with final reply — see msg#1 reply)` so the queue stays consistent. The msg#2+ caller does not see an extra delivery, only the original sender (msg#1) does.
  - **Multi-caller race (Finding A — fixed in Meeting 010).** Two callers firing `async_inform: true` to the same idle target in the same tick now resolve atomically: exactly one of them is registered as msg#1 (becomes the seed of the dispatch), the other becomes msg#2+ (queued + drained mid-run). `trackPendingAsyncInform` performs a single in-memory `Map.has`-then-`set` with no awaits between, so concurrent callers see a coherent winner without trace-event divergence.
  - **Autonomous wake of idle parents (Issue 2a — fixed in Meeting 011).** When the parent's runChat has already ended, the sub-agent's reply still triggers a fresh `runChat` on the parent within milliseconds. The parent's first iteration drains the queued reply via `drainNonFollowup` and processes it as a normal user message. Pre-fix, the reply rotted in the queue until the parent received a new HTTP chat. Post-fix the documented "delivered as a new user message" contract is honored autonomously — applies to both engines.
  - **Mid-run injection safety on claude-cli (Issue 2d — fixed in Meeting 011).** When a chat HTTP call routes to a claude-cli session that's actively mid-tool, the runtime now buffers the injection until the SDK is parked on `streamInput`'s next-promise (post-`result` boundary). Pre-fix, mid-tool `pushMessage` interrupted the SDK: tool_results became an "interruption" sentinel and partial assistant text was dropped from context. Post-fix, the tool_result lands intact and the injection delivers cleanly after the current turn ends.

`agent_message` requires a `sessionId` (from `agent_spawn`); the target agent is derived from the session row. Unknown sessions return an immediate error instead of silently timing out. **Per-call `budget_override` is not accepted on `agent_message` or `agent_spawn`** — set per-session caps via `agent_control { action: 'update-session', params: { budget_override: {...} } }` (applied immediately to the running loop) or at the agent / harness level.

---

## Inspecting Subagents — `agent_control`

`agent_control` is auto-paired with `agent_spawn`: any agent whose mode lists `agent_spawn` in its `tools` (or `preActivatedTools`) automatically gets `agent_control` too. Three actions:

| Action | Cost | Returns |
|--------|------|---------|
| `get-state` | Free (no LLM) | `<is-idle>`, `<number-of-messages>`, `<number-of-tools>`, `<instance-name>`, `<session-id>`. Counters are **since the caller's last contact**. |
| `get-summary` | One LLM call | `get-state` fields + `<summary>` (2–3 sentences). Uses the `summarizerModel` from settings, falling back to `config/config.json`'s `defaultSummarizerModel`. |
| `stop` | Free | Cooperative cancel — same path as `user_cancel`. Dangling tool calls receive `[cancelled — no result]` placeholders so the session stays valid for inspection. Idempotent. |

**Optional rate cap on `get-summary`.** Set `settings.summarizerMaxCallsPerMinute` to a positive integer to cap how many summary calls one workspace can make per rolling minute. **Default is `null` = unlimited.** When the cap is set and exceeded, `get-summary` returns the same envelope plus `<rate-limited>true</rate-limited>` and `<retry-after-ms>60000</retry-after-ms>` instead of running the LLM call. The orchestrator agent should back off accordingly — or fall back to `get-state` (which is always free).

---

## Sub-agent Configuration

Spawned agents run in **chat mode** — the target agent just needs `chat` enabled:

```json
{
  "name": "researcher",
  "modes": {
    "chat": {
      "enabled": true,
      "maxIterations": 20,
      "permissions": {
        "allow": ["web_search", "web_fetch", "read_file", "memory_write"]
      }
    }
  }
}
```

---

## Agent Restrictions

Orchestrators can whitelist which sub-agents they're allowed to spawn:

```json
{
  "modes": {
    "chat": {
      "allowedAgents": ["researcher", "writer", "coder"],
      "disallowedAgents": ["admin"]
    }
  }
}
```

---

## Depth and Budget

Phase 3 introduced a 3-axis budget governor for `agent_spawn` / `agent_message`. All three axes default to `null` = **unlimited** unless explicitly configured.

**Resolution order** (each layer overrides the next): caller-session column (set via `agent_control update-session { budget_override }`) > per-agent `agent.json`'s `budget` > harness `settings.budget`. Note: `agent_spawn` no longer accepts `budget_override` — limits flow from the spawned agent's config and harness defaults at spawn time.

```json
{
  "budget": {
    "max_tokens": 200000,
    "max_wall_seconds": 600,
    "max_spawn_depth": 3
  }
}
```

| Axis | Effect |
|------|--------|
| `max_tokens` | Caps combined input + output tokens for one runChat. Breach emits `session.budget_exceeded` and stops the loop. |
| `max_wall_seconds` | Caps wall-clock time for one runChat. Same breach event. |
| `max_spawn_depth` | Caps the depth of nested `agent_spawn` chains. When a child would exceed the cap, `agent_spawn` returns `BUDGET_EXCEEDED` with `details.budgetType: "max_spawn_depth"`, `details.limit`, `details.actual`, and `details.remediation: "edit_agent_or_caller_budget"`. To raise: edit the spawned agent's `budget.max_spawn_depth` config, or update the caller session's row via `agent_control update-session { budget_override: { max_spawn_depth: N } }`. |

**Null vs zero:** `null` is unlimited; `0` means "immediate breach on every call." The resolver uses `??` semantics — `0` is honored as a real cap, not coerced to "unlimited."

---

## Practical Example: Code Review Pipeline

**Agents**: `coordinator`, `coder`, `reviewer`

**Coordinator AGENT.md**:
```markdown
You are a code review coordinator.

When given a directory to review:
1. Use list_dir to find all .js files
2. For each file, agent_spawn a "coder" agent with
   initial_message={ message: "Analyse <file> ...", async_inform: true }
3. Their analyses arrive in your session as new messages — collect them
4. Once all analyses are in, agent_spawn a "reviewer" agent (sync)
   with the combined analysis as the initial_message
5. Return the final review report
```

**Coder `agent.json`**:
```json
{
  "name": "coder",
  "modes": {
    "chat": {
      "enabled": true,
      "permissions": { "allow": ["read_file", "grep", "glob"] }
    }
  }
}
```

**Reviewer `agent.json`**:
```json
{
  "name": "reviewer",
  "modes": {
    "chat": {
      "enabled": true,
      "permissions": { "allow": ["read_file", "memory_write"] }
    }
  }
}
```
