Files
tai/cap.md
T

295 lines
13 KiB
Markdown
Raw Normal View History

# 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).