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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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`).
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user