Files
tai/cap.md
T
retoor cc1c130b51 CAP protocol both directions: agent speak plus orchestrator drivers (1.39.0)
- Agent side: --cap-manifest plus --cap-speak stream-json binding with
  transcript-diff frames, diff/transcript artifacts, session config
- Orchestrator: PTY driver (settle heuristics on PtyProc/TuiVtScreen),
  stream-json, ACP-stdio (permission/elicit/fs/terminal serving) and
  A2A drivers (dual draft/current spelling, SSE mapping)
- Sessions, fleets: worktrees, master plans, mediated routing,
  budget refusal, audit log, research auto-answerer, --cap CLI
- cap.md contract with drift tests; research/cap-protocol archive
- Suites 1119 + 16 green
2026-10-18 13:17:35 +02:00

295 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CAP support in tai (`Cap*`)
Tai speaks the CLI Agent Protocol both directions: as an agent
(`--cap-manifest`, `--cap-speak`) and as an orchestrator driving
other CLI agents. Normative upstream: CAP Core v1 plus the coding
profile; research archive is `research/cap-protocol/`.
Protocol id `CAP_VERSION`, manifest schema
`CAP_MANIFEST_SCHEMA`, coding profile id `CAP_PROFILE_CODING`,
shipped manifest revision `CAP_MANIFEST_REVISION`.
## Conformance claim
- Agent: CLI-invokable, publishes `TAI_CAP_MANIFEST` via
`--cap-manifest`, matches the PTY contract and speaks
stream-json via `--cap-speak`. Claims the `coding` profile.
- Orchestrator: reads manifests via `CapRegistry`, implements PTY
plus stream-json plus ACP-stdio plus A2A bindings (gRPC
excluded, see Deviations), honors the §10 rules below, and
serves the reverse RPC it advertises.
## Phases
1. Events, errors, manifest registry, `--cap-manifest`. DONE.
2. Agent `--cap-speak` stream-json binding, coding artifacts. DONE.
3. Orchestrator PTY driver (`CapPtyDriver` on `PtyProc` plus
`TuiVtScreen`, parse). DONE.
4. Orchestrator stream-json driver plus §10 orchestration
(`CapSession`, `CapFleet`, worktrees, budget). DONE.
5. ACP-stdio and A2A drivers, reverse RPC serving, research
auto-answerer (`CapAnswerer`). DONE.
## Core events (`CapEvent`)
Kinds in `CapEvent.KINDS`:
Agent to orchestrator: `cap.session.ready`, `cap.text_chunk`,
`cap.tool_call.start`, `cap.tool_call.delta`, `cap.tool_call.end`,
`cap.plan`, `cap.thought`, `cap.ask_user`, `cap.permission.request`,
`cap.usage`, `cap.error`, `cap.artifact`, `cap.fs.change`,
`cap.pty.raw_bytes`.
Orchestrator to agent: `cap.user_input.inject`, `cap.cancel`,
`cap.session.config`, plus answers `cap.ask_user.answer` and
`cap.permission.response`. Directions live in
`CapEvent.DIRECTIONS`, required fields in `CapEvent.REQUIRED`.
Enum vocab: `channel` is `assistant`, `thought` or `system`;
`status` is `pending`, `in_progress`, `completed`, `cancelled` or
`blocked`; `priority` is `urgent`, `high`, `medium` or `low`;
`scope` (cancel) is `current_turn` or `session`;
`risk_level` is `low`, `medium` or `high`; `decision` is
`allow_once`, `allow_always` or `deny`; `permission_mode` is
`none`, `confirm` or `interactive`; `stop_reason` is `end_turn`,
`max_tokens`, `tool_use`, `stop_sequence`, `cancelled` or `error`.
## Errors
`CAP_ERROR_CODES` maps numerics to symbols, `CAP_ERROR_SYMBOLS`
maps back. Core: `-32001 cap_manifest_missing`, `-32002
cap_session_config_missing`, `-32003 cap_unsupported_capability`,
`-32004 cap_budget_exceeded`, `-32005 cap_invalid_answer`,
`-32006 cap_unknown_permission`, `-32007
cap_queued_input_unsupported`, `-32008 cap_cancel_unsupported`,
`-32009 cap_binding_unavailable`, `-32010 cap_profile_unsupported`.
Coding: `-32020 cap_scope_violation`, `-32021 cap_path_not_found`,
`-32022 cap_io_error`, `-32023 cap_terminal_unavailable`,
`-32024 cap_terminal_killed`.
`CAP_ERROR_STRINGS` holds the `cap.error` string codes: core
`rate_limit`, `auth_error`, `tool_timeout`, `context_overflow`,
`model_unavailable`, `pty_died`, `parse_failed`; coding
`git_dirty`, `test_failed`, `merge_conflict`,
`language_server_down`. Wire failures raise `CapError`.
## Manifests (`CapManifest`, `CapRegistry`)
Required (§5.2): `agent.name`, `agent.binary`,
`agent.profiles`, `startup.command`, `startup.ready_when`,
`pty.cols`, `pty.rows`, `capabilities.streaming_output`.
Defaults (§5.3) in `CapManifest.DEFAULTS`:
`pty.bracketed_paste` false, `pty.sigint_cancels_turn` hard,
`pty.queued_input_supported` false, `capabilities.multi_session`
false, `capabilities.ask_user.*` all false,
`capabilities.streaming_tool_output` false, `cost.metered` false,
`parse.idle` the `>`/`❯` pair, all other `parse.*` unset,
`parse.error_lines` empty.
Resolution order in `CapRegistry`: binary sibling
`cap-agent.toml`, user-local `~/.config/cap/agents/`,
system-wide `/usr/share/cap-agents/`, probe
`<binary> --cap-manifest` with timeout `CAP_PROBE_TIMEOUT`
seconds. Override dirs via constructor args in tests.
## Bindings (`CapBinding`)
Priority `CapBinding.ORDER`: `grpc`, `stream_json`, `acp_stdio`,
`a2a`, `pty`. `CapBinding.UNSUPPORTED` is `grpc` only (stdlib
has no HTTP/2 or protobuf); selection falls through past it.
Manifest booleans `false` and absent fields mean the same:
not supported. A2A tries draft endpoints first
(`POST /message/send`, `/tasks/cancel`), then current A2A
(`POST /message:send`, `POST /tasks/{id}:cancel`). The ACP
server argv is out-of-band (fleet `argv`, chat `--argv`):
the manifest carries no ACP command. Pipe drivers share
`CapPipeReader` (non-blocking NDJSON, `CAP_STREAM_LINE_MAX`
shared cap), `cap_stderr_drain`, `cap_proc_kill` and
`cap_proc_reap`.
## Reverse RPC served by the orchestrator
Core: `cap.user_io.show`, `cap.user_io.input`, `cap.notify`.
Coding fs: `cap.fs.read`, `cap.fs.write`, `cap.fs.list`,
`cap.fs.exists`, `cap.fs.delete`, `cap.fs.watch`. Coding
terminal: `cap.terminal.create`, `cap.terminal.output`,
`cap.terminal.wait`, `cap.terminal.kill`, `cap.terminal.release`.
Scope enforcement: `workspace_only` is the session `cwd`
subtree, `project_root` the VCS-root ancestor subtree,
`unrestricted` nothing (discouraged); symlinks resolve before
the check and MUST NOT escape.
`CapAcpDriver` speaks ACP v1 (`CAP_ACP_VERSION`) over stdio:
initialize handshake, `session/new`, prompt turns with the
C.3 update mapping (message/thought chunks, tool calls,
plan, usage deltas). It serves `fs/read_text_file` and
`fs/write_text_file` (line windows, `CAP_ACP_FS_MAX` bytes),
`terminal/*` (grants, `CAP_ACP_TERMINALS_MAX` concurrent,
`CAP_ACP_TERM_BUF` retention, `CAP_ACP_TERM_WAIT_MAX` wait
cap), form `elicitation/create` (URL declined), and tai
`_cap/*` extensions for `fs.list`/`exists`/`delete` plus
`user_io.show`/`input` and `notify`. Permission, elicitation
and input answers flow through the unified `open_asks` stash.
`CapA2aDriver` refuses cards without the `cap-protocol/v1`
extension, posts messages (flavor sticky after discovery),
and maps SSE payloads: raw `cap.*` events, `_meta.cap.kind`
data parts, text parts, task states (input-required opens
an ask, terminal states end the turn), plain artifacts.
Answers return as marked data parts. HTTP timeout
`CAP_A2A_TIMEOUT` (30). Tai does not expose sessions over
A2A (server side unbuilt).
## Orchestration (§10)
Sessions get `cap://` URNs stable per orchestrator. Master
plans carry `_meta.cap.assigned_to` and `_meta.cap.depends_on`;
sub-agents never talk directly, every `@cap://` directive
routes through the orchestrator into the audit log. Coding
sub-agents get git worktrees under `.cap-worktrees/`, merged
by the orchestrator. Usage aggregates across the fleet and the
orchestrator hard-cancels past budget. Interruption is
cooperative: cancel the turn, keep partial text, resend with
the correction.
`CapSession` pumps one driver on its own thread, queues
events, and blocks per turn in `ask` with `CAP_TURN_TIMEOUT`
(600); `CapTurnResult` carries the turn events, end state,
usage delta and joined text. `CapFleet` provisions git
worktrees per coding session (plain dirs outside git, both
tracked and removed on close), publishes validated master
plans, routes `@cap://` mentions (`CAP_MENTION_RE`) through
per-session pending queues (`CAP_ROUTE_QUEUE_MAX`, 16,
idle-only delivery), refuses new turns past `budget_usd`,
and keeps every mediation in the audit log.
`collect_diffs` reports bounded per-worktree diffs
(`CAP_FLEET_DIFF_MAX`, 65536); merging stays the operator's
call. Poll cadence `CAP_SESSION_POLL` (0.1), worktree root
`CAP_WORKTREE_DIR`.
`CapFleet.set_answerer` installs a `CapAnswerer`: open asks
and permission requests each get one daemon research thread
(deduped, counted busy by `wait_idle`, audited). Policy
first (deny list, allow list, `max_risk` over
`CAP_ANSWER_RISKS`), then an isolated tai one-shot
(`--auto`, own vault under `cap-research`) bounded by
`CAP_ANSWER_TIMEOUT` (300) and `CAP_ANSWER_PARALLEL` (4);
the value comes from the last `CAP_ANSWER_MARKER` line.
Failures fall back safe (deny, False, unknown). CLI:
`--auto-answer`, `--allow-tool`, `--deny-tool`, `--max-risk`
(`cap_answer_flags`, `cap_build_answerer`); fleet file
`auto_answer`, `allow_tools`, `deny_tools`, `max_risk`.
## Stream driver (`CapStreamDriver`)
NDJSON over pipes with the C.1 mapping (init to ready,
assistant blocks to chunk/thought/tool start, tool results
to tool end, result to usage or error, tai `artifact`
frames to `cap.artifact`, unknown lines counted in
`ignored_frames`). stdout is non-blocking with a manual
line buffer capped at `CAP_STREAM_LINE_MAX` (1048576);
stderr drains on a daemon thread into the last
`CAP_STREAM_STDERR_KEEP` (50) lines, quoted on unexpected
exit. Turn cancel kills the one-shot process and the next
send respawns lazily.
## PTY heuristics (`CapPtyDriver`)
`CapDriver` is the shared base (identity, event queue, id
sequences, send guards, spawn-then-ready start). States in
`CapDriver.STATES`: starting, ready, working,
input_required, done, failed, closed. `done` ends the turn
with the session alive; `closed` and `failed` are terminal.
Spawns under `PtyProc`, renders through `TuiVtScreen`
(`text_lines`), writes terminal replies back. Settle window
`CAP_PTY_SETTLE_MS` (800), prompt gate (no Done before the
first real prompt), bounded resubmits
`CAP_PTY_RESUBMITS_MAX` (3), wait-for-ready before the first
prompt, 150ms text-to-Enter gap `CAP_PTY_SUBMIT_GAP_MS`,
scrollback `CAP_PTY_SCROLLBACK` (10000), raw-bytes stream
opt-in only via `raw_sink`. Idle is the idle regex on the
last line plus 200ms silence `CAP_PTY_IDLE_MS`; boot ready
needs the marker plus the same silence. Boot timeout
`CAP_PTY_INIT_TIMEOUT` (60) when the manifest sets none.
Line events from fresh rendered rows, first match wins:
error lines to `cap.error` (`rate_limit`/`auth_error` by
substring, else `error`), tool start/end pairing by call
id, ask patterns to `cap.ask_user` (answered via
`CapPtyDriver.answer`), plan/thought markers to `cap.plan`
/ `cap.thought`. Identical line events suppress repeats
per turn. Cancel consults `capabilities.mid_turn_cancel`
over `pty.sigint_cancels_turn` over hard; queued input
consults `capabilities.queued_input` over
`pty.queued_input_supported` over false.
## CLI surface
- `--cap-manifest`: print `TAI_CAP_MANIFEST` and exit 0. No
vault, no network, no boot. Phase 1.
- `--cap-speak`: stream-json over stdio (agent side). Bulk
session config via `CAP_SESSION_CONFIG` (JSON
`cap.session.config`); `--cap-cwd`, `--cap-model`,
`--cap-permission-mode`, `--cap-max-turns` override single
fields. Phase 2.
- `cap` orchestrator commands via `--cap` (`cap_cli_main`,
`cap_cli_help`): `resolve` (`cap_resolve_main`), `chat`
(`cap_chat_main`, task once plus tty REPL, `--argv` for
ACP servers), `run` (`cap_run_main` over a `fleet.toml`
validated by `cap_fleet_plan`). Results print via
`cap_print_result`. Phase 4.
`fleet.toml` schema: `[fleet]` with `start` (a session URN),
`task`, optional `budget_usd`, `auto_answer`, `allow_tools`,
`deny_tools`, `max_risk`; `[[sessions]]` with `name`,
`agent` (registry key), optional `cwd`/`model`/`argv`;
`[[routes]]` with `when` (source URN, `.done` suffix
allowed) and `route_to`. Routes run statically in order,
each feeding the source's last text into the target.
## Speak path (`CapSpeakSession`)
`CapSessionConfig` parses env plus flags; `CapStreamJson`
builds Claude-compatible frames and parses inbound user
frames; `CapSpeakSession` runs each turn through
`Agent.run_turn` with stdout parked on stderr and emits the
transcript diff (assistant frames, grouped tool results,
result frame) plus a `diff` artifact when the tree changed.
`CAP_SPEAK_MAX_TURNS` (50) caps turns when unset;
`CAP_SPEAK_DIFF_MAX` (65536) caps inline diff bytes.
Permission mapping: `none` boots yolo, `confirm` and
`interactive` boot auto (research instead of asking); stdin
is never read for prompts. Artifacts travel as tai
`artifact` frames (receivers ignore unknown types); the
`transcript` artifact is a `file://` URI under
`cap-transcripts/`, written at session end. Constants
`CAP_SESSION_CONFIG_ENV`, `CAP_SPEAK_MAX_TURNS`,
`CAP_SPEAK_DIFF_MAX`; entry `cap_speak_main`; cancel via
`cap_interrupt` (SIGTERM acts like Ctrl-C, emits an error
result, exits 130).
## Deviations from upstream
- gRPC binding not implemented (stdlib).
- Regex flavor: tai accepts Python named groups `(?P<>)`
(every shipped example uses them) and rejects only
lookaround and backreferences, against the letter of §5.4.
- No fs/terminal mediation on the agent speak path: reverse
RPC framing over stream-json is unspecified upstream.
- A2A dual spelling (draft first, current fallback).
- Worktrees under `.cap-worktrees/` per the profile text, not
the reference CLI's `.cap/`.
- PTY submit sends CR, not the spec's LF: CR is Enter in both
canonical and raw terminal modes.
- Idle/prompt rows never emit `cap.text_chunk`; no usage
events on the PTY path (agents report none).
- Tool end output is empty (PTY shows no result payload);
plan entries carry the marker line as content.
- A2A expose (serving tai sessions over A2A) is not built;
tai drives A2A agents but does not serve them.
- ACP `terminal/wait_for_exit` is capped at 600s and reports
null exit codes when still running; v2 renames deferred.
- `_cap/fs.list`/`exists`/`delete` and `_cap/user_io.*`
shapes are tai-defined (the profile fixes methods, not
JSON shapes).