docs: cap agents and models, fuzzy matcher, orchestration, feed panes, README overview

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 19:16:18 +01:00
co-authored by Claude Sonnet 5.5
parent ec9c471780
commit 4b43660e2b
9 changed files with 794 additions and 121 deletions
+1
View File
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased] - 2026-10-25
- Documentation: new [docs/cap-agents.md](docs/cap-agents.md) (agents, models, probes, `@agent` resolution, errors, limits, troubleshooting), rewritten [docs/fuzzy.md](docs/fuzzy.md) (algorithm, constants, measured worked examples) and [docs/orchestration.md](docs/orchestration.md) (protocol with examples, layout, lifecycle, state machine, formatter, event mapping, supervision, collection, caps, measurements, verification, gaps), feed panes in [docs/mplex.md](docs/mplex.md), README overview; `make docs` checks the new pages; drift tests keep the constant tables in step with the code.
- Orchestration on the multiplexer screen ([docs/orchestration.md](docs/orchestration.md)): every sub agent (`fork` worker, CAP session, `@agent` mention) gets a live, word-wrapped panel; the main pane stays left at full height and sub agents stack on the right (1, 2, 3 ... up to what fits at 6 rows each). New: feed panes in the multiplexer (`vpane_*` control commands, `MplexVpanes`), `src/orch/` (`OrchBoard`, `OrchPanel`, `OrchFormatter`, `OrchTransport`, `OrchCapAdapter`), `Agent::observer` and cancel flag, supervision (idle warn 60 s, idle cancel 300 s, soft cancel then close after 5 s, closing the pane cancels the job), CAP answers collected through the spill path. Tests: `build/orch_test`, `tests/orch_mplex_e2e.py` with `build/orch_live`. Measured: about 64 frames/s, median 12.9 ms between frames, p90 25 ms with three agents streaming 500 tokens/s each.
- Generic `detz::FuzzyMatcher` (src/util/fuzzy.*, docs/fuzzy.md, tests/fuzzy_test.cpp): subsequence DP with fzf constants, Damerau-Levenshtein, per-token typo and trigram signals; `@agent` / `@model` mentions now always pick the most related candidate (floor 0.3, ties shorter then earlier, a note shows what was resolved). Research: research/fuzzy-matching-selectors.
- `@agent`, `@agent:model` and bare `@model` mentions resolve against CAP agents via the new `CapModelCatalog` (manifest `[models]` list or `--cap-models` probe); turns run through `CapMention`; failures print a per-agent report of status, binary and models. Test: `build/capmcp/cap_model_catalog_test`. Spec: docs/cap.md.
+1 -1
View File
@@ -156,7 +156,7 @@ release-dry:
PART=$(PART) MSG="$(MSG)" sh -n tools/release.sh && echo "release dry-run OK"
docs:
@for f in agent cap fuzzy mcp tui mplex shell store util llm misc foundation parity; do test -f docs/$$f.md || { echo "missing docs/$$f.md"; exit 1; }; done; echo "docs OK"
@for f in agent cap cap-agents fuzzy orchestration mcp tui mplex shell store util llm misc foundation parity; do test -f docs/$$f.md || { echo "missing docs/$$f.md"; exit 1; }; done; echo "docs OK"
clean:
rm -rf $(BUILD)
+36
View File
@@ -0,0 +1,36 @@
# detz: C++23 port of tai.py
Future `molo detz` subcommand. See plan.md, SPEC.md, docs/.
Build: `make && make test`. Binary: `build/detz`. Rules for working here: `AGENTS.md`.
## What taicpp adds on top of the port
The port is behavior-compatible with `../tai.py` and checked against it (`PARITY.md`, `docs/parity.md`).
These parts exist only here; each has a specification and tests:
| Feature | Spec | Code | Tests |
|---|---|---|---|
| Fuzzy matching of identifiers (letter order, word boundaries, typos, trigrams) | [docs/fuzzy.md](docs/fuzzy.md) | `src/util/fuzzy.*` | `build/fuzzy_test` |
| CAP agents and their models: discovery, probes, `@agent`, `@agent:model`, `@model` | [docs/cap-agents.md](docs/cap-agents.md) | `src/cap/cap_model_catalog.*`, `src/cap/cap_mention.*` | `build/capmcp/cap_model_catalog_test` |
| Sub agents in live panels on the multiplexer screen, with supervision | [docs/orchestration.md](docs/orchestration.md) | `src/orch/`, `src/mplex/mplex_vpane.*` | `build/orch_test`, `tests/orch_mplex_e2e.py` |
Reading order for a new contributor: `docs/cap.md` (the protocol), `docs/cap-agents.md` (agents and
models), `docs/fuzzy.md` (how a mention is matched), `docs/mplex.md` and `docs/orchestration.md` (the screen).
## Using it
```
@claude refactor the parser run one turn on a CAP agent (typos are fine: @clde)
@claude:opus review the diff on a specific model of that agent
@opus review the diff a model name alone works when one agent offers it
@claude pin the agent for following messages; @auto clears all pins
```
Sub agents (`fork` workers and agent turns) appear in panels on the right of the multiplexer screen while
they work; closing a panel cancels its agent. `TAI_ORCH_PANELS=0` turns the panels off.
## Tests
`make test` builds every test binary and runs `tests/run_all.py` (36 steps, logs in `build/test-logs/`).
Single steps: `python3 tests/run_all.py --only orch`, `--only mplex`, `--only capmcp`, `--only fuzzy`.
The research behind the decisions is in `../research/` (index: `../research/INDEX.md`).
+265
View File
@@ -0,0 +1,265 @@
# CAP agents, their models, and `@agent` mentions
Code: `src/cap/cap_model_catalog.*` (discovery and resolution), `src/cap/cap_mention.*` (running a turn on
an agent), `Agent::resolve_tag`, `Agent::apply_mentions`, `Agent::run_agent` in `src/agent/agent.cpp`.
Tests: `tests/cap_model_catalog_test.cpp` (step `capmcp.catalog`). Research:
`research/cap-protocol/` (CAP, ACP, MCP sampling, delegation as a tool) and
`research/fuzzy-matching-selectors/`. This page is the user guide and the reference for what exists
today. The protocol itself (events, bindings, manifest, fleet) is in [cap.md](cap.md); how the running
agents are shown and supervised is in [orchestration.md](orchestration.md); the scoring is in
[fuzzy.md](fuzzy.md).
Everything here is a taicpp extension of the Python app except the CAP protocol itself.
## 1. What you can do
In the interactive session, start a message with `@` followed by an agent, an agent and a model, or just a
model:
```
@claude refactor the parser run this turn on the claude agent
@claude:opus review the diff same, on its opus model
@opus review the diff a model name alone works when one agent offers it
@clde:opu review the diff typos are fine: the closest agent and model are picked
@claude no text: pin the agent for the following messages
@auto clear every pin (agent, provider, model)
```
The turn runs in a panel on the multiplexer screen ([orchestration.md](orchestration.md)) and the answer is
printed and filed in the conversation. If what was typed differs from what was picked, one line says so:
```
'tia:zne' resolved to tai:zen-free (fuzzy 0.67)
```
When nothing fits, the message names the closest candidate and lists every agent with its status,
binary, manifest, binding and models (section 6).
## 2. Which agents exist
An agent is a CAP manifest, a TOML file named `<agent>.toml` in:
1. `~/.config/cap/agents/` (user), or
2. `/usr/share/cap-agents/` (system).
The catalog lists the files of both directories (at most `CAP_AGENT_MAX` = 64, sorted by name) and checks each
one before anything uses it:
| Status | Meaning |
|---|---|
| `ready` | manifest parses, `agent.binary` is found on `PATH` and is executable |
| `no_binary` | manifest is fine but the binary is missing or not executable |
| `bad_manifest` | the file does not parse, a required field is missing, a pattern is outside the CAP subset, or the name inside differs from the file name |
| `no_manifest` | the registry found nothing for that name (`cap_manifest_missing`) |
Only `ready` agents take part in fuzzy picks. An agent that is not ready is mentioned only when nothing
ready fits, and then the message says why it cannot be used.
Manifest resolution itself follows CAP ([cap.md](cap.md), `CapRegistry`): a `cap-agent.toml` next to the
binary, then the user directory, then the system directory, then `<binary> --cap-manifest` with a
`CAP_PROBE_TIMEOUT` of 15 s.
A minimal manifest:
```toml
[agent]
name = "myagent"
binary = "myagent"
profiles = ["coding"]
[startup]
command = ["myagent"]
ready_when = { pattern = "ready" }
model_arg = "--model" # how a chosen model is passed to the agent
[pty]
cols = 120
rows = 40
[capabilities]
streaming_output = true
[models]
list = ["myagent-large", "myagent-small"]
default = "myagent-small"
```
`startup.model_arg` is CAP's own field: when a model is chosen, the driver appends `model_arg model` to
the start command. If it is missing, the choice is kept but cannot reach the agent and a warning is
recorded (CAP behavior, see the opencode manifest in the research archive).
## 3. Which models an agent has
CAP itself defines no model list (`research/cap-protocol/cap-core-v1.md`). taicpp adds two sources, merged
(duplicates removed, at most `CAP_MODEL_MAX` = 500 per agent, the manifest list first):
**Static, in the manifest**
```toml
[models]
list = ["a", "b"] # strings, or tables with id / name / model
default = "a"
```
**Live, by probe** (optional)
```toml
[models]
probe = ["myagent", "--cap-models"]
# or, to use "<binary> --cap-models":
[capabilities]
models = true
```
The probe runs without a terminal and without input, is limited to `CAP_MODEL_PROBE_TIMEOUT` = 5 s and
`CAP_MODEL_PROBE_MAX_BYTES` = 1 MiB, and must exit 0. Accepted output:
```
["m1", "m2"] a JSON array of ids
[{"id": "m1"}, {"name": "m2"}, {"model": "m3"}] objects (id, then name, then model)
{"models": ["m1", "m2"], "default": "m1"} an object with a default
m1\nm2\n# comment\n one id per line; blank lines and # lines are skipped
```
A probe is never trusted to be safe to run for every binary, which is why it only runs when the manifest asks
for it. A failed probe (timeout, non-zero exit, no models in the output) never raises: the reason is kept on
the agent and shown in the report, and the static list still applies.
Results are cached for `CAP_CATALOG_TTL` = 60 s and the agents are loaded in parallel, so a miss costs one
probe round, not one per agent in sequence.
`tai --cap-models` is not implemented on tai's own side; tai's manifest declares no models. For ACP agents a
live list exists in the protocol (a `model` config option at session setup) and is the recommended next
source ([research conclusion](../../research/cap-protocol/conclusions/cap-model-handling-practice.md));
it is not built.
## 4. How a mention is resolved
Order inside `Agent::resolve_tag` (first hit wins), for the word after `@`:
1. a bot name or nickname,
2. a provider label, then `molodetz`, then a configured backend label,
3. a model id already known to the application (exact),
4. `auto`,
5. an exact agent name, or `agent:...` whose first part is an exact agent name,
6. an existing file path,
7. the fuzzy catalog (below),
8. otherwise an unknown tag (the usual error, plus the agent report when agents exist).
Steps 1 to 6 never go through fuzzy matching, so a real bot, provider, model or file is never taken over
by an agent. A file is checked before the fuzzy step so `@README.md` is never rerouted.
The fuzzy catalog (`CapModelCatalog::resolve`) works on the text as typed:
1. **Exact:** an agent whose name equals the text (case-insensitive), or any ready agent's model id equal to
the text. A matching agent that is not ready fails here with its reason.
2. **`head:tail`** (split at the first `:`): `head` is matched against the names of the ready agents with
a floor of `CAP_FUZZY_FLOOR` = 0.3. If one is found:
- empty `tail`: that agent, its default model;
- the agent has no model list: the tail is passed through unchecked, with a note saying so;
- otherwise `tail` is matched against that agent's models with no floor, because the agent already
narrows it down. The reported score is the lower of the two.
If `head` matches no ready agent but matches one that is not ready, the message says it is unusable;
if it matches nothing, the whole text is tried as step 3 (so `llama3:8b` still works as a model id).
3. **Everything:** the whole text is ranked against every ready agent name and every model of every ready
agent together. The best score at or above the floor wins; ties go to the shorter id, then to list order,
and never raise an error. If no ready candidate reaches the floor, agents that are not ready are
considered, then the request fails with the closest candidate named.
The scoring is [fuzzy.md](fuzzy.md). A pick that differs from the typed text sets a note (`'x' resolved to
y (fuzzy 0.xx)`), printed before the turn runs.
## 5. Running the turn
`Agent::apply_mentions` reads leading `@` tags (several can be combined, for example a bot and a model).
For an agent tag:
| Situation | Result |
|---|---|
| agent plus text | the text runs once on that agent (`Agent::run_agent`) |
| agent only, no text | the agent (and model) is pinned: `Agent::agent_pin`; every following plain message goes to it |
| `@auto` | clears the pin together with the provider and model pins |
| agent tag together with a bot tag | the agent takes the turn; the bot tag does not switch bots in that message |
`CapMention::ask` does the work:
- one `CapSession` per (agent, model, working directory) is created on first use and kept for later turns, so
an agent keeps its own context between mentions;
- every turn opens a panel and feeds it from the session's `cap.*` events (text, thoughts, tool calls, plan,
usage, errors), whatever the binding ([orchestration.md](orchestration.md));
- the turn is limited to `CAP_TURN_TIMEOUT` = 600 s; a panel idle for 300 s or closed by the user cancels the
turn: `cap.cancel` first, and the session is closed after a 5 s grace if the agent ignores it;
- the answer comes back as text, or as a single line starting `cap agent <name> ...` when the turn timed out,
was cancelled, ended without an answer, or failed to start; a start failure also appends the agent report.
The answer then takes the same path every large tool result takes: above `SPILL_LIMIT` (6000 characters) it is
stored as a record and the conversation receives the head, the tail and the record id
(`spill_or_truncate`); `record_read` pages the rest. The exchange is logged and filed in the session history
with backend `cap:<agent>`.
## 6. Errors and the agent report
Every failure is printed with the reason first. Selection failures:
| Message | Meaning |
|---|---|
| `empty agent mention` | nothing after the `@` |
| `agent 'x' is not usable: <why>` | the agent exists but its binary or manifest is broken |
| `no model of agent 'x' resembles 'y'` | the agent has a model list and nothing in it relates to `y` |
| `no agent or model resembles 'x', closest 'y' scored 0.12 (floor 0.30)` | nothing reached the floor |
After the reason, the report lists every agent:
```
cap agents (2), use @agent or @agent:model:
claude [ready] binding=stream_json binary=claude manifest=/home/me/.config/cap/agents/claude.toml
models (3, from manifest): claude-opus-4-5, claude-sonnet-4-5, claude-haiku-4-5
default: claude-sonnet-4-5
codex [no_binary] binding=pty binary=codex manifest=/usr/share/cap-agents/codex.toml
problem: binary 'codex' is not on PATH or not executable
models: none known (any id is passed through)
```
Per agent it shows the status, the binding the driver would use, the binary, where the manifest came from,
the problem if any, the models with their source (`manifest`, `probe`, `manifest+probe`), the default, and
the probe error if the probe failed. An unknown `@tag` gets the same report appended, but only when at least
one agent is installed (with none, the message is byte-identical to the Python app's).
## 7. Limits
| Constant | Value | Where |
|---|---|---|
| `CAP_AGENT_MAX` | 64 | agents listed |
| `CAP_MODEL_MAX` | 500 | models per agent |
| `CAP_MODEL_PROBE_TIMEOUT` | 5 s | one probe |
| `CAP_MODEL_PROBE_MAX_BYTES` | 1 MiB | probe output kept |
| `CAP_CATALOG_TTL` | 60 s | cache lifetime of an agent entry |
| `CAP_FUZZY_FLOOR` | 0.3 | minimum score for a pick across agents and models |
| `CAP_TURN_TIMEOUT` | 600 s | one turn |
## 8. Troubleshooting
| Symptom | Check |
|---|---|
| `@name` says unknown bot | no manifest named `name.toml` in either directory, or no bot; the message lists both |
| the agent is listed `no_binary` | `which <binary>`; the manifest's `agent.binary` is what is looked up |
| `bad_manifest` | the report prints the parser's message with line and column |
| a model is not found although it exists | the probe failed or timed out (see the `probe:` line); run the probe command by hand |
| the wrong model is picked | use more of its name, or the exact id; the note shows what was picked and its score |
| an agent's panel shows nothing for a long time | some agents emit their transcript at the end of the turn (tai over `--cap-speak`) |
## 9. Verification
`tests/cap_model_catalog_test.cpp` covers discovery, statuses, the manifest list, the probe formats and
failures, caching, exact and fuzzy resolution of all three forms, pass-through for agents without a list,
and the error text with the report. The CAP protocol layer is covered by `capmcp.unit`, `capmcp.wire_diff`
and `capmcp.cli_diff` ([cap.md](cap.md)). A live check with a stream-json agent was run: `@tia:zne ...`
resolved to `tai:zen-free` and the turn ran.
## 10. Gaps
- No completion of agents and models after `@` in the line editor or the chat scene.
- No `--cap-models` on tai's own side; no ACP live model discovery.
- Model switching inside a running session is not defined by CAP and is not done: a different model is a
different session.
- The 0.3 floor is a judgment, not a measurement; the constant is in `cap_model_catalog.h`.
+7 -8
View File
@@ -84,15 +84,14 @@ hooks: `docs/wiring/capmcp.md`.
- Terminal `terminal/output` keeps the `read(65536)` blocking-chunk behavior.
- Signals: SIGINT/SIGTERM set a flag polled by waits (`CapInterrupt`).
## Model catalog and `@agent` mentions
## Agents, models and `@agent` mentions
`CapModelCatalog` (`src/cap/cap_model_catalog.*`) lists agents from the `*.toml` manifests in the registry user and system dirs (cap `CAP_AGENT_MAX` 64) and checks each: manifest parses, binary on PATH. Status: `ready`, `no_binary`, `bad_manifest`, `no_manifest`. Entries are cached `CAP_CATALOG_TTL` (60) seconds; entries load in parallel.
Model protocol (tai-specific extension, absent from the Python app): `[models] list = [...]` and `default` in the manifest, plus an optional probe, `[models] probe = ["bin", "--cap-models"]` or `capabilities.models = true` for `<binary> --cap-models`. Probe output: JSON array of ids or `{id|name|model}` objects, `{"models": [...], "default": "..."}`, or one id per line. Bounds: `CAP_MODEL_PROBE_TIMEOUT` 5 s, `CAP_MODEL_PROBE_MAX_BYTES` 1 MiB, `CAP_MODEL_MAX` 500 per agent. A failed probe is recorded on the entry, never thrown. Tai's own manifest is unchanged and declares no models.
Mention resolution (after bots, providers, backends, known model ids, `@auto`, exact agent, and file paths; so a real file is never shadowed): the typed text is fuzzy-matched with `FuzzyMatcher` ([fuzzy.md](fuzzy.md)). Order: exact agent name or model id; `head:tail` splits at the first `:`, head fuzzy to a ready agent, tail fuzzy to that agent's models (no floor, the agent already disambiguates); otherwise the whole text is ranked against every ready agent name and every model. Cross-namespace picks need a score of `CAP_FUZZY_FLOOR` (0.3); the best score wins and equal scores resolve shorter id then list order, never an error. Unusable agents (no binary, bad manifest) are considered only when no ready candidate reaches the floor and then report why. An agent without a model list passes the model through with a note. When the pick differs from the typed text a note line shows the resolution and score. With a prompt the turn runs on that agent (`CapMention`, one cached `CapSession` per agent/model/cwd, model sent via `startup.model_arg`), in its own panel on the multiplexer screen, supervised (turn timeout, idle watchdog, closing the panel cancels it) and collected through the spill path ([orchestration.md](orchestration.md)); without a prompt the agent is pinned (`Agent::agent_pin`) until `@auto`. A miss prints the closest candidate with its score and the full per-agent report (status, binary, manifest, binding, models, default, probe error); an unknown tag appends the same report.
Gaps: no TUI completion for agents yet; `tai --cap-models` is not implemented on tai's own side.
taicpp lists the installed CAP agents, discovers their models (manifest `[models]` list, optional
`--cap-models` probe), and lets a message start with `@agent`, `@agent:model` or `@model`, matched fuzzily and
run in a supervised panel on the multiplexer screen. Everything about it is in
[cap-agents.md](cap-agents.md); the matching in [fuzzy.md](fuzzy.md); the panels, supervision and result
collection in [orchestration.md](orchestration.md). CAP itself (events, bindings, manifest, fleet) is
unchanged by this and stays specified on this page.
## Verification
+156 -32
View File
@@ -1,44 +1,168 @@
# FuzzyMatcher (src/util/fuzzy.h)
# FuzzyMatcher: "which candidate did the user mean"
The one generic "which candidate did the user mean" scorer. Research: `research/fuzzy-matching-selectors/` (conclusions `generic-fuzzy-scorer-design`, `agent-model-mention-resolution`). Used today by `CapModelCatalog` for `@agent` / `@agent:model` mentions.
Code: `src/util/fuzzy.h`, `src/util/fuzzy.cpp`. Tests: `tests/fuzzy_test.cpp` (`build/fuzzy_test`, step
`fuzzy.unit` of `tests/run_all.py`). Research: `research/fuzzy-matching-selectors/` (12 sources read,
53 listed, conclusion `generic-fuzzy-scorer-design`). This is a taicpp component; the Python app has no
equivalent. The code is the only definition of the scoring; this page states what it does today.
## Contract
## 1. What it is for
`Score(query, text)` is in [0, 1]. 1 only for a case-insensitive exact match, 0 when nothing relates, never above 0.999 otherwise. It is the max of four signals:
One scorer, used wherever a short, typed, possibly wrong word has to pick one item out of a list of
identifiers. Today that is `@agent`, `@agent:model` and `@model` mentions ([cap-agents.md](cap-agents.md)).
It is deliberately generic and has no dependency on anything else in the application: pass a query and a
list of strings, get scores or the best index.
| Signal | Definition | Scale |
|---|---|---|
| `Subsequence` | query bytes in order inside text; DP with D = best ending in a match, M = best prefix (fzy recurrences); bonuses by the byte before the match; normalized by the perfect score `16n + 20 + 4(n-1)` | 1.0 |
| `Typo` | 1 - restricted Damerau-Levenshtein distance / max(len); insert, delete, substitute, adjacent swap cost 1 | 1.0 |
| `TokenTypo` | best `Typo` against each alphanumeric token of the text | 0.95 |
| `Trigram` | pg_trgm: per word two leading and one trailing space, Jaccard over trigram sets | 0.9 |
Design rules it follows (project rules, see `AGENTS.md`):
## Constants (fzf algo.go)
- Static ceilings, no auto tuning (section 6).
- Deterministic: the same inputs always give the same order, ties included.
- Loud limits: nothing is silently truncated beyond the ceilings listed in section 6.
- No dependency: it is a pure function over `std::string`.
| Name | Value | Meaning |
|---|---|---|
| `kScoreMatch` | 16 | per matched byte |
| `kGap` | -1 | per skipped byte (leading, inner, trailing) |
| `kBonusBoundary` | 8 | match after a non-alphanumeric byte |
| `kBonusWhite` | 10 | match after whitespace or at the start |
| `kBonusCamel` | 7 | lower to Upper, or non-digit to digit |
| `kBonusConsecutive` | 4 | minimum bonus for a run |
| `kFirstMultiplier` | 2 | first query byte bonus factor |
| `kTrigramScale` / `kTokenScale` | 0.9 / 0.95 | signal scales |
## 2. API
## Ranking and ceilings
```cpp
#include "fuzzy.h"
using detz::FuzzyMatcher;
`Rank` returns candidates with score > 0: score descending, then shorter text, then earlier index (fzf default tie rule). Equal matches never error. `Best(query, texts, floor)` returns the top index above `floor` or -1.
double s = FuzzyMatcher::Score("opus", "claude-opus-4-5"); // 0..1
auto ranked = FuzzyMatcher::Rank("opus", {"claude-opus-4-5", "opencode"}); // vector<Ranked{index, score}>
long best = FuzzyMatcher::Best("opus", candidates, /*floor=*/0.3, &score); // index or -1
```
| Ceiling | Value |
| Function | Returns |
|---|---|
| `kFuzzyQueryMax` | 64 bytes |
| `kFuzzyTextMax` | 256 bytes |
| `kFuzzyCandidateMax` | 4096 candidates |
| `Score(query, text)` | 0 when nothing relates; 1.0 only for a case-insensitive exact match; otherwise the maximum of the four signals below, never above 0.999 |
| `Subsequence`, `Typo`, `TokenTypo`, `Trigram` | the individual signals, public for tests and tuning discussions |
| `Rank(query, texts)` | every candidate with score above 0, best first |
| `Best(query, texts, floor, &score)` | index of the top candidate, or -1 when there is none or its score is not strictly above `floor` |
## Gaps
Empty query or empty text scores 0. Comparison is case-insensitive for ASCII only (section 7).
- ASCII case folding only; non-ASCII bytes compare as-is.
- Constants are fzf's (paths), not tuned on model ids; no measurement on a real id corpus yet.
- The CAP floor (`CAP_FUZZY_FLOOR` 0.3) is a judgment, not a measurement.
- Tests: `tests/fuzzy_test.cpp` (`build/fuzzy_test`).
## 3. The four signals
`Score` = max( `Subsequence`, `Typo`, 0.95 x `TokenTypo`, 0.9 x `Trigram` ), then capped at 0.999 unless the
folded strings are equal (then exactly 1.0).
### 3.1 Subsequence (letters in order, boundary aware)
The query bytes must appear in the text in order, case-insensitively, not necessarily next to each other.
The best alignment is found by dynamic programming and scored with the constants of fzf's
`FuzzyMatchV2` (`research/fuzzy-matching-selectors/github-fzf-algo-go.md`), using the two-matrix recurrence
of fzy (`github-fzy-match-c.md`):
- `D[i][j]` = best score of query[0..i] where query[i] is matched exactly at text[j].
- `M[i][j]` = best score of query[0..i] using text[0..j], matched or skipped.
- Matching text[j] for query[i]:
- `i == 0`: `kScoreMatch + bonus[j] * kFirstMultiplier + kGap * j` (the skipped leading bytes cost `kGap` each).
- `i > 0`: `max( M[i-1][j-1] + kScoreMatch + bonus[j], D[i-1][j-1] + kScoreMatch + max(bonus[j], kBonusConsecutive) )`.
The second branch is "continuing a run"; it always earns at least `kBonusConsecutive`.
- A skipped byte costs `kGap` (-1), at the start, inside and at the end.
- The raw result is `M[n-1][m-1]`; it is normalized by the perfect score `n * 16 + 10 * 2 + (n-1) * 4`
(every byte matched, the first at a whitespace boundary, all consecutive) and clamped to [0, 1].
| Constant | Value | Applies when |
|---|---|---|
| `kScoreMatch` | 16 | each matched byte |
| `kGap` | -1 | each skipped byte |
| `kBonusWhite` | 10 | match at the start of the text or after whitespace |
| `kBonusBoundary` | 8 | match after a byte that is not a letter or digit (`-`, `_`, `/`, `:`, `.`) |
| `kBonusCamel` | 7 | lower to Upper, or a non-digit to a digit |
| `kBonusConsecutive` | 4 | minimum bonus for continuing a run |
| `kFirstMultiplier` | 2 | first query byte's bonus is doubled |
Consequences you can rely on: a query matches best at the start of a word; `fb` finds `FooBar` (camel
humps) better than `fxxxxbxx`; shorter texts lose fewer points to skipped bytes. A query longer than the
text cannot be a subsequence and scores 0 here.
### 3.2 Typo (restricted Damerau-Levenshtein)
`1 - d / max(len(query), len(text))` where `d` is the optimal-string-alignment distance: insert, delete,
substitute, or swap two adjacent bytes, each costing 1. It catches typos and transpositions
(`snonet` for `sonnet`) that are not subsequences. It is low for a short query against a long text, which
is what the next signal is for.
### 3.3 TokenTypo (typo against each word)
The text is split into runs of letters and digits (`claude-opus-4-5` gives `claude`, `opus`, `4`, `5`);
the result is the best `Typo` of the query against any one token, scaled by 0.95. It finds a misspelled
word inside a long identifier (`opuss` in `claude-opus-4-5`) and gives an exact word match (`opus`, `free`,
`pro`) a score of 0.95.
### 3.4 Trigram (pg_trgm)
Each alphanumeric word gets two leading spaces and one trailing space; the set of three-byte windows is
compared by Jaccard (shared / total distinct), the way PostgreSQL's `pg_trgm` does
(`postgresql-pg-trgm.md`: `word` against `words` is 4/7 = 0.571). Scaled by 0.9. It helps with scrambled or
partial words and is the weakest signal.
## 4. Ranking and ties
`Rank` keeps candidates with score above 0 and sorts by:
1. score, descending (differences under 1e-9 count as equal);
2. shorter text first (byte length), the default tie rule of fzf (`github-fzf-man-page.md`);
3. earlier position in the input list.
So two equal matches never produce an error or a coin flip: `free` against `zen-free` and `devplace-free`
(both 0.95) always picks `zen-free`.
## 5. Worked examples
Measured with the code (`Subsequence`, `Typo`, `TokenTypo` before the 0.95 scale, `Trigram` before the 0.9
scale, then the final `Score`):
| query | text | subseq | typo | token | trigram | score |
|---|---|---|---|---|---|---|
| `opus` | `claude-opus-4-5` | 0.84 | 0.27 | 1.00 | 0.31 | 0.95 |
| `opus` | `opencode` | 0.00 | 0.25 | 0.25 | 0.17 | 0.25 |
| `son` | `claude-sonnet-4-5` | 0.76 | 0.18 | 0.50 | 0.16 | 0.76 |
| `sonet` (dropped letter) | `claude-sonnet-4-5` | 0.83 | 0.29 | 0.83 | 0.26 | 0.83 |
| `snonet` (swap) | `claude-sonnet-4-5` | 0.00 | 0.29 | 0.83 | 0.14 | 0.79 |
| `opuss` (extra letter) | `claude-opus-4-5` | 0.00 | 0.27 | 0.80 | 0.22 | 0.76 |
| `g4m` (word starts) | `gpt-4o-mini` | 1.00 | 0.27 | 0.33 | 0.07 | 1.00 |
| `gpt4` | `gpt-4o` | 1.00 | 0.67 | 0.75 | 0.33 | 1.00 |
| `gpt4` | `gpt-4o-mini` | 0.97 | 0.36 | 0.75 | 0.21 | 0.97 |
| `free` | `zen-free` | 0.92 | 0.50 | 1.00 | 0.56 | 0.95 |
| `pro` | `gemini-2.5-pro` | 0.80 | 0.21 | 1.00 | 0.27 | 0.95 |
| `fb` | `FooBar` | 0.98 | 0.33 | 0.33 | 0.11 | 0.98 |
| `fb` | `fxxxxbxx` | 0.82 | 0.25 | 0.25 | 0.10 | 0.82 |
| `clde` | `claude` | 0.94 | 0.67 | 0.67 | 0.33 | 0.94 |
| `word` | `words` | 0.99 | 0.80 | 0.80 | 0.57 | 0.99 |
| `zzqq` | `zen-free` | 0.00 | 0.12 | 0.25 | 0.08 | 0.24 |
(`g4m` and `gpt4` show 1.00 because the subsequence is perfect after rounding; the function itself returns
at most 0.999 unless the strings are equal.) Reading the table: `opus` finds `claude-opus-4-5` mainly through
the exact word (token signal) and not at all through `opencode`; a swap is rescued by the token signal; a
nonsense query tops out around 0.25.
## 6. Ceilings
| Constant | Value | Effect |
|---|---|---|
| `kFuzzyQueryMax` | 64 bytes | longer queries are cut |
| `kFuzzyTextMax` | 256 bytes | longer candidates are cut |
| `kFuzzyCandidateMax` | 4096 | candidates beyond this are not ranked |
| `kTrigramScale` | 0.9 | |
| `kTokenScale` | 0.95 | |
Cost per score is at most 64 x 256 cells for the subsequence DP and for the edit distance, so ranking a
full catalog stays in the low milliseconds.
## 7. Known limits
- ASCII case folding only; bytes of 0x80 and above are treated as lowercase letters and compared as they
are. Accented text is not normalized (fzf can).
- The constants are fzf's, tuned for file paths, not for model identifiers. They were not tuned on a real
corpus of model ids; the examples above are the evidence. No claim of optimality.
- A subsequence can match scattered letters inside a long identifier; boundary bonuses make this rare, not
impossible.
- The caller decides whether a score is good enough: `Best(..., floor)` takes the floor. The CAP catalog
uses 0.3 (`CAP_FUZZY_FLOOR`); `zzqq` against everything scores at most 0.24 and is rejected.
## 8. Reusing it
Anything that picks from a list of names can call it: pass the user's text and the list, take `Best`
with a floor, show the chosen candidate to the user when it differs from what they typed (the catalog
prints `'tia:zne' resolved to tai:zen-free (fuzzy 0.67)`). Do not use it for security decisions or for
free text search; it is built for short identifiers.
+18 -7
View File
@@ -101,10 +101,21 @@ Listed next to what would close them; the final status of each is recorded in th
## Feed panes (taicpp extension, orchestration)
A pane can be a feed pane: no process, text written through the control socket, row 0 is a header
drawn by the renderer. Added for sub agent panels; specified in [orchestration.md](orchestration.md)
(commands `vpane_open`, `vpane_write`, `vpane_close`, `vpane_list`; caps `MPLEX_VPANE_*`). The
Python app does not have them, so they are outside the tables `mplex_spec_test` compares with
`mplex.md`. Code: `src/mplex/mplex_vpane.*`, `MplexPane::virtual_pane`, the header row in
`MplexRenderer::frame`, a feed pane hook in `MplexServer::discard_pane` and `tick`, and
`MplexServer::kill_pane` closing a window whose real panes are all gone.
A feed pane is a pane without a process: text is written into it through the control socket and the server
composes it like any other pane. They exist for sub agent panels; the full contract (protocol, layout
policy, lifecycle, caps, examples) is [orchestration.md](orchestration.md) section 3. The Python app has
none of this, so it is outside the tables `mplex_spec_test` compares with `mplex.md`.
| Piece | Where |
|---|---|
| registry, layout, linger, orphan lease, the four commands | `src/mplex/mplex_vpane.{h,cpp}` (`MplexVpanes`) |
| `virtual_pane`, `header`, `header_style`, `open_log`; screen has `h - 1` rows; `write` is a no-op | `src/mplex/mplex_pane.{h,cpp}` |
| header row drawn above the pane's grid | `MplexRenderer::frame` |
| `vpane_open`, `vpane_write`, `vpane_close`, `vpane_list` dispatch | `MplexServer::dispatch_control` |
| entry forgotten when a pane is discarded (also by `kill-pane`) | `MplexServer::discard_pane` |
| lease and linger sweep, once a second | `MplexServer::tick` |
| a window whose real panes are all gone is closed | `MplexServer::kill_pane` |
Behavior that differs from a process pane: no pty, no reaper, no input; `list-panes`, `capture-pane -p`,
`kill-pane`, copy mode, zoom and the `read` control command work; `select-layout` can be used but the next
panel open or close re-applies `main-vertical` with the main pane at 55 percent.
+299 -73
View File
@@ -1,13 +1,18 @@
# Orchestration: sub agents on the multiplexer screen (taicpp)
# Orchestration: sub agents on the multiplexer screen
Status: implemented in taicpp, not in the Python app. Research: `research/background-work-limits`,
`research/cap-protocol` (agent teams, claim check, timeouts), `research/prompt-context-scaling`.
Status: implemented in taicpp, not in the Python app. Code: `src/mplex/mplex_vpane.*`, `src/orch/`,
`src/agent/registry.*`, `src/agent/agent.*`, `src/cap/cap_mention.*`. Tests: `tests/orch_test.cpp`
(`build/orch_test`, step `orch.unit`), `tests/orch_mplex_e2e.py` with `tests/orch_live.cpp`
(`build/orch_live`, step `orch.mplex_e2e`). Research: `research/background-work-limits/` (agent teams,
Temporal timeouts, claim check, Celery limits, tmux layouts, terminal streaming) and
`research/cap-protocol/`. Related: [cap-agents.md](cap-agents.md) (what `@agent` runs),
[mplex.md](mplex.md) (the multiplexer), [fuzzy.md](fuzzy.md).
## The rule
## 1. The rule
The main application orchestrates. Every sub agent it runs, whatever kind (an internal `fork`
worker, a CAP session over any binding, a `@agent` mention), gets a panel on the main multiplexer
session screen while it works, fed live, and is supervised by one clock whether it is shown or not.
The main application orchestrates. Every sub agent it runs, whatever kind (an internal `fork` worker, a CAP
session over any binding, an `@agent` mention), gets a panel on the main multiplexer session screen while it
works, fed live, and is supervised by one clock whether it is shown or not.
```
one sub agent two sub agents three
@@ -20,81 +25,275 @@ session screen while it works, fed live, and is supervised by one clock whether
+-------+-------+
```
The main pane stays on the left at full height (`MPLEX_VPANE_MAIN_PERCENT` 55 percent wide), the
sub agents are stacked equally on the right (tmux `main-vertical`). Panels appear when an agent
starts and leave `ORCH_LINGER` seconds after it finishes, then the column rebalances. Outside a
multiplexer (plain terminal, tmux) nothing is drawn and everything else works unchanged. A bare
`tai` run is not wrapped automatically; the installed shell hook (`--plexer-wrap`) is the default
entry that puts tai inside the multiplexer.
The main pane stays on the left at full height, `MPLEX_VPANE_MAIN_PERCENT` (55) percent of the window wide.
Sub agents are stacked equally on the right (tmux `main-vertical`). Panels appear when an agent starts and
leave `ORCH_LINGER` seconds after it finishes; the column then rebalances. Outside a multiplexer (plain
terminal, tmux) nothing is drawn and everything else works unchanged.
## Architecture
How the application gets into the multiplexer: the installed shell hook runs `tai --plexer-wrap` for interactive
shells. A bare `tai` run is not wrapped automatically (changing how the program starts was not asked).
Inside the multiplexer every pane has `TAI_PLEXER_ACTIVE=1`, `TAI_PLEXER_SOCKET` and `TAI_PLEXER_PANE`, and
that is how the orchestrator finds its screen.
## 2. Architecture
| Layer | Class | File | Job |
|---|---|---|---|
| server | `MplexVpanes` | `src/mplex/mplex_vpane.*` | feed panes, layout policy, linger, orphan lease |
| server | `MplexPane::virtual_pane` | `src/mplex/mplex_pane.*` | pane without a process; row 0 is a header drawn by `MplexRenderer` |
| server | `MplexVpanes` | `src/mplex/mplex_vpane.*` | feed panes: open, write, close, list; layout; linger; orphan lease |
| server | `MplexPane::virtual_pane` | `src/mplex/mplex_pane.*` | a pane without a process; row 0 is a header |
| server | `MplexRenderer::frame` | `src/mplex/mplex_renderer.cpp` | draws the header row and the screen below it |
| transport | `OrchTransport`, `OrchMplexTransport`, `OrchNullTransport` | `src/orch/orch_transport.*` | control-socket client, one round trip per write |
| core | `OrchBoard` | `src/orch/orch_board.*` | opens panels, coalesced flusher thread, watchdog clock |
| core | `OrchPanel` | `src/orch/orch_panel.*` | one agent's buffer, state, counters, cancel hook |
| view | `OrchFormatter` | `src/orch/orch_formatter.*` | events to sanitized ANSI text |
| view | `OrchFormatter` | `src/orch/orch_formatter.*` | events to sanitized, word-wrapped ANSI text |
| events | `OrchEvent` | `src/orch/orch_event.h` | what an agent tells its panel |
| adapter | `OrchCapAdapter` | `src/orch/orch_cap_adapter.*` | `cap.*` events to `OrchEvent` |
| adapter | `Agent::observer` | `src/agent/agent.*` | tool calls, results, streamed text of an internal worker |
| owners | `spawn_agent`, `CapMention` | `src/agent/registry.cpp`, `src/cap/cap_mention.*` | open a panel, wire cancel, finish it |
| adapter | `Agent::observer` | `src/agent/agent.*` | tool calls, results and streamed text of an internal worker |
| owner | `spawn_agent` / `AgentRecord` | `src/agent/registry.*` | opens a panel per `fork` worker, wires cancel, finishes it |
| owner | `CapMention` | `src/cap/cap_mention.*` | opens a panel per CAP turn, supervises, escalates cancel |
| policy | `orch_policy.h` | `src/orch/orch_policy.h` | every cap and floor (section 9) |
## Control protocol (multiplexer socket, JSON lines, taicpp extension)
Data flow for one streamed token:
| Command | Request | Reply data |
|---|---|---|
| `vpane_open` | `pane` (origin), `label`, `header`, `state`, `main_percent` | `{id, cols, rows}` |
| `vpane_write` | `ops: [{pane, data, header, state, done, linger}]` | `{closed: [ids]}` (panes that no longer exist: the user closed them) |
| `vpane_close` | `pane` | `true` |
| `vpane_list` | none | `[{id, state, header, cols, rows, closing}]` |
```
agent thread --emit(OrchEvent)--> OrchPanel (format, append to buffer, mutex)
OrchBoard thread, every 25 ms: panel.collect(op) for every panel
--> one "vpane_write" with all ops --> multiplexer server --> pane.feed() --> screen
server render loop, at most every 12 ms: diff by row --> attached terminal
```
`read` and `capture-pane` work on a feed pane like on any pane. Keys typed into a feed pane go
nowhere. Killing it (`prefix x`) cancels the agent. If the process that owns the panes dies, each
pane shows `owner gone` after `MPLEX_VPANE_LEASE` seconds and closes after `MPLEX_VPANE_ORPHAN_LINGER`.
If the main pane exits, the feed panes are closed with its window.
Producers never wait for I/O. If the screen cannot keep up the buffer is bounded (section 4).
## Streaming
## 3. The multiplexer side: feed panes
Producers append formatted bytes to the panel under a mutex and never block on I/O. One board
thread drains every panel every `ORCH_FLUSH_INTERVAL` (25 ms) into a single `vpane_write`, so a
token stream costs at most 40 round trips a second however many agents run. The server feeds the
bytes into the pane's screen at once and renders at most one frame per `MPLEX_RENDER_INTERVAL`
(12 ms), diffed by row. Text is sanitized before it reaches the screen: only SGR colors, newline and
tab survive, so an agent cannot move the cursor, change the title or write outside its pane.
If the screen falls behind by `ORCH_PENDING_MAX` bytes the oldest bytes are dropped at a line end
and a visible `output skipped` line says so (loud skip, never a silent one).
A feed pane is an `MplexPane` with `virtual_pane = true`: no pty, no process, `master_fd` is -1, so the
server loop never polls it and no reaper touches it. Its `h - 1` rows are a normal `MplexVtScreen` (scrollback,
copy mode, `capture-pane`, `read` all work); row 0 of its rectangle is a header line drawn by
`MplexRenderer::frame` with the pane's `header_style`. Keys typed into it are dropped (`MplexPane::write` returns
early for a feed pane).
## Scheduling, monitoring, timeouts
### 3.1 Control protocol (JSON lines on the multiplexer socket)
All requests are one line, one reply line, one connection per request, like the existing `send` and `read`
commands. Replies are `{"ok": true, "data": ...}` or `{"ok": false, "error": "..."}`.
**`vpane_open`**
```json
{"cmd":"vpane_open","pane":"0","label":"agent #1","header":" ● agent #1 · running · 0s","state":"running","main_percent":55}
{"ok":true,"data":{"id":3,"cols":53,"rows":38}}
```
`pane` is the origin pane (default: the active pane of the latest session). `label` becomes the pane title,
`header` the first line, `state` selects the header color. Errors: `too many feed panes (limit 8)`,
`too many panes (limit 256)`, `no space for another feed pane` (the window is too short for another pane of
at least `MPLEX_VPANE_MIN_ROWS` rows), `window too narrow for a feed pane` (under 41 columns).
**`vpane_write`**
```json
{"cmd":"vpane_write","ops":[
{"pane":3,"data":"\u001b[1mhello\u001b[0m\r\n","header":" ● agent #1 · running · 4s","state":"running"},
{"pane":4,"data":"bye\r\n","state":"done","done":true,"linger":20}]}
{"ok":true,"data":{"closed":[],"cols":{"3":53,"4":53}}}
```
Per op: `pane` (required), `data` (text for the screen, at most `MPLEX_VPANE_WRITE_MAX` = 65536 bytes), `header`,
`state` (`running`, `idle`, `done`, `failed`, `timeout`, `cancelled`), `done` (start the linger countdown) with
`linger` seconds (clamped to `MPLEX_VPANE_LINGER_MAX` = 300). At most `MPLEX_VPANE_OPS_MAX` = 16 ops per request.
Every op also refreshes the pane's lease. The reply lists in `closed` the pane ids that no longer exist (the
user closed them or they timed out) and in `cols` the current width of every pane written to, which the client
uses for word wrap. Errors: `ops must be a list`, `too many ops in one write`, `op data too large`.
**`vpane_close`** closes one pane now. **`vpane_list`** returns
`[{id, state, header, cols, rows, closing}]`.
### 3.2 Layout policy (`MplexVpanes::arrange`)
After every open and every close the window is rebuilt with `MplexLayout::preset("main-vertical", panes, ...)`:
- the first element is the main pane (the first real, non-feed pane of the window);
- then any other real panes the user made;
- then the feed panes in creation order.
The main width is `window width x percent / 100`, clamped to [1, width - 2]; when the right column would be
narrower than `MPLEX_VPANE_MIN_COLS` (20) the main pane gives up width to keep it. Heights are shared equally
by the existing `shares` rule (the first pane gets the remainder), for example `[13, 13, 12]` for three agents
in a 40-row window (the heights plus two border rows). The layout is re-applied on every open and close, so a layout the user
set by hand does not survive a panel change.
### 3.3 Lifecycle on the server
| Event | Behavior |
|---|---|
| `done` op | `close_at = now + linger`; the pane closes on the next tick after that and the column rebalances |
| user kills a feed pane (`prefix x`) | the pane is gone; the next write reports its id in `closed`; the orchestrator cancels the job |
| owner stops writing for `MPLEX_VPANE_LEASE` (30 s) | the header becomes `owner gone: ...`, state `failed`, closes after `MPLEX_VPANE_ORPHAN_LINGER` (10 s) |
| the main pane exits | `MplexServer::kill_pane` closes the whole window when only feed panes remain |
| window zoomed | opening a panel un-zooms it |
The server tick runs once a second, so linger and lease have one-second granularity.
## 4. The orchestrator side
### 4.1 `OrchBoard`
One per process (`OrchBoard::instance()`, created on first use over `OrchMplexTransport::from_env()`, flushed
at exit with a 2 s limit, never destroyed so detached worker threads cannot outlive it). `open(label, task)`
never returns null: when there is no multiplexer (`TAI_PLEXER_ACTIVE` unset), `TAI_ORCH_PANELS=0`, no space,
or `ORCH_PANELS_MAX` panels are already shown, it returns a panel with no pane behind it and records the reason
(`last_error()`). Such a panel behaves identically except nothing is drawn.
The board thread runs every `ORCH_FLUSH_INTERVAL` (25 ms):
1. once per `ORCH_MONITOR_INTERVAL` (1 s) call `watchdog` on every panel;
2. call `collect` on every panel; combine the non-empty results into one `vpane_write`;
3. apply the reply: widths to the panels, `closed` ids to `closed_by_user()`;
4. drop panels that are drained.
Three consecutive failed writes mark the transport broken (`transport_ok() == false`, reason in
`last_error()`): drawing stops, supervision continues, new panels are hidden panels.
### 4.2 `OrchPanel`
Thread-safe. State: `Queued`, `Running`, `Idle`, `Done`, `Failed`, `Timeout`, `Cancelled`.
```
emit() finish(state)
Running ------> Running Running/Idle ----------> Done | Failed | Timeout | Cancelled
| no event 60 s ^ emit() while Idle returns to Running
v
Idle --- no event 300 s ---> cancel hook fires once
any --- pane closed by user ---> Cancelled, cancel hook fires
```
- `emit(event)` formats the event, appends to the buffer, counts tools and tokens, and is a heartbeat. After
`finish` events are ignored.
- `finish(state, summary)` flushes the held word, appends the footer, and arms the `done` op, which is sent only
after the buffer is drained.
- `collect(op)` takes up to `ORCH_WRITE_MAX` (32768) bytes without splitting a UTF-8 character, adds a changed
header and state, and returns false when there is nothing to send. A word held back by the formatter is
released when the text stream has paused for 0.1 s.
- The buffer is capped at `ORCH_PENDING_MAX` (262144). Over it, the oldest bytes are dropped at a line end and a
visible line `output skipped: the screen fell behind` is inserted. Dropping is counted (`dropped_bytes()`).
- `watchdog(now)` is the idle logic above; `set_cancel` registers the owner's cancel function, called at most once.
Header text: ` <icon> <label> · <state> · <elapsed> · <n> tools · <tokens> tok`, with `●` running, `◌` idle,
`✓` done, `✗` failed, `⏱` timeout, `⊘` cancelled; elapsed is `Ns` or `MmSSs`; tokens abbreviated (`1.5k`).
Header colors (server side, by state): running blue, idle and timeout orange, done green, failed red, cancelled grey.
### 4.3 `OrchFormatter`
Turns events into bytes for a feed pane (CRLF line ends, because a feed pane has no tty to translate them).
| Event | Rendering |
|---|---|
| (opening) | dim `▸ <task>` on one line, at most `ORCH_TASK_SHOW_MAX` (300) characters, then a blank line |
| Text | word-wrapped to the pane width |
| Thought | the same, dim italic |
| ToolStart | on a new line: cyan `⏺ name`, then dim `(arguments)` on one line, clipped to the pane width and `ORCH_TOOL_ARGS_MAX` (120) |
| ToolEnd | dim, red when failed: ` ⎿` the first `ORCH_TOOL_RESULT_LINES` (3) non-empty lines, each clipped to the width and `ORCH_TOOL_RESULT_COLS` (160), then `· N chars` |
| Plan | magenta `▤` and the plan lines |
| Note | yellow `· text`, at most 400 characters |
| Error | red `✗ text`, at most 400 characters |
| Usage | nothing in the stream; shown in the header |
| (footer) | blank line, colored `✓ done` / `✗ failed` / `◌ timeout` / `◌ cancelled`, seconds, ` · summary` (300) |
Sanitizing: agent text may contain only SGR color sequences, newline and tab. Every other escape sequence
(cursor movement, OSC titles, ...) and every other control byte is removed, so an agent cannot move the
cursor, retitle the terminal or write outside its pane.
Word wrap: the width comes from the server (`cols - 1`, so a character in the last column cannot wrap by
itself). A word that does not fit the rest of the line moves to the next line; a word longer than a line is
broken; multi-byte characters count as one column; SGR sequences count as zero. The last, possibly incomplete word of a chunk is held until
a space or newline arrives, because tokens often split a word.
### 4.4 Event sources
**Internal worker (`fork`)**: `Agent::observer` is called from `Agent::show_call` (tool start, with secrets
masked: `store_secret` arguments become `[hidden]`, everything else goes through the vault redaction), from
`Agent::show_result` (tool end, failure detected from the result text the way the main display does), from the
stream sink of `chat.complete` (text pieces as they arrive), and after each reply (the whole text when the
backend did not stream; reasoning when there is no text and no tool call).
**CAP session**: `CapSession::addTap` receives every event of the session; `OrchCapAdapter::map` turns them:
| CAP event | OrchEvent |
|---|---|
| `cap.text_chunk` (channel `thought`) | Thought |
| `cap.text_chunk`, `cap.thought` | Text / Thought, newline appended when missing |
| `cap.tool_call.start` | ToolStart (name, input as JSON) |
| `cap.tool_call.end` | ToolEnd; the tool name comes from the remembered call id (at most 256 remembered) |
| `cap.plan` | Plan: `[status] content` per entry |
| `cap.usage` | Usage: input plus output tokens |
| `cap.error` | Error: `code: message` |
| `cap.ask_user`, `cap.permission.request`, `cap.artifact` | Note |
| anything else | ignored |
## 5. Owners
### 5.1 `fork` workers (`spawn_agent`)
1. refuse loudly past `AGENTS_MAX_PARALLEL` (8) live workers (`AgentsFull`, existing behavior);
2. open a panel `agent #<id>` with the task; register a cancel function that raises `AgentRecord::cancel`;
3. in the worker thread `default_runner` sets `worker.observer` (events to the panel) and `worker.cancel_flag`;
4. the agent loop checks the flag before every step: a raised flag ends the turn with `[cancelled]`;
5. on completion the record gets its status and the panel is finished: `done` to Done, the text
`[time limit reached]` to Timeout, a raised cancel flag to Cancelled (record status `error`, result
`worker cancelled (<reason>); partial result: ...`), any exception to Failed.
`poll` and the lanes display are unchanged. `poll` collects the result and spills a large one (section 7).
### 5.2 CAP turns (`CapMention::ask`)
1. open a panel `cap <agent>[:<model>]` with the prompt;
2. reuse or create the session for (agent, model, directory); a tap routes its events to the current panel;
3. register a cancel function: `cap.cancel` for the current turn at once, and after `ORCH_CANCEL_GRACE` (5 s)
`close()` on the session if the turn is still running;
4. `session.ask(prompt, timeout)`; a `CapTimeoutError` cancels and drops the session;
5. the caller (`Agent::run_agent`) collects the answer (section 7) and finishes the panel: Done, Timeout,
Cancelled or Failed with a one-line summary.
## 6. Supervision
| Concern | Rule | Constant |
|---|---|---|
| Parallelism | at most 8 live workers (`AGENTS_MAX_PARALLEL`), refused loudly | `ORCH_PANELS_MAX`, `MPLEX_VPANE_MAX` |
| Space | a pane needs 6 rows (header plus 5); when the column is full the agent runs without a panel and `OrchBoard::last_error` says why | `MPLEX_VPANE_MIN_ROWS` |
| Wall clock | `fork` timeout 30..3600 s (default 600) ends the worker with `[time limit reached]`; a CAP turn ends at `CAP_TURN_TIMEOUT` | existing |
| Heartbeat | every event is a heartbeat; no event for 60 s shows `idle` in the header | `ORCH_IDLE_WARN` |
| Stall | no event for 300 s cancels the job | `ORCH_IDLE_KILL` |
| User close | closing the pane cancels the job | none |
| Cancel | soft first: the worker stops at its next step, a CAP session gets `cap.cancel`; a CAP agent that ignores it loses its session after the grace | `ORCH_CANCEL_GRACE` |
| Display failure | three failed writes in a row stop drawing; supervision continues | board |
| Header | `icon label state elapsed tools tokens`, colored by state (running, idle, done, failed, timeout, cancelled) | none |
| Parallelism | at most 8 live workers, refused loudly; at most 8 panels per process; the server allows 8 feed panes | `ORCH_PANELS_MAX`, `MPLEX_VPANE_MAX` |
| Space | a pane needs `MPLEX_VPANE_MIN_ROWS` (6, header plus five) and the window 41 columns; otherwise the agent runs without a panel and `last_error` says why | `MPLEX_VPANE_MIN_ROWS`, `MPLEX_VPANE_MIN_COLS` |
| Wall clock | `fork`: 30..3600 s, default 600, the worker ends with `[time limit reached]`; CAP turn: 600 s | existing |
| Heartbeat | every event is one; 60 s of silence shows `idle` | `ORCH_IDLE_WARN` |
| Stall | 300 s of silence cancels the job | `ORCH_IDLE_KILL` |
| User | closing the pane cancels the job | none |
| Cancel | soft first (flag read between steps, `cap.cancel`); a CAP session that ignores it is closed after the grace | `ORCH_CANCEL_GRACE` |
| Display failure | three failed writes stop drawing; supervision continues | board |
These follow the practice the research found: a start-to-close timeout always set, a heartbeat for
long jobs so a dead worker is found before the wall clock (Temporal), soft stop before hard stop
(Celery, Kubernetes SIGTERM then SIGKILL), and a hard cap on parallel work (agent teams advice:
three to five, token cost scales with the number of agents).
These follow the research: a limit on every job and a heartbeat to find a stalled worker earlier
(Temporal), soft stop before hard stop (Celery, Kubernetes), a cap on useful team size (agent teams advice: 3 to
5, token cost scales with the number of agents). The idle limits are static judgments, not measurements.
## Data collection
Supervision does not depend on a panel being shown: the watchdog runs on every panel the board tracks.
Unchanged and shared: the panel is a view, the answer travels the claim-check path every big tool
result already uses. A result longer than `SPILL_LIMIT` (6000 characters) is stored as a record
(`mem:` id, chunked and indexed), and the parent conversation receives the head, the tail and the
record id (`spilled_view`); `record_read` pages the rest. `poll` does this for `fork` workers,
`Agent::run_agent` does it for CAP answers (`spill_or_truncate`). The panel footer states the size
and whether the answer was stored.
## 7. Collecting results
## Caps and floors (all static)
Unchanged, shared and deliberately not the panel's job: the panel is a view with a scrollback cap, not a store.
- A result longer than `SPILL_LIMIT` (6000 characters) is stored as a record (`mem:` id, chunked and indexed)
and the conversation receives the head, the tail and the record id (`spilled_view`); `record_read` pages the rest.
This is the claim-check pattern (`research/background-work-limits/azure-claim-check-pattern.md`).
- `poll` does this for `fork` workers (once; the record id is cached on the agent record).
- `Agent::run_agent` does it for CAP answers with `spill_or_truncate`, tag `agent-output`.
- The panel footer shows the size and, for CAP answers, whether it was stored.
## 8. Switches and environment
| Name | Effect |
|---|---|
| `TAI_PLEXER_ACTIVE`, `TAI_PLEXER_SOCKET`, `TAI_PLEXER_PANE` | set by the multiplexer in every pane; the orchestrator's way to find its screen |
| `TAI_ORCH_PANELS=0` | draw nothing (supervision stays on) |
There is no configuration file for the caps; they are constants (section 9).
## 9. Caps and floors (all static)
| Constant | Value |
|---|---|
@@ -124,22 +323,49 @@ and whether the answer was stored.
| `MPLEX_VPANE_ORPHAN_LINGER` | 10 s |
| `MPLEX_VPANE_LINGER_MAX` | 300 s |
Switch: `TAI_ORCH_PANELS=0` draws nothing (supervision stays on).
The feed panes and the `vpane_*` commands are outside the tables that `mplex_spec_test` compares with the
Python `mplex.md`, because the Python app has none of them.
## Verification
## 10. Measured behavior
`tests/orch_test.cpp` (formatter, panel, board over a fake transport, CAP adapter, this page's
constant table), `tests/orch_mplex_e2e.py` (real server: geometry for 1, 2, 3 and 8 panels, content,
linger, rebalance, user close, orphan lease, main pane exit), and a live run of the real binary
inside the multiplexer with real `fork` workers and a CAP agent.
Measured with the real binaries (an attached client on a pty, three agents each streaming 500 tokens/s for
about 1.3 s):
## Gaps
| Measure | Value |
|---|---|
| frames received by the client | 85 in 1.33 s (about 64 per second) |
| interval between frames | median 12.9 ms, p90 25.3 ms (the single 295 ms gap is the scripted pause at the end of the stream) |
| bytes to the terminal | 234 KB (row-diffed frames, three panes changing) |
| writes to the multiplexer | at most 40 per second however many agents stream (unit test: 200 events in far fewer than 120 writes) |
Layout measured on a 120 x 40 window: one agent, main pane 66 columns and 40 rows, agent pane 53 x 40; three
agents, right column heights `[13, 13, 12]`; six agents fit; a seventh gets no panel.
Live check: the real REPL as the main pane (`detz --yolo`), a real model asked to fork two workers: two panels
appeared, streamed the tool calls (`⏺ shell(...)`, `⎿ exit 0`, output), changed to `✓ ... done`, showed the
footer, and left after the linger. A real `@tai:zen` CAP turn did the same (header `cap tai:zen-free`, the
panel filled when the turn ended, section 11).
## 11. Verification
| Test | Covers |
|---|---|
| `tests/orch_test.cpp` | sanitizing, clipping, CRLF, word wrap (inside a word never, across chunks rejoined, long words broken, multi-byte kept), tool and error rendering, the CAP adapter, panel buffering and the byte ceiling, never splitting a character, idle warn and idle cancel (once), cancel on user close, a hidden panel supervised like a shown one, the board over a fake transport (ordering, coalescing, done op, closed pane to cancel), no space, repeated write failure, the panel ceiling, and that this page lists every constant and command |
| `tests/orch_mplex_e2e.py` | a real multiplexer server and the real board: geometry for 1, 3 and 6 panels, content and footers, the space limit, linger and rebalance, a killed pane reported as closed, a failed footer, the main pane exiting |
| `tests/run_all.py` | steps `orch.unit`, `orch.mplex_e2e`, plus all `mplex.*` steps (the multiplexer itself is unchanged for existing behavior) |
Not covered by an automated test: the orphan lease (30 s plus 10 s) and the live REPL runs above.
## 12. Gaps
- tmux and bare terminals get no panels (a tail-file transport would close this).
- Keys typed into a feed pane are ignored; steering an agent from its pane is not built.
- The idle watchdog needs events; a CAP agent that streams nothing for 300 s is cancelled even if it works.
- A hung model request inside an internal worker is only bounded by the worker's own timeout; the
cancel flag is read between steps.
- Panels are not restored after the orchestrator restarts.
- tai as a CAP agent (`--cap-speak`) sends its transcript after the turn, with generic tool names, so a tai sub agent over CAP fills its panel at the end of the turn; agents that stream (stream-json, ACP) fill it live. An internal `fork` worker is live.
- Agent output records have no retention policy yet.
- A hung model request inside an internal worker is bounded only by the worker's own wall clock; the cancel
flag is read between steps.
- tai as a CAP agent (`--cap-speak`) sends its transcript after the turn, with generic tool names, so a tai
sub agent over CAP fills its panel at the end of the turn; agents that stream (stream-json, ACP) fill it
live. An internal `fork` worker is live.
- Panels are not restored after the orchestrator restarts; agent output records have no retention policy yet.
- A bare `tai` is not wrapped in the multiplexer automatically.
- Cancel escalation closes a CAP session from a second thread; a race inside the driver on close is possible.
+11
View File
@@ -2,6 +2,7 @@
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <sstream>
#include <string>
#include "cap_model_catalog.h"
@@ -76,6 +77,16 @@ int main() {
CapAgentEntry b = failing.entry("beta");
CHECK(b.ready() && b.models.empty() && b.modelError.find("timed out") != std::string::npos);
{ // docs/cap-agents.md lists every constant and status the code defines
std::ifstream in("docs/cap-agents.md");
std::stringstream ss;
ss << in.rdbuf();
const std::string doc = ss.str();
CHECK(!doc.empty());
for (const char* name : {"CAP_AGENT_MAX", "CAP_MODEL_MAX", "CAP_MODEL_PROBE_TIMEOUT", "CAP_MODEL_PROBE_MAX_BYTES", "CAP_CATALOG_TTL",
"CAP_FUZZY_FLOOR", "CAP_TURN_TIMEOUT", "ready", "no_binary", "bad_manifest", "no_manifest"})
if (doc.find(name) == std::string::npos) { std::printf("FAIL docs/cap-agents.md lacks %s\n", name); ++failures; }
}
if (std::system(("rm -rf " + dir).c_str()) != 0) std::printf("cleanup failed\n");
std::printf(failures ? "FAILED %d\n" : "cap_model_catalog_test OK\n", failures);
return failures ? 1 : 0;