Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ loosely while pre-1.0 (breaking changes can land on minor bumps).

## [Unreleased]

### Added
- **Constitution delegation policies.** Optional `delegation` object on an
agent constitution (`max_depth`, `allowed_callees`, `denied_callees`,
`max_subtree_tokens`, `max_subtree_usd`). The runtime fail-closes at
`/agent`, `/parallel`, and JIT `run_ephemeral` spawn with a tool `ERR:`
(same posture as the global depth cap of 2). Unknown/malformed policy
fields throw at parse. Stock `agents/*.json` stay unrestricted unless
an operator adds a block. Presence review does not consult the gate.
See [Delegation policies](docs/concepts/delegation.md).
## [0.13.11] — 2026-09-21

- **Fleet dashboard pane (Phase C1).** The TUI consumes fleet SSE (`stream_id` +
Expand Down
44 changes: 44 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1664,13 +1664,57 @@ if(INDEX_BUILD_TESTS)
${CMAKE_SOURCE_DIR}/third_party/doctest
${OPENSSL_INCLUDE_DIR}
)
target_compile_definitions(unit_constitution PRIVATE
ARBITER_AGENTS_DIR="${CMAKE_SOURCE_DIR}/agents")
target_link_libraries(unit_constitution PRIVATE
OpenSSL::SSL
OpenSSL::Crypto
CURL::libcurl
)
add_test(NAME unit_constitution COMMAND unit_constitution)

# Delegation policy: parse lives in unit_constitution; this binary
# drives Orchestrator spawn denial (/agent, /parallel, JIT ephemeral)
# without a live LLM (gates return ERR before send_internal).
add_executable(unit_delegation
tests/test_delegation.cpp
src/orchestrator.cpp
src/agent.cpp
src/constitution.cpp
src/commands.cpp
src/context_compaction.cpp
src/model_catalog.cpp
src/model_context.cpp
src/advisor.cpp
src/advisor_gate.cpp
src/presence.cpp
src/intent.cpp
src/atomic_file.cpp
src/message_codec.cpp
src/tui/stream_filter.cpp
src/tui/block_parser.cpp
src/workspace_root.cpp
src/workspace_map.cpp
src/ssrf_guard.cpp
src/event_routing.cpp
src/api_client.cpp
src/circuit_breaker.cpp
src/metrics.cpp
src/json.cpp
)
target_include_directories(unit_delegation PRIVATE
${CMAKE_SOURCE_DIR}/include
${CMAKE_SOURCE_DIR}/third_party/doctest
${OPENSSL_INCLUDE_DIR}
)
target_link_libraries(unit_delegation PRIVATE
OpenSSL::SSL
OpenSSL::Crypto
CURL::libcurl
Threads::Threads
)
add_test(NAME unit_delegation COMMAND unit_delegation)

# Per-conversation Agent history isolation (ConversationScope / #40).
add_executable(unit_agent_conversation
tests/test_agent_conversation.cpp
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ calendar commitments.
- [x] **Always-on presence-** Constitution `presence.mode: always_on`; a pair colleague looks over a peer's shoulder after each tool batch and may inject `[PRESENCE: …]` context. Fail-open; cannot halt. SSE `presence` + TUI `◎ presence`. Opposite lifetime of JIT (#208).
- [x] **Fleet dashboard pane-** Live tree of depth, agent, tools, tokens; click-to-focus / `Ctrl-w f` ([docs](docs/tui/fleet.md))
- [ ] **Plan to execution observability-** Planner plans as first-class objects with progress against todos
- [ ] **Delegation policies-** Consitutions declare max depth, allowed callees, budget caps (tokens/$)
- [x] **Delegation policies-** Constitutions declare max depth, allowed callees, budget caps (tokens/$)
- [ ] ~~**Workflow recipes-** Checked-in “crews” (JSON): ordered/parallel graphs of agents + shared todo board~~
- [ ] **Advisor policy packs-** Reusable gate profiles (strict / coding / research)

Expand Down
13 changes: 13 additions & 0 deletions docs/api/agents/create.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Either a bare constitution or wrapped under `agent_def`:
| `memory` | object | no | Per-agent memory enrichment toggles for `/mem search` and `/mem add entry`. See schema below and [Memory enrichment](../../concepts/structured-memory.md#memory-enrichment) in the structured-memory concept. |
| `intent` | object | no | Pre-dispatch classify/route. Distinct from `memory.intent_routing`. File agents default `mode: "off"`; the built-in `index` master defaults `hybrid`. See [Intent](../../concepts/intent.md). |
| `presence` | object \| string | no | Always-on residency. Object form: `{mode?, watch?, interject?, model?, prompt?, max_notes_per_turn?}`. String `"always_on"` is `{mode: "always_on"}`. See [Presence](../../concepts/presence.md). |
| `delegation` | object | no | Runtime spawn gates for `/agent`, `/parallel`, and JIT ensure covers. Absent keeps today's behaviour (global depth 2, any catalog callee, no subtree budget). Unknown keys fail closed. See [Delegation policies](../../concepts/delegation.md) and the schema below. |
| `personality` | string | no | Free-form personality overlay. |

#### `advisor` object schema
Expand Down Expand Up @@ -94,6 +95,18 @@ Always-on peer observation. Absent / `mode: "off"` keeps a request-scoped specia
| `prompt` | string | built-in | Override the review system prompt. |
| `max_notes_per_turn` | int | `1` | Cap on `CONTEXT` notes per working-agent `stream_id` (1–4). |

#### `delegation` object schema

Runtime-enforced spawn policy. Distinct from `max_tokens` (per-turn response size). Empty / omitted fields do not tighten the stock roster.

| Sub-field | Type | Default | Notes |
|-----------|------|---------|-------|
| `max_depth` | int | `2` | Absolute child depth this agent may spawn to (0..2). `0` = cannot spawn and cannot run as a delegated / JIT worker. |
| `allowed_callees` | array\<string\> | `[]` | Primary allowlist of agent ids. Empty / omitted = no extra restriction beyond catalog existence. |
| `denied_callees` | array\<string\> | `[]` | Optional denylist applied after the allowlist. |
| `max_subtree_tokens` | int | unlimited | Cap on delegated work (children + descendants) for the current top-level turn. Fail closed with `ERR:` when spent ≥ cap. |
| `max_subtree_usd` | number | unlimited | Same window; coarse model-family USD estimate, not a billing ledger. |

```bash
curl -X POST \
-H "Authorization: Bearer atr_…" \
Expand Down
2 changes: 1 addition & 1 deletion docs/cli/init.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Index speaks in a **conversational** register (complete sentences, collaborative

The starter JSON files are the **single source of truth** for what gets written. They live in `agents/` in the source tree and are embedded into the binary at build time. `--init` writes them verbatim — pretty-printed, in source order, byte-identical to the source tree — so the file you see on disk matches what a maintainer would see in the repo.

Each file is a plain JSON document — a model id, system prompt, tool allowlist, optional advisor block, optional `presence` residency, optional cost-attribution metadata. `jules` is the bundled [always-on presence](../concepts/presence.md) example (pair colleague, not a second advisor). Edit them in place, or copy one as the basis for your own agent. Drop a new `agents/<id>.json` into the source tree and it'll show up in `--init` automatically on the next build (no code changes required).
Each file is a plain JSON document — a model id, system prompt, tool allowlist, optional advisor block, optional `presence` residency, optional `delegation` spawn policy, optional cost-attribution metadata. `jules` is the bundled [always-on presence](../concepts/presence.md) example (pair colleague, not a second advisor). Starters omit `delegation`, so they keep the global depth cap of 2 and may call any catalog agent; add a block to tighten. Edit them in place, or copy one as the basis for your own agent. Drop a new `agents/<id>.json` into the source tree and it'll show up in `--init` automatically on the next build (no code changes required).

## Re-seeding from defaults

Expand Down
3 changes: 2 additions & 1 deletion docs/concepts/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ flowchart LR
subgraph EXECUTION["Agent execution"]
direction TB

CONSTITUTION["Constitution<br/>model · role · rules · tool allowlist"]
CONSTITUTION["Constitution<br/>model · role · rules · tool allowlist<br/>delegation policy"]

AGENT["Agent"]

Expand Down Expand Up @@ -124,6 +124,7 @@ For implementation details and deeper explanations, see:
- [Reconcile](reconcile.md)
- [Advisor](advisor.md)
- [Presence](presence.md)
- [Delegation policies](delegation.md)
- [Structured memory](structured-memory.md)
- [MCP](mcp.md)
- [A2A](a2a.md)
Expand Down
1 change: 1 addition & 0 deletions docs/concepts/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ Deleting a folder unfiles its conversations (`folder_id` cleared) rather than ca
| `advisor` | object? | Structured advisor config: `{model, prompt?, mode?, max_redirects?, malformed_halts?}`. `mode: "consult"` (default) makes `/advise` available; `mode: "gate"` additionally enforces a runtime gate at the executor's terminating turn. See [advisor](advisor.md). |
| `intent` | object? | Ingress classify/route: `{mode?, min_confidence?, apply_routing?, model?}`. Distinct from `memory.intent_routing`. See [intent](intent.md). |
| `presence` | object? | Pair-colleague residency: `{mode?, watch?, interject?, model?, prompt?, max_notes_per_turn?}`. `mode: "always_on"` looks over a matching peer's shoulder after each tool batch and may inject a `[PRESENCE: …]` note. See [presence](presence.md). |
| `delegation` | object? | Runtime spawn gates for `/agent`, `/parallel`, and JIT ensure covers: `{max_depth?, allowed_callees?, denied_callees?, max_subtree_tokens?, max_subtree_usd?}`. Absent = global depth cap 2, any catalog callee, no subtree budget. Distinct from `max_tokens`. See [delegation policies](delegation.md). |
| `advisor_model` | string? | **Legacy** shorthand for `advisor.model` with `mode: "consult"`. New configs should use `advisor`. |
| `personality` | string? | Free-form personality overlay. |
| `created_at` | integer | Epoch seconds. Stored agents only; absent for the built-in `index`. |
Expand Down
88 changes: 88 additions & 0 deletions docs/concepts/delegation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Delegation policies

Constitutions can declare **runtime** limits on who an agent may spawn, how deep the pipeline may go, and how much delegated work may cost. The orchestrator enforces them at `/agent`, `/parallel`, and JIT ensure cover spawn. The model does not get a vote — a violation is a tool `ERR:`, the same posture as the global depth cap of 2.

This is not a new orchestrator. Conversational [`POST /v1/orchestrate`](../api/orchestrate.md) and reconcile observe/ensure stay on their existing loops; policy is a gate on spawn.

## Why a runtime gate

`capabilities` already decides whether an agent may emit `/agent` at all. That is a verb allowlist. Delegation policy is the *callee* allowlist (and budget) sitting next to it:

- Index can be told it may not send work to `forge`.
- A specialist can be told it may not re-delegate (max depth 1), or may not run as a child at all (max depth 0).
- A token or spend cap on the delegated subtree stops further spawn once it is exhausted.

Prompt text cannot do this. The runtime already refuses `depth >= 2` with `ERR: delegation depth limit reached (max 2 levels)` regardless of what the model wrote. Per-constitution policy uses that same fail-closed path.

Presence is the opposite: it is fail-open and **cannot halt**. Presence review does not consult this gate.

## Configuration

The `delegation` object lives on a `Constitution`. Absent / omitted fields keep today's behaviour (global depth 2, any catalog callee, no subtree budget). Stock `agents/*.json` starters do not set the block.

```jsonc
"delegation": {
"max_depth": 1,
"allowed_callees": ["scout", "vera"],
"denied_callees": ["forge"],
"max_subtree_tokens": 8000,
"max_subtree_usd": 0.50
}
```

| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `max_depth` | int 0..2 | `2` | Absolute pipeline depth this agent may spawn *to* (the child's depth). `0` = cannot spawn, and cannot itself run as a delegated or JIT worker. Must not exceed the global cap of 2. |
| `allowed_callees` | array\<string\> | `[]` | **Primary allowlist.** Non-empty: callee id must be in the list. Empty / omitted = no extra restriction beyond catalog existence. |
| `denied_callees` | array\<string\> | `[]` | Optional denylist applied *after* the allowlist. Empty / omitted = nobody extra is forbidden. Use this to forbid one id (`forge`) without listing the rest of the roster. |
| `max_subtree_tokens` | int ≥ 0 | unlimited | Cap on delegated work (children + their descendants) for the current top-level turn. Distinct from constitution `max_tokens` (response size). `0` = no spawn. Further spawn returns `ERR:` when spent ≥ cap; the in-flight child is not killed mid-turn. |
| `max_subtree_usd` | number ≥ 0 | unlimited | Same window as the token cap, using a coarse model-family USD estimate (Haiku / Sonnet / Opus / GPT / local=0; unknown hosted ≈ Sonnet). Not a billing ledger. |

Unknown keys, wrong types, `max_depth` outside 0..2, or invalid agent ids **throw** at `Constitution::from_json` (admit fail closed). A malformed policy file is skipped by `load_agents`; `POST /v1/agents` returns 400.

Both lists may be set: the callee must pass the allowlist (if any) and must not be on the denylist.

## What the runtime checks

On every spawn the caller is the agent that emitted `/agent` / `/parallel` (or `index` for JIT `run_ephemeral`). The child depth is `caller_depth + 1`.

1. Global cap: `depth >= 2` still returns the historical `ERR: delegation depth limit reached (max 2 levels)` / `ERR: /parallel cannot delegate past depth 2`.
2. Self-invoke and `index` as callee — unchanged.
3. Catalog existence — unchanged (`ERR: no agent '…'`).
4. **Caller `max_depth`:** child depth must be ≤ the caller's effective cap.
5. **Caller `allowed_callees` / `denied_callees`.**
6. **Callee `max_depth`:** the child must be allowed to *run* at that depth (a `max_depth: 0` agent cannot be a depth-1 worker).
7. **Caller subtree budget** (tokens and/or USD) against spend recorded for this top-level turn.

A child cannot re-delegate past *its* own `max_depth`. Index cannot send work to a forbidden callee. JIT ensure covers go through `run_ephemeral` at depth 1 with `index` as the caller, so a constitution on index that forbids `forge` (or a cover constitution with `max_depth: 0`) is honoured the same way as `/agent`. Nested `/agent` from a JIT clone uses that clone's constitution.

The gate runs **before** the child LLM call. Presence review does not.

## `ERR:` shapes

```
ERR: delegation depth limit reached (max 2 levels)
ERR: delegation depth limit reached (constitution max_depth 1)
ERR: callee 'forge' is not permitted by constitution.delegation.allowed_callees
ERR: callee 'forge' is forbidden by constitution.delegation.denied_callees
ERR: agent 'scout' cannot run at depth 1 (constitution max_depth 0)
ERR: delegation token budget exceeded (max_subtree_tokens 8000)
ERR: delegation spend budget exceeded (max_subtree_usd 0.5)
```

Do not retry the same spawn; the runtime will refuse it again. When a policy is present the system prompt also grows a `DELEGATION POLICY` block so the model sees the same numbers — that is hint, not enforcement.

## What this is not

- Not workflow recipes (ordered crews).
- Not advisor policy packs.
- Not plan-to-execution observability.
- Not a second fleet dashboard. A denied spawn is an `ERR:` tool result (`tool_call` `ok: false` on the existing stream).

## See also

- [Writ](writ.md) — `/agent` / `/parallel` verbs.
- [Reconcile](reconcile.md) — JIT ensure covers (`run_ephemeral`).
- [Fleet streaming](fleet-streaming.md) — depth 0 / 1 / 2.
- [Presence](presence.md) — fail-open; cannot halt.
- [`POST /v1/agents`](../api/agents/create.md) — constitution schema.
4 changes: 2 additions & 2 deletions docs/concepts/fleet-streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Open a UI slot on `stream_start`, route every subsequent event with matching `st
| 1 | A delegated sub-agent (via `/agent` or `/parallel`). |
| 2 | A sub-sub-agent (delegation by a depth-1 agent). |

The depth cap is 2; attempts to delegate past depth 2 surface to the requesting agent as an `ERR:` tool result.
The depth cap is 2; attempts to delegate past depth 2 surface to the requesting agent as an `ERR:` tool result. A constitution may tighten that further with `delegation.max_depth` (0..2) and restrict callees / subtree budget — see [Delegation policies](delegation.md). Those gates also return `ERR:` and do not invent a third depth.

## When `/parallel` is in play

Expand Down Expand Up @@ -59,7 +59,7 @@ done ok=true
## Parallel safety rails

- **Same `agent_id` reused in `/parallel` is allowed.** Each child runs on an ephemeral `Agent` instance built from the canonical agent's `Constitution`, so siblings have independent `history_` vectors and don't race. (This was a constraint pre-2026-04 but is now lifted.)
- **Depth cap.** A depth-2 turn cannot `/parallel`; attempts return an ERR tool result.
- **Depth cap.** A depth-2 turn cannot `/parallel`; attempts return an ERR tool result. A constitution `delegation.max_depth` of 0 or 1 refuses earlier, with a constitution-scoped ERR.
- **Each parallel child gets its own dedup cache.** Sibling threads fetching the same URL both fetch — accept the duplicate over a `std::map` data race.
- **SSE writes are serialized.** A shared mutex on the wire-writer means events interleave cleanly even when N threads emit at once.

Expand Down
1 change: 1 addition & 0 deletions docs/concepts/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Start here if you want the model before the reference material.
| [Voice](voice.md) | Spoken register + `channel: "voice"` for Intercom-style bridges; PA memory habit |
| [Advisor](advisor.md) | Structural supervision gates (`CONTINUE` / `REDIRECT` / `HALT`) |
| [Presence](presence.md) | Pair colleague at a peer's shoulder — useful context mid-turn |
| [Delegation policies](delegation.md) | Per-constitution spawn gates: max depth, callees, subtree budget |
| [Intent](intent.md) | Pre-dispatch classify/route (heuristic + optional LLM) |
| [Reconcile](reconcile.md) | Desired end state → workspace contract, tests, rollback |
| [SSE events](sse-events.md) | The stream contract shared by TUI and HTTP |
Expand Down
2 changes: 1 addition & 1 deletion docs/concepts/presence.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ The review is history-less — one snapshot in, one signal out — matching the
<note>one or two sentences the working agent should see now</note>
```

`SILENT` is the default. Surrounding prose is tolerated. Missing `<signal>`, an unknown token, or `CONTEXT` without `<note>` is **malformed Silent** (fail-open). Presence cannot `HALT` or `REDIRECT` — that remains the advisor's job.
`SILENT` is the default. Surrounding prose is tolerated. Missing `<signal>`, an unknown token, or `CONTEXT` without `<note>` is **malformed Silent** (fail-open). Presence cannot `HALT` or `REDIRECT` — that remains the advisor's job. Constitution `delegation` policy does not apply to presence review.

## Runtime control flow

Expand Down
3 changes: 3 additions & 0 deletions docs/concepts/reconcile.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,16 @@ Same SSE fabric as orchestrate. Persist + replay via `request_status` / `request
| `reconcile.done` | Structured result (`status`, `contract`, `delta`, `evidence`, `waves`). |
| `done` | Terminal aggregate (`ok` true only when `status=satisfied`). |

JIT covers honour the covering agent's constitution **and** index's `delegation` policy: a constitution that forbids calling `forge` or `max_depth: 0` fail-closes `run_ephemeral` with `error_type: delegation_policy` before the clone's LLM turn. Nested `/agent` from a clone uses that clone's policy. See [Delegation policies](delegation.md).

TUI / `--send` do not call this path yet. Use [`POST /v1/reconcile`](../api/reconcile.md) or [`@arbiter/sdk`](../../sdk/ts/README.md).

## See also

- [`POST /v1/reconcile`](../api/reconcile.md)
- [Intent](intent.md) — classify/route, not reconcile
- [Presence](presence.md) — always-on residency; opposite of JIT
- [Delegation policies](delegation.md) — spawn gates on JIT covers
- [Sandbox](sandbox.md)
- [Durable execution](durable-execution.md)
- ROADMAP Phase 5
Loading
Loading