diff --git a/.gitignore b/.gitignore index 6592aef..56b286b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ *.md !research/**/*.md !uiflow.md +!cap.md # Backup files *.bak diff --git a/AGENTS.md b/AGENTS.md index 8b28532..aabd71d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ - The grr terminal client (`tai.py --tui`, the `Grr*` classes) is specified in [grrtui.md](grrtui.md): read it before touching any `Grr*` code, and keep its screen, key and cap tables in step with the code (`GrrSpecTests` fails when they drift). It is a client of the grr server and shows what the server's `/api/spec` and `/locales.json` say; never copy a string, color or limit of the server into `tai.py`. `tests/grr_tui.py` drives the real binary against a real grr server (sibling `../grr`, `GRR_DIR` to move it) and a real model. - The terminal multiplexer (`Mplex*` classes, `tai.py --mplex`, `~/.tai/mplex.toml`) is specified in [mplex.md](mplex.md): read it before touching any `Mplex*` code, and keep its option, command and key tables in step with the code (`MplexSpecTests` fails when they drift). - Interactive UI flows (`plexer_show`, `PlexerFlow`, `PlexerController.ask`, the `show`/`result`/`cancel` control commands) are specified in [uiflow.md](uiflow.md): read it before touching any flow code, and keep its contract and cap tables in step with the code (`UiflowDocTests` fails when they drift). +- CAP protocol support (`Cap*` classes, `--cap-manifest`, `--cap-speak`, orchestrator drivers) is specified in [cap.md](cap.md): read it before touching any `Cap*` code, and keep its event, error and manifest tables in step with the code (`CapDocTests` fails when they drift). ## Testing diff --git a/cap.md b/cap.md new file mode 100644 index 0000000..1c2d935 --- /dev/null +++ b/cap.md @@ -0,0 +1,294 @@ +# 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 +` --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). diff --git a/research/INDEX.md b/research/INDEX.md index 263b806..97ab941 100644 --- a/research/INDEX.md +++ b/research/INDEX.md @@ -6,11 +6,11 @@ | Metric | Value | |---|---| -| Subjects | 12 | -| Total source documents | 667 | -| — Tier 1 (Source Dossier, independently analyzed) | 193 | -| — Tier 2 (Source Citation Card, listed only) | 474 | -| Total conclusion documents | 27 | +| Subjects | 13 | +| Total source documents | 691 | +| — Tier 1 (Source Dossier, independently analyzed) | 216 | +| — Tier 2 (Source Citation Card, listed only) | 475 | +| Total conclusion documents | 29 | | Archive opened | 2026-10-09 | | Last updated | 2026-10-18 | @@ -30,6 +30,7 @@ | [`prompt-context-scaling`](prompt-context-scaling/) | 34 (10 / 24) | 3 | 2026-10-18 | 2026-10-18 | Scaling prompts past the context window and reusing RAG/graph for more; tai already matches the converged arsenal, giant prompts should spill to retrievable records (specified, not built), full GraphRAG and memory vendors rejected | | [`background-work-limits`](background-work-limits/) | 10 (4 / 6) | 2 | 2026-10-18 | 2026-10-18 | Bounding parallel subagents and hardening the scheduler; parallel cap 8 with reject-loud, per-tick fires 4, one live run per schedule via lease, stated misfire policy; implemented same day | | [`sandbox-resource-caps`](sandbox-resource-caps/) | 21 (3 / 18) | 1 | 2026-10-18 | 2026-10-18 | Capping the agent sandbox box CPU/memory/pids after the coverage audit found it uncapped; static trio 2g/2.0/256 at create, best-effort update for existing boxes, pids via recreation; implemented same day | +| [`cap-protocol`](cap-protocol/) | 24 (23 / 1) | 2 | 2026-10-18 | 2026-10-18 | Supporting CAP (CLI Agent Protocol) as agent and orchestrator: full read of core + coding profile, quickstart, STATUS, all 5 manifests; selective A2A/ACP wire reads; decided phased rollout without gRPC plus dual A2A spelling | ## Recent activity log @@ -57,6 +58,7 @@ | 2026-10-18 | `sqlite-rag-hybrid-search` | Extended with 28 sources (7 Tier 1 / 21 Tier 2) + 2 conclusions (`conclusions/standing-retrieval-eval.md`, `conclusions/lexical-tuning-roadmap.md`), closing the original conclusion's open risk on unmeasured retrieval quality: rank metrics (Hit@K/MRR/nDCG/Context Precision), goldens generalized from the fingerprint method, implicit search-then-read preferences, query logging gap, and an ordered tuning backlog (column weights, OR-fallback, prefix) with HyDE/RM3/embeddings rejected. Counts verified directly against the written files via `research/audit.py` (PASS). | | 2026-10-18 | `background-work-limits` | New subject: 10 sources (4 Tier 1 / 6 Tier 2) + 2 conclusions, covering supervisor bounds (OTP intensity, semaphore reject-loud) and scheduler policies (APScheduler max_instances/coalesce, Temporal Skip, cron) after a feature-coverage audit found orchestration and scheduling unresearched. Decisions implemented same day: AGENTS_MAX_PARALLEL 8 with AgentsFull refusal, 64-record retention with oldest-collected purge, 4 fires per tick, one live run per schedule via last_status lease, stated misfire policy, no auto-retry. Counts verified directly against the written files via `research/audit.py` (PASS). | | 2026-10-18 | `sandbox-resource-caps` | New subject: 21 sources (3 Tier 1 / 18 Tier 2) + 1 conclusion, covering container resource caps (Docker limits reference, podman-run flag parity, podman-update create-only pids gap) plus snippet corroboration of the memory/cpus/pids trio after the coverage audit found the sandbox box fully uncapped. Decision implemented same day: static `--memory=2g --cpus=2.0 --pids-limit=256` at box create for both engines, one best-effort `update` (memory plus cpus) per process for existing boxes, concrete `rm -f` recovery pointer on failure. Counts verified directly against the written files via `research/audit.py` (PASS). | +| 2026-10-18 | `cap-protocol` | New subject: 24 sources (23 Tier 1 / 1 Tier 2) + 2 conclusions, covering CAP core v1 and the coding profile read in full, quickstart, STATUS, all 5 example manifests, A2A spec + proto (selective), 10 ACP pages (selective), and the reference A2A driver (grepped). Decisions: implement all bindings except gRPC in 5 phases (manifest/events, agent speak, PTY driver, stream-json + orchestration, ACP/A2A + auto-answer); try draft then current A2A endpoint spellings. Counts verified directly against the written files via `research/audit.py` (PASS). | ## How to use this index diff --git a/research/cap-protocol/a2a-proto.md b/research/cap-protocol/a2a-proto.md new file mode 100644 index 0000000..0b6321f --- /dev/null +++ b/research/cap-protocol/a2a-proto.md @@ -0,0 +1,50 @@ +# A2A a2a.proto (wire shapes) + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | A2A specification/a2a.proto | +| Source URL | https://github.com/a2aproject/A2A/blob/main/specification/a2a.proto | +| Source type | schema | +| Source date (published) | Unknown, not stated (living file) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct raw-file fetch, read selectively (TaskState, TaskStatus, Part, TaskStatusUpdateEvent, AgentExtension) | +| Confidence | High | +| Status | Active | + +## Summary + +The machine-readable A2A schema behind the template-generated spec tables: 9-state TaskState enum, TaskStatus (state + message + timestamp), Part oneof (text/raw/url/data + metadata + filename + media_type), status/artifact update events, AgentExtension (uri/description/required/params). + +## Key findings + +- `TASK_STATE_INPUT_REQUIRED = 6` exists, validating CAP's C.4 mapping row; JSON form is lowercase (`working`, `input-required`, ...). +- Parts are a oneof: exactly one of text/raw/url/data, with media_type on all variants; CAP's TextPart/DataPart map to the text/data arms. +- TaskStatusUpdateEvent carries task_id + context_id + full TaskStatus; artifact updates are separate events, matching CAP's split mapping. +- context_id is REQUIRED on updates: multi-turn correlation key tai's driver must persist per session. + +## Pros / strengths + +- Unambiguous field shapes with presence/required annotations; no prose interpretation needed. +- Confirms CAP's event mapping (C.4) is implementable against real A2A frames. + +## Cons / limitations / caveats + +- Read selectively (5 messages of ~40); security/push/video-part messages not reviewed. +- Proto is the schema, not the JSON mapping; JSON enum casing confirmed only via the reference driver's test fixtures. + +## Relevance to tai + +Schema source for tai's A2A driver frame parsing and expose-server synthesis. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/a2a-specification.md b/research/cap-protocol/a2a-specification.md new file mode 100644 index 0000000..b1f0da8 --- /dev/null +++ b/research/cap-protocol/a2a-specification.md @@ -0,0 +1,52 @@ +# A2A protocol specification (current) + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | Agent2Agent (A2A) Protocol Specification | +| Source URL | https://github.com/a2aproject/A2A/blob/main/docs/specification.md | +| Source type | specification | +| Source date (published) | Unknown, not stated (living LF doc) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct raw-file fetch, read selectively: full outline, §3.1 ops, §5.3 method mapping, §5.4 errors, discovery sections via grep | +| Confidence | High | +| Status | Active | + +## Summary + +Current A2A: JSON-RPC PascalCase methods (`SendMessage`, `SendStreamingMessage`, `CancelTask`...) or colon-REST equivalents (`POST /message:send`), AgentCard at `/.well-known/agent-card.json`, cursor-paginated task listing, extension declarations with required flags. Critically, its method names do NOT match what CAP v1 cites (`message/send`, `tasks/cancel`). + +## Key findings + +- Method mapping (§5.3): JSON-RPC `SendMessage` / REST `POST /message:send`; streaming `SendStreamingMessage` / `POST /message:stream`; cancel `CancelTask` / `POST /tasks/{id}:cancel`. CAP's slash-forms match the pre-1.0 A2A draft, not current A2A. +- Stream patterns: message-only (exactly one Message then close) or task-lifecycle (Task first, then status/artifact updates, close at terminal state). +- Terminal states: completed/failed/canceled/rejected; interrupted: input-required (6), auth-required (8). +- AgentCard discovery via well-known URI; extensions carry uri/description/required/params, matching CAP's `cap-protocol/v1` extension check. +- A2A error codes (-32001..-32009) collide numerically with CAP's registry but with different meanings; bindings must not mix the two registries. + +## Pros / strengths + +- Normative LF spec with explicit binding-equivalence rules, giving tai a stable target for the A2A driver and expose-server. +- The extension mechanism is exactly what CAP's compatibility check needs. + +## Cons / limitations / caveats + +- Markdown tables are template-generated (`proto_to_table`), so the .md alone omits field shapes; the .proto file is the real schema. +- Large surface (push notifications, OAuth flows, mTLS) far beyond what CAP's binding needs; tai implements the message/stream/cancel/card subset only. +- Read selectively (operations, mappings, discovery); auth and push sections not read. + +## Relevance to tai + +Forces the dual-spelling decision in the naming-drift conclusion: tai's A2A driver must speak CAP-literal draft endpoints to CAP peers and current endpoints to real A2A agents. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/acp-cancellation.md b/research/cap-protocol/acp-cancellation.md new file mode 100644 index 0000000..2397b91 --- /dev/null +++ b/research/cap-protocol/acp-cancellation.md @@ -0,0 +1,47 @@ +# ACP cancellation ($/cancel_request, -32800) + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | ACP Cancellation | +| Source URL | https://agentclientprotocol.com/protocol/v1/cancellation.md | +| Source type | specification | +| Source date (published) | Unknown, not stated (living docs) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct page fetch, read in full (75 lines) | +| Confidence | High | +| Status | Active | + +## Summary + +`$/cancel_request` notification cancels any JSON-RPC request; the callee MUST answer the original request (valid response or `-32800` Request Cancelled). Prompt turns ALSO have `session/cancel`, under which the client MUST answer pending permission requests with `cancelled` and the agent MUST end the prompt with stop reason `cancelled`. + +## Key findings + +- Two-level cancel: generic `$/cancel_request` for any request, plus turn-scoped `session/cancel` with stricter MUSTs; tai's driver needs both (turn cancel maps from `cap.cancel` scope current_turn). +- Cascading cancel is illustrated but explicitly optional: no automatic propagation guarantee. +- Internal cancellation (either side) SHOULD surface as the same `-32800`, keeping one code path. + +## Pros / strengths + +- Clean separation of generic vs turn cancel; the worked cascade example removes sequencing doubt. + +## Cons / limitations / caveats + +- `$/cancel_request` support is optional per implementation; tai must handle agents that ignore it (timeout + kill). + +## Relevance to tai + +Cancel mapping for the ACP driver: `cap.cancel` to `session/cancel` (+ `$/cancel_request` for in-flight requests), with timeout fallback. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/acp-elicitation.md b/research/cap-protocol/acp-elicitation.md new file mode 100644 index 0000000..63000cf --- /dev/null +++ b/research/cap-protocol/acp-elicitation.md @@ -0,0 +1,48 @@ +# ACP elicitation (form and URL modes) + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | ACP Elicitation | +| Source URL | https://agentclientprotocol.com/protocol/v1/elicitation.md | +| Source type | specification | +| Source date (published) | Unknown, not stated (living docs) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct page fetch, read in full (199 lines) | +| Confidence | High | +| Status | Active | + +## Summary + +Elicitation via `elicitation/create` with explicit `mode` (no MCP-style default): form mode (flat object schema, primitives plus enums, never secrets) and URL mode (out-of-band, `elicitationId` plus `elicitation/complete` notification). Responses are accept/decline/cancel with optional content; unadvertised modes get JSON-RPC -32602. Clients advertise per-mode support explicitly (`{}` means NO modes). + +## Key findings + +- Form mode is the ACP counterpart of CAP `cap.ask_user` (CAP cites the same JSON-Schema-subset convention); tai's driver maps form elicitations to ask_user events. +- Explicit per-mode advertisement means tai can offer form-only and stay compliant while declining URL flows. +- MCP-compatibility trap documented in-spec: empty object does NOT mean form-only here, unlike MCP's release candidate. + +## Pros / strengths + +- Gives ask_user a structured cross-protocol lineage (MCP elicitation to ACP to CAP), so tai's auto-answerer targets one shape. + +## Cons / limitations / caveats + +- URL mode is out of scope for tai (no browser/redirect handler); tai answers URL elicitations with decline, per the spec's safe-fallback rule. +- Secrets policy is agent-side; tai-as-client cannot verify an agent obeys the no-secrets-in-form rule. + +## Relevance to tai + +Shapes the ask_user mapping and the research auto-answerer input contract (restricted JSON Schema form). + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/acp-file-system.md b/research/cap-protocol/acp-file-system.md new file mode 100644 index 0000000..d43ac97 --- /dev/null +++ b/research/cap-protocol/acp-file-system.md @@ -0,0 +1,48 @@ +# ACP file-system methods + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | ACP File System | +| Source URL | https://agentclientprotocol.com/protocol/v1/file-system.md | +| Source type | specification | +| Source date (published) | Unknown, not stated (living docs) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct page fetch, method inventory via grep plus surrounding prose (capability gating, read/write shapes) | +| Confidence | High | +| Status | Active | + +## Summary + +Two client-hosted methods: `fs/read_text_file` (path + optional line/limit, returns content) and `fs/write_text_file` (path + content), both capability-gated per method; agents MUST NOT call unadvertised methods. No list/exists/delete, confirming the coding profile's `_cap/` extension gap. + +## Key findings + +- Read supports line/limit windowing, which CAP's `cap.fs.read` lacks; the bridge must either emulate (read-all then slice) or pass through as an extension param. +- Absolute paths required, matching CAP's fs contract; unsaved-editor-state access is the rationale (N/A to tai, which reads disk). +- Per-method capability flags give tai's driver a truthful advertisement surface (offer read/write only when the coding profile grants them). + +## Pros / strengths + +- Minimal surface with strict gating: exactly the two methods a code agent needs most. + +## Cons / limitations / caveats + +- Inventory-level reading (grep + prose); error shapes and write atomicity unspecified in what was read. +- No list/exists/delete forces the `_cap` extension path for full coding-profile parity. + +## Relevance to tai + +Confirms the §9 bridge table: fs.read/write map natively, the rest need `_cap` extensions the driver must implement. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/acp-initialization.md b/research/cap-protocol/acp-initialization.md new file mode 100644 index 0000000..66e260c --- /dev/null +++ b/research/cap-protocol/acp-initialization.md @@ -0,0 +1,49 @@ +# ACP initialization (initialize, session/new baseline) + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | ACP Initialization | +| Source URL | https://agentclientprotocol.com/protocol/v1/initialization.md | +| Source type | specification | +| Source date (published) | Unknown, not stated (living docs) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct page fetch, read in full (initialization handshake, version negotiation, client capabilities) plus session-setup page for session/new | +| Confidence | High | +| Status | Active | + +## Summary + +Connection baseline: `initialize` handshake negotiates client/agent capabilities (prompt content types, fs methods, terminals, elicitation modes, notices, compaction), and all agents MUST support `session/new`, `session/prompt`, `session/cancel`, `session/update`. + +## Key findings + +- The four-method MUST baseline is the liveness contract tai's ACP driver can rely on for any agent. +- Capability negotiation is where tai declines surface (notices, compaction, URL elicitation) and advertises coding-profile grants (fs, terminals); initialize carries `protocolVersion: 1`, `clientCapabilities`, and `clientInfo`. +- Version negotiation: agent answers with the same version or its latest; tai speaks v1 only and closes on anything else. +- `session/new` takes `{cwd, mcpServers}` and returns `{sessionId}` (see the session-setup dossier). + +## Pros / strengths + +- Explicit baseline + negotiation keeps a minimal driver compliant by construction. + +## Cons / limitations / caveats + +- v1 only; v2 migration (method renames) deferred until an agent requires it. +- Auth methods and MCP server passing are out of scope for tai's driver. + +## Relevance to tai + +Handshake design for the ACP driver: capability set tai advertises and the baseline it requires. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/acp-introduction.md b/research/cap-protocol/acp-introduction.md new file mode 100644 index 0000000..5ec2574 --- /dev/null +++ b/research/cap-protocol/acp-introduction.md @@ -0,0 +1,46 @@ +# ACP introduction and architecture + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | ACP Introduction | +| Source URL | https://agentclientprotocol.com/get-started/introduction.md | +| Source type | documentation | +| Source date (published) | Unknown, not stated (living docs) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct page fetch, read in full | +| Confidence | High | +| Status | Active | + +## Summary + +ACP standardizes editor-to-agent communication (LSP analogy): local agents run as editor subprocesses over JSON-RPC stdio; remote agents over HTTP/WebSocket (remote support still in progress). Reuses MCP JSON shapes where possible; user text defaults to Markdown. + +## Key findings + +- Local = subprocess + JSON-RPC over stdio is the stable, specified path; remote is explicitly work-in-progress, so tai's ACP binding targets stdio only. +- Markdown as the default user-text format aligns with tai's TUI rendering. + +## Pros / strengths + +- Confirms stdio as the SHOULD-support transport, matching CAP's ACP-stdio binding exactly. + +## Cons / limitations / caveats + +- Positioning page only; no wire detail (covered by the sibling dossiers). + +## Relevance to tai + +Scopes tai's ACP binding to local stdio and records why remote ACP is out of scope. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/acp-llms-index.md b/research/cap-protocol/acp-llms-index.md new file mode 100644 index 0000000..3017fac --- /dev/null +++ b/research/cap-protocol/acp-llms-index.md @@ -0,0 +1,46 @@ +# ACP documentation index (llms.txt) + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | ACP llms.txt documentation index | +| Source URL | https://agentclientprotocol.com/llms.txt | +| Source type | documentation index | +| Source date (published) | Unknown, not stated (living docs) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct page fetch; v1 page list read in full, v2 list partially (fetch truncated past session-delete) | +| Confidence | High | +| Status | Active | + +## Summary + +The ACP doc map: v1 covers overview, initialization, auth, session setup/list/delete, prompt turn, content, tool calls, elicitation, file system, cancellation, terminals, plan, modes, config options, slash commands, extensibility, transports, schema; v2 exists with a migration guide. + +## Key findings + +- v1 already includes elicitation and extensibility pages, so CAP's "(v2)" annotations on elicit/cancel are about method renames, not feature existence. +- The index drove selective fetching: transports, prompt-turn, tool-calls, elicitation, file-system, cancellation, terminals, agent-plan, content. + +## Pros / strengths + +- Complete map in one fetch; every binding-relevant page identified without crawling. + +## Cons / limitations / caveats + +- v2 section only partially seen; v2 migration details deferred to the ACP-driver phase. + +## Relevance to tai + +Research roadmap for the ACP binding; v2 migration page is queued reading for Phase 5. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/acp-prompt-turn.md b/research/cap-protocol/acp-prompt-turn.md new file mode 100644 index 0000000..7e23f10 --- /dev/null +++ b/research/cap-protocol/acp-prompt-turn.md @@ -0,0 +1,51 @@ +# ACP prompt turn (session/update variants, stop reasons) + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | ACP Prompt Turn | +| Source URL | https://agentclientprotocol.com/protocol/v1/prompt-turn.md | +| Source type | specification | +| Source date (published) | Unknown, not stated (living docs) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct page fetch, read in full (590 lines) | +| Confidence | High | +| Status | Active | + +## Summary + +The prompt-turn lifecycle: `session/prompt` request, `session/update` notification variants (message/thought chunks, tool_call, tool_call_update, plan, usage_update, notice, compaction...), permission interleaving, cancel semantics, and the `session/prompt` response carrying the stop reason. Turn boundary = prompt RESPONSE, not a notification. + +## Key findings + +- Update variants map 1:1 onto CAP C.3 rows (agent_message_chunk, agent_thought_chunk, tool_call, tool_call_update, plan) plus extras tai can ignore or log (notices, compaction, slash commands). +- `messageId` groups chunks into messages (opaque, agent-chosen); a changed id means a new message. +- Clients MUST restrict prompt content types to negotiated prompt capabilities. +- notice/compaction updates are capability-gated (absent capability means MUST NOT send); tai's driver advertises neither, keeping agents on plain messages. +- Cancel: client sends `session/cancel`, MUST answer pending permission requests with `cancelled` outcome, agent MUST reply to the original prompt with stop reason `cancelled`. + +## Pros / strengths + +- Precise turn-boundary rule (response, not notification) eliminates the PTY-style guessing for ACP agents. +- Capability gating lets a minimal driver stay minimal without breaking agents. + +## Cons / limitations / caveats + +- Long page with heavy component markup; schema-level field types live on the schema page (not fetched). +- Compaction/notice semantics add client-side state tai deliberately declines via capabilities. + +## Relevance to tai + +The C.3 mapping source for the ACP driver: update-variant to core-event table plus the prompt-response turn boundary. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/acp-session-setup.md b/research/cap-protocol/acp-session-setup.md new file mode 100644 index 0000000..5ac6c42 --- /dev/null +++ b/research/cap-protocol/acp-session-setup.md @@ -0,0 +1,47 @@ +# ACP session setup (session/new) + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | ACP Session Setup | +| Source URL | https://agentclientprotocol.com/protocol/v1/session-setup.md | +| Source type | specification | +| Source date (published) | Unknown, not stated (living docs) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct page fetch, targeted read (session/new request/response; load-session section skimmed) | +| Confidence | High | +| Status | Active | + +## Summary + +Session creation: `session/new` with `{cwd, mcpServers: []}` returns `{sessionId}`; agents advertising `loadSession` also accept `session/load` for resume. Tai needs only the create path. + +## Key findings + +- `session/new` params are exactly `{cwd, mcpServers}` (servers carry name/command/args/env); tai sends an empty server list. +- Result carries only `sessionId`, the correlation key for every later call. +- Resume via `session/load` exists but tai sessions are single-connection; not implemented. + +## Pros / strengths + +- Trivial handshake; no negotiation beyond initialize. + +## Cons / limitations / caveats + +- Targeted read; auth and load-session details not synthesized. + +## Relevance to tai + +Exact `session/new` shapes for the ACP driver handshake. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/acp-terminals.md b/research/cap-protocol/acp-terminals.md new file mode 100644 index 0000000..a3bc458 --- /dev/null +++ b/research/cap-protocol/acp-terminals.md @@ -0,0 +1,48 @@ +# ACP terminal methods + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | ACP Terminals | +| Source URL | https://agentclientprotocol.com/protocol/v1/terminals.md | +| Source type | specification | +| Source date (published) | Unknown, not stated (living docs) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct page fetch, read in full (284 lines) | +| Confidence | Medium | +| Status | Active | + +## Summary + +Client-hosted terminals: capability-gated in `initialize`, then `terminal/create` (command/args/env-list/cwd/outputByteLimit, truncation at char boundary), `terminal/output` (full output plus truncated flag plus exitStatus when exited, no offset param), `terminal/wait_for_exit`, `terminal/kill` (terminal stays valid), `terminal/release` (kills plus invalidates the id), mirroring the coding profile's terminal methods with `wait` vs `wait_for_exit` the only name difference. + +## Key findings + +- Near-perfect method correspondence with `cap.terminal.*` makes the ACP bridge for terminals a rename-only adapter. +- Capability gating in initialize matches CAP's `terminal.enabled` advertisement; a disabled terminal is a refused capability, not an error path. +- `session/update` notifications interleave with terminal management (per the method list), so output streaming shares the turn channel. + +## Pros / strengths + +- The 1:1 mapping de-risks the terminal half of the ACP bridge to near-zero design work. + +## Cons / limitations / caveats + +- No output paging: `terminal/output` returns the whole retained buffer every call, so tai must set outputByteLimit-aware retention itself. +- Buffer-retention semantics differ from CAP's 60s rule: release invalidates the ACP terminal id immediately. + +## Relevance to tai + +Terminal bridge is rename-only; full param shapes queued for Phase 5 reading. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/acp-tool-calls.md b/research/cap-protocol/acp-tool-calls.md new file mode 100644 index 0000000..de0c0b0 --- /dev/null +++ b/research/cap-protocol/acp-tool-calls.md @@ -0,0 +1,48 @@ +# ACP tool calls and permission flow + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | ACP Tool Calls | +| Source URL | https://agentclientprotocol.com/protocol/v1/tool-calls.md | +| Source type | specification | +| Source date (published) | Unknown, not stated (living docs) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct page fetch, read selectively (permission request/response, option kinds, statuses; content-block sections skimmed) | +| Confidence | High | +| Status | Active | + +## Summary + +Tool-call reporting (`tool_call` + `tool_call_update` keyed by `toolCallId`, statuses pending/in_progress/completed/failed) and the `session/request_permission` flow: agent offers options with kinds (allow_once/allow_always/reject_once/reject_always), client answers `selected` + optionId or `cancelled`. + +## Key findings + +- Permission option kinds map directly onto CAP's `allow_once`/`allow_always`/`deny` decisions (reject kinds collapse to deny). +- Client MAY auto-allow/reject per user settings: tai's orchestrator auto-answer policy has a spec-sanctioned hook here. +- Tool statuses are a 4-state lifecycle with no streaming-output state; output arrives via content on updates. + +## Pros / strengths + +- Small, closed vocabulary (4 kinds, 4 statuses, 2 outcomes) that translates to CAP events without loss. + +## Cons / limitations / caveats + +- Read selectively; diff/image/audio content-block variants skimmed, not synthesized. +- No risk-level concept: tai must derive CAP `risk_level` from tool identity/args heuristically. + +## Relevance to tai + +Permission/response translation table for the ACP driver and the auto-approval hook for research-driven answers. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/acp-transports.md b/research/cap-protocol/acp-transports.md new file mode 100644 index 0000000..19a10a1 --- /dev/null +++ b/research/cap-protocol/acp-transports.md @@ -0,0 +1,47 @@ +# ACP transports (JSON-RPC stdio) + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | ACP Transports | +| Source URL | https://agentclientprotocol.com/protocol/v1/transports.md | +| Source type | specification | +| Source date (published) | Unknown, not stated (living docs) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct page fetch, read in full (57 lines) | +| Confidence | High | +| Status | Active | + +## Summary + +ACP stdio: client launches agent subprocess, NDJSON JSON-RPC both ways (no embedded newlines), UTF-8, agent logs on stderr only, stdout carries nothing but ACP messages, teardown is close-stdin plus terminate. + +## Key findings + +- Newline-delimited JSON-RPC with no embedded newlines: tai's stdlib json + pipes implementation needs no framing layer beyond line splitting. +- stderr is log-only and MAY be ignored: tai's driver can capture it for diagnostics without protocol risk. +- Streamable HTTP is draft-only; custom transports MUST preserve JSON-RPC message format and lifecycle. + +## Pros / strengths + +- Minimal transport contract; a few dozen lines of stdlib implement it fully. + +## Cons / limitations / caveats + +- No heartbeat/keepalive specified; hung agents are detected only via timeouts tai must choose itself. + +## Relevance to tai + +The exact transport contract for the Phase 5 ACP-stdio driver (and its tests: NDJSON framing, stderr tolerance, close-stdin teardown). + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/cap-core-v1.md b/research/cap-protocol/cap-core-v1.md new file mode 100644 index 0000000..37c02bc --- /dev/null +++ b/research/cap-protocol/cap-core-v1.md @@ -0,0 +1,56 @@ +# CAP Core v1 specification + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | CAP CLI Agent Protocol (Core v1) | +| Source URL | https://github.com/rsclaw-ai/cap-protocol/blob/main/docs/cap-v1.md | +| Source type | specification | +| Source date (published) | 2026-05-18 (draft, public review) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct raw-file fetch, read in full (1036 lines) | +| Confidence | High | +| Status | Active | + +## Summary + +The normative core: PTY is the REQUIRED universal binding, four OPTIONAL fast-paths (stream-json, gRPC, ACP-stdio, A2A), 14 core event kinds, 3 core reverse-RPC methods, a lifecycle starting with `cap.session.config` then `cap.session.ready`, first-class multi-agent orchestration (§10), A2A interop both directions (§11), and a dual numeric/string error registry (§14.2). + +## Key findings + +- Agent conformance = CLI-invokable + publishes Manifest + matches one binding contract; orchestrator conformance = reads manifests + PTY binding + §10 semantics + required reverse RPC. +- Manifest resolution order: in-package, user-local (`~/.config/cap/agents/`), system-wide (`/usr/share/cap-agents/`), `--cap-manifest` probe. +- Parse regexes use a POSIX ERE subset (no backrefs/lookaround); drivers MUST reject richer patterns. Note: the spec's own examples use `(?P...)` named groups, which are NOT in that subset. +- PTY details: ANSI/VT100 screen buffer, regexes against rendered text on line boundaries, idle = idle-regex on last line + 200ms silence, bracketed paste over 1KB, cancel graceful/hard/none, 10k scrollback SHOULD, `pty_died` error on unexpected exit. +- Orchestration: `cap://` URNs, master plan with `_meta.cap.assigned_to`, no direct sub-agent contact (all mediated + audited), git worktrees SHOULD, orchestrator MUST aggregate usage and hard-cancel over budget, cooperative interruption pattern (cancel + preserve + resend). +- Reverse RPC has no defined wire encoding for stream-json/gRPC bindings; only the ACP binding gives it a native transport (JSON-RPC methods). +- Error registry: -32001..-32010 with string twins; `cap.error` string codes include `rate_limit`, `pty_died`, `parse_failed`, `context_overflow`. + +## Pros / strengths + +- Complete contract in one file: events, lifecycle, orchestration, errors, and informative binding mappings (C.1-C.4) sufficient to implement drivers without guessing. +- PTY-required/fast-path-optional layering matches tai's stdlib-only reality: full conformance is reachable without gRPC or any dependency. +- The §10 orchestration semantics (URNs, mediated routing, budget aggregation) map directly onto tai's existing subagent + scheduler machinery. + +## Cons / limitations / caveats + +- Draft status with real drift risk: A2A method names in §6.5/C.4 (`message/send`, `tasks/cancel`) do not match current A2A (PascalCase / colon-REST); see the naming-drift conclusion. +- Self-contradiction on regex flavor (§5.4 ERE-only vs `(?P<>)` in §5.1 examples); tai must decide what to accept. +- Reverse RPC over stream-json is unspecified, so tai-as-agent cannot offer fs/terminal mediation on its speak path without inventing framing. +- No conformance test suite ships with the spec; interop is validated against the reference Rust implementation only. + +## Relevance to tai + +The build blueprint for both directions: tai-as-agent implements §3.1 via `--cap-manifest` + stream-json speak; tai-as-orchestrator implements §3.2 minus gRPC. The §7 event table and §14.2 error table become `cap.md` drift tables. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/cap-homepage.md b/research/cap-protocol/cap-homepage.md new file mode 100644 index 0000000..d04dd0f --- /dev/null +++ b/research/cap-protocol/cap-homepage.md @@ -0,0 +1,51 @@ +# CAP homepage (cap-protocol.org) + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | CAP CLI Agent Protocol (homepage) | +| Source URL | https://cap-protocol.org/ | +| Source type | project page | +| Source date (published) | Unknown, not stated (living page; draft 2026-05-18 banner) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct page fetch, read in full via saved output | +| Confidence | High | +| Status | Active | + +## Summary + +Positioning page: the protocol-stack table (MCP/ACP/A2A/CAP layers), the PTY-substrate plus fast-paths plus orchestration pitch, a manifest example, the two normative doc links, the reserved-profile table, status timeline, and FAQ (ACP/A2A differences, binding optionality, cooperative interruption pointer to §10.6). + +## Key findings + +- Layer claim: CAP is orch-to-CLI-agent at fleet scale; one agent may speak all four protocols at once. +- Only PTY is required; "most agents implement at most one fast-path" (FAQ) sets the interop expectation tai should design to. +- Reserved profiles: devops, data, security, research, content, sysadmin, network. Only coding is defined. +- Governance: rsclaw maintainers transitional; LF proposal once 3+ independent implementations exist. Tai would count toward that bar. +- Spec text CC BY 4.0, reference code MIT. + +## Pros / strengths + +- Fastest correct mental model of where CAP sits; the FAQ preempts the ACP/A2A confusion directly. +- Confirms fast-path optionality as the norm, which justifies tai's phased binding rollout. + +## Cons / limitations / caveats + +- Marketing surface, not normative; the manifest example on the page trims fields the spec requires (e.g. no `capabilities.streaming_output`). +- Status timeline may lag the repo (page says reference impl "in progress", repo STATUS says the driver matrix is done). + +## Relevance to tai + +Justifies phased conformance (PTY + one fast-path first) and records the licensing and governance facts the implementation conclusion relies on. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/cap-profile-coding-v1.md b/research/cap-protocol/cap-profile-coding-v1.md new file mode 100644 index 0000000..96a9341 --- /dev/null +++ b/research/cap-protocol/cap-profile-coding-v1.md @@ -0,0 +1,56 @@ +# CAP Profile Coding v1 specification + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | CAP Profile: Coding (v1, draft) | +| Source URL | https://github.com/rsclaw-ai/cap-protocol/blob/main/docs/cap-profile-coding-v1.md | +| Source type | specification | +| Source date (published) | 2026-05-18 (draft) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct raw-file fetch, read in full (446 lines) | +| Confidence | High | +| Status | Active | + +## Summary + +The first vertical: `[capabilities.coding]` manifest section, 6 fs reverse-RPC methods (watch optional with `cap.fs.change` events), 5 terminal methods (create/output/wait/kill/release with a 60s buffer-retention rule), 6 artifact MIME types, scope enforcement (`workspace_only`/`project_root`/`unrestricted`), permission-mode-gated terminal vetting, and profile error codes -32020..-32024. + +## Key findings + +- Only `fs.scope` (when any fs is true) and `tool_permission` are required in the manifest section; everything else optional. +- `cap.fs.read` MUST reject out-of-scope paths without revealing existence; symlink resolution happens BEFORE the scope check and escaping symlinks MUST NOT be followed. +- Terminal defaults: kill signal SIGTERM escalating to SIGKILL after 5s; release keeps the buffer queryable 60s; wait with null timeout waits indefinitely. +- `tool_permission = interactive` means EVERY terminal.create surfaces as a permission request; `confirm` allows allow-list auto-approve; `none` logs everything but users MUST be warned prominently. +- Artifacts: diff (unified, one file RECOMMENDED), pr_link, test_result (output truncated to 64KB), commit, plan_doc, transcript (RECOMMENDED for audit/replay). +- ACP bridge table (§9): fs.list/exists/delete have NO ACP counterpart and go over `_cap/` extension methods. +- Multi-agent worktree naming suggestion: `/.cap-worktrees//` (the reference CLI instead uses `.cap//`). + +## Pros / strengths + +- Tight, implementable RPC surface: every method has params/result/errors, and the scope table leaves no enforcement ambiguity. +- The transcript artifact type gives tai's orchestrator audit log a standard export shape for free. +- tai already has the enforcement primitives (approval tiers, sandbox box, git) that §7 requires of an orchestrator. + +## Cons / limitations / caveats + +- `fs.watch` has no unsubscribe method defined; subscriptions presumably live for the session. +- `cap.terminal.create` takes a single command string plus args array, but quoting/escaping rules between them are unspecified. +- Worktree path suggestion conflicts with the reference implementation's actual `.cap//` layout; either is compliant (suggestion only). +- No binary-file story for fs.read/write beyond a base64 encoding flag; large-file chunking is unaddressed. + +## Relevance to tai + +Defines tai's coding-profile surface both ways: orchestrator MUST implement the fs/terminal reverse RPC it advertises, agent SHOULD emit diff/test_result/transcript artifacts. The scope table becomes a `cap.md` drift table. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/cap-quickstart.md b/research/cap-protocol/cap-quickstart.md new file mode 100644 index 0000000..c64eca8 --- /dev/null +++ b/research/cap-protocol/cap-quickstart.md @@ -0,0 +1,51 @@ +# CAP quickstart (cap run/chat CLI) + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | CAP Quickstart | +| Source URL | https://github.com/rsclaw-ai/cap-protocol/blob/main/docs/quickstart.md | +| Source type | documentation | +| Source date (published) | Unknown, not stated (repo living doc) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct raw-file fetch, read in full (142 lines) | +| Confidence | High | +| Status | Active | + +## Summary + +Operator view of the reference orchestrator: `fleet.yaml` sessions/routes, `cap run` with static/llm/hybrid routing, `cap chat` interactive multi-agent talk, `cap manifest validate/resolve`, `cap list-drivers`, and the driver matrix (`claude`, `openclaude`, `codex`, `opencode`, `aider`, `a2a:`, `acp:`, `grpc:`, `pty:`). + +## Key findings + +- Fleet patterns: pipeline, fan-out+join, parallel+human-collect, by-subtask split via a ` ```cap-subtasks ` fence convention (convention, not spec). +- Routing strategies: static (routes block verbatim), llm (model picks next session), hybrid. +- `cap manifest validate/resolve` confirms manifest tooling is expected orchestrator surface, not just internal plumbing. +- Worktree isolation path in practice: `.cap//`, cleaned up at fleet end (differs from the profile's `.cap-worktrees` suggestion). +- `--bypass` auto-approves all permissions at the fleet level. + +## Pros / strengths + +- Shows what "orchestrator" means operationally beyond the spec's MUSTs; directly reusable as tai's `cap` CLI command sketch. +- The `driver:` URI scheme (`a2a:`, `acp:`, `grpc:`, `pty:`) is a compact dispatch design tai can borrow. + +## Cons / limitations / caveats + +- Reference-implementation surface, not spec; fleet.yaml schema and fence conventions are rsclaw inventions. +- NoFleet error semantics or exit codes documented here. + +## Relevance to tai + +Shapes tai's orchestrator UX (manifest validate/resolve, driver URIs, chat/run split) and confirms worktree cleanup as expected behavior. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/cap-rs-a2a-driver.md b/research/cap-protocol/cap-rs-a2a-driver.md new file mode 100644 index 0000000..6a2bde1 --- /dev/null +++ b/research/cap-protocol/cap-rs-a2a-driver.md @@ -0,0 +1,28 @@ +# CAP reference A2A driver (cap-rs a2a.rs) + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Citation Card | +| Tier | 2 | +| Source title | crates/cap-rs/src/driver/a2a.rs | +| Source URL | https://github.com/rsclaw-ai/cap-protocol/blob/main/crates/cap-rs/src/driver/a2a.rs | +| Source type | reference implementation | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct raw-file fetch, grepped for endpoint strings, request bodies, and the event-mapping core (not read in full) | +| Confidence | High | +| Status | Active | + +## Why it's here + +Grepped to settle which A2A spelling the reference driver actually uses: `POST {endpoint}/message/send` with `{message: {role, parts: [{kind: text, text}]}}` and `POST {endpoint}/tasks/cancel` with no body (draft-style), card at `/.well-known/agent-card.json` refused without the `cap-protocol/v1` extension. The event mapper accepts raw `cap.*` JSON, data parts unwrapped via `_meta.cap.kind` (from message, task-status and artifact parts alike), text parts (kind or type `text`, id from messageId/message_id), and failed task states; outbound answers travel as data parts with the `_meta.cap.kind` marker. Control flow around the mapper was not reviewed. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Logged as consulted-but-not-analyzed. | diff --git a/research/cap-protocol/cap-status.md b/research/cap-protocol/cap-status.md new file mode 100644 index 0000000..b18ab98 --- /dev/null +++ b/research/cap-protocol/cap-status.md @@ -0,0 +1,51 @@ +# CAP repo STATUS (implementation reality) + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | cap-protocol status (docs/STATUS.md) | +| Source URL | https://github.com/rsclaw-ai/cap-protocol/blob/main/docs/STATUS.md | +| Source type | project notes | +| Source date (published) | 2026-06-01 working snapshot | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct raw-file fetch, read in full (first 120 lines carry the state; rest is prior-session log, skimmed) | +| Confidence | High | +| Status | Active | + +## Summary + +What actually works against real agents: full driver matrix done, CAP core v1 conformance claimed, A2A driver validated against a real endpoint, `cap chat`, LLM/hybrid routing, aider PTY driver, codex defaulting to stream-json. The PTY floor section documents the turn-boundary heuristics that make TUI agents reliable: idle-settle + ready-marker + prompt-sent gate, submit verification with bounded resubmits, wait-for-ready, 150ms text-to-Enter gap. + +## Key findings + +- Agent fidelity tiers in practice: claude/codex stream-json, opencode ACP (`opencode acp` v1.14 wire-verified), aider/codex-TUI PTY fallback. "PTY screen-scraping is the universal floor, not the goal." +- PTY turn boundary = byte-silence ~800ms AND prompt glyph at rendered-screen bottom; first settle is Ready, post-prompt settles are Done; trailing prompt lines stripped from boundary chunks. +- Three reliability fixes: prompt gate (no Done before first real prompt), submit verification (re-send Enter if prompt text still in input box, bounded MAX_RESUBMITS), wait-for-ready (hold first prompt until Ready). +- codex app-server websocket transport was REMOVED as unreliable; stream-json supersedes it. Dead-end warning for tai: do not build a codex websocket driver. +- Test gate: 54 + 25 + 6 + 2 green, clippy/fmt clean; live-validated codex-to-claude fleet run. + +## Pros / strengths + +- The only source of ground truth on what the spec costs in practice (heuristics, resubmits, timing constants). +- Explicit dead-end marking (app-server removal) saves tai from the same mistake. + +## Cons / limitations / caveats + +- Explicitly non-normative and overwritten each session; facts may be stale relative to main. +- Timing constants (800ms, 150ms) are tuned to the authors' machines, not portable guarantees. + +## Relevance to tai + +Directly specifies tai's PTY driver heuristics (settle window, prompt gate, resubmit bound) and the per-agent binding preference order. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/conclusions/a2a-naming-drift.md b/research/cap-protocol/conclusions/a2a-naming-drift.md new file mode 100644 index 0000000..25ef2be --- /dev/null +++ b/research/cap-protocol/conclusions/a2a-naming-drift.md @@ -0,0 +1,55 @@ +# Speak both A2A spellings, CAP-literal first + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Conclusion Document | +| Decided | 2026-10-18 | +| Decided by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | A2A spec method-mapping table vs CAP spec text vs grep of reference driver's endpoint strings | +| Confidence | High | +| Status | Active | + +## Question / problem statement + +CAP v1 cites A2A methods (`message/send`, `tasks/cancel`) that current A2A does not define (it uses `SendMessage`/`POST /message:send`, `CancelTask`/`POST /tasks/{id}:cancel`). Which spelling does tai's A2A driver and expose-server use? + +## Conclusion + +Both, CAP-literal first: the driver tries draft-style endpoints (`POST /message/send`, `/tasks/cancel`) first since that is what CAP peers and the reference implementation speak (validated against a real endpoint), then falls back to current A2A (`POST /message:send`, `/tasks/{id}:cancel`, PascalCase JSON-RPC). The expose-server accepts both spellings. Error registries stay separate: A2A -32001..-32009 mean different things than CAP -32001..-32010 and MUST NOT be mixed. + +## Supporting sources + +- [A2A specification](../a2a-specification.md): current method mapping table (§5.3) and error-code table (§5.4). +- [Reference A2A driver](../cap-rs-a2a-driver.md): proves the CAP ecosystem speaks draft-style endpoints in practice. +- [CAP Core v1](../cap-core-v1.md): §6.5/§7.8/C.4 as the CAP-literal requirements. +- [A2A proto](../a2a-proto.md): state enum and update events both spellings share. + +## Alternatives considered and rejected + +- CAP-literal only: rejected, real (non-CAP) A2A agents would be undrivable. +- Current-A2A only: rejected, breaks interop with CAP peers and the reference implementation, the primary interop target. +- Content-negotiate via AgentCard version: rejected, no standard version signal exists on the card for this; try/fallback is simpler and observable. + +## Risks / open questions + +- Double-spelling doubles the test matrix for the A2A driver; keep the fallback behind one helper so tests cover both through the same path. +- If the spec finalizes on current-A2A spellings, flip the order (revisit condition). + +## Action taken + +Recorded as the A2A driver contract for Phase 5; no code yet. + +## Revisit when + +- CAP leaves draft with a stated A2A version pin (adopt it, drop the other spelling). +- Draft endpoints vanish from the wild. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Conclusion recorded. | diff --git a/research/cap-protocol/conclusions/scope-and-phasing.md b/research/cap-protocol/conclusions/scope-and-phasing.md new file mode 100644 index 0000000..101d2be --- /dev/null +++ b/research/cap-protocol/conclusions/scope-and-phasing.md @@ -0,0 +1,60 @@ +# Tai implements CAP both directions, phased, without gRPC + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Conclusion Document | +| Decided | 2026-10-18 | +| Decided by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | full read of CAP core + coding profile, quickstart, STATUS, all 5 example manifests; selective first-hand read of A2A spec + proto and 10 ACP pages; grep of reference A2A driver | +| Confidence | High | +| Status | Active | + +## Question / problem statement + +How does tai support "the literal whole protocol" as both agent (speaking CAP under a startup flag) and orchestrator (driving claude/codex/grok-class CLI agents, auto-answering their questions via research) while staying a single stdlib-only file? + +## Conclusion + +Implement every binding except gRPC, in five phases: (1) events/errors/manifest registry plus `--cap-manifest`; (2) agent-side `--cap-speak` stream-json binding with coding artifacts; (3) orchestrator PTY driver on the reference heuristics; (4) stream-json driver plus §10 orchestration (URNs, mediated routing, worktrees, budget); (5) ACP-stdio and A2A drivers, reverse-RPC serving, and the research auto-answerer. gRPC is rejected: stdlib has no HTTP/2 or protobuf, and hand-rolling both for one optional binding fails the boring-technology rule. A bare `grpc = true` manifest still parses; binding selection falls through to PTY. + +## Supporting sources + +- [CAP Core v1](../cap-core-v1.md): PTY-required/fast-path-optional layering makes full conformance reachable without gRPC; §7/§10/§14.2 are the build tables. +- [CAP coding profile](../cap-profile-coding-v1.md): reverse-RPC surface and scope table for the orchestrator side. +- [STATUS](../cap-status.md): PTY heuristics (800ms settle, prompt gate, resubmit bound) and the codex-websocket dead-end warning. +- [openclaude manifest](../manifest-openclaude.md): `grpc = true` carries no address, so the flag alone is undrivable anyway. +- [ACP transports](../acp-transports.md) + [prompt turn](../acp-prompt-turn.md): stdio NDJSON JSON-RPC is a few dozen stdlib lines; turn boundary is the prompt response. +- [A2A specification](../a2a-specification.md): message/stream/cancel/card subset is stdlib-feasible (http.client, SSE parse, http.server for expose). + +## Alternatives considered and rejected + +- Full gRPC via hand-rolled HTTP/2 + protobuf: rejected, thousands of lines of bug surface for an optional binding whose own manifest example omits the server address. +- PTY-only orchestrator (skip all fast-paths): rejected, the reference fidelity tiers show structured paths are strictly more reliable, and stream-json is cheap once PTY exists. +- Separate package for CAP: rejected, the single-file stdlib-only constraint is load-bearing for tai distribution; CAP fits in `Cap*` classes plus a `cap.md` mapping doc. + +## Risks / open questions + +- Regex flavor: spec says ERE-only but all examples use `(?P<>)`; tai accepts Python-compatible patterns and rejects only backrefs/lookaround, flagged as a deliberate deviation. +- Reverse RPC over stream-json is unspecified; tai-as-agent offers no fs/terminal mediation on the speak path until the spec defines framing. +- ACP v2 migration and full terminal/fs param shapes still queued for Phase 5 reading. +- Research auto-answerer quality depends on verdict/judge reliability already flagged as unmeasured. + +## Action taken + +Phases tracked as session todos; Phase 1 (events, errors, manifest registry, `--cap-manifest`, `cap.md`, tests) implemented same session. Later phases scheduled as follow-up turns. + +## Revisit when + +- The spec leaves draft (breaking changes possible; watch revision field). +- A CAP conformance suite ships (adopt it as the integration gate). +- A2A draft endpoints disappear from the wild (drop the draft spelling). + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Conclusion recorded. | diff --git a/research/cap-protocol/manifest-aider.md b/research/cap-protocol/manifest-aider.md new file mode 100644 index 0000000..1610bbe --- /dev/null +++ b/research/cap-protocol/manifest-aider.md @@ -0,0 +1,47 @@ +# Example manifest: aider + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | CAP agent manifest for aider | +| Source URL | https://github.com/rsclaw-ai/cap-protocol/blob/main/examples/aider.toml | +| Source type | example config | +| Source date (published) | Unknown, not stated (repo living file) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct raw-file fetch, read in full (51 lines) | +| Confidence | High | +| Status | Active | + +## Summary + +PTY-only REPL agent: startup command carries agent-specific flags (`--no-auto-commits`, `--no-suggest-shell-commands`), `^>$` idle, `/run` tool-call prefix, `multi_session = false`, unmetered cost. + +## Key findings + +- Startup commands embed policy flags; drivers append model/session args to this prefix rather than replacing it. +- `multi_session = false` is the only false in the set: orchestrators MUST serialize aider sessions. +- `metered = false`: no `cap.usage` expected; budget enforcement for aider is wall-clock/turn-count only. + +## Pros / strengths + +- The serial/unmetered corner that tai's orchestrator caps must respect. + +## Cons / limitations / caveats + +- `/run`-prefixed tool-call pattern is thin; PTY tool events for aider will be sparse by design. + +## Relevance to tai + +Orchestrator fixture for single-session agents and unmetered budget handling. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/manifest-claude-code.md b/research/cap-protocol/manifest-claude-code.md new file mode 100644 index 0000000..dec21c2 --- /dev/null +++ b/research/cap-protocol/manifest-claude-code.md @@ -0,0 +1,47 @@ +# Example manifest: claude-code + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | CAP agent manifest for claude-code | +| Source URL | https://github.com/rsclaw-ai/cap-protocol/blob/main/examples/claude-code.toml | +| Source type | example config | +| Source date (published) | Unknown, not stated (repo living file) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct raw-file fetch, read in full (57 lines) | +| Confidence | High | +| Status | Active | + +## Summary + +The canonical full manifest: probe via `--version`, stream-json fast-path, PTY fallback with `❯`/`>` idle patterns, `◉ tool(args)` tool-call patterns, yes/no + select prompts, error lines, full capabilities, metered cost. + +## Key findings + +- `stream_json = ["claude", "-p", "--input-format=stream-json", "--output-format=stream-json"]` is the reference fast-path shape tai's stream-json driver must spawn. +- Uses `(?P...)` named groups in tool patterns, again contradicting the §5.4 ERE-only rule (third occurrence: spec example, claude manifest, openclaude manifest). +- `cost.metered = true`: drivers MUST surface `cap.usage` for this agent. + +## Pros / strengths + +- The one manifest exercising every section; ideal fixture for tai's manifest parser tests. + +## Cons / limitations / caveats + +- Example, not normative; `version_match`/`probe` shapes may not match real claude releases. + +## Relevance to tai + +Becomes a parser test fixture and the template for tai's own `--cap-manifest` output shape. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/manifest-codex.md b/research/cap-protocol/manifest-codex.md new file mode 100644 index 0000000..456e136 --- /dev/null +++ b/research/cap-protocol/manifest-codex.md @@ -0,0 +1,47 @@ +# Example manifest: codex + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | CAP agent manifest for codex | +| Source URL | https://github.com/rsclaw-ai/cap-protocol/blob/main/examples/codex.toml | +| Source type | example config | +| Source date (published) | Unknown, not stated (repo living file) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct raw-file fetch, read in full (51 lines) | +| Confidence | High | +| Status | Active | + +## Summary + +PTY-first manifest for codex: all fast-paths false, `›` idle glyph, `✗` error prefix, 120s init timeout, metered cost. + +## Key findings + +- Shows the all-false fast-path form explicitly rather than omitting the section; tai's parser must treat false and absent identically. +- The `›` (U+203A) prompt glyph is the live-captured turn-boundary marker the STATUS doc references. +- `init_timeout_seconds = 120` doubles the claude value: slow-booting TUI agents need per-manifest patience. + +## Pros / strengths + +- Minimal-but-valid shape that pins down which fields a PTY-only agent can omit. + +## Cons / limitations / caveats + +- Contradicts STATUS.md, which says codex defaults to stream-json; the example file lags the implementation. + +## Relevance to tai + +Second parser fixture (the sparse PTY-only shape) and the binding-preference test case (all false means PTY). + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/manifest-openclaude.md b/research/cap-protocol/manifest-openclaude.md new file mode 100644 index 0000000..5282e08 --- /dev/null +++ b/research/cap-protocol/manifest-openclaude.md @@ -0,0 +1,47 @@ +# Example manifest: openclaude + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | CAP agent manifest for openclaude | +| Source URL | https://github.com/rsclaw-ai/cap-protocol/blob/main/examples/openclaude.toml | +| Source type | example config | +| Source date (published) | Unknown, not stated (repo living file) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct raw-file fetch, read in full (50 lines) | +| Confidence | High | +| Status | Active | + +## Summary + +The gRPC-first manifest: `grpc = true` with stream-json/ACP/A2A false, PTY fallback fully specified (`◉` tool patterns again), metered cost. + +## Key findings + +- `grpc = true` is a bare boolean, not an address: the address presumably comes from out-of-band config (the quickstart's `grpc:` driver URI), a gap between manifest schema and CLI. +- Third use of `(?P<>)` named groups in shipped examples, confirming the ERE-subset rule is aspirational; tai's parser should accept Python-compatible named groups and only reject backrefs/lookaround. +- Otherwise structurally identical to claude-code, confirming one parser handles all five. + +## Pros / strengths + +- Documents the grpc-true shape tai must parse even though tai will not implement the gRPC binding (stdlib has no HTTP/2 or protobuf). + +## Cons / limitations / caveats + +- No address in the manifest makes the grpc flag alone undrivable; resolution rules are unspecified. + +## Relevance to tai + +Sets the binding-selection test case (grpc preferred but unavailable in tai means fall through to PTY) and settles the regex-flavor decision. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/research/cap-protocol/manifest-opencode.md b/research/cap-protocol/manifest-opencode.md new file mode 100644 index 0000000..a1300cc --- /dev/null +++ b/research/cap-protocol/manifest-opencode.md @@ -0,0 +1,47 @@ +# Example manifest: opencode + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | cap-protocol | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | CAP agent manifest for opencode | +| Source URL | https://github.com/rsclaw-ai/cap-protocol/blob/main/examples/opencode.toml | +| Source type | example config | +| Source date (published) | Unknown, not stated (repo living file) | +| Date researched | 2026-10-18 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct raw-file fetch, read in full (52 lines) | +| Confidence | High | +| Status | Active | + +## Summary + +PTY-shape manifest for opencode with `❯`/`›` idle alternatives and `^Error:` error lines; notably `ask_user` all-false except free_text, and no model_arg. + +## Key findings + +- No `model_arg`: drivers must cope with agents that accept no model flag (config `model` then has nowhere to go; warn, not fail). +- Idle alternatives (`❯`, `›`) show the idle list is OR-matched across glyphs, matching multiple TUI states. +- Like codex, lags STATUS.md, which routes opencode over ACP in practice. + +## Pros / strengths + +- Pins the no-model-arg and multi-glyph-idle cases tai's driver must handle. + +## Cons / limitations / caveats + +- Stale relative to the implementation (ACP is the real opencode path). + +## Relevance to tai + +Parser/driver fixture for the no-model-arg case and multi-pattern idle matching. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-18 | Initial dossier created. | diff --git a/tai.py b/tai.py index cdda0c7..f0c188d 100755 --- a/tai.py +++ b/tai.py @@ -102,7 +102,7 @@ def _fromisoformat(text): return parsed.replace(tzinfo=tzinfo) raise ValueError("invalid datetime, use ISO like 2026-10-08T09:00") -VERSION = "1.38.0" +VERSION = "1.39.0" WORKER_STEPS = 12 CREATE_SKILL_STEPS = 40 FORK_MAX_DEPTH = 2 @@ -1252,10 +1252,10 @@ class PtyProc: def available(): return pty is not None and termios is not None and fcntl is not None and select is not None - def __init__(self, argv, shell, cwd, env): + def __init__(self, argv, shell, cwd, env, rows=None, cols=None): master, slave = pty.openpty() try: - fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", RUN_ROWS, RUN_COLS, 0, 0)) + fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", rows or RUN_ROWS, cols or RUN_COLS, 0, 0)) def adopt(): os.setsid() @@ -1279,11 +1279,16 @@ class PtyProc: return None def write(self, text): - data = text.encode("utf-8", "replace") + self.write_bytes(text.encode("utf-8", "replace")) + + def write_bytes(self, data): while data: sent = os.write(self.master, data) data = data[sent:] + def resize(self, rows, cols): + fcntl.ioctl(self.master, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + def echo_off(self): try: return not termios.tcgetattr(self.master)[3] & termios.ECHO @@ -20684,6 +20689,19 @@ class TuiVtScreen: def blank(self): return (" ", None, self.bg, 0) + def text_lines(self): + """Rendered rows as plain strings. Trailing never-written cells + are cut (a written "> " keeps its space); fully-written rows, + where written and padded blanks are indistinguishable, rstrip.""" + lines = [] + for row in self.grid: + end = len(row) + while end > 0 and row[end - 1] is None: + end -= 1 + text = "".join(cell[0] if cell else " " for cell in row[:end]) + lines.append(text if end < len(row) else text.rstrip()) + return lines + def feed(self, data): if not data: return @@ -31949,6 +31967,3249 @@ def grr_tui(): shell.stop_stream() +# --------------------------------------------------------------------------- +# CAP (CLI Agent Protocol), agent side and orchestrator side. See cap.md. +# Phase 1: events, errors, manifest registry. Later phases add --cap-speak +# plus the PTY/stream-json/ACP/A2A drivers and orchestration on top. +# --------------------------------------------------------------------------- + +CAP_VERSION = "cap-protocol/v1" +CAP_MANIFEST_SCHEMA = "https://cap-protocol.org/schema/manifest/v1.json" +CAP_PROFILE_CODING = "cap-protocol/v1/profile/coding" +CAP_MANIFEST_REVISION = "2026-05-18" +CAP_PROBE_TIMEOUT = 15 + +CAP_ERROR_CODES = { + -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", + -32020: "cap_scope_violation", + -32021: "cap_path_not_found", + -32022: "cap_io_error", + -32023: "cap_terminal_unavailable", + -32024: "cap_terminal_killed", +} +CAP_ERROR_SYMBOLS = {symbol: numeric for numeric, symbol in CAP_ERROR_CODES.items()} +CAP_ERROR_STRINGS = ( + "rate_limit", "auth_error", "tool_timeout", "context_overflow", + "model_unavailable", "pty_died", "parse_failed", + "git_dirty", "test_failed", "merge_conflict", "language_server_down", +) + + +class CapError(Exception): + """A CAP wire failure: always carries the spec symbol and, when the + symbol is registered, its JSON-RPC numeric twin.""" + + def __init__(self, symbol, message=""): + super().__init__(message or symbol) + self.symbol = symbol + self.numeric = CAP_ERROR_SYMBOLS.get(symbol, 0) + + +class CapEvent: + """Core event constructors and validators (spec §7 plus the coding + profile's cap.artifact and cap.fs.change). Internal construction + mistakes raise ValueError; wire failures raise CapError.""" + + FROM_AGENT = "agent_to_orchestrator" + TO_AGENT = "orchestrator_to_agent" + + KINDS = ( + "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", + "cap.user_input.inject", "cap.cancel", "cap.session.config", + "cap.ask_user.answer", "cap.permission.response", + ) + DIRECTIONS = { + "cap.session.ready": FROM_AGENT, "cap.text_chunk": FROM_AGENT, + "cap.tool_call.start": FROM_AGENT, "cap.tool_call.delta": FROM_AGENT, + "cap.tool_call.end": FROM_AGENT, "cap.plan": FROM_AGENT, + "cap.thought": FROM_AGENT, "cap.ask_user": FROM_AGENT, + "cap.permission.request": FROM_AGENT, "cap.usage": FROM_AGENT, + "cap.error": FROM_AGENT, "cap.artifact": FROM_AGENT, + "cap.fs.change": FROM_AGENT, "cap.pty.raw_bytes": FROM_AGENT, + "cap.user_input.inject": TO_AGENT, "cap.cancel": TO_AGENT, + "cap.session.config": TO_AGENT, "cap.ask_user.answer": TO_AGENT, + "cap.permission.response": TO_AGENT, + } + REQUIRED = { + "cap.session.ready": (), + "cap.text_chunk": ("message_id", "text", "channel"), + "cap.tool_call.start": ("call_id", "name", "input"), + "cap.tool_call.delta": ("call_id", "output_chunk"), + "cap.tool_call.end": ("call_id", "output", "is_error", "duration_ms"), + "cap.plan": ("entries",), + "cap.thought": ("message_id", "text"), + "cap.ask_user": ("ask_id", "prompt", "form"), + "cap.ask_user.answer": ("ask_id", "value"), + "cap.permission.request": ("req_id", "tool", "intent", "scope", "risk_level"), + "cap.permission.response": ("req_id", "decision"), + "cap.user_input.inject": ("content",), + "cap.cancel": ("scope", "reason"), + "cap.usage": ("model_id",), + "cap.session.config": (), + "cap.error": ("code", "message", "retryable"), + "cap.pty.raw_bytes": ("bytes_b64",), + "cap.artifact": ("artifact_id", "mime_type", "name", "size"), + "cap.fs.change": ("subscription_id", "path", "event"), + } + ENUMS = { + ("cap.text_chunk", "channel"): ("assistant", "thought", "system"), + ("cap.cancel", "scope"): ("current_turn", "session"), + ("cap.permission.request", "scope"): ("read", "write", "execute", "network"), + ("cap.permission.request", "risk_level"): ("low", "medium", "high"), + ("cap.permission.response", "decision"): ("allow_once", "allow_always", "deny"), + ("cap.session.config", "permission_mode"): ("none", "confirm", "interactive"), + ("cap.usage", "stop_reason"): ("end_turn", "max_tokens", "tool_use", "stop_sequence", "cancelled", "error"), + ("cap.fs.change", "event"): ("created", "modified", "deleted", "renamed"), + } + + @staticmethod + def make(kind, meta=None, **fields): + if kind not in CapEvent.KINDS: + raise ValueError("unknown CAP event kind: %r" % kind) + missing = [name for name in CapEvent.REQUIRED.get(kind, ()) if name not in fields] + if missing: + raise ValueError("%s missing required fields: %s" % (kind, ", ".join(missing))) + for (enum_kind, field), allowed in CapEvent.ENUMS.items(): + if enum_kind == kind and field in fields and fields[field] not in allowed: + raise ValueError("%s.%s must be one of %s, got %r" % (kind, field, "|".join(allowed), fields[field])) + event = {"kind": kind} + event.update(fields) + if meta is not None: + event["_meta"] = meta + return event + + @staticmethod + def validate(event): + if not isinstance(event, dict) or event.get("kind") not in CapEvent.KINDS: + raise ValueError("not a CAP event: %r" % (event,)) + kind = event["kind"] + missing = [name for name in CapEvent.REQUIRED.get(kind, ()) if name not in event] + if missing: + raise ValueError("%s missing required fields: %s" % (kind, ", ".join(missing))) + return kind + + @staticmethod + def direction(kind): + return CapEvent.DIRECTIONS[kind] + + +class CapBinding: + """Binding priority and selection (spec §6). grpc is the only + binding tai cannot drive: the standard library has no HTTP/2 or + protobuf, so selection falls through past it.""" + + ORDER = ("grpc", "stream_json", "acp_stdio", "a2a", "pty") + UNSUPPORTED = ("grpc",) + + @staticmethod + def declared(manifest): + fast = manifest.get("fast_path", {}) or {} + found = [] + if fast.get("grpc"): + found.append("grpc") + if fast.get("stream_json"): + found.append("stream_json") + if fast.get("acp_stdio"): + found.append("acp_stdio") + if fast.get("a2a_serve_at"): + found.append("a2a") + found.append("pty") + return found + + @staticmethod + def select(manifest): + for name in CapBinding.ORDER: + if name in CapBinding.declared(manifest) and name not in CapBinding.UNSUPPORTED: + return name + return "pty" + + +class CapManifest: + """One parsed agent manifest (spec §5): required-field validation, + §5.3 defaults, and the regex-flavor gate. Tai deliberately accepts + Python named groups, which every shipped example manifest uses, + and rejects only lookaround plus backreferences.""" + + REQUIRED = ( + "agent.name", "agent.binary", "agent.profiles", + "startup.command", "startup.ready_when", + "pty.cols", "pty.rows", "capabilities.streaming_output", + ) + DEFAULTS = { + "pty.bracketed_paste": False, + "pty.sigint_cancels_turn": "hard", + "pty.queued_input_supported": False, + "capabilities.multi_session": False, + "capabilities.streaming_tool_output": False, + "cost.metered": False, + "parse.idle": ["^>\\s*$", "^❯\\s*$"], + "parse.error_lines": [], + } + PATTERN_FIELDS = ( + "agent.version_match", "probe.version_pattern", + "startup.ready_when.pattern", + "parse.tool_call_start", "parse.tool_call_end", + "parse.plan_section", "parse.thought_section", + "parse.ask_yes_no", "parse.ask_options", + ) + PATTERN_LIST_FIELDS = ("parse.idle", "parse.error_lines") + LOOKAROUND_RE = re.compile(r"\(\? 0 else CAP_SPEAK_MAX_TURNS + + def apply_pre_boot(self): + if self.cwd and os.path.abspath(self.cwd) != os.getcwd(): + os.chdir(self.cwd) + + def apply(self, agent): + """Wire the config into a booted agent. Returns notes about + accepted-but-unmapped fields for the stderr log.""" + agent.quiet = True + notes = [] + system_prompt = self.data.get("system_prompt") or "" + if system_prompt: + agent.system_message = system_prompt + "\n" + agent.system_message + if self.data.get("model"): + notes.append("model %r noted, tai picks its own backend" % self.data["model"]) + if self.data.get("budget_usd") is not None: + notes.append("budget_usd %s noted, tai backends are unmetered" % self.data["budget_usd"]) + if self.data.get("profile_config"): + notes.append("profile_config noted, tai has no tool allow-list to enforce it with") + return notes + + +class CapStreamJson: + """stream-json wire shapes (Claude SDK compatible): constructors for + outbound frames plus the inbound user-frame parser. Unknown inbound + frame types raise ValueError; receivers MUST ignore unknown outbound + extension fields and the tai `artifact` frame type.""" + + @staticmethod + def system_init(session_id, model): + return {"type": "system", "subtype": "init", "session_id": session_id, "model": model} + + @staticmethod + def assistant(message_id, text="", calls=()): + content = [] + if text: + content.append({"type": "text", "text": text}) + for call in calls: + try: + payload = json.loads(call.get("arguments") or "{}") + except ValueError: + payload = {"raw": call.get("arguments") or ""} + if not isinstance(payload, dict): + payload = {"raw": call.get("arguments") or ""} + content.append({"type": "tool_use", "id": call.get("id") or "", "name": call.get("name") or "", "input": payload}) + if not content: + return None + return {"type": "assistant", "message": {"role": "assistant", "id": message_id, "content": content}} + + @staticmethod + def tool_results(results): + return {"type": "user", "message": {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": call_id, "content": text, "is_error": failed} + for call_id, text, failed in results + ]}} + + @staticmethod + def result_ok(session_id, model, text, duration_ms, usage, extra=None): + frame = {"type": "result", "subtype": "success", "session_id": session_id, + "model": model, "text": text, "duration_ms": duration_ms, "usage": usage} + if extra: + frame.update(extra) + return frame + + @staticmethod + def result_error(session_id, model, message): + return {"type": "result", "subtype": "error", "session_id": session_id, + "model": model, "error": message} + + @staticmethod + def artifact_frame(artifact): + return {"type": "artifact", "artifact": artifact} + + @staticmethod + def parse_user_text(line): + try: + frame = json.loads(line) + except ValueError as exc: + raise ValueError("not JSON: %s" % exc) + if not isinstance(frame, dict) or frame.get("type") != "user": + raise ValueError("expected a {type: user} frame") + message = frame.get("message", "") + if isinstance(message, str): + text = message + elif isinstance(message, dict): + parts = [] + for block in message.get("content", []) or []: + if isinstance(block, dict) and block.get("type") == "text": + parts.append(block.get("text") or "") + elif isinstance(block, str): + parts.append(block) + text = "\n".join(part for part in parts if part) + else: + raise ValueError("user frame has no usable message") + if not text.strip(): + raise ValueError("user frame carries no text") + return text + + +class CapSpeakSession: + """One --cap-speak agent session: stdin NDJSON user frames in, NDJSON + assistant/tool/result frames out. Turns run through Agent.run_turn + with stdout parked on stderr, so the wire stays pure NDJSON; the + emitted frames are also the session transcript, saved at the end.""" + + def __init__(self, agent, config, stdin=None, stdout=None): + self.agent = agent + self.config = config + self.stdin = stdin or sys.stdin + self.stdout = stdout or sys.stdout + self.session_id = "cap_%s" % uuid.uuid4().hex[:12] + self.model = config.get("model") or "tai" + self.frames = [] + self.msg_seq = 0 + self.art_seq = 0 + self.last_diff = b"" + self.turns = 0 + + def emit(self, frame): + self.frames.append(frame) + self.stdout.write(json.dumps(frame) + "\n") + self.stdout.flush() + + def run(self): + old_term = signal.signal(signal.SIGTERM, cap_interrupt) + try: + return self._run() + except KeyboardInterrupt: + self.emit(CapStreamJson.result_error(self.session_id, self.model, "cancelled")) + return 130 + finally: + signal.signal(signal.SIGTERM, old_term) + + def _run(self): + self.emit(CapStreamJson.system_init(self.session_id, self.model)) + if self.config.get("session_resume_id"): + self.run_resume() + while True: + line = self.stdin.readline() + if line == "": + break + if not line.strip(): + continue + try: + text = CapStreamJson.parse_user_text(line) + except ValueError as exc: + self.emit(CapStreamJson.result_error(self.session_id, self.model, "invalid frame: %s" % exc)) + continue + if self.turns >= self.config.max_turns: + self.emit(CapStreamJson.result_error(self.session_id, self.model, "max turns (%d) reached" % self.config.max_turns)) + break + self.run_one(text) + self.turns += 1 + self.finish() + return 0 + + def run_resume(self): + before = len(self.agent.messages) + with contextlib.redirect_stdout(sys.stderr): + resume_turn(self.agent) + if len(self.agent.messages) == before: + return + self.emit_transcript_diff(before, time.time(), time.time(), 0) + + def run_one(self, user_text): + before = len(self.agent.messages) + tokens_before = conversation_tokens(self.agent.messages) + started = time.time() + with contextlib.redirect_stdout(sys.stderr): + try: + final = self.agent.run_turn(user_text, capture=True) + except Exception as exc: + self.emit(CapStreamJson.result_error(self.session_id, self.model, "turn failed: %s" % short_error(exc))) + return + total = time.time() - started + self.emit_transcript_diff(before, started, time.time(), conversation_tokens(self.agent.messages) - tokens_before, final=final, duration=total) + + def emit_transcript_diff(self, before, started, ended, tokens_new, final="", duration=0.0): + pending_results = [] + + def flush_results(): + if pending_results: + self.emit(CapStreamJson.tool_results(pending_results)) + del pending_results[:] + + for message in self.agent.messages[before:]: + role = message.get("role") + if role == "assistant": + flush_results() + self.msg_seq += 1 + frame = CapStreamJson.assistant("msg_%d" % self.msg_seq, message.get("content") or "", message.get("tool_calls") or ()) + if frame is not None: + self.emit(frame) + elif role == "tool": + text = message.get("content") or "" + failed = text == "denied by user" or text.startswith(("error:", "[error]", "[missing]")) + pending_results.append((message.get("tool_call_id") or "", text, failed)) + flush_results() + usage = {"total_tokens": max(tokens_new, 0), "duration_ms": int(duration * 1000), + "tool_calls": self.agent._turn_tools, "model": self.model} + if self.config.get("budget_usd") is not None: + usage["budget_usd"] = self.config.get("budget_usd") + if final.startswith("backend error:"): + self.emit(CapStreamJson.result_error(self.session_id, self.model, final)) + else: + self.emit(CapStreamJson.result_ok(self.session_id, self.model, final, int(duration * 1000), usage)) + artifact = self.diff_artifact() + if artifact is not None: + self.emit(CapStreamJson.artifact_frame(artifact)) + + def diff_artifact(self): + diff = self.current_diff() + if not diff or diff == self.last_diff: + return None + self.last_diff = diff + self.art_seq += 1 + content = diff[:CAP_SPEAK_DIFF_MAX] + return {"artifact_id": "art_%d" % self.art_seq, + "mime_type": "application/vnd.cap.coding.diff.v1+text", + "name": "turn-%d.diff" % (self.turns + 1), "size": len(diff), + "content": content.decode("utf-8", "replace"), + "truncated": len(diff) > CAP_SPEAK_DIFF_MAX} + + def current_diff(self): + try: + inside = subprocess.run(["git", "rev-parse", "--is-inside-work-tree"], stdout=subprocess.PIPE, + stderr=subprocess.PIPE, timeout=10) + except (OSError, subprocess.SubprocessError): + inside = None + if inside is not None and inside.returncode == 0 and inside.stdout.strip() == b"true": + try: + done = subprocess.run(["git", "diff", "HEAD", "--"], stdout=subprocess.PIPE, + stderr=subprocess.PIPE, timeout=30) + except (OSError, subprocess.SubprocessError): + return b"" + return done.stdout if done.returncode == 0 else b"" + preview = getattr(self.agent.tools, "diff_preview", None) + if not preview: + return b"" + lines = difflib.unified_diff((preview.get("old") or "").splitlines(True), (preview.get("new") or "").splitlines(True), + fromfile=preview.get("path") or "file", tofile=preview.get("path") or "file") + return "".join(lines).encode("utf-8") + + def finish(self): + try: + folder = os.path.join(self.agent.config.home, "cap-transcripts") + os.makedirs(folder, exist_ok=True) + path = os.path.join(folder, "%s.json" % self.session_id) + with open(path, "w", encoding="utf-8") as handle: + json.dump({"session_id": self.session_id, "agent": "tai", "model": self.model, + "events": self.frames}, handle) + size = os.path.getsize(path) + except (OSError, ValueError) as exc: + print("cap-speak: transcript not saved: %s" % short_error(exc), file=sys.stderr) + return + self.art_seq += 1 + self.emit(CapStreamJson.artifact_frame({ + "artifact_id": "art_%d" % self.art_seq, + "mime_type": "application/vnd.cap.coding.transcript.v1+json", + "name": "%s.transcript.json" % self.session_id, "size": size, + "uri": "file://%s" % path})) + + +def cap_interrupt(_signum, _frame): + raise KeyboardInterrupt + + +def cap_speak_main(args): + try: + config = CapSessionConfig.from_args(args) + except ValueError as exc: + print("cap-speak: %s" % exc, file=sys.stderr) + return 2 + try: + config.apply_pre_boot() + except OSError as exc: + print("cap-speak: cannot use cwd %r: %s" % (config.cwd, short_error(exc)), file=sys.stderr) + return 2 + args.auto = args.auto or config.permission_mode != "none" + args.yolo = args.yolo or config.permission_mode == "none" + with contextlib.redirect_stdout(sys.stderr): + try: + agent = boot(args) + except KeyboardInterrupt: + print(paint("\ninterrupted", Ansi.YELLOW), file=sys.stderr) + return 130 + except SealError as exc: + print("seal error: %s" % exc, file=sys.stderr) + return 2 + for note in config.apply(agent): + print("cap-speak: %s" % note, file=sys.stderr) + session = CapSpeakSession(agent, config) + try: + return session.run() + finally: + try: + agent.store.close() + except Exception: + pass + + +CAP_PTY_SETTLE_MS = 800 +CAP_PTY_IDLE_MS = 200 +CAP_PTY_SUBMIT_GAP_MS = 150 +CAP_PTY_RESUBMITS_MAX = 3 +CAP_PTY_SCROLLBACK = 10000 +CAP_PTY_INIT_TIMEOUT = 60 + + +class CapDriver: + """Shared orchestrator-driver plumbing: session identity, event + queue, id sequences, open questions, the send/cancel guards, and + the spawn-then-wait-ready start flow. Bindings implement _spawn, + send_text, poll, cancel and close; all drivers are synchronous + and poll-based, one per session thread. + + State contract: done means the TURN completed and the session is + alive (send_text starts a new turn); closed and failed are + terminal (send_text raises).""" + + STATES = ("starting", "ready", "working", "input_required", "done", "failed", "closed") + + def __init__(self, manifest, urn="cap://agent-1", cwd=None, model=None, env=None): + self.manifest = manifest + self.urn = urn + self.cwd = cwd or os.getcwd() + self.model = model + self.extra_env = dict(env or {}) + self.proc = None + self.state = "starting" + self.events = [] + self.warnings = [] + self.open_asks = {} + self.msg_seq = 0 + self.call_seq = 0 + self.ask_seq = 0 + self.plan_seq = 0 + + @property + def cancel_mode(self): + mode = self.manifest.get("capabilities.mid_turn_cancel") + if mode is None: + mode = self.manifest.get("pty.sigint_cancels_turn", "hard") + return mode if mode in ("graceful", "hard", "none") else "hard" + + @property + def queued(self): + value = self.manifest.get("capabilities.queued_input") + if value is None: + value = self.manifest.get("pty.queued_input_supported", False) + return bool(value) + + @property + def init_timeout(self): + return self.manifest.get("startup.init_timeout_seconds") or CAP_PTY_INIT_TIMEOUT + + def drain(self): + events, self.events = self.events, [] + return events + + def _emit(self, event): + self.events.append(event) + + def _session_env(self): + env = dict(os.environ) + env.update(self.extra_env) + sid_env = self.manifest.get("startup.session_id_env") + if sid_env: + env[sid_env] = self.urn + return env + + def _model_argv(self, argv): + if not self.model: + return argv + model_arg = self.manifest.get("startup.model_arg") + if model_arg: + return argv + [model_arg, self.model] + self.warnings.append("model %r has nowhere to go, manifest lacks model_arg" % self.model) + return argv + + def start(self, timeout=None): + """Spawn, then block until ready convicts or the init timeout + hits. Returns boot events.""" + self._spawn() + return self._run_until_ready(timeout) + + def _run_until_ready(self, timeout=None): + limit = timeout or self.init_timeout + deadline = time.time() + max(1, limit) + collected = [] + while time.time() < deadline: + collected += self.poll(0.1) + if self.state == "ready": + return collected + if self.state == "failed": + raise CapError("cap_binding_unavailable", "agent died before ready") + self.close() + self.state = "failed" + raise CapError("cap_binding_unavailable", "agent never became ready within %ds" % limit) + + def _wait_ready(self): + if self.proc is None and self.state == "starting": + self._spawn() + deadline = time.time() + max(1, self.init_timeout) + kept = [] + while time.time() < deadline: + kept += self.poll(0.1) + if self.state == "ready": + self.events = kept + self.events + return + if self.state == "failed": + raise CapError("cap_binding_unavailable", "agent died before ready") + raise CapError("cap_binding_unavailable", "agent never became ready") + + def _pre_send(self): + if self.state in ("failed", "closed"): + raise CapError("cap_binding_unavailable", "session is over") + if self.state == "starting": + self._wait_ready() + if self.proc is None: + raise CapError("cap_binding_unavailable", "session never started") + if self.state == "working" and not self.queued: + raise CapError("cap_queued_input_unsupported", "agent does not queue mid-turn input") + + def answer(self, ask_id, value): + if ask_id not in self.open_asks: + raise CapError("cap_invalid_answer", "no open question %r" % ask_id) + return self._answer_open(ask_id, self.open_asks.pop(ask_id), value) + + def _answer_open(self, ask_id, kind, value): + raise CapError("cap_invalid_answer", "this binding takes no answers") + + @staticmethod + def create(manifest, urn="cap://agent-1", cwd=None, model=None, env=None, raw_sink=None, acp_argv=None): + """Build the driver for the manifest's selected binding.""" + binding = manifest.binding() + if binding == "pty": + return CapPtyDriver(manifest, urn=urn, cwd=cwd, model=model, env=env, raw_sink=raw_sink) + if binding == "stream_json": + return CapStreamDriver(manifest, urn=urn, cwd=cwd, model=model, env=env) + if binding == "acp_stdio": + return CapAcpDriver(manifest, urn=urn, cwd=cwd, model=model, env=env, acp_argv=acp_argv) + if binding == "a2a": + return CapA2aDriver(manifest, urn=urn, cwd=cwd, model=model, env=env) + raise CapError("cap_binding_unavailable", "no tai driver for binding %r" % binding) + + def _spawn(self): + raise NotImplementedError + + def send_text(self, text): + raise NotImplementedError + + def poll(self, timeout=0.0): + raise NotImplementedError + + def cancel(self, scope="current_turn"): + raise NotImplementedError + + def close(self): + raise NotImplementedError + + +class CapPtyDriver(CapDriver): + """Orchestrator-side PTY binding (spec §6.1, Appendix A): spawn the + agent under PtyProc, render through TuiVtScreen, match the manifest + [parse] patterns against rendered rows, and convict turn ends with + the reference settle heuristic (byte silence plus prompt glyph).""" + + def __init__(self, manifest, urn="cap://agent-1", cwd=None, model=None, env=None, raw_sink=None): + super().__init__(manifest, urn=urn, cwd=cwd, model=model, env=env) + self.raw_sink = raw_sink + rows = manifest.get("pty.rows") or 24 + cols = manifest.get("pty.cols") or 80 + self.screen = TuiVtScreen(rows, cols) + self.screen.history = collections.deque(maxlen=CAP_PTY_SCROLLBACK) + self.idle = [re.compile(pattern) for pattern in manifest.get("parse.idle", []) or []] + self.tool_start = self._compile(manifest.get("parse.tool_call_start")) + self.tool_end = self._compile(manifest.get("parse.tool_call_end")) + self.plan_mark = self._compile(manifest.get("parse.plan_section")) + self.thought_mark = self._compile(manifest.get("parse.thought_section")) + self.ask_yes_no = self._compile(manifest.get("parse.ask_yes_no")) + self.ask_options = self._compile(manifest.get("parse.ask_options")) + self.error_res = [re.compile(pattern) for pattern in manifest.get("parse.error_lines", []) or []] + ready = (manifest.get("startup.ready_when") or {}).get("pattern") or "" + self.ready_re = re.compile(ready) if ready else None + self.prev_rows = [""] * rows + self.open_tool = None + self.prompt_sent = False + self.submit_key = "" + self.resubmits = 0 + self.seen_line_events = set() + self.last_bytes_at = 0.0 + self.turn_msg_id = "msg_boot" + self.ready_at = None + + @staticmethod + def _compile(pattern): + return re.compile(pattern) if pattern else None + + def _spawn(self): + if not PtyProc.available(): + raise CapError("cap_binding_unavailable", "PTY binding needs pty/termios/fcntl/select") + argv = list(self.manifest.get("startup.command") or []) + if not argv: + raise CapError("cap_binding_unavailable", "manifest has no startup.command") + self.proc = PtyProc(self._model_argv(argv), False, self.cwd, self._session_env(), self.screen.rows, self.screen.cols) + if self.manifest.get("pty.bracketed_paste"): + self._write("\x1b[?2004h") + self.last_bytes_at = time.time() + + def send_text(self, text): + """Inject a prompt: bracketed-paste wrap for big/multiline input, + a submit gap, then CR (Enter in both canonical and raw modes).""" + self._pre_send() + if self.manifest.get("pty.bracketed_paste") and (len(text) > 1024 or "\n" in text): + self._write("\x1b[200~" + text + "\x1b[201~") + else: + self._write(text) + time.sleep(CAP_PTY_SUBMIT_GAP_MS / 1000.0) + self._write("\r") + self.prompt_sent = True + self.submit_key = text.split("\n", 1)[0][:60] + self.resubmits = 0 + self.open_asks.clear() + self.seen_line_events.clear() + self.msg_seq += 1 + self.turn_msg_id = "msg_%d" % self.msg_seq + self.state = "working" + + def _answer_open(self, ask_id, kind, value): + """Booleans/yes-no words become y/n for yes-no prompts, anything + else is typed literally.""" + if kind == "yes_no": + self._write("y" if str(value).strip().lower() in ("y", "yes", "true", "1") else "n") + time.sleep(CAP_PTY_SUBMIT_GAP_MS / 1000.0) + self._write("\r") + else: + self.send_text(str(value)) + return + self.state = "working" + + def cancel(self, scope="current_turn"): + if self.proc is None: + raise CapError("cap_binding_unavailable", "session never started") + if scope == "session": + self.close() + return + mode = self.cancel_mode + if mode == "none": + raise CapError("cap_cancel_unsupported", "manifest says mid_turn_cancel is none") + if mode == "graceful": + self._write("\x03") + else: + self._terminate() + self.state = "done" + + def resize(self, rows, cols): + if self.proc is not None: + try: + self.proc.resize(rows, cols) + except OSError: + pass + self.screen.resize(rows, cols) + self.prev_rows = [""] * self.screen.rows + + def close(self): + if self.proc is not None: + try: + self._terminate() + finally: + self.proc.close() + self.proc = None + if self.state != "failed": + self.state = "closed" + + def poll(self, timeout=0.0): + """Read once, parse, run the settle checks. Returns new events.""" + if self.proc is None or self.state in ("failed", "closed"): + return self.drain() + try: + chunk = self.proc.read(timeout) + except OSError: + chunk = None + now = time.time() + if chunk is None: + self._on_eof() + return self.drain() + if chunk: + self.last_bytes_at = now + if self.raw_sink is not None: + self.raw_sink(CapEvent.make("cap.pty.raw_bytes", bytes_b64=base64.b64encode(chunk).decode("ascii"))) + self.screen.feed(chunk) + replies = self.screen.take_replies() + if replies: + try: + self.proc.write_bytes(replies) + except OSError: + pass + self._parse_screen() + self._check_settle(now) + return self.drain() + + def _write(self, text): + try: + self.proc.write(text) + except OSError as exc: + raise CapError("cap_binding_unavailable", "pty write failed: %s" % short_error(exc)) + + def _terminate(self): + try: + os.killpg(self.proc.proc.pid, signal.SIGTERM) + except (OSError, ProcessLookupError, AttributeError): + try: + self.proc.kill() + except (OSError, ProcessLookupError): + pass + return + deadline = time.time() + 5 + while time.time() < deadline: + if self.proc.proc.poll() is not None: + return + time.sleep(0.05) + try: + self.proc.kill() + except (OSError, ProcessLookupError): + pass + + def _on_eof(self): + if self.state == "done": + self.state = "closed" + return + if self.state not in ("failed",): + self._emit(CapEvent.make("cap.error", code="pty_died", + message="agent process exited unexpectedly", retryable=False, details={})) + self.state = "failed" + + def _parse_screen(self): + rows = self.screen.text_lines() + prev = set(self.prev_rows) + self.prev_rows = rows + fresh = [text for text in rows if text and text not in prev] + for text in fresh: + self._match_line(text) + spoken = [text for text in fresh if not self._is_idle(text)] + if spoken: + self._emit(CapEvent.make("cap.text_chunk", message_id=self.turn_msg_id, + text="\n".join(spoken), channel="assistant")) + + def _is_idle(self, text): + return any(item.search(text) for item in self.idle) + + def _match_line(self, text): + for regex in self.error_res: + if regex.search(text): + lowered = text.lower() + if "rate_limit" in lowered or "rate limit" in lowered: + code = "rate_limit" + elif "auth" in lowered or "login" in lowered: + code = "auth_error" + else: + code = "error" + self._emit(CapEvent.make("cap.error", code=code, message=text[:500], + retryable=code == "rate_limit", details={})) + return + if self.tool_end is not None: + match = self.tool_end.search(text) + if match is not None and self._fresh("tool_end", text): + groups = match.groupdict() + status = groups.get("status", "") + failed = status == "failed" + if self.open_tool is not None: + call_id = self.open_tool[0] + self.open_tool = None + else: + self.call_seq += 1 + call_id = "c%d" % self.call_seq + self._emit(CapEvent.make("cap.tool_call.end", call_id=call_id, output="", + is_error=failed, duration_ms=0)) + return + if self.tool_start is not None: + match = self.tool_start.search(text) + if match is not None and self._fresh("tool_start", text): + groups = match.groupdict() + name = groups.get("tool") or "tool" + raw_args = groups.get("args") or "" + try: + args = json.loads(raw_args) if raw_args else {} + except ValueError: + args = {"raw": raw_args} if raw_args else {} + if not isinstance(args, dict): + args = {"raw": raw_args} + self.call_seq += 1 + call_id = "c%d" % self.call_seq + self.open_tool = (call_id, name) + self._emit(CapEvent.make("cap.tool_call.start", call_id=call_id, name=name, input=args)) + return + if self.ask_yes_no is not None and self.ask_yes_no.search(text) and self._fresh("ask", text): + self._raise_ask("yes_no", text, {"type": "string", "oneOf": [ + {"const": "yes", "title": "Yes"}, {"const": "no", "title": "No"}]}) + return + if self.ask_options is not None and self.ask_options.search(text) and self._fresh("ask", text): + self._raise_ask("options", text, {"type": "string"}) + return + if self.plan_mark is not None and self.plan_mark.search(text) and self._fresh("plan", text): + self.plan_seq += 1 + self._emit(CapEvent.make("cap.plan", entries=[ + {"id": "p%d" % self.plan_seq, "content": text[:500], "status": "in_progress"}])) + return + if self.thought_mark is not None and self.thought_mark.search(text) and self._fresh("thought", text): + self._emit(CapEvent.make("cap.thought", message_id=self.turn_msg_id, text=text[:2000])) + + def _fresh(self, group, text): + key = (group, text) + if key in self.seen_line_events: + return False + self.seen_line_events.add(key) + return True + + def _raise_ask(self, kind, prompt, form): + self.ask_seq += 1 + ask_id = "ask_%d" % self.ask_seq + self.open_asks[ask_id] = kind + self._emit(CapEvent.make("cap.ask_user", ask_id=ask_id, prompt=prompt[:2000], + form=form, timeout_seconds=None)) + self.state = "input_required" + + def _check_settle(self, now): + silent_ms = (now - self.last_bytes_at) * 1000.0 + rows = self.screen.text_lines() + if self.state == "starting": + if self.ready_re is not None and silent_ms >= CAP_PTY_IDLE_MS: + if any(self.ready_re.search(text) for text in rows if text): + self.ready_at = now + self.state = "ready" + self._emit(CapEvent.make("cap.session.ready", version=CAP_VERSION, session_id=self.urn)) + self.seen_line_events.clear() + return + if self.state != "working" or not self.prompt_sent: + return + if silent_ms < CAP_PTY_SETTLE_MS: + return + rest = [text for text in rows if text] + if not rest or not self._is_idle(rest[-1]): + return + if self.submit_key and any(self.submit_key in text for text in rows): + if self.resubmits < CAP_PTY_RESUBMITS_MAX: + self.resubmits += 1 + self.last_bytes_at = now + try: + self.proc.write("\r") + except OSError: + pass + return + self.state = "done" + + def last_text(self): + rest = [text for text in self.screen.text_lines() if text] + return "\n".join(rest) + + +CAP_STREAM_LINE_MAX = 1048576 +CAP_STREAM_STDERR_KEEP = 50 + + +class CapPipeReader: + """Non-blocking NDJSON line pump over one fd, shared by the + stream-json and ACP-stdio drivers. Overlong lines are dropped + loudly via the dropped counter.""" + + def __init__(self, fd): + self.fd = fd + self.buf = bytearray() + self.eof = False + self.dropped = 0 + + def read_lines(self, timeout=0.0): + if select is not None and not self.eof: + try: + ready, _, _ = select.select([self.fd], [], [], max(0.0, timeout)) + except (OSError, ValueError): + ready = [self.fd] + if not ready: + return self._split_lines() + while not self.eof: + try: + chunk = os.read(self.fd, 65536) + except BlockingIOError: + break + except OSError: + self.eof = True + break + if not chunk: + self.eof = True + break + self.buf += chunk + if len(self.buf) > CAP_STREAM_LINE_MAX and b"\n" not in self.buf: + self.buf = bytearray() + self.dropped += 1 + break + return self._split_lines() + + def _split_lines(self): + lines = [] + while True: + mark = self.buf.find(b"\n") + if mark < 0: + if len(self.buf) > CAP_STREAM_LINE_MAX: + self.buf = bytearray() + self.dropped += 1 + break + raw = bytes(self.buf[:mark]).decode("utf-8", "replace") + del self.buf[:mark + 1] + if raw.strip(): + lines.append(raw) + return lines + + def take_dropped(self): + count, self.dropped = self.dropped, 0 + return count + + +def cap_proc_kill(proc): + try: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + except OSError: + pass + + +def cap_proc_reap(proc, stderr_thread=None): + cap_proc_kill(proc) + for pipe in (proc.stdin, proc.stdout, proc.stderr): + try: + if pipe is not None: + pipe.close() + except OSError: + pass + if stderr_thread is not None: + stderr_thread.join(timeout=5) + + +def cap_stderr_drain(proc, keep=CAP_STREAM_STDERR_KEEP): + lines = collections.deque(maxlen=keep) + + def _drain(): + try: + for line in proc.stderr: + lines.append(line.decode("utf-8", "replace").rstrip()[:500]) + except (OSError, ValueError): + pass + + thread = threading.Thread(target=_drain, daemon=True) + thread.start() + return lines, thread + + +class CapStreamDriver(CapDriver): + """Orchestrator-side stream-json binding (spec §6.2, C.1): NDJSON + over pipes, frames translated to core events. Cancelling a turn + kills the one-shot process; the next send respawns lazily. The + only thread is the stderr drain; stdout is pumped via poll().""" + + def __init__(self, manifest, urn="cap://agent-1", cwd=None, model=None, env=None): + super().__init__(manifest, urn=urn, cwd=cwd, model=model, env=env) + self.stderr_lines = collections.deque(maxlen=CAP_STREAM_STDERR_KEEP) + self.ignored_frames = 0 + self.turn_msg_id = "msg_boot" + self.reader = None + self._stderr_thread = None + + def _spawn(self): + argv = self.manifest.get("fast_path.stream_json") + if not argv: + raise CapError("cap_binding_unavailable", "manifest has no fast_path.stream_json") + try: + self.proc = subprocess.Popen(list(self._model_argv(list(argv))), stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + cwd=self.cwd, env=self._session_env()) + except OSError as exc: + raise CapError("cap_binding_unavailable", "cannot spawn %s: %s" % (argv[0], short_error(exc))) + try: + os.set_blocking(self.proc.stdout.fileno(), False) + except OSError: + pass + self.reader = CapPipeReader(self.proc.stdout.fileno()) + self.stderr_lines, self._stderr_thread = cap_stderr_drain(self.proc) + + def send_text(self, text): + self._pre_send() + line = (json.dumps({"type": "user", "message": text}) + "\n").encode("utf-8") + try: + self.proc.stdin.write(line) + self.proc.stdin.flush() + except (OSError, ValueError) as exc: + raise CapError("cap_binding_unavailable", "agent input failed: %s" % short_error(exc)) + self.msg_seq += 1 + self.turn_msg_id = "msg_%d" % self.msg_seq + self.open_asks.clear() + self.state = "working" + + def poll(self, timeout=0.0): + if self.proc is None or self.state in ("failed", "closed"): + return self.drain() + for line in self.reader.read_lines(timeout): + self._map_frame(line) + if self.reader.take_dropped(): + self._emit(CapEvent.make("cap.error", code="parse_failed", + message="line over %d bytes dropped" % CAP_STREAM_LINE_MAX, + retryable=False, details={})) + if self.reader.eof and not self.reader.buf and self.proc.poll() is not None: + self._on_proc_gone() + return self.drain() + + def cancel(self, scope="current_turn"): + if self.proc is None: + raise CapError("cap_binding_unavailable", "session never started") + if scope == "session": + self.close() + return + if self.cancel_mode == "none": + raise CapError("cap_cancel_unsupported", "manifest says mid_turn_cancel is none") + cap_proc_kill(self.proc) + self.proc = None + self.reader = None + self.state = "starting" + + def close(self): + if self.proc is not None: + proc, self.proc = self.proc, None + cap_proc_reap(proc, self._stderr_thread) + self._stderr_thread = None + self.reader = None + if self.state != "failed": + self.state = "closed" + + def _map_frame(self, line): + try: + frame = json.loads(line) + except ValueError: + self.ignored_frames += 1 + return + if not isinstance(frame, dict): + self.ignored_frames += 1 + return + ftype = frame.get("type") + if ftype == "system" and frame.get("subtype") == "init": + if self.state == "starting": + self.state = "ready" + self._emit(CapEvent.make("cap.session.ready", version=CAP_VERSION, + session_id=frame.get("session_id") or self.urn)) + return + if ftype == "assistant": + self._map_assistant(frame) + return + if ftype == "user": + self._map_tool_results(frame) + return + if ftype == "result": + self._map_result(frame) + return + if ftype == "artifact" and isinstance(frame.get("artifact"), dict): + try: + artifact = frame["artifact"] + self._emit(CapEvent.make("cap.artifact", artifact_id=artifact["artifact_id"], + mime_type=artifact["mime_type"], name=artifact.get("name", ""), + size=int(artifact.get("size", 0)), + uri=artifact.get("uri"), content=artifact.get("content"))) + except (KeyError, ValueError, TypeError): + self.ignored_frames += 1 + return + self.ignored_frames += 1 + + def _map_assistant(self, frame): + message = frame.get("message") or {} + msg_id = message.get("id") or self.turn_msg_id + for block in message.get("content") or []: + if not isinstance(block, dict): + continue + btype = block.get("type") + if btype == "text" and block.get("text"): + self._emit(CapEvent.make("cap.text_chunk", message_id=msg_id, + text=block["text"], channel="assistant")) + elif btype == "thinking" and block.get("text"): + self._emit(CapEvent.make("cap.thought", message_id=msg_id, text=block["text"])) + elif btype == "tool_use": + payload = block.get("input") + if not isinstance(payload, dict): + payload = {"raw": payload} + self.call_seq += 1 + self._emit(CapEvent.make("cap.tool_call.start", call_id=block.get("id") or "c%d" % self.call_seq, + name=block.get("name") or "tool", input=payload)) + + def _map_tool_results(self, frame): + message = frame.get("message") or {} + blocks = message.get("content") or [] + if isinstance(message, str): + return + for block in blocks: + if not isinstance(block, dict) or block.get("type") != "tool_result": + continue + content = block.get("content") + if not isinstance(content, str): + try: + content = json.dumps(content) + except (ValueError, TypeError): + content = str(content) + self._emit(CapEvent.make("cap.tool_call.end", call_id=block.get("tool_use_id") or "", + output=content or "", is_error=bool(block.get("is_error", False)), + duration_ms=0)) + + def _map_result(self, frame): + if frame.get("subtype") == "error": + self._emit(CapEvent.make("cap.error", code="error", + message=str(frame.get("error") or "agent failed")[:2000], + retryable=False, details={})) + self.state = "done" + return + usage = frame.get("usage") or {} + try: + out_tokens = int(usage.get("output_tokens", usage.get("total_tokens", 0)) or 0) + in_tokens = int(usage.get("input_tokens", 0) or 0) + except (ValueError, TypeError): + out_tokens, in_tokens = 0, 0 + event = CapEvent.make("cap.usage", model_id=self.model or self.manifest.name, + input_tokens=in_tokens, output_tokens=out_tokens, + duration_ms=int(frame.get("duration_ms", 0) or 0), stop_reason="end_turn") + if frame.get("total_cost_usd") is not None: + try: + event["cost_usd_estimate"] = float(frame["total_cost_usd"]) + except (ValueError, TypeError): + pass + self._emit(event) + self.state = "done" + + def _on_proc_gone(self): + if self.state == "done": + self.state = "closed" + return + if self.state != "failed": + hint = "; stderr: %s" % " | ".join(self.stderr_lines) if self.stderr_lines else "" + self._emit(CapEvent.make("cap.error", code="error", + message=("agent process exited unexpectedly%s" % hint)[:2000], + retryable=False, details={})) + self.state = "failed" + + +CAP_ACP_VERSION = 1 +CAP_ACP_TERMINALS_MAX = 4 +CAP_ACP_TERM_BUF = 1048576 +CAP_ACP_TERM_WAIT_MAX = 600 +CAP_ACP_FS_MAX = 1048576 +_ACP_DEFERRED = object() + + +class CapAcpDriver(CapDriver): + """Orchestrator-side ACP-stdio binding (spec §6.4, C.3): JSON-RPC + over pipes, poll-pumped like the stream driver (no reader thread). + Serves fs/terminal/elicitation plus tai `_cap/*` extensions for + fs.list/exists/delete and user_io/notify. Answers flow through + the unified open_asks stash keyed by cap id.""" + + def __init__(self, manifest, urn="cap://agent-1", cwd=None, model=None, env=None, + acp_argv=None, fs_scope="workspace_only", terminal=True, io_sink=None): + super().__init__(manifest, urn=urn, cwd=cwd, model=model, env=env) + self.acp_argv = list(acp_argv or []) + self.fs_scope = fs_scope + self.grant_terminal = terminal + self.io_sink = io_sink + self.io_log = [] + self.stderr_lines = collections.deque(maxlen=CAP_STREAM_STDERR_KEEP) + self.ignored_frames = 0 + self.reader = None + self._stderr_thread = None + self._next_id = 0 + self._pending = {} + self._prompt_id = None + self.acp_session = None + self.turn_msg_id = "msg_boot" + self._last_in = 0 + self._last_out = 0 + self.terminals = {} + self.term_seq = 0 + + def _spawn(self): + if not self.acp_argv: + raise CapError("cap_binding_unavailable", "acp_stdio needs an explicit server argv (fleet argv or chat --argv)") + try: + self.proc = subprocess.Popen(list(self.acp_argv), stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + cwd=self.cwd, env=self._session_env()) + except OSError as exc: + raise CapError("cap_binding_unavailable", "cannot spawn %s: %s" % (self.acp_argv[0], short_error(exc))) + try: + os.set_blocking(self.proc.stdout.fileno(), False) + except OSError: + pass + self.reader = CapPipeReader(self.proc.stdout.fileno()) + self.stderr_lines, self._stderr_thread = cap_stderr_drain(self.proc) + try: + self._handshake() + except Exception: + cap_proc_reap(self.proc, self._stderr_thread) + self.proc = None + self.reader = None + self._stderr_thread = None + raise + + def _handshake(self): + caps = {} + if self.fs_scope: + caps["fs"] = {"readTextFile": True, "writeTextFile": True} + if self.grant_terminal: + caps["terminal"] = True + caps["elicitation"] = {"form": {}} + init_id = self._request("initialize", {"protocolVersion": CAP_ACP_VERSION, + "clientCapabilities": caps, + "clientInfo": {"name": "tai", "version": VERSION}}) + init = self._wait_response(init_id, "initialize") + if not isinstance(init, dict) or init.get("protocolVersion") != CAP_ACP_VERSION: + raise CapError("cap_binding_unavailable", "agent speaks ACP v%s, tai speaks v1" % ( + init.get("protocolVersion") if isinstance(init, dict) else "?")) + new_id = self._request("session/new", {"cwd": self.cwd, "mcpServers": []}) + created = self._wait_response(new_id, "session/new") + if not isinstance(created, dict) or not created.get("sessionId"): + raise CapError("cap_binding_unavailable", "agent created no session") + self.acp_session = created["sessionId"] + self.state = "ready" + self._emit(CapEvent.make("cap.session.ready", version=CAP_VERSION, session_id=self.urn)) + + def _wait_response(self, rpc_id, what): + deadline = time.time() + max(1, self.init_timeout) + while time.time() < deadline: + for line in self.reader.read_lines(0.1): + self._map_message(line) + if rpc_id in self._pending and self._pending[rpc_id].get("done"): + entry = self._pending.pop(rpc_id) + if entry.get("error") is not None: + raise CapError("cap_binding_unavailable", "%s failed: %s" % (what, entry["error"])) + return entry.get("result") + if self.state == "failed": + raise CapError("cap_binding_unavailable", "agent died during %s" % what) + raise CapError("cap_binding_unavailable", "%s timed out" % what) + + def _request(self, method, params): + self._next_id += 1 + rpc_id = self._next_id + self._pending[rpc_id] = {"method": method} + self._write({"jsonrpc": "2.0", "id": rpc_id, "method": method, "params": params}) + return rpc_id + + def _notify(self, method, params): + self._write({"jsonrpc": "2.0", "method": method, "params": params}) + + def _write(self, frame): + try: + self.proc.stdin.write((json.dumps(frame) + "\n").encode("utf-8")) + self.proc.stdin.flush() + except (OSError, ValueError) as exc: + raise CapError("cap_binding_unavailable", "agent input failed: %s" % short_error(exc)) + + def _respond(self, rpc_id, result=None, error=None): + frame = {"jsonrpc": "2.0", "id": rpc_id} + if error is None: + frame["result"] = result if result is not None else {} + else: + frame["error"] = error + try: + self._write(frame) + except CapError: + pass + + @staticmethod + def _rpc_error(code, message): + return {"code": code, "message": str(message)[:500]} + + def send_text(self, text): + self._pre_send() + self.msg_seq += 1 + self.turn_msg_id = "msg_%d" % self.msg_seq + self.open_asks.clear() + self._prompt_id = self._request("session/prompt", {"sessionId": self.acp_session, + "prompt": [{"type": "text", "text": text}]}) + self._pending[self._prompt_id]["prompt"] = True + self.state = "working" + + def poll(self, timeout=0.0): + if self.proc is None or self.state in ("failed", "closed"): + return self.drain() + for line in self.reader.read_lines(timeout): + self._map_message(line) + if self.reader.take_dropped(): + self._emit(CapEvent.make("cap.error", code="parse_failed", + message="line over %d bytes dropped" % CAP_STREAM_LINE_MAX, + retryable=False, details={})) + if self.reader.eof and not self.reader.buf and self.proc.poll() is not None: + self._on_proc_gone() + return self.drain() + + def cancel(self, scope="current_turn"): + if self.proc is None: + raise CapError("cap_binding_unavailable", "session never started") + if scope == "session": + self.close() + return + if self.cancel_mode == "none": + raise CapError("cap_cancel_unsupported", "manifest says mid_turn_cancel is none") + if self.acp_session is not None: + try: + self._request("session/cancel", {"sessionId": self.acp_session}) + except CapError: + pass + if self._prompt_id is not None: + try: + self._notify("$/cancel_request", {"id": self._prompt_id}) + except CapError: + pass + for ask_id, (kind, rpc_id, _extra) in list(self.open_asks.items()): + if kind == "permission": + self._respond(rpc_id, {"outcome": {"outcome": "cancelled"}}) + self.open_asks.pop(ask_id, None) + + def close(self): + if self.proc is not None: + for tid in list(self.terminals): + self._terminal_drop(tid) + proc, self.proc = self.proc, None + cap_proc_reap(proc, self._stderr_thread) + self._stderr_thread = None + self.reader = None + if self.state != "failed": + self.state = "closed" + + def _on_proc_gone(self): + if self.state == "done": + self.state = "closed" + return + if self.state != "failed": + hint = "; stderr: %s" % " | ".join(self.stderr_lines) if self.stderr_lines else "" + self._emit(CapEvent.make("cap.error", code="error", + message=("agent process exited unexpectedly%s" % hint)[:2000], + retryable=False, details={})) + self.state = "failed" + + def _map_message(self, line): + try: + frame = json.loads(line) + except ValueError: + self.ignored_frames += 1 + return + if not isinstance(frame, dict): + self.ignored_frames += 1 + return + if "method" in frame: + self._map_incoming(frame) + elif "id" in frame: + self._map_response(frame) + else: + self.ignored_frames += 1 + + def _map_response(self, frame): + rpc_id = frame.get("id") + entry = self._pending.get(rpc_id) + if entry is None: + self.ignored_frames += 1 + return + entry["done"] = True + if "error" in frame: + entry["error"] = frame["error"] + else: + entry["result"] = frame.get("result") + if entry.get("prompt"): + self._prompt_id = None + if "error" in frame: + self._emit(CapEvent.make("cap.error", code="error", + message="prompt failed: %s" % str(frame["error"])[:1000], + retryable=False, details={})) + self.state = "done" + + def _map_incoming(self, frame): + method = frame.get("method") + params = frame.get("params") or {} + rpc_id = frame.get("id") + if method == "session/update" and rpc_id is None: + self._map_update(params.get("update") or {}) + return + if rpc_id is None: + return + handler = {"session/request_permission": self._serve_permission, + "elicitation/create": self._serve_elicit, + "fs/read_text_file": self._serve_fs_read, + "fs/write_text_file": self._serve_fs_write, + "terminal/create": self._serve_term_create, + "terminal/output": self._serve_term_output, + "terminal/wait_for_exit": self._serve_term_wait, + "terminal/kill": self._serve_term_kill, + "terminal/release": self._serve_term_release, + "_cap/fs.list": self._serve_fs_list, + "_cap/fs.exists": self._serve_fs_exists, + "_cap/fs.delete": self._serve_fs_delete, + "_cap/user_io.show": self._serve_io_show, + "_cap/user_io.input": self._serve_io_input, + "_cap/notify": self._serve_notify}.get(method) + if handler is None: + self._respond(rpc_id, error=self._rpc_error(-32601, "unknown method %s" % method)) + return + try: + result = handler(params, rpc_id) + except CapError as exc: + self._respond(rpc_id, error=self._rpc_error(exc.numeric or -32603, str(exc))) + return + if result is not _ACP_DEFERRED: + self._respond(rpc_id, result=result) + + def _map_update(self, update): + kind = update.get("sessionUpdate") + if kind == "agent_message_chunk": + content = update.get("content") or {} + if isinstance(content, dict) and content.get("type") == "text" and content.get("text"): + self._emit(CapEvent.make("cap.text_chunk", message_id=self.turn_msg_id, + text=content["text"], channel="assistant")) + else: + self.ignored_frames += 1 + elif kind == "agent_thought_chunk": + content = update.get("content") or {} + text = content.get("text") if isinstance(content, dict) else None + if text: + self._emit(CapEvent.make("cap.thought", message_id=self.turn_msg_id, text=text)) + else: + self.ignored_frames += 1 + elif kind == "tool_call": + self._emit(CapEvent.make("cap.tool_call.start", + call_id=str(update.get("toolCallId") or "c%d" % (self.call_seq + 1)), + name=update.get("title") or update.get("kind") or "tool", input={})) + elif kind == "tool_call_update": + status = update.get("status") or "" + text = self._update_text(update.get("content")) + if status in ("completed", "failed"): + self._emit(CapEvent.make("cap.tool_call.end", call_id=str(update.get("toolCallId") or ""), + output=text, is_error=status == "failed", duration_ms=0)) + else: + self._emit(CapEvent.make("cap.tool_call.delta", call_id=str(update.get("toolCallId") or ""), + output_chunk=text)) + elif kind == "plan": + entries = [] + for nth, entry in enumerate(update.get("entries") or []): + if not isinstance(entry, dict): + continue + status = entry.get("status") or "pending" + entries.append({"id": str(entry.get("id") or "p%d" % nth), + "content": str(entry.get("content") or "")[:2000], + "status": status if status in ("pending", "in_progress", "completed") else "pending"}) + if entries: + self._emit(CapEvent.make("cap.plan", entries=entries)) + else: + self.ignored_frames += 1 + elif kind == "usage_update": + usage = update.get("usage") or update + try: + total_in = int(usage.get("inputTokens", usage.get("input_tokens", 0)) or 0) + total_out = int(usage.get("outputTokens", usage.get("output_tokens", usage.get("total_tokens", 0))) or 0) + except (ValueError, TypeError): + self.ignored_frames += 1 + return + self._emit(CapEvent.make("cap.usage", model_id=self.model or self.manifest.name, + input_tokens=max(0, total_in - self._last_in), + output_tokens=max(0, total_out - self._last_out))) + self._last_in, self._last_out = total_in, total_out + else: + self.ignored_frames += 1 + + @staticmethod + def _update_text(content): + if isinstance(content, str): + return content[:2000] + parts = [] + for block in content or []: + if isinstance(block, dict) and block.get("type") == "text" and block.get("text"): + parts.append(block["text"]) + return "\n".join(parts)[:2000] + + def _serve_permission(self, params, rpc_id): + call = params.get("toolCall") or {} + options = params.get("options") or [] + kind = str(call.get("kind") or "").lower() + if kind in ("delete", "execute"): + risk = "high" + elif kind in ("edit", "write"): + risk = "medium" + else: + risk = "low" + self.ask_seq += 1 + req_id = "perm_%d" % self.ask_seq + self.open_asks[req_id] = ("permission", rpc_id, options) + self._emit(CapEvent.make("cap.permission.request", req_id=req_id, + tool=call.get("title") or call.get("kind") or "tool", + intent={"toolCallId": call.get("toolCallId"), + "kind": call.get("kind"), + "content": str(call.get("content") or "")[:1000]}, + scope="execute" if kind == "execute" else "write" if kind in ("edit", "write", "delete") else "read", + risk_level=risk)) + self.state = "input_required" + return _ACP_DEFERRED + + def _serve_elicit(self, params, rpc_id): + if params.get("mode") != "form": + return {"action": "decline"} + schema = params.get("requestedSchema") or {"type": "string"} + self.ask_seq += 1 + ask_id = "ask_%d" % self.ask_seq + self.open_asks[ask_id] = ("elicit", rpc_id, schema) + self.state = "input_required" + self._emit(CapEvent.make("cap.ask_user", ask_id=ask_id, + prompt=str(params.get("message") or "input needed")[:2000], form=schema)) + return _ACP_DEFERRED + + def _serve_io_show(self, params, _rpc_id): + self._io_log("show", params) + return {} + + def _serve_notify(self, params, _rpc_id): + self._io_log("notify", params) + return {} + + def _serve_io_input(self, params, rpc_id): + self.ask_seq += 1 + ask_id = "ask_%d" % self.ask_seq + self.open_asks[ask_id] = ("input", rpc_id, params) + self.state = "input_required" + self._emit(CapEvent.make("cap.ask_user", ask_id=ask_id, + prompt=str(params.get("prompt") or "input needed")[:2000], + form={"type": "string"})) + return _ACP_DEFERRED + + def _io_log(self, kind, params): + entry = {"kind": kind, "params": params} + self.io_log.append(entry) + if self.io_sink is not None: + try: + self.io_sink(entry) + except Exception: + pass + + def _answer_open(self, ask_id, kind, value): + kind_name, rpc_id, extra = kind + if kind_name == "permission": + self._respond(rpc_id, self._permission_outcome(value, extra)) + elif kind_name == "elicit": + if value is False or value is None or value == "decline": + self._respond(rpc_id, {"action": "decline"}) + elif value == "cancel": + self._respond(rpc_id, {"action": "cancel"}) + else: + self._respond(rpc_id, {"action": "accept", "content": value}) + elif kind_name == "input": + self._respond(rpc_id, {"value": value}) + else: + raise CapError("cap_invalid_answer", "unknown question kind") + if not self.open_asks and self.state == "input_required": + self.state = "working" + + @staticmethod + def _permission_outcome(value, options): + by_id = {opt.get("optionId"): opt for opt in options if isinstance(opt, dict)} + if value in by_id: + return {"outcome": {"outcome": "selected", "optionId": value}} + kinds = {opt.get("optionId"): str(opt.get("kind") or "") for opt in options if isinstance(opt, dict)} + pick = None + if value == "deny": + pick = next((oid for oid, k in kinds.items() if k.startswith("reject")), None) + elif value in ("allow_once", "allow_always"): + pick = next((oid for oid, k in kinds.items() if k == value), None) + if pick is None: + pick = next((oid for oid, k in kinds.items() if k.startswith("allow")), None) + if pick is not None: + return {"outcome": {"outcome": "selected", "optionId": pick}} + return {"outcome": {"outcome": "cancelled"}} + + def _fs_root(self): + if self.fs_scope == "unrestricted": + return None + if self.fs_scope == "project_root": + return self._vcs_root() + if self.fs_scope == "workspace_only": + return os.path.realpath(self.cwd) + return False + + def _vcs_root(self): + node = os.path.realpath(self.cwd) + while True: + if os.path.isdir(os.path.join(node, ".git")): + return node + parent = os.path.dirname(node) + if parent == node: + return os.path.realpath(self.cwd) + node = parent + + def _fs_check(self, path): + root = self._fs_root() + if root is False: + raise CapError("cap_scope_violation", "file access is not granted") + target = os.path.realpath(path if os.path.isabs(path) else os.path.join(self.cwd, path)) + if root is not None and target != root and not target.startswith(root + os.sep): + raise CapError("cap_scope_violation", "path escapes the granted scope") + return target + + def _serve_fs_read(self, params, _rpc_id): + target = self._fs_check(params.get("path") or "") + try: + with open(target, "r", encoding="utf-8") as handle: + lines = handle.read().splitlines(True) + except FileNotFoundError: + raise CapError("cap_path_not_found", "no such file") + except (OSError, ValueError) as exc: + raise CapError("cap_io_error", short_error(exc)) + line = params.get("line") or 0 + limit = params.get("limit") or 0 + try: + start = max(int(line) - 1, 0) if line else 0 + picked = lines[start:start + int(limit)] if limit else lines[start:] + except (ValueError, TypeError): + raise CapError("cap_io_error", "bad line window") + return {"content": "".join(picked)[:CAP_ACP_FS_MAX]} + + def _serve_fs_write(self, params, _rpc_id): + target = self._fs_check(params.get("path") or "") + try: + os.makedirs(os.path.dirname(target) or self.cwd, exist_ok=True) + with open(target, "w", encoding="utf-8") as handle: + handle.write(params.get("content") or "") + except OSError as exc: + raise CapError("cap_io_error", short_error(exc)) + return {} + + def _serve_fs_list(self, params, _rpc_id): + target = self._fs_check(params.get("path") or "") + try: + names = sorted(os.listdir(target)) + except FileNotFoundError: + raise CapError("cap_path_not_found", "no such directory") + except OSError as exc: + raise CapError("cap_io_error", short_error(exc)) + return {"entries": [{"name": name, "is_dir": os.path.isdir(os.path.join(target, name))} for name in names[:1000]]} + + def _serve_fs_exists(self, params, _rpc_id): + try: + target = self._fs_check(params.get("path") or "") + except CapError: + return {"exists": False} + return {"exists": os.path.exists(target)} + + def _serve_fs_delete(self, params, _rpc_id): + target = self._fs_check(params.get("path") or "") + if not os.path.lexists(target): + raise CapError("cap_path_not_found", "no such path") + try: + if os.path.isdir(target) and not os.path.islink(target): + if params.get("recursive"): + shutil.rmtree(target) + else: + os.rmdir(target) + else: + os.remove(target) + except OSError as exc: + raise CapError("cap_io_error", short_error(exc)) + return {} + + def _serve_term_create(self, params, _rpc_id): + if not self.grant_terminal: + raise CapError("cap_terminal_unavailable", "terminals are not granted") + if len(self.terminals) >= CAP_ACP_TERMINALS_MAX: + raise CapError("cap_terminal_unavailable", "too many terminals") + command = params.get("command") or "" + if not command: + raise CapError("cap_io_error", "no command") + cwd = params.get("cwd") or self.cwd + if not os.path.isabs(cwd): + raise CapError("cap_io_error", "cwd must be absolute") + env = dict(os.environ) + for item in params.get("env") or []: + if isinstance(item, dict) and item.get("name"): + env[item["name"]] = item.get("value") or "" + try: + limit = int(params.get("outputByteLimit") or CAP_ACP_TERM_BUF) + except (ValueError, TypeError): + limit = CAP_ACP_TERM_BUF + limit = max(1024, min(limit, CAP_ACP_TERM_BUF * 4)) + try: + proc = subprocess.Popen([command] + list(params.get("args") or []), stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, cwd=cwd, env=env) + except OSError as exc: + raise CapError("cap_terminal_unavailable", short_error(exc)) + self.term_seq += 1 + tid = "term_%d" % self.term_seq + term = {"proc": proc, "buf": bytearray(), "limit": limit, "truncated": False, + "lock": threading.Lock(), "killed": None} + self.terminals[tid] = term + thread = threading.Thread(target=self._term_pump, args=(tid, proc, term), daemon=True) + thread.start() + return {"terminalId": tid} + + def _term_pump(self, tid, proc, term): + try: + while True: + chunk = proc.stdout.read(65536) + if not chunk: + break + with term["lock"]: + term["buf"] += chunk + if len(term["buf"]) > term["limit"]: + del term["buf"][:len(term["buf"]) - term["limit"]] + while term["buf"][:1] and term["buf"][0] & 0xC0 == 0x80: + del term["buf"][:1] + term["truncated"] = True + except (OSError, ValueError): + pass + + def _term_get(self, tid): + term = self.terminals.get(tid) + if term is None: + raise CapError("cap_terminal_unavailable", "no such terminal") + return term + + @staticmethod + def _term_status(term): + code = term["proc"].poll() + if code is None: + return None + name = None + if code < 0: + try: + name = signal.Signals(-code).name + except ValueError: + name = str(-code) + elif term["killed"]: + name = term["killed"] + return {"exitCode": code if code >= 0 else None, "signal": name} + + def _serve_term_output(self, params, _rpc_id): + term = self._term_get(params.get("terminalId") or "") + with term["lock"]: + output = bytes(term["buf"]).decode("utf-8", "replace") + truncated = term["truncated"] + result = {"output": output, "truncated": truncated} + status = self._term_status(term) + if status is not None: + result["exitStatus"] = status + return result + + def _serve_term_wait(self, params, _rpc_id): + term = self._term_get(params.get("terminalId") or "") + deadline = time.time() + CAP_ACP_TERM_WAIT_MAX + while time.time() < deadline: + status = self._term_status(term) + if status is not None: + return {"exitCode": status["exitCode"], "signal": status["signal"]} + time.sleep(0.05) + return {"exitCode": None, "signal": None} + + def _serve_term_kill(self, params, _rpc_id): + term = self._term_get(params.get("terminalId") or "") + term["killed"] = "SIGTERM" + cap_proc_kill(term["proc"]) + return {} + + def _serve_term_release(self, params, _rpc_id): + self._terminal_drop(params.get("terminalId") or "") + return {} + + def _terminal_drop(self, tid): + term = self.terminals.pop(tid, None) + if term is None: + raise CapError("cap_terminal_unavailable", "no such terminal") + if term["proc"].poll() is None: + term["killed"] = "SIGTERM" + cap_proc_kill(term["proc"]) + + +CAP_A2A_TIMEOUT = 30 +CAP_ANSWER_TIMEOUT = 300 +CAP_ANSWER_PARALLEL = 4 +CAP_ANSWER_MARKER = "ANSWER:" +CAP_ANSWER_RISKS = ("low", "medium", "high") + + +class CapAnswerer: + """Answers subagent questions so no turn stalls: permissions by + policy (unknown tools researched), asks by an isolated research + subprocess (a tai one-shot in its own vault). Everything bounded, + every failure falls back to the safe answer.""" + + def __init__(self, allow_tools=(), deny_tools=(), max_risk="medium", + research_home=None, timeout=CAP_ANSWER_TIMEOUT, tai_binary=None): + self.allow_tools = set(allow_tools or ()) + self.deny_tools = set(deny_tools or ()) + self.max_risk = max_risk if max_risk in CAP_ANSWER_RISKS else "medium" + home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") + self.research_home = research_home or os.path.join(home, "cap-research") + self.timeout = timeout + self.tai_binary = tai_binary or sys.argv[0] + self._slots = threading.Semaphore(CAP_ANSWER_PARALLEL) + + def handle(self, session, event): + kind = event.get("kind") + if kind == "cap.permission.request": + session.answer(event["req_id"], self.decide_permission(session.urn, event)) + elif kind == "cap.ask_user": + session.answer(event["ask_id"], self.answer_ask(session.urn, event)) + + def decide_permission(self, urn, event): + tool = event.get("tool") or "" + if tool in self.deny_tools: + return "deny" + if tool in self.allow_tools: + return "allow_once" + risk = event.get("risk_level") or "medium" + if CAP_ANSWER_RISKS.index(risk) > CAP_ANSWER_RISKS.index(self.max_risk): + return "deny" + verdict = self.research("Decide whether the supervised subagent %s may perform this action.\n" + "Tool: %s\nIntent: %s\nRisk: %s\nReply with reasoning, then end with exactly one " + "line: %s allow OR %s deny." % ( + urn, tool, json.dumps(event.get("intent") or {})[:2000], risk, + CAP_ANSWER_MARKER, CAP_ANSWER_MARKER)) + if verdict is None: + return "deny" + return "allow_once" if verdict.strip().lower().startswith("allow") else "deny" + + def answer_ask(self, urn, event): + form = event.get("form") or {} + prompt = event.get("prompt") or "" + boolean = self._is_yes_no(form) + shape = "yes/no" if boolean else json.dumps(form)[:2000] + verdict = self.research("Answer for the supervised subagent %s. Research with web_search and web_fetch, " + "then end with exactly one line: %s .\nQuestion: %s\nAnswer shape: %s" % ( + urn, CAP_ANSWER_MARKER, prompt[:2000], shape)) + if verdict is None: + return False if boolean else "unknown (research failed)" + if boolean: + return self._parse_bool(verdict) + return verdict[:2000] + + @staticmethod + def _is_yes_no(form): + if not isinstance(form, dict): + return False + consts = set() + for option in form.get("oneOf") or []: + if isinstance(option, dict) and "const" in option: + consts.add(str(option["const"]).lower()) + return consts == {"yes", "no"} + + @staticmethod + def _parse_bool(text): + lowered = (text or "").strip().lower() + first = lowered.split(None, 1)[0] if lowered.split(None, 1) else "" + if first in ("y", "yes", "true", "1"): + return True + if first in ("n", "no", "false", "0"): + return False + if re.search(r"\byes\b", lowered): + return True + if re.search(r"\bno\b", lowered): + return False + return False + + def research(self, prompt): + """Run one isolated research turn. Returns the marker value, or + None when the turn failed, timed out, or never marked.""" + with self._slots: + env = dict(os.environ, TAI_HOME=self.research_home) + try: + done = subprocess.run([sys.executable, self.tai_binary, "--auto", prompt], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + timeout=self.timeout, env=env) + except (OSError, subprocess.SubprocessError): + return None + if done.returncode != 0: + return None + try: + output = done.stdout.decode("utf-8", "replace") + except UnicodeDecodeError: + return None + for line in reversed(output.splitlines()): + if line.strip().upper().startswith(CAP_ANSWER_MARKER): + return line.strip()[len(CAP_ANSWER_MARKER):].strip() + return None + + +class CapA2aDriver(CapDriver): + """Orchestrator-side A2A binding (spec §6.5, §7.8, C.4): plain + https? client over http.client. Tries draft endpoints first + (what CAP peers speak), then current A2A spellings; the SSE + reader is the one thread (blocking socket reads), everything + else pumps through poll(). Answers travel back as data parts + with the _meta.cap.kind marker, reference-literal.""" + + def __init__(self, manifest, urn="cap://agent-1", cwd=None, model=None, env=None): + super().__init__(manifest, urn=urn, cwd=cwd, model=model, env=env) + self.endpoint = (manifest.get("fast_path.a2a_serve_at") or "").rstrip("/") + self.card_name = "" + self.flavor = None + self.ignored_frames = 0 + self.turn_msg_id = "msg_boot" + self._context_id = None + self._task_id = None + self._sse_lines = queue.Queue() + self._sse_thread = None + self._resp = None + self._conn = None + self._turn_events = 0 + + def _split_endpoint(self): + parsed = urllib.parse.urlparse(self.endpoint) + if parsed.scheme not in ("http", "https"): + raise CapError("cap_binding_unavailable", "a2a endpoint must be http(s): %r" % self.endpoint) + host = parsed.hostname or "" + port = parsed.port or (443 if parsed.scheme == "https" else 80) + return parsed.scheme, host, port, parsed.path.rstrip("/") + + def _request(self, method, path, body=None, accept_stream=False): + scheme, host, port, prefix = self._split_endpoint() + cls = http.client.HTTPSConnection if scheme == "https" else http.client.HTTPConnection + conn = cls(host, port, timeout=CAP_A2A_TIMEOUT) + headers = {"Content-Type": "application/json", + "Accept": "text/event-stream, application/json" if accept_stream else "application/json"} + payload = json.dumps(body) if body is not None else None + try: + conn.request(method, prefix + path, body=payload, headers=headers) + return conn, conn.getresponse() + except (OSError, http.client.HTTPException) as exc: + try: + conn.close() + except OSError: + pass + raise CapError("cap_binding_unavailable", "a2a %s %s failed: %s" % (method, path, short_error(exc))) + + def _get_json(self, path): + conn, resp = self._request("GET", path) + try: + if resp.status != 200: + raise CapError("cap_binding_unavailable", "a2a GET %s returned %d" % (path, resp.status)) + try: + return json.loads(resp.read().decode("utf-8")) + except ValueError as exc: + raise CapError("cap_binding_unavailable", "a2a GET %s is not JSON: %s" % (path, exc)) + finally: + try: + conn.close() + except OSError: + pass + + def _spawn(self): + if not self.endpoint: + raise CapError("cap_binding_unavailable", "manifest has no fast_path.a2a_serve_at") + try: + card = self._get_json("/.well-known/agent-card.json") + except CapError: + raise + extensions = card.get("extensions") or [] + if not any(isinstance(ext, dict) and ext.get("uri") == "cap-protocol/v1" for ext in extensions): + raise CapError("cap_binding_unavailable", "A2A card does not advertise cap-protocol/v1") + self.card_name = card.get("name") or "" + self.proc = True + self.state = "ready" + self._emit(CapEvent.make("cap.session.ready", version=CAP_VERSION, session_id=self.urn)) + + def _message_body(self, parts): + message = {"role": "user", "messageId": uuid.uuid4().hex, "parts": parts} + body = {"message": message} + if self._context_id: + body["contextId"] = self._context_id + message["contextId"] = self._context_id + if self._task_id: + body["taskId"] = self._task_id + return body + + def send_text(self, text): + self._pre_send() + self.msg_seq += 1 + self.turn_msg_id = "msg_%d" % self.msg_seq + self.open_asks.clear() + self.state = "working" + self._post_message([{"kind": "text", "text": text}]) + + def _post_message(self, parts): + body = self._message_body(parts) + attempts = [self.flavor] if self.flavor else ["draft", "stream", "send"] + last_error = None + for flavor in attempts: + try: + if flavor == "draft": + self._start_sse("/message/send", body) + elif flavor == "stream": + self._start_sse("/message:stream", body) + else: + self._post_blocking("/message:send", body) + self.flavor = flavor + return + except CapError as exc: + last_error = exc + if self.flavor or " failed: " in str(exc): + raise + raise last_error + + def _start_sse(self, path, body): + conn, resp = self._request("POST", path, body, accept_stream=True) + if resp.status in (404, 405, 410): + try: + conn.close() + except OSError: + pass + raise CapError("cap_binding_unavailable", "a2a POST %s returned %d" % (path, resp.status)) + if resp.status != 200: + try: + conn.close() + except OSError: + pass + raise CapError("cap_binding_unavailable", "a2a POST %s returned %d" % (path, resp.status)) + self._stop_stream() + self._conn, self._resp = conn, resp + self._turn_events = 0 + self._sse_thread = threading.Thread(target=self._sse_pump, args=(resp,), daemon=True) + self._sse_thread.start() + + def _post_blocking(self, path, body): + _conn, resp = self._request("POST", path, body) + try: + if resp.status in (404, 405, 410): + raise CapError("cap_binding_unavailable", "a2a POST %s returned %d" % (path, resp.status)) + if resp.status != 200: + raise CapError("cap_binding_unavailable", "a2a POST %s returned %d" % (path, resp.status)) + try: + payload = json.loads(resp.read().decode("utf-8")) + except ValueError as exc: + raise CapError("cap_binding_unavailable", "a2a response is not JSON: %s" % exc) + finally: + try: + _conn.close() + except OSError: + pass + if isinstance(payload, dict) and "error" in payload: + raise CapError("cap_binding_unavailable", "a2a error: %s" % str(payload["error"])[:500]) + result = payload.get("result", payload) if isinstance(payload, dict) else {} + if isinstance(result, dict): + self._capture_ids(result) + self._map_payload(result) + if self.state == "working": + self.state = "done" + + def _sse_pump(self, resp): + buf = [] + try: + while True: + line = resp.readline() + if not line: + break + text = line.decode("utf-8", "replace").rstrip("\n").rstrip("\r") + if text == "": + if buf: + self._sse_lines.put(("data", "\n".join(buf))) + buf = [] + continue + if text.startswith(":"): + continue + if text.startswith("data:"): + buf.append(text[len("data:"):].lstrip()) + except (OSError, ValueError, http.client.HTTPException): + pass + finally: + if buf: + self._sse_lines.put(("data", "\n".join(buf))) + self._sse_lines.put(("end", None)) + + def _stop_stream(self): + if self._resp is not None: + try: + self._resp.close() + except (OSError, ValueError, http.client.HTTPException): + pass + self._resp = None + if self._conn is not None: + try: + self._conn.close() + except OSError: + pass + self._conn = None + if self._sse_thread is not None: + self._sse_thread.join(timeout=5) + self._sse_thread = None + while True: + try: + self._sse_lines.get_nowait() + except queue.Empty: + break + + def poll(self, timeout=0.0): + if self.proc is None or self.state in ("failed", "closed"): + return self.drain() + deadline = time.time() + max(0.0, timeout) + while True: + try: + kind, payload = self._sse_lines.get_nowait() + except queue.Empty: + break + if kind == "data": + self._map_sse_data(payload) + else: + self._on_stream_end() + if timeout > 0 and self.state == "working": + remaining = deadline - time.time() + if remaining > 0: + time.sleep(min(remaining, 0.05)) + return self.poll(0.0) + return self.drain() + + def _map_sse_data(self, data): + try: + payload = json.loads(data) + except ValueError: + self.ignored_frames += 1 + return + if isinstance(payload, dict): + self._map_payload(payload) + else: + self.ignored_frames += 1 + + def _capture_ids(self, payload): + task = payload.get("task") or {} + if isinstance(task, dict): + self._task_id = task.get("id") or task.get("taskId") or self._task_id + self._context_id = task.get("contextId") or self._context_id + message = payload.get("message") or {} + if isinstance(message, dict): + self._context_id = message.get("contextId") or self._context_id + + def _map_payload(self, payload): + self._capture_ids(payload) + kind = payload.get("kind") + if isinstance(kind, str) and kind.startswith("cap."): + try: + CapEvent.validate(payload) + except ValueError: + self.ignored_frames += 1 + return + self._turn_events += 1 + self._emit(dict(payload)) + return + if self._map_data_parts(payload): + return + state = ((payload.get("task") or {}).get("status") or {}).get("state") if isinstance(payload.get("task"), dict) else None + if state is not None: + self._map_task_state(payload, state) + text, msg_id = self._payload_text(payload) + if text: + self._turn_events += 1 + self._emit(CapEvent.make("cap.text_chunk", message_id=msg_id, text=text, channel="assistant")) + return + artifact = payload.get("artifact") + if isinstance(artifact, dict): + self._map_plain_artifact(artifact) + return + if state is None: + self.ignored_frames += 1 + + def _map_data_parts(self, payload): + buckets = [] + message = payload.get("message") + if isinstance(message, dict) and isinstance(message.get("parts"), list): + buckets += message["parts"] + task = payload.get("task") + if isinstance(task, dict) and isinstance((task.get("status") or {}).get("message"), dict): + parts = (task["status"]["message"]).get("parts") + if isinstance(parts, list): + buckets += parts + artifact = payload.get("artifact") + if isinstance(artifact, dict) and isinstance(artifact.get("parts"), list): + buckets += artifact["parts"] + for part in buckets: + if not isinstance(part, dict): + continue + meta = part.get("_meta") or {} + cap_kind = (meta.get("cap") or {}).get("kind") if isinstance(meta, dict) else None + if not isinstance(cap_kind, str) or not cap_kind.startswith("cap."): + continue + data = part.get("data") + if not isinstance(data, dict): + continue + event = dict(data) + event.setdefault("kind", cap_kind) + try: + CapEvent.validate(event) + except ValueError: + continue + self._turn_events += 1 + self._emit(event) + return True + return False + + @staticmethod + def _payload_text(payload): + message = payload.get("message") + if not isinstance(message, dict): + status = (payload.get("task") or {}).get("status") if isinstance(payload.get("task"), dict) else None + message = (status or {}).get("message") if isinstance(status, dict) else None + if not isinstance(message, dict): + return "", "a2a-message" + for part in message.get("parts") or []: + if not isinstance(part, dict): + continue + if part.get("kind", part.get("type")) == "text" and part.get("text"): + msg_id = message.get("messageId") or message.get("message_id") or "a2a-message" + return part["text"], msg_id + return "", "a2a-message" + + def _map_task_state(self, payload, state): + if state in ("submitted", "working"): + return + if state == "input-required": + text, _msg_id = self._payload_text(payload) + self.ask_seq += 1 + ask_id = "ask_%d" % self.ask_seq + self.open_asks[ask_id] = ("a2a_ask", None, {}) + self._emit(CapEvent.make("cap.ask_user", ask_id=ask_id, + prompt=text or "agent needs input", form={"type": "string"})) + self.state = "input_required" + return + if state in ("completed", "canceled", "rejected"): + self.state = "done" + return + if state == "failed": + message = payload.get("task", {}).get("status", {}).get("message") if isinstance(payload.get("task"), dict) else None + text = message if isinstance(message, str) else (self._payload_text(payload)[0] or "A2A task failed") + self._turn_events += 1 + self._emit(CapEvent.make("cap.error", code="error", message=str(text)[:2000], + retryable=False, details={"task_state": state})) + self.state = "done" + return + self.ignored_frames += 1 + + def _map_plain_artifact(self, artifact): + texts = [] + for part in artifact.get("parts") or []: + if isinstance(part, dict) and part.get("kind", part.get("type")) == "text" and part.get("text"): + texts.append(part["text"]) + content = "\n".join(texts) + self.ask_seq += 1 + self._turn_events += 1 + self._emit(CapEvent.make("cap.artifact", artifact_id=str(artifact.get("artifactId") or "a2a_%d" % self.ask_seq), + mime_type=str(artifact.get("mimeType") or "application/octet-stream"), + name=str(artifact.get("name") or "artifact"), size=len(content.encode("utf-8")), + content=content)) + + def _on_stream_end(self): + if self.state != "working": + return + if self._turn_events: + self.state = "done" + else: + self._emit(CapEvent.make("cap.error", code="error", message="a2a stream ended without events", + retryable=False, details={})) + self.state = "failed" + + def _answer_open(self, ask_id, kind, value): + kind_name, _rpc_id, _extra = kind + if kind_name != "a2a_ask": + raise CapError("cap_invalid_answer", "unknown question kind") + event = CapEvent.make("cap.ask_user.answer", ask_id=ask_id, value=value) + self._post_message([{"kind": "data", "data": event, "_meta": {"cap": {"kind": "cap.ask_user.answer"}}}]) + if not self.open_asks and self.state == "input_required": + self.state = "working" + + def cancel(self, scope="current_turn"): + if self.proc is None: + raise CapError("cap_binding_unavailable", "session never started") + if scope == "session": + self.close() + return + if self.cancel_mode == "none": + raise CapError("cap_cancel_unsupported", "manifest says mid_turn_cancel is none") + try: + conn, resp = self._request("POST", "/tasks/cancel") + try: + resp.read() + finally: + conn.close() + except CapError: + if self._task_id: + try: + conn, resp = self._request("POST", "/tasks/%s:cancel" % self._task_id) + try: + resp.read() + finally: + conn.close() + except CapError: + pass + self._stop_stream() + self._task_id = None + if self.state not in ("failed", "closed"): + self.state = "ready" + + def close(self): + self._stop_stream() + self.proc = None + if self.state != "failed": + self.state = "closed" + + +CAP_SESSION_POLL = 0.1 +CAP_TURN_TIMEOUT = 600 +CAP_WORKTREE_DIR = ".cap-worktrees" +CAP_ROUTE_QUEUE_MAX = 16 +CAP_FLEET_DIFF_MAX = 65536 +CAP_MENTION_RE = re.compile(r"@(cap://[A-Za-z0-9_.-]+)") + + +class CapTurnResult: + """One blocking turn: the collected events, the end state, summed + usage, and the assistant text joined for routing.""" + + def __init__(self, events, state, usage=None): + self.events = list(events) + self.state = state + self.usage = dict(usage or {}) + + @property + def text(self): + return "\n".join(event.get("text", "") for event in self.events + if event.get("kind") == "cap.text_chunk") + + +class CapSession: + """One orchestrated agent: the driver pumps on its own thread, all + events land on a queue, ask() blocks one turn with a timeout.""" + + def __init__(self, urn, driver): + self.urn = urn + self.driver = driver + self.events = queue.Queue() + self.taps = [] + self.usage = {"input_tokens": 0, "output_tokens": 0, "cost_usd_estimate": 0.0} + self._stop = threading.Event() + self._thread = None + + def start(self, timeout=None): + for event in self.driver.start(timeout): + self._note(event) + while True: + try: + self.events.get_nowait() + except queue.Empty: + break + self._thread = threading.Thread(target=self._pump_loop, daemon=True) + self._thread.start() + return self + + def ask(self, text, timeout=CAP_TURN_TIMEOUT): + baseline = dict(self.usage) + self.driver.send_text(text) + return self.wait_done(timeout, baseline) + + def wait_done(self, timeout=CAP_TURN_TIMEOUT, baseline=None): + events = [] + deadline = time.time() + max(1, timeout) + while True: + try: + event = self.events.get(timeout=CAP_SESSION_POLL) + except queue.Empty: + event = None + if event is not None: + events.append(event) + state = self.driver.state + if state in ("done", "failed", "closed", "input_required") and self.events.empty(): + events += self._grace_drain() + return CapTurnResult(events, state, self._delta(baseline)) + if self._stop.is_set(): + return CapTurnResult(events, state, self._delta(baseline)) + if time.time() > deadline: + raise TimeoutError("turn timed out after %ds" % timeout) + + def _grace_drain(self): + extra = [] + deadline = time.time() + 0.2 + while time.time() < deadline: + try: + extra.append(self.events.get(timeout=0.05)) + except queue.Empty: + pass + return extra + + def _delta(self, baseline): + baseline = baseline or {"input_tokens": 0, "output_tokens": 0, "cost_usd_estimate": 0.0} + return {key: self.usage.get(key, 0.0) - baseline.get(key, 0.0) for key in self.usage} + + def answer(self, ask_id, value): + self.driver.answer(ask_id, value) + + def cancel(self, scope="current_turn"): + self.driver.cancel(scope) + + def close(self): + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=5) + self._thread = None + self.driver.close() + + def _note(self, event): + self.events.put(event) + for tap in self.taps: + tap((self.urn, event)) + if event.get("kind") == "cap.usage": + self.usage["input_tokens"] += int(event.get("input_tokens", 0) or 0) + self.usage["output_tokens"] += int(event.get("output_tokens", 0) or 0) + try: + self.usage["cost_usd_estimate"] += float(event.get("cost_usd_estimate", 0.0) or 0.0) + except (ValueError, TypeError): + pass + + def _pump_loop(self): + while not self._stop.is_set(): + try: + events = self.driver.poll(CAP_SESSION_POLL) + except Exception as exc: + self._note(CapEvent.make("cap.error", code="error", + message="driver pump failed: %s" % short_error(exc), + retryable=False, details={})) + return + for event in events: + self._note(event) + if self.driver.state in ("failed", "closed"): + return + + +class CapFleet: + """N orchestrated sessions (spec §10): worktree isolation, master + plan, mediated @cap:// routing with per-session pending queues, + budget aggregation with hard refusal, and the human audit log.""" + + def __init__(self, workdir=None, budget_usd=None): + self.workdir = os.path.abspath(workdir or os.getcwd()) + self.budget_usd = budget_usd + self.sessions = {} + self.worktrees = {} + self.pending_routes = {} + self.master_plan = None + self.audit = [] + self.feed = queue.Queue() + self.answerer = None + self._answering = set() + self._answering_lock = threading.Lock() + + def set_answerer(self, answerer): + self.answerer = answerer + + def _log(self, kind, sender="", recipient="", text=""): + self.audit.append({"ts": time.time(), "kind": kind, "from": sender, "to": recipient, "text": text[:2000]}) + + def add(self, name, agent, cwd=None, model=None, env=None, worktree=True, registry=None, argv=None): + urn = "cap://%s" % name + if urn in self.sessions: + raise ValueError("fleet already has %s" % urn) + manifest = agent if isinstance(agent, CapManifest) else (registry or CapRegistry()).resolve(agent, agent) + path = self._session_cwd(name, cwd, manifest, worktree) + driver = CapDriver.create(manifest, urn=urn, cwd=path, model=model, env=env, acp_argv=argv) + session = CapSession(urn, driver) + session.taps.append(self.feed.put) + self.sessions[urn] = session + self.pending_routes[urn] = collections.deque(maxlen=CAP_ROUTE_QUEUE_MAX) + self._log("session.add", recipient=urn, text="%s via %s in %s" % (manifest.name, manifest.binding(), path)) + return session + + def _session_cwd(self, name, cwd, manifest, worktree): + if cwd: + return os.path.abspath(cwd) + if not worktree or "coding" not in (manifest.get("agent.profiles") or []): + return self.workdir + slug = re.sub(r"[^a-z0-9-]+", "-", name.lower()).strip("-") or "agent" + path = os.path.join(self.workdir, CAP_WORKTREE_DIR, slug) + if self._git_inside(): + branch = "cap/%s" % slug + done = subprocess.run(["git", "worktree", "add", "-b", branch, path, "HEAD"], + cwd=self.workdir, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, universal_newlines=True, timeout=120) + if done.returncode != 0: + raise CapError("cap_io_error", "worktree for %s failed: %s" % (name, (done.stderr or "").strip()[:300])) + self.worktrees["cap://%s" % name] = path + self._log("worktree.add", recipient="cap://%s" % name, text="%s on %s" % (path, branch)) + return path + os.makedirs(path, exist_ok=True) + self.worktrees["cap://%s" % name] = path + self._log("worktree.plain", recipient="cap://%s" % name, text="%s (no git, unisolated)" % path) + return path + + def _git_inside(self): + try: + done = subprocess.run(["git", "rev-parse", "--is-inside-work-tree"], cwd=self.workdir, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=15) + except (OSError, subprocess.SubprocessError): + return False + return done.returncode == 0 and done.stdout.strip() == b"true" + + def start_all(self, timeout=None): + for urn, session in self.sessions.items(): + session.start(timeout) + self._log("session.ready", recipient=urn) + + def publish_plan(self, plan): + if not isinstance(plan, dict) or plan.get("kind") != "cap.plan" or not isinstance(plan.get("entries"), list): + raise ValueError("master plan must be a cap.plan event with entries") + for entry in plan["entries"]: + target = ((entry.get("_meta") or {}).get("cap.assigned_to") + if isinstance(entry.get("_meta"), dict) else None) + if target is not None and target not in self.sessions: + raise ValueError("plan assigns %s to unknown session" % target) + self.master_plan = plan + self._log("plan.publish", text="%d entries" % len(plan["entries"])) + + def ask(self, urn, text, timeout=CAP_TURN_TIMEOUT): + if urn not in self.sessions: + raise ValueError("unknown session %s" % urn) + if self.budget_usd is not None and self.spent_usd() >= self.budget_usd: + raise CapError("cap_budget_exceeded", "fleet spent %.4f of %.4f USD" % (self.spent_usd(), self.budget_usd)) + self._log("turn.start", recipient=urn, text=text) + result = self.sessions[urn].ask(text, timeout) + self._log("turn.end", recipient=urn, text="%s, %d events" % (result.state, len(result.events))) + self.pump() + return result + + def spent_usd(self): + return sum(session.usage.get("cost_usd_estimate", 0.0) for session in self.sessions.values()) + + def pump(self): + """Drain the tapped feed, route @cap:// mentions, hand open + questions to the answerer, deliver pending routes to idle + sessions. Returns routed count.""" + routed = 0 + while True: + try: + urn, event = self.feed.get_nowait() + except queue.Empty: + break + if event.get("kind") == "cap.text_chunk": + routed += self._route_mentions(urn, event.get("text", "")) + elif event.get("kind") in ("cap.ask_user", "cap.permission.request"): + self._maybe_answer(urn, event) + for urn, session in self.sessions.items(): + routed += self._deliver_pending(urn, session) + return routed + + def _maybe_answer(self, urn, event): + if self.answerer is None: + return + key = event.get("ask_id") or event.get("req_id") or "" + session = self.sessions.get(urn) + if session is None or key not in session.driver.open_asks: + return + with self._answering_lock: + if key in self._answering: + return + self._answering.add(key) + self._log("answer.start", recipient=urn, text=key) + thread = threading.Thread(target=self._answer_one, args=(urn, key, event), daemon=True) + thread.start() + + def _answer_one(self, urn, key, event): + try: + session = self.sessions.get(urn) + if session is not None and self.answerer is not None: + self.answerer.handle(session, event) + self._log("answer.done", recipient=urn, text=key) + except Exception as exc: + self._log("answer.error", recipient=urn, text="%s: %s" % (key, short_error(exc))) + finally: + with self._answering_lock: + self._answering.discard(key) + + def _route_mentions(self, sender, text): + count = 0 + for target in CAP_MENTION_RE.findall(text or ""): + if target == sender or target not in self.sessions: + self._log("route.drop", sender, target, "unknown or self target") + continue + pending = self.pending_routes[target] + if len(pending) >= CAP_ROUTE_QUEUE_MAX: + self._log("route.drop", sender, target, "pending queue full") + continue + pending.append((sender, text.strip()[:2000])) + self._log("route.queue", sender, target, text) + count += 1 + return count + + def _deliver_pending(self, urn, session): + if session.driver.state not in ("ready", "done"): + return 0 + count = 0 + pending = self.pending_routes[urn] + while pending: + sender, text = pending.popleft() + try: + session.driver.send_text("[from %s]\n%s" % (sender, text)) + except CapError as exc: + self._log("route.drop", sender, urn, str(exc)) + continue + self._log("route.send", sender, urn, text) + count += 1 + return count + + def wait_idle(self, timeout=CAP_TURN_TIMEOUT): + deadline = time.time() + max(1, timeout) + while time.time() < deadline: + self.pump() + busy = [urn for urn, session in self.sessions.items() + if session.driver.state in ("starting", "working") or self.pending_routes[urn]] + with self._answering_lock: + busy = busy or list(self._answering) + if not busy: + return True + time.sleep(CAP_SESSION_POLL) + return False + + def collect_diffs(self): + """Per-session uncommitted diffs (bounded); merging them is still + the operator's call.""" + result = {} + for urn, path in self.worktrees.items(): + try: + done = subprocess.run(["git", "diff", "HEAD", "--"], cwd=path, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, timeout=60) + except (OSError, subprocess.SubprocessError) as exc: + result[urn] = {"note": "diff failed: %s" % short_error(exc)} + continue + diff = done.stdout or b"" + result[urn] = {"size": len(diff), "truncated": len(diff) > CAP_FLEET_DIFF_MAX, + "diff": diff[:CAP_FLEET_DIFF_MAX].decode("utf-8", "replace")} + for urn in self.sessions: + if urn not in result: + result[urn] = {"note": "no worktree"} + return result + + def cancel_all(self, scope="session"): + for session in self.sessions.values(): + try: + session.cancel(scope) + except CapError: + pass + + def close_all(self): + for session in self.sessions.values(): + try: + session.close() + except Exception: + pass + for urn, path in self.worktrees.items(): + try: + done = subprocess.run(["git", "worktree", "remove", "--force", path], cwd=self.workdir, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120) + if done.returncode != 0: + shutil.rmtree(path, ignore_errors=True) + subprocess.run(["git", "worktree", "prune"], cwd=self.workdir, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, timeout=60) + except (OSError, subprocess.SubprocessError): + pass + self._log("worktree.remove", recipient=urn, text=path) + + +def cap_cli_help(): + return ("usage: tai.py --cap [...]\n" + " resolve [--binary B] print the manifest source, binding and profiles\n" + " chat --driver NAME [--task T] [--cwd D] [--model M]\n" + " talk to one agent (task once, then REPL on a tty)\n" + " run [--task T] static-route fleet run, see cap.md for the schema") + + +def cap_cli_main(argv): + if not argv or argv[0] in ("-h", "--help", "help"): + print(cap_cli_help()) + return 0 + verb, rest = argv[0], argv[1:] + if verb == "resolve": + return cap_resolve_main(rest) + if verb == "chat": + return cap_chat_main(rest) + if verb == "run": + return cap_run_main(rest) + print("unknown cap command %r\n%s" % (verb, cap_cli_help()), file=sys.stderr) + return 2 + + +def cap_resolve_main(argv): + parser = argparse.ArgumentParser(prog="tai.py --cap resolve") + parser.add_argument("name") + parser.add_argument("--binary", default=None) + args = parser.parse_args(argv) + try: + manifest = CapRegistry().resolve(args.name, args.binary) + except (CapError, ValueError) as exc: + print("cap resolve: %s" % exc, file=sys.stderr) + return 1 + print("%s: %s via %s profiles=%s" % (manifest.name, manifest.source, manifest.binding(), + ",".join(manifest.get("agent.profiles") or []) or "-")) + return 0 + + +def cap_print_result(result): + if result.text.strip(): + print(result.text) + print("[%s] tokens in=%d out=%d" % (result.state, result.usage.get("input_tokens", 0), + result.usage.get("output_tokens", 0))) + + +def cap_build_answerer(auto, allow=(), deny=(), max_risk="medium"): + if not auto: + return None + return CapAnswerer(allow_tools=allow, deny_tools=deny, max_risk=max_risk) + + +def cap_answer_flags(parser): + parser.add_argument("--auto-answer", action="store_true", help="answer subagent questions by policy plus research turns") + parser.add_argument("--allow-tool", action="append", default=[], metavar="TOOL", help="pre-approve a tool (repeatable)") + parser.add_argument("--deny-tool", action="append", default=[], metavar="TOOL", help="pre-deny a tool (repeatable)") + parser.add_argument("--max-risk", default="medium", choices=CAP_ANSWER_RISKS, help="riskiest auto-approved unknown tool") + + +def cap_chat_main(argv): + parser = argparse.ArgumentParser(prog="tai.py --cap chat") + parser.add_argument("--driver", required=True) + parser.add_argument("--task", default="") + parser.add_argument("--cwd", default=None) + parser.add_argument("--model", default=None) + parser.add_argument("--argv", default=None, help="ACP server command line (shell-split) for acp_stdio agents") + cap_answer_flags(parser) + args = parser.parse_args(argv) + try: + manifest = CapRegistry().resolve(args.driver, args.driver) + except (CapError, ValueError) as exc: + print("cap chat: %s" % exc, file=sys.stderr) + return 1 + fleet = CapFleet() + fleet.set_answerer(cap_build_answerer(args.auto_answer, args.allow_tool, args.deny_tool, args.max_risk)) + try: + argv = shlex.split(args.argv) if args.argv else None + session = fleet.add("chat", manifest, cwd=args.cwd, model=args.model, argv=argv) + session.start() + except (CapError, ValueError) as exc: + print("cap chat: %s" % exc, file=sys.stderr) + return 1 + try: + if args.task: + try: + cap_print_result(fleet.ask(session.urn, args.task)) + except (CapError, TimeoutError) as exc: + print("cap chat: %s" % exc, file=sys.stderr) + return 1 + if not sys.stdin.isatty(): + return 0 + while True: + try: + line = input("cap[%s]› " % args.driver) + except EOFError: + print() + break + except KeyboardInterrupt: + print() + continue + if line.strip() in ("", "/quit", "/exit"): + if line.strip(): + break + continue + try: + cap_print_result(fleet.ask(session.urn, line)) + except TimeoutError as exc: + print("cap chat: %s" % exc, file=sys.stderr) + try: + session.cancel() + except CapError: + pass + except CapError as exc: + print("cap chat: %s" % exc, file=sys.stderr) + finally: + fleet.close_all() + return 0 + + +def cap_run_main(argv): + parser = argparse.ArgumentParser(prog="tai.py --cap run") + parser.add_argument("fleet_file") + parser.add_argument("--task", default=None) + cap_answer_flags(parser) + args = parser.parse_args(argv) + try: + import tomllib + except ImportError: + print("cap run: fleet files need Python 3.11 or newer (tomllib)", file=sys.stderr) + return 2 + try: + with open(args.fleet_file, "rb") as handle: + spec = tomllib.load(handle) + except (OSError, ValueError) as exc: + print("cap run: cannot load %s: %s" % (args.fleet_file, short_error(exc)), file=sys.stderr) + return 2 + try: + plan = cap_fleet_plan(spec, args.task) + except ValueError as exc: + print("cap run: %s" % exc, file=sys.stderr) + return 2 + fleet = CapFleet(budget_usd=plan["budget_usd"]) + auto = args.auto_answer or plan["auto_answer"] + max_risk = plan["max_risk"] if args.max_risk == "medium" else args.max_risk + fleet.set_answerer(cap_build_answerer(auto, args.allow_tool + plan["allow_tools"], + args.deny_tool + plan["deny_tools"], max_risk)) + try: + for entry in plan["sessions"]: + fleet.add(entry["name"], entry["agent"], cwd=entry.get("cwd"), model=entry.get("model"), + argv=entry.get("argv")) + fleet.start_all() + last_text = {} + result = fleet.ask(plan["start"], plan["task"]) + print("== %s [%s] ==" % (plan["start"], result.state)) + cap_print_result(result) + last_text[plan["start"]] = result.text + for source, target in plan["routes"]: + payload = last_text.get(source, "") + print("== route %s -> %s ==" % (source, target)) + result = fleet.ask(target, payload or "[no output from %s]" % source) + print("== %s [%s] ==" % (target, result.state)) + cap_print_result(result) + last_text[target] = result.text + except (CapError, TimeoutError, ValueError) as exc: + print("cap run: %s" % exc, file=sys.stderr) + return 1 + finally: + fleet.close_all() + return 0 + + +def cap_fleet_plan(spec, task_override=None): + """Validate a fleet.toml mapping into {start, task, budget_usd, + sessions, routes}. Pure function, no processes.""" + if not isinstance(spec, dict): + raise ValueError("fleet file must hold TOML tables") + fleet = spec.get("fleet") or {} + sessions = spec.get("sessions") or [] + if not sessions: + raise ValueError("fleet needs at least one [[sessions]] entry") + urns = set() + for entry in sessions: + if not isinstance(entry, dict) or not entry.get("name") or not entry.get("agent"): + raise ValueError("every [[sessions]] entry needs name and agent") + if entry.get("argv") is not None and not isinstance(entry.get("argv"), list): + raise ValueError("session %r argv must be a list" % entry.get("name")) + urns.add("cap://%s" % entry["name"]) + start = fleet.get("start") or "" + if start not in urns: + raise ValueError("[fleet] start must name one of: %s" % ", ".join(sorted(urns))) + task = task_override if task_override is not None else fleet.get("task") or "" + if not task.strip(): + raise ValueError("fleet needs a task ([fleet] task or --task)") + routes = [] + for route in spec.get("routes") or []: + if not isinstance(route, dict): + raise ValueError("every [[routes]] entry needs when and route_to") + source = route.get("when") or "" + source = source[:-len(".done")] if source.endswith(".done") else source + target = route.get("route_to") or "" + if source not in urns or target not in urns: + raise ValueError("route %r -> %r names an unknown session" % (source, target)) + routes.append((source, target)) + budget = fleet.get("budget_usd") + if budget is not None: + try: + budget = float(budget) + except (ValueError, TypeError): + raise ValueError("[fleet] budget_usd must be a number") + max_risk = fleet.get("max_risk", "medium") + if max_risk not in CAP_ANSWER_RISKS: + raise ValueError("[fleet] max_risk must be one of low, medium, high") + for key in ("allow_tools", "deny_tools"): + if key in fleet and not isinstance(fleet[key], list): + raise ValueError("[fleet] %s must be a list" % key) + return {"start": start, "task": task, "budget_usd": budget, "sessions": sessions, "routes": routes, + "auto_answer": bool(fleet.get("auto_answer", False)), + "allow_tools": list(fleet.get("allow_tools") or []), + "deny_tools": list(fleet.get("deny_tools") or []), "max_risk": max_risk} + + +TAI_CAP_MANIFEST = """# tai CAP agent manifest. Emitted by `tai.py --cap-manifest`. +[agent] +name = "tai" +binary = "tai.py" +profiles = ["coding"] + +[probe] +command = ["tai.py", "--version"] +version_pattern = "tai (\\\\d+\\\\.\\\\d+\\\\.\\\\d+)" + +[startup] +command = ["tai.py"] +ready_when = { pattern = "type /help for commands" } +init_timeout_seconds = 60 + +[fast_path] +stream_json = ["tai.py", "--cap-speak"] +grpc = false +acp_stdio = false +a2a_serve_at = false + +[pty] +cols = 200 +rows = 50 +bracketed_paste = false +sigint_cancels_turn = "graceful" +queued_input_supported = false + +[parse] +idle = ["^tai\\\\[.*\\\\]› $"] +error_lines = ["^seal error:", "^sandbox unavailable:"] + +[capabilities] +streaming_output = true +queued_input = false +mid_turn_cancel = "graceful" +multi_session = true +input_modalities = ["text"] +output_modalities = ["text"] + +[capabilities.ask_user] +yes_no = true +options = false +free_text = true + +[capabilities.coding] +revision = "2026-05-18" +fs = { read = true, write = true, list = true, exists = true, scope = "workspace_only" } +terminal = { enabled = true, concurrent_max = 4 } +tool_permission = "interactive" +artifacts = ["diff", "transcript"] + +[cost] +metered = true +currency = "USD" +""" + + def build_parser(): parser = argparse.ArgumentParser(prog="tai", description="single-file autonomous agent, standard library only") parser.add_argument("--profile", default=None) @@ -31961,6 +35222,13 @@ def build_parser(): parser.add_argument("--update-models", action="store_true", help="rediscover the free model lists of every provider, drop models that are gone, and exit") parser.add_argument("--eval-retrieval", action="store_true", help="score vault retrieval (Hit@K, MRR, nDCG, Context Precision) on generated goldens plus implicit pairs, print the report, and exit") parser.add_argument("--version", action="store_true") + parser.add_argument("--cap-manifest", action="store_true", help="print tai's CAP agent manifest (cap-agent.toml) and exit") + parser.add_argument("--cap-speak", action="store_true", help="speak CAP stream-json on stdio as an agent (bulk session config via CAP_SESSION_CONFIG)") + parser.add_argument("--cap-cwd", default=None, metavar="DIR", help="speak session working directory (overrides CAP_SESSION_CONFIG cwd)") + parser.add_argument("--cap-model", default=None, metavar="MODEL", help="speak session model label (overrides CAP_SESSION_CONFIG model)") + parser.add_argument("--cap-permission-mode", default=None, choices=("none", "confirm", "interactive"), help="speak session permission mode (overrides CAP_SESSION_CONFIG permission_mode)") + parser.add_argument("--cap-max-turns", default=None, type=int, metavar="N", help="speak session turn cap (overrides CAP_SESSION_CONFIG max_turns)") + parser.add_argument("--cap", nargs=argparse.REMAINDER, default=None, metavar="COMMAND", help="orchestrator: resolve, chat or run fleet commands (see cap.md)") parser.add_argument("--telegram", action="store_true") parser.add_argument("--scheduler", action="store_true") parser.add_argument("--install", action="store_true") @@ -32088,6 +35356,13 @@ def main(argv=None): if args.version: print("tai %s" % VERSION) return 0 + if args.cap_manifest: + print(TAI_CAP_MANIFEST) + return 0 + if args.cap_speak: + return cap_speak_main(args) + if args.cap is not None: + return cap_cli_main(args.cap) if args.update_models: return update_models_command(args) if args.demo: diff --git a/test_tai.py b/test_tai.py index afce22b..c8d9cb3 100644 --- a/test_tai.py +++ b/test_tai.py @@ -1,4 +1,6 @@ # retoor +import base64 +import collections import contextlib import http.server import io @@ -13109,5 +13111,2432 @@ class GrrSpecTests(unittest.TestCase): self.assertIn("GRR_URL", self.doc) +class CapEventTests(unittest.TestCase): + def test_make_text_chunk(self): + event = tai.CapEvent.make("cap.text_chunk", message_id="m1", text="hi", channel="assistant") + self.assertEqual(event, {"kind": "cap.text_chunk", "message_id": "m1", "text": "hi", "channel": "assistant"}) + + def test_make_with_meta(self): + event = tai.CapEvent.make("cap.plan", entries=[], meta={"cap": {"assigned_to": "cap://a-1"}}) + self.assertEqual(event["_meta"], {"cap": {"assigned_to": "cap://a-1"}}) + + def test_make_rejects_unknown_kind(self): + with self.assertRaises(ValueError): + tai.CapEvent.make("cap.nope", text="x") + + def test_make_rejects_missing_required(self): + with self.assertRaises(ValueError): + tai.CapEvent.make("cap.text_chunk", message_id="m1", text="hi") + + def test_make_rejects_bad_enum(self): + with self.assertRaises(ValueError): + tai.CapEvent.make("cap.text_chunk", message_id="m1", text="hi", channel="shout") + with self.assertRaises(ValueError): + tai.CapEvent.make("cap.cancel", scope="everything", reason="user") + with self.assertRaises(ValueError): + tai.CapEvent.make("cap.permission.request", req_id="p1", tool="Bash", + intent={}, scope="write", risk_level="extreme") + + def test_scopes_differ_per_kind(self): + cancel = tai.CapEvent.make("cap.cancel", scope="current_turn", reason="user") + self.assertEqual(cancel["scope"], "current_turn") + request = tai.CapEvent.make("cap.permission.request", req_id="p1", tool="Bash", + intent={}, scope="write", risk_level="high") + self.assertEqual(request["scope"], "write") + with self.assertRaises(ValueError): + tai.CapEvent.make("cap.cancel", scope="write", reason="user") + + def test_validate_roundtrip(self): + event = tai.CapEvent.make("cap.usage", model_id="m", input_tokens=1, output_tokens=2) + self.assertEqual(tai.CapEvent.validate(event), "cap.usage") + with self.assertRaises(ValueError): + tai.CapEvent.validate({"kind": "cap.text_chunk"}) + with self.assertRaises(ValueError): + tai.CapEvent.validate("not a dict") + + def test_directions_cover_all_kinds(self): + for kind in tai.CapEvent.KINDS: + self.assertIn(kind, tai.CapEvent.DIRECTIONS, kind) + self.assertEqual(tai.CapEvent.direction("cap.text_chunk"), tai.CapEvent.FROM_AGENT) + self.assertEqual(tai.CapEvent.direction("cap.session.config"), tai.CapEvent.TO_AGENT) + self.assertEqual(tai.CapEvent.direction("cap.ask_user.answer"), tai.CapEvent.TO_AGENT) + + +class CapErrorTests(unittest.TestCase): + def test_twins_roundtrip(self): + for numeric, symbol in tai.CAP_ERROR_CODES.items(): + self.assertEqual(tai.CAP_ERROR_SYMBOLS[symbol], numeric, symbol) + self.assertEqual(len(tai.CAP_ERROR_CODES), 15) + + def test_core_and_coding_codes_present(self): + self.assertEqual(tai.CAP_ERROR_CODES[-32001], "cap_manifest_missing") + self.assertEqual(tai.CAP_ERROR_CODES[-32010], "cap_profile_unsupported") + self.assertEqual(tai.CAP_ERROR_CODES[-32020], "cap_scope_violation") + self.assertEqual(tai.CAP_ERROR_CODES[-32024], "cap_terminal_killed") + + def test_string_codes_present(self): + for code in ("rate_limit", "pty_died", "parse_failed", "git_dirty", + "test_failed", "merge_conflict", "language_server_down"): + self.assertIn(code, tai.CAP_ERROR_STRINGS) + + def test_exception_carries_symbol_and_numeric(self): + exc = tai.CapError("cap_budget_exceeded", "over by a lot") + self.assertEqual(exc.symbol, "cap_budget_exceeded") + self.assertEqual(exc.numeric, -32004) + self.assertEqual(str(exc), "over by a lot") + self.assertEqual(tai.CapError("cap_custom").numeric, 0) + + +class CapManifestTests(unittest.TestCase): + MINIMAL = """ +[agent] +name = "mini" +binary = "mini" +profiles = [] + +[startup] +command = ["mini"] +ready_when = { pattern = "ready" } + +[pty] +cols = 80 +rows = 24 + +[capabilities] +streaming_output = true +""" + + def test_minimal_manifest_gets_defaults(self): + manifest = tai.CapManifest.from_toml(self.MINIMAL, source="test") + self.assertEqual(manifest.name, "mini") + self.assertEqual(manifest.binding(), "pty") + self.assertFalse(manifest.get("pty.bracketed_paste")) + self.assertEqual(manifest.get("pty.sigint_cancels_turn"), "hard") + self.assertFalse(manifest.get("capabilities.multi_session")) + self.assertFalse(manifest.get("cost.metered")) + self.assertEqual(manifest.get("parse.idle"), ["^>\\s*$", "^❯\\s*$"]) + self.assertFalse(manifest.ask_user("yes_no")) + + def test_missing_required_raises(self): + with self.assertRaises(ValueError): + tai.CapManifest.from_toml("[agent]\nname = \"x\"\n", source="test") + + def test_bad_toml_raises(self): + with self.assertRaises(ValueError): + tai.CapManifest.from_toml("not [ valid", source="test") + + def test_named_groups_accepted_lookaround_rejected(self): + ok = self.MINIMAL + '\n[parse]\ntool_call_start = "^X (?P\\\\w+)$"\n' + manifest = tai.CapManifest.from_toml(ok, source="test") + self.assertIn("?P", manifest.get("parse.tool_call_start")) + backref = "^(a)" + chr(92) + "1$" + for bad in ("^x(?=y)$", "^x(?!y)$", "^x(?<=y)$", "^x(?" } + +[pty] +cols = 80 +rows = 24 +bracketed_paste = true +sigint_cancels_turn = "graceful" +queued_input_supported = false + +[parse] +idle = ["^> $"] +tool_call_start = "^X (?P\\\\w+)\\\\((?P.*?)\\\\)$" +tool_call_end = "^X (?P\\\\w+) (?Pcompleted|failed)$" +ask_yes_no = "continue\\\\? \\\\(y/n\\\\)$" +error_lines = ["rate_limit_error", "auth_error", "BOOM"] + +[capabilities] +streaming_output = true +queued_input = false +mid_turn_cancel = "graceful" +multi_session = true +""" + + +class CapPtyDriverTests(unittest.TestCase): + def driver(self, **overrides): + manifest = tai.CapManifest.from_toml(CAP_PTY_MANIFEST, source="test") + return tai.CapPtyDriver(manifest, **overrides) + + def test_tool_start_end_pairing(self): + harness = self.driver() + harness.proc = FakePty([b"X shell(ls)\r\n", b"X shell completed\r\n"]) + first = harness.poll(0) + second = harness.poll(0) + starts = [event for event in first if event["kind"] == "cap.tool_call.start"] + ends = [event for event in second if event["kind"] == "cap.tool_call.end"] + self.assertEqual(len(starts), 1) + self.assertEqual(starts[0]["name"], "shell") + self.assertEqual(starts[0]["input"], {"raw": "ls"}) + self.assertEqual(len(ends), 1) + self.assertEqual(ends[0]["call_id"], starts[0]["call_id"]) + self.assertFalse(ends[0]["is_error"]) + chunks = [event for event in first + second if event["kind"] == "cap.text_chunk"] + self.assertTrue(chunks) + + def test_ask_yes_no_answer_roundtrip(self): + harness = self.driver() + harness.proc = FakePty([b"continue? (y/n)\r\n"]) + events = harness.poll(0) + asks = [event for event in events if event["kind"] == "cap.ask_user"] + self.assertEqual(len(asks), 1) + self.assertEqual(harness.state, "input_required") + harness.answer(asks[0]["ask_id"], True) + self.assertEqual(harness.proc.writes[-2:], ["y", "\r"]) + self.assertEqual(harness.state, "working") + with self.assertRaises(tai.CapError) as caught: + harness.answer("ask_999", True) + self.assertEqual(caught.exception.symbol, "cap_invalid_answer") + + def test_error_line_mapping(self): + harness = self.driver() + harness.proc = FakePty([b"rate_limit_error, slow down\r\n", b"BOOM happened\r\n"]) + events = harness.poll(0) + harness.poll(0) + errors = [event for event in events if event["kind"] == "cap.error"] + self.assertEqual(errors[0]["code"], "rate_limit") + self.assertTrue(errors[0]["retryable"]) + self.assertEqual(errors[1]["code"], "error") + + def test_repeated_line_suppressed_until_next_turn(self): + harness = self.driver() + harness.proc = FakePty([b"X shell(ls)\r\n"]) + first = harness.poll(0) + starts = lambda events: [event for event in events if event["kind"] == "cap.tool_call.start"] + self.assertEqual(len(starts(first)), 1) + harness.proc.chunks.append(b"\r\n" * 30) + harness.poll(0) + harness.proc.chunks.append(b"X shell(ls)\r\n") + second = harness.poll(0) + self.assertEqual(len(starts(second)), 0) + harness.state = "ready" + harness.send_text("again") + harness.proc.chunks.append(b"\r\n" * 30) + harness.poll(0) + harness.proc.chunks.append(b"X shell(ls)\r\n") + third = harness.poll(0) + self.assertEqual(len(starts(third)), 1) + + def test_ready_needs_marker_plus_silence(self): + harness = self.driver() + harness.screen.feed(b"booting\r\nREADY>\r\n") + harness.last_bytes_at = time.time() + harness._check_settle(time.time()) + self.assertEqual(harness.state, "starting") + harness.last_bytes_at = time.time() - 1.0 + harness._check_settle(time.time()) + self.assertEqual(harness.state, "ready") + ready = [event for event in harness.drain() if event["kind"] == "cap.session.ready"] + self.assertEqual(len(ready), 1) + self.assertEqual(ready[0]["session_id"], "cap://agent-1") + + def test_done_needs_prompt_gate_and_idle(self): + harness = self.driver() + harness.state = "working" + harness.screen.feed(b"some output\r\n> \r\n") + harness.last_bytes_at = time.time() - 2.0 + harness._check_settle(time.time()) + self.assertEqual(harness.state, "working") + harness.prompt_sent = True + harness._check_settle(time.time()) + self.assertEqual(harness.state, "done") + + def test_resubmit_bounded_then_done(self): + harness = self.driver() + harness.state = "working" + harness.prompt_sent = True + harness.submit_key = "hello" + harness.proc = FakePty() + harness.screen.feed(b"hello\r\n> \r\n") + for _turn in range(tai.CAP_PTY_RESUBMITS_MAX): + harness.last_bytes_at = time.time() - 2.0 + harness._check_settle(time.time()) + self.assertEqual(harness.state, "working") + self.assertEqual(harness.proc.writes, ["\r"] * tai.CAP_PTY_RESUBMITS_MAX) + harness.last_bytes_at = time.time() - 2.0 + harness._check_settle(time.time()) + self.assertEqual(harness.state, "done") + + def test_send_text_writes_and_transitions(self): + harness = self.driver() + harness.proc = FakePty() + harness.state = "ready" + harness.send_text("hi") + self.assertEqual(harness.proc.writes, ["hi", "\r"]) + self.assertEqual(harness.state, "working") + with self.assertRaises(tai.CapError) as caught: + harness.send_text("again") + self.assertEqual(caught.exception.symbol, "cap_queued_input_unsupported") + + def test_send_text_bracketed_wrap(self): + harness = self.driver() + harness.proc = FakePty() + harness.state = "ready" + harness.send_text("one\ntwo") + self.assertEqual(harness.proc.writes[0], "\x1b[200~one\ntwo\x1b[201~") + self.assertEqual(harness.proc.writes[1], "\r") + + def test_done_starts_new_turn_closed_and_failed_reject(self): + harness = self.driver() + harness.proc = FakePty() + harness.state = "done" + harness.send_text("x") + self.assertEqual(harness.state, "working") + for terminal in ("closed", "failed"): + harness.state = terminal + with self.assertRaises(tai.CapError) as caught: + harness.send_text("x") + self.assertEqual(caught.exception.symbol, "cap_binding_unavailable") + + def test_cancel_modes(self): + harness = self.driver() + harness.proc = FakePty() + harness.cancel("current_turn") + self.assertEqual(harness.proc.writes, ["\x03"]) + harness.manifest.data["capabilities"]["mid_turn_cancel"] = "none" + with self.assertRaises(tai.CapError) as caught: + harness.cancel("current_turn") + self.assertEqual(caught.exception.symbol, "cap_cancel_unsupported") + with mock.patch.object(tai.CapPtyDriver, "_terminate") as terminated: + harness.cancel("session") + terminated.assert_called_once() + self.assertEqual(harness.state, "closed") + self.assertIsNone(harness.proc) + + def test_eof_maps_to_pty_died(self): + harness = self.driver() + harness.proc = FakePty([None]) + harness.state = "working" + events = harness.poll(0) + self.assertEqual(harness.state, "failed") + errors = [event for event in events if event["kind"] == "cap.error"] + self.assertEqual(errors[0]["code"], "pty_died") + harness.state = "done" + harness.proc.chunks.append(None) + self.assertEqual(harness.poll(0), []) + self.assertEqual(harness.state, "closed") + + def test_raw_sink_gets_b64(self): + seen = [] + harness = self.driver(raw_sink=seen.append) + harness.proc = FakePty([b"hi\r\n"]) + harness.poll(0) + self.assertEqual(len(seen), 1) + self.assertEqual(seen[0]["kind"], "cap.pty.raw_bytes") + self.assertEqual(base64.b64decode(seen[0]["bytes_b64"]), b"hi\r\n") + + def test_resize_flows_to_proc_and_screen(self): + harness = self.driver() + harness.proc = FakePty() + harness.resize(40, 100) + self.assertEqual(harness.proc.resizes, [(40, 100)]) + self.assertEqual((harness.screen.rows, harness.screen.cols), (40, 100)) + + def test_start_timeout_raises_and_cleans_up(self): + if not tai.PtyProc.available(): + self.skipTest("no pty on this platform") + text = CAP_PTY_MANIFEST.replace('command = ["fake"]', 'command = ["sleep", "30"]') + text = text.replace('ready_when = { pattern = "READY>" }', 'ready_when = { pattern = "NEVER" }') + manifest = tai.CapManifest.from_toml(text, source="test") + harness = tai.CapPtyDriver(manifest) + with self.assertRaises(tai.CapError) as caught: + harness.start(timeout=1) + self.assertEqual(caught.exception.symbol, "cap_binding_unavailable") + self.assertIsNone(harness.proc) + self.assertEqual(harness.state, "failed") + + def test_live_echo_agent_roundtrip(self): + if not tai.PtyProc.available(): + self.skipTest("no pty on this platform") + with tempfile.TemporaryDirectory() as tmp: + script = os.path.join(tmp, "fake_agent.py") + with open(script, "w", encoding="utf-8") as handle: + handle.write("import sys\r\n" + "print('READY>', flush=True)\r\n" + "print('> ', end='', flush=True)\r\n" + "for line in sys.stdin:\r\n" + " line = line.strip()\r\n" + " if line:\r\n" + " print('echo:%s' % line, flush=True)\r\n" + " print('> ', end='', flush=True)\r\n") + text = CAP_PTY_MANIFEST.replace('command = ["fake"]', 'command = ["%s", "%s"]' % (sys.executable, script)) + manifest = tai.CapManifest.from_toml(text, source="test") + harness = tai.CapPtyDriver(manifest) + try: + boot = harness.start(timeout=15) + self.assertEqual(harness.state, "ready") + self.assertTrue(any(event["kind"] == "cap.session.ready" for event in boot)) + harness.send_text("hello") + deadline = time.time() + 15 + seen = [] + while time.time() < deadline and harness.state != "done": + seen += harness.poll(0.2) + self.assertEqual(harness.state, "done") + chunks = [event for event in seen if event["kind"] == "cap.text_chunk"] + self.assertTrue(any("echo:hello" in event["text"] for event in chunks)) + self.assertFalse(any(event["text"].strip() == ">" for event in chunks)) + finally: + harness.close() + + +CAP_STREAM_MANIFEST = """ +[agent] +name = "echo" +binary = "echo" +profiles = [] + +[startup] +command = ["echo"] +ready_when = { pattern = "init" } + +[fast_path] +stream_json = ["echo", "-n"] + +[pty] +cols = 80 +rows = 24 + +[capabilities] +streaming_output = true +""" + + +class FakeStreamProc: + def __init__(self, stdout_chunks=(), stderr_text=b""): + self.stdin_r, stdin_w = os.pipe() + self.stdin = os.fdopen(stdin_w, "wb") + stdout_r, self.stdout_w = os.pipe() + self.stdout = os.fdopen(stdout_r, "rb") + os.set_blocking(stdout_r, False) + for chunk in stdout_chunks: + os.write(self.stdout_w, chunk) + stderr_r, stderr_w = os.pipe() + self.stderr = os.fdopen(stderr_r, "rb") + if stderr_text: + os.write(stderr_w, stderr_text) + os.close(stderr_w) + self.returncode = None + self.killed = [] + self._fds = [self.stdin_r, stdout_r, stderr_r] + + def poll(self): + return self.returncode + + def terminate(self): + self.killed.append("term") + + def kill(self): + self.killed.append("kill") + + def wait(self, timeout=None): + return self.returncode + + def close_stdout(self): + try: + os.close(self.stdout_w) + except OSError: + pass + + def read_stdin(self): + os.set_blocking(self.stdin_r, False) + try: + chunks = [] + while True: + try: + piece = os.read(self.stdin_r, 65536) + except BlockingIOError: + break + if not piece: + break + chunks.append(piece) + return b"".join(chunks) + finally: + os.set_blocking(self.stdin_r, True) + + def cleanup(self): + for pipe in (self.stdin, self.stdout, self.stderr): + try: + pipe.close() + except OSError: + pass + for fd in self._fds: + try: + os.close(fd) + except OSError: + pass + self.close_stdout() + + +def stream_frame(**fields): + return (json.dumps(fields) + "\n").encode("utf-8") + + +class CapStreamDriverTests(unittest.TestCase): + def driver(self, **overrides): + manifest = tai.CapManifest.from_toml(CAP_STREAM_MANIFEST, source="test") + return tai.CapStreamDriver(manifest, **overrides) + + def test_frame_mapping(self): + chunks = [stream_frame(type="system", subtype="init", session_id="s9"), + stream_frame(type="assistant", message={"id": "m1", "content": [ + {"type": "text", "text": "hello"}, + {"type": "thinking", "text": "hmm"}, + {"type": "tool_use", "id": "t1", "name": "shell", "input": {"c": "ls"}}]}), + stream_frame(type="user", message={"content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "ok", "is_error": False}]}), + stream_frame(type="result", subtype="success", duration_ms=12, + usage={"input_tokens": 3, "output_tokens": 5}, total_cost_usd=0.01)] + harness = self.driver() + proc = FakeStreamProc(chunks) + harness.proc = proc + harness.reader = tai.CapPipeReader(proc.stdout.fileno()) + try: + events = harness.poll(0) + kinds = [event["kind"] for event in events] + self.assertEqual(kinds, ["cap.session.ready", "cap.text_chunk", "cap.thought", + "cap.tool_call.start", "cap.tool_call.end", "cap.usage"]) + self.assertEqual(events[0]["session_id"], "s9") + self.assertEqual(events[1]["text"], "hello") + self.assertEqual(events[3]["name"], "shell") + self.assertEqual(events[4]["call_id"], "t1") + self.assertEqual(events[5]["output_tokens"], 5) + self.assertAlmostEqual(events[5]["cost_usd_estimate"], 0.01) + self.assertEqual(harness.state, "done") + finally: + proc.cleanup() + + def test_result_error_artifact_and_ignored(self): + chunks = [b"not json\n", + stream_frame(type="mystery", x=1), + stream_frame(type="artifact", artifact={"artifact_id": "a1", + "mime_type": "application/vnd.cap.coding.diff.v1+text", "name": "d", "size": 3, + "content": "x"}), + stream_frame(type="artifact", artifact={"bogus": True}), + stream_frame(type="result", subtype="error", error="kaput")] + harness = self.driver() + proc = FakeStreamProc(chunks) + harness.proc = proc + harness.reader = tai.CapPipeReader(proc.stdout.fileno()) + try: + events = harness.poll(0) + kinds = [event["kind"] for event in events] + self.assertEqual(kinds, ["cap.artifact", "cap.error"]) + self.assertEqual(events[1]["message"], "kaput") + self.assertEqual(harness.state, "done") + self.assertEqual(harness.ignored_frames, 3) + finally: + proc.cleanup() + + def test_send_text_writes_user_frame(self): + harness = self.driver() + proc = FakeStreamProc() + harness.proc = proc + harness.reader = tai.CapPipeReader(proc.stdout.fileno()) + harness.state = "ready" + try: + harness.send_text("hi there") + frame = json.loads(proc.read_stdin().decode("utf-8")) + self.assertEqual(frame, {"type": "user", "message": "hi there"}) + self.assertEqual(harness.state, "working") + finally: + proc.cleanup() + + def test_eof_clean_and_dirty(self): + harness = self.driver() + proc = FakeStreamProc() + proc.close_stdout() + proc.returncode = 0 + harness.proc = proc + harness.reader = tai.CapPipeReader(proc.stdout.fileno()) + try: + harness.state = "done" + self.assertEqual(harness.poll(0), []) + self.assertEqual(harness.state, "closed") + finally: + proc.cleanup() + harness = self.driver() + proc = FakeStreamProc(stderr_text=b"traceback here\n") + proc.close_stdout() + proc.returncode = 1 + harness.proc = proc + harness.reader = tai.CapPipeReader(proc.stdout.fileno()) + harness.state = "working" + try: + harness.stderr_lines.append("traceback here") + events = harness.poll(0) + self.assertEqual(harness.state, "failed") + self.assertEqual(events[0]["code"], "error") + self.assertIn("traceback here", events[0]["message"]) + finally: + proc.cleanup() + + def test_cancel_kills_and_later_send_respawns(self): + harness = self.driver() + proc = FakeStreamProc() + harness.proc = proc + harness.reader = tai.CapPipeReader(proc.stdout.fileno()) + harness.state = "working" + try: + harness.cancel("current_turn") + self.assertIsNone(harness.proc) + self.assertEqual(harness.state, "starting") + self.assertEqual(proc.killed, ["term"]) + finally: + proc.cleanup() + replacement = FakeStreamProc([stream_frame(type="system", subtype="init")]) + + def fake_spawn(self): + self.proc = replacement + self.reader = tai.CapPipeReader(replacement.stdout.fileno()) + + with mock.patch.object(tai.CapStreamDriver, "_spawn", fake_spawn): + harness.send_text("again") + self.assertEqual(harness.state, "working") + frame = json.loads(replacement.read_stdin().decode("utf-8")) + self.assertEqual(frame["message"], "again") + replacement.cleanup() + + def test_overlong_line_dropped_loud(self): + harness = self.driver() + proc = FakeStreamProc([b"x" * 100]) + harness.proc = proc + harness.reader = tai.CapPipeReader(proc.stdout.fileno()) + try: + with mock.patch.object(tai, "CAP_STREAM_LINE_MAX", 64): + events = harness.poll(0) + codes = [event["code"] for event in events if event["kind"] == "cap.error"] + self.assertEqual(codes, ["parse_failed"]) + finally: + proc.cleanup() + + def test_missing_stream_json_raises(self): + manifest = tai.CapManifest.from_toml(CapManifestTests.MINIMAL, source="test") + harness = tai.CapStreamDriver(manifest) + with self.assertRaises(tai.CapError) as caught: + harness.start(timeout=1) + self.assertEqual(caught.exception.symbol, "cap_binding_unavailable") + + def test_live_stream_roundtrip(self): + with tempfile.TemporaryDirectory() as tmp: + script = os.path.join(tmp, "echo_agent.py") + with open(script, "w", encoding="utf-8") as handle: + handle.write("import sys, json\n" + "print(json.dumps({'type': 'system', 'subtype': 'init', 'session_id': 'live'}), flush=True)\n" + "for line in sys.stdin:\n" + " line = line.strip()\n" + " if not line:\n" + " continue\n" + " msg = json.loads(line).get('message', '')\n" + " print(json.dumps({'type': 'assistant', 'message': {'id': 'm1', 'content': [{'type': 'text', 'text': 'got:' + str(msg)}]}}), flush=True)\n" + " print(json.dumps({'type': 'result', 'subtype': 'success', 'duration_ms': 1, 'usage': {'input_tokens': 1, 'output_tokens': 2}}), flush=True)\n") + text = CAP_STREAM_MANIFEST.replace('stream_json = ["echo", "-n"]', + 'stream_json = ["%s", "%s"]' % (sys.executable, script)) + manifest = tai.CapManifest.from_toml(text, source="test") + harness = tai.CapStreamDriver(manifest) + try: + boot = harness.start(timeout=15) + self.assertEqual(harness.state, "ready") + self.assertTrue(any(event["kind"] == "cap.session.ready" for event in boot)) + for turn in ("one", "two"): + harness.send_text(turn) + deadline = time.time() + 15 + seen = [] + while time.time() < deadline and harness.state != "done": + seen += harness.poll(0.2) + self.assertEqual(harness.state, "done") + chunks = [event for event in seen if event["kind"] == "cap.text_chunk"] + self.assertTrue(any("got:%s" % turn in event["text"] for event in chunks)) + usages = [event for event in seen if event["kind"] == "cap.usage"] + self.assertEqual(usages[0]["output_tokens"], 2) + finally: + harness.close() + self.assertEqual(harness.state, "closed") + + +CAP_ACP_MANIFEST = """ +[agent] +name = "acp-agent" +binary = "acp-agent" +profiles = [] + +[startup] +command = ["acp-agent"] +ready_when = { pattern = "ready" } + +[fast_path] +acp_stdio = true + +[pty] +cols = 80 +rows = 24 + +[capabilities] +streaming_output = true +""" + + +def acp_line(**fields): + return (json.dumps(dict(fields, jsonrpc="2.0")) + "\n").encode("utf-8") + + +class CapAcpDriverTests(unittest.TestCase): + def driver(self, tmp=None, **overrides): + manifest = tai.CapManifest.from_toml(CAP_ACP_MANIFEST, source="test") + params = {"cwd": tmp or tempfile.gettempdir()} + params.update(overrides) + return tai.CapAcpDriver(manifest, **params) + + def attach(self, harness, proc): + harness.proc = proc + harness.reader = tai.CapPipeReader(proc.stdout.fileno()) + return proc + + def update(self, session_update, **extra): + params = {"sessionId": "s1", "update": dict(extra, sessionUpdate=session_update)} + return acp_line(method="session/update", params=params) + + def test_update_mapping(self): + harness = self.driver() + harness.acp_session = "s1" + harness.state = "working" + chunks = [self.update("agent_message_chunk", content={"type": "text", "text": "hello"}), + self.update("agent_thought_chunk", content={"type": "text", "text": "hmm"}), + self.update("tool_call", toolCallId="c1", title="Shell", kind="execute", status="in_progress"), + self.update("tool_call_update", toolCallId="c1", status="in_progress", + content=[{"type": "text", "text": "part"}]), + self.update("tool_call_update", toolCallId="c1", status="completed", + content=[{"type": "text", "text": "done"}]), + self.update("plan", entries=[{"id": "p1", "content": "x", "status": "in_progress"}, + {"id": "p2", "content": "y", "status": "bogus"}]), + self.update("usage_update", usage={"inputTokens": 10, "outputTokens": 20}), + self.update("usage_update", usage={"inputTokens": 15, "outputTokens": 26}), + self.update("notice", title="sup")] + proc = self.attach(harness, FakeStreamProc(chunks)) + try: + events = harness.poll(0) + finally: + proc.cleanup() + kinds = [event["kind"] for event in events] + self.assertEqual(kinds, ["cap.text_chunk", "cap.thought", "cap.tool_call.start", + "cap.tool_call.delta", "cap.tool_call.end", "cap.plan", + "cap.usage", "cap.usage"]) + self.assertEqual(events[2]["name"], "Shell") + self.assertEqual(events[4]["call_id"], "c1") + self.assertFalse(events[4]["is_error"]) + self.assertEqual(events[5]["entries"][1]["status"], "pending") + self.assertEqual((events[6]["input_tokens"], events[6]["output_tokens"]), (10, 20)) + self.assertEqual((events[7]["input_tokens"], events[7]["output_tokens"]), (5, 6)) + self.assertEqual(harness.ignored_frames, 1) + + def test_prompt_response_ends_turn(self): + harness = self.driver() + harness.acp_session = "s1" + harness.state = "ready" + proc = self.attach(harness, FakeStreamProc()) + try: + harness.send_text("go") + self.assertEqual(harness.state, "working") + os.write(proc.stdout_w, acp_line(id=1, result={"stopReason": "end_turn"})) + events = harness.poll(0) + self.assertEqual(harness.state, "done") + self.assertEqual(events, []) + harness.send_text("again") + os.write(proc.stdout_w, acp_line(id=2, error={"code": -32603, "message": "bad"})) + events = harness.poll(0) + self.assertEqual(harness.state, "done") + self.assertEqual(events[0]["kind"], "cap.error") + finally: + proc.cleanup() + + def test_permission_roundtrip(self): + harness = self.driver() + harness.acp_session = "s1" + harness.state = "working" + request = acp_line(id=7, method="session/request_permission", + params={"sessionId": "s1", + "toolCall": {"toolCallId": "c1", "title": "Shell", "kind": "execute"}, + "options": [{"optionId": "a1", "kind": "allow_once"}, + {"optionId": "r1", "kind": "reject_once"}]}) + proc = self.attach(harness, FakeStreamProc([request])) + try: + events = harness.poll(0) + self.assertEqual(events[0]["kind"], "cap.permission.request") + self.assertEqual(events[0]["risk_level"], "high") + self.assertEqual(events[0]["scope"], "execute") + self.assertEqual(harness.state, "input_required") + req_id = events[0]["req_id"] + harness.answer(req_id, "allow_once") + replies = proc.read_stdin().decode("utf-8").splitlines() + self.assertEqual(json.loads(replies[0]), {"jsonrpc": "2.0", "id": 7, + "result": {"outcome": {"outcome": "selected", "optionId": "a1"}}}) + self.assertEqual(harness.state, "working") + finally: + proc.cleanup() + + def test_permission_deny_and_unknown_value(self): + harness = self.driver() + harness.acp_session = "s1" + harness.state = "working" + request = acp_line(id=8, method="session/request_permission", + params={"sessionId": "s1", "toolCall": {"kind": "read"}, + "options": [{"optionId": "r1", "kind": "reject_once"}]}) + proc = self.attach(harness, FakeStreamProc([request])) + try: + events = harness.poll(0) + self.assertEqual(events[0]["risk_level"], "low") + harness.answer(events[0]["req_id"], "deny") + replies = proc.read_stdin().decode("utf-8").splitlines() + self.assertEqual(json.loads(replies[0])["result"]["outcome"]["optionId"], "r1") + finally: + proc.cleanup() + harness = self.driver() + harness.acp_session = "s1" + harness.state = "working" + request = acp_line(id=9, method="session/request_permission", + params={"sessionId": "s1", "toolCall": {}, "options": []}) + proc = self.attach(harness, FakeStreamProc([request])) + try: + events = harness.poll(0) + harness.answer(events[0]["req_id"], "whatever") + replies = proc.read_stdin().decode("utf-8").splitlines() + self.assertEqual(json.loads(replies[0])["result"], {"outcome": {"outcome": "cancelled"}}) + finally: + proc.cleanup() + + def test_elicit_form_stashes_url_declines(self): + harness = self.driver() + harness.acp_session = "s1" + harness.state = "working" + form = acp_line(id=11, method="elicitation/create", + params={"sessionId": "s1", "mode": "form", "message": "pick one", + "requestedSchema": {"type": "object"}}) + proc = self.attach(harness, FakeStreamProc([form])) + try: + events = harness.poll(0) + self.assertEqual(events[0]["kind"], "cap.ask_user") + self.assertEqual(events[0]["prompt"], "pick one") + self.assertEqual(harness.state, "input_required") + harness.answer(events[0]["ask_id"], {"strategy": "balanced"}) + replies = proc.read_stdin().decode("utf-8").splitlines() + self.assertEqual(json.loads(replies[0])["result"], + {"action": "accept", "content": {"strategy": "balanced"}}) + finally: + proc.cleanup() + harness = self.driver() + harness.acp_session = "s1" + harness.state = "working" + url = acp_line(id=12, method="elicitation/create", + params={"sessionId": "s1", "mode": "url", "url": "https://x"}) + proc = self.attach(harness, FakeStreamProc([url])) + try: + events = harness.poll(0) + self.assertEqual(events, []) + self.assertEqual(harness.open_asks, {}) + replies = proc.read_stdin().decode("utf-8").splitlines() + self.assertEqual(json.loads(replies[0])["result"], {"action": "decline"}) + finally: + proc.cleanup() + + def test_fs_serve_and_scope(self): + with tempfile.TemporaryDirectory() as tmp: + with open(os.path.join(tmp, "f.txt"), "w", encoding="utf-8") as handle: + handle.write("l1\nl2\nl3\n") + harness = self.driver(tmp=tmp) + harness.acp_session = "s1" + harness.state = "working" + path = os.path.join(tmp, "f.txt") + chunks = [acp_line(id=21, method="fs/read_text_file", + params={"sessionId": "s1", "path": path, "line": 2, "limit": 1}), + acp_line(id=22, method="fs/write_text_file", + params={"sessionId": "s1", "path": os.path.join(tmp, "new.txt"), "content": "hi"}), + acp_line(id=23, method="fs/read_text_file", + params={"sessionId": "s1", "path": os.path.join(tmp, "..", "escape.txt")}), + acp_line(id=24, method="fs/read_text_file", + params={"sessionId": "s1", "path": os.path.join(tmp, "missing.txt")}), + acp_line(id=25, method="_cap/fs.exists", + params={"sessionId": "s1", "path": os.path.join(tmp, "..", "escape.txt")}), + acp_line(id=26, method="_cap/fs.exists", + params={"sessionId": "s1", "path": path}), + acp_line(id=27, method="_cap/fs.list", + params={"sessionId": "s1", "path": tmp})] + proc = self.attach(harness, FakeStreamProc(chunks)) + try: + self.assertEqual(harness.poll(0), []) + replies = [json.loads(line) for line in proc.read_stdin().decode("utf-8").splitlines()] + finally: + proc.cleanup() + by_id = {reply["id"]: reply for reply in replies} + self.assertEqual(by_id[21]["result"], {"content": "l2\n"}) + self.assertEqual(by_id[22]["result"], {}) + with open(os.path.join(tmp, "new.txt"), encoding="utf-8") as handle: + self.assertEqual(handle.read(), "hi") + self.assertEqual(by_id[23]["error"]["code"], -32020) + self.assertEqual(by_id[24]["error"]["code"], -32021) + self.assertEqual(by_id[25]["result"], {"exists": False}) + self.assertEqual(by_id[26]["result"], {"exists": True}) + names = sorted(entry["name"] for entry in by_id[27]["result"]["entries"]) + self.assertIn("f.txt", names) + + def test_terminal_lifecycle(self): + harness = self.driver() + harness.acp_session = "s1" + harness.state = "working" + proc = self.attach(harness, FakeStreamProc()) + try: + harness._map_message(json.dumps({"jsonrpc": "2.0", "id": 31, "method": "terminal/create", + "params": {"sessionId": "s1", "command": "echo", + "args": ["hi"], "cwd": tempfile.gettempdir()}})) + replies = [json.loads(line) for line in proc.read_stdin().decode("utf-8").splitlines()] + tid = replies[0]["result"]["terminalId"] + deadline = time.time() + 10 + status = None + while time.time() < deadline: + harness._map_message(json.dumps({"jsonrpc": "2.0", "id": 32, "method": "terminal/wait_for_exit", + "params": {"sessionId": "s1", "terminalId": tid}})) + replies = [json.loads(line) for line in proc.read_stdin().decode("utf-8").splitlines()] + status = replies[-1]["result"] + if status["exitCode"] is not None: + break + self.assertEqual(status["exitCode"], 0) + harness._map_message(json.dumps({"jsonrpc": "2.0", "id": 33, "method": "terminal/output", + "params": {"sessionId": "s1", "terminalId": tid}})) + replies = [json.loads(line) for line in proc.read_stdin().decode("utf-8").splitlines()] + self.assertIn("hi", replies[-1]["result"]["output"]) + harness._map_message(json.dumps({"jsonrpc": "2.0", "id": 34, "method": "terminal/release", + "params": {"sessionId": "s1", "terminalId": tid}})) + harness._map_message(json.dumps({"jsonrpc": "2.0", "id": 35, "method": "terminal/output", + "params": {"sessionId": "s1", "terminalId": tid}})) + replies = [json.loads(line) for line in proc.read_stdin().decode("utf-8").splitlines()] + self.assertEqual(replies[-1]["error"]["code"], -32023) + finally: + proc.cleanup() + + def test_terminal_grants(self): + harness = self.driver(terminal=False) + with self.assertRaises(tai.CapError) as caught: + harness._serve_term_create({"command": "echo"}, 1) + self.assertEqual(caught.exception.numeric, -32023) + harness = self.driver() + for _turn in range(tai.CAP_ACP_TERMINALS_MAX): + harness.terminals["x%d" % _turn] = {} + with self.assertRaises(tai.CapError) as caught: + harness._serve_term_create({"command": "echo"}, 1) + self.assertEqual(caught.exception.numeric, -32023) + + def test_io_show_notify_input(self): + seen = [] + harness = self.driver(io_sink=seen.append) + harness.acp_session = "s1" + harness.state = "working" + chunks = [acp_line(id=41, method="_cap/user_io.show", params={"message": "hi"}), + acp_line(id=42, method="_cap/notify", params={"message": "yo"}), + acp_line(id=43, method="_cap/user_io.input", params={"prompt": "name?"}), + acp_line(id=44, method="bogus/method", params={})] + proc = self.attach(harness, FakeStreamProc(chunks)) + try: + events = harness.poll(0) + self.assertEqual(len(seen), 2) + self.assertEqual(len(harness.io_log), 2) + self.assertEqual(events[0]["kind"], "cap.ask_user") + harness.answer(events[0]["ask_id"], "retoor") + replies = [json.loads(line) for line in proc.read_stdin().decode("utf-8").splitlines()] + by_id = {reply["id"]: reply for reply in replies} + self.assertEqual(by_id[43]["result"], {"value": "retoor"}) + self.assertEqual(by_id[44]["error"]["code"], -32601) + finally: + proc.cleanup() + + def test_cancel_answers_permissions_cancelled(self): + harness = self.driver() + harness.acp_session = "s1" + harness.state = "working" + request = acp_line(id=51, method="session/request_permission", + params={"sessionId": "s1", "toolCall": {}, "options": []}) + proc = self.attach(harness, FakeStreamProc([request])) + try: + harness.poll(0) + harness.cancel("current_turn") + replies = [json.loads(line) for line in proc.read_stdin().decode("utf-8").splitlines()] + outcomes = [reply["result"] for reply in replies if "result" in reply] + self.assertIn({"outcome": {"outcome": "cancelled"}}, outcomes) + self.assertEqual(harness.open_asks, {}) + finally: + proc.cleanup() + + def test_handshake_version_mismatch(self): + harness = self.driver() + proc = FakeStreamProc([acp_line(id=1, result={"protocolVersion": 99})]) + harness.proc = proc + harness.reader = tai.CapPipeReader(proc.stdout.fileno()) + try: + with self.assertRaises(tai.CapError) as caught: + harness._handshake() + self.assertIn("v99", str(caught.exception)) + finally: + proc.cleanup() + + def test_live_acp_roundtrip(self): + with tempfile.TemporaryDirectory() as tmp: + script = os.path.join(tmp, "acp_agent.py") + with open(script, "w", encoding="utf-8") as handle: + handle.write("import sys, json\n" + "def send(obj):\n" + " print(json.dumps(obj), flush=True)\n" + "for line in sys.stdin:\n" + " line = line.strip()\n" + " if not line:\n" + " continue\n" + " frame = json.loads(line)\n" + " method, rpc_id = frame.get('method'), frame.get('id')\n" + " if method == 'initialize':\n" + " send({'jsonrpc': '2.0', 'id': rpc_id, 'result': {'protocolVersion': 1, 'agentCapabilities': {}}})\n" + " elif method == 'session/new':\n" + " send({'jsonrpc': '2.0', 'id': rpc_id, 'result': {'sessionId': 's1'}})\n" + " elif method == 'session/prompt':\n" + " send({'jsonrpc': '2.0', 'method': 'session/update', 'params': {'sessionId': 's1', 'update': {'sessionUpdate': 'agent_message_chunk', 'content': {'type': 'text', 'text': 'live-hi'}}}})\n" + " send({'jsonrpc': '2.0', 'id': rpc_id, 'result': {'stopReason': 'end_turn'}})\n" + " elif rpc_id is not None:\n" + " send({'jsonrpc': '2.0', 'id': rpc_id, 'result': {}})\n") + manifest = tai.CapManifest.from_toml(CAP_ACP_MANIFEST, source="test") + harness = tai.CapAcpDriver(manifest, cwd=tmp, acp_argv=[sys.executable, script]) + try: + boot = harness.start(timeout=15) + self.assertEqual(harness.state, "ready") + self.assertTrue(any(event["kind"] == "cap.session.ready" for event in boot)) + self.assertEqual(harness.acp_session, "s1") + harness.send_text("go") + deadline = time.time() + 15 + seen = [] + while time.time() < deadline and harness.state != "done": + seen += harness.poll(0.2) + self.assertEqual(harness.state, "done") + chunks = [event for event in seen if event["kind"] == "cap.text_chunk"] + self.assertTrue(any("live-hi" in event["text"] for event in chunks)) + finally: + harness.close() + self.assertEqual(harness.state, "closed") + + +class FakeA2aServer: + def __init__(self, card=None, stream_payloads=(), blocking=None, fail_paths=()): + self.card = {"name": "fake", "extensions": [{"uri": "cap-protocol/v1"}]} if card is None else card + self.stream_payloads = list(stream_payloads) + self.blocking = blocking + self.fail_paths = set(fail_paths) + self.calls = [] + self.server = None + self.thread = None + + def start(self): + outer = self + + class Handler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + pass + + def reply(self, status, data, kind="application/json"): + self.send_response(status) + self.send_header("Content-Type", kind) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def body(self): + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"" + try: + return json.loads(raw.decode("utf-8")) if raw else None + except ValueError: + return raw + + def do_GET(self): + path = self.path.split("?")[0] + outer.calls.append(("GET", path, None)) + if path in outer.fail_paths: + return self.reply(404, b"{}", "application/json") + if path == "/.well-known/agent-card.json": + return self.reply(200, json.dumps(outer.card).encode("utf-8")) + return self.reply(404, b"{}") + + def do_POST(self): + path = self.path.split("?")[0] + payload = self.body() + outer.calls.append(("POST", path, payload)) + if path in outer.fail_paths: + return self.reply(404, b"{}") + if path in ("/message/send", "/message:stream"): + self.close_connection = True + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + try: + for item in outer.stream_payloads: + if isinstance(item, bytes): + self.wfile.write(item) + else: + self.wfile.write(("data: %s\n\n" % json.dumps(item)).encode("utf-8")) + self.wfile.flush() + except OSError: + pass + return + if path == "/message:send": + return self.reply(200, json.dumps(outer.blocking or {}).encode("utf-8")) + if path == "/tasks/cancel" or path.endswith(":cancel"): + return self.reply(200, b"{}") + return self.reply(404, b"{}") + + class Server(socketserver.ThreadingMixIn, http.server.HTTPServer): + daemon_threads = True + + self.server = Server(("127.0.0.1", 0), Handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + return "http://127.0.0.1:%d" % self.server.server_address[1] + + def stop(self): + if self.server is not None: + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + self.server = None + + +class CapA2aDriverTests(unittest.TestCase): + def driver(self, endpoint): + text = CapManifestTests.MINIMAL + '\n[fast_path]\na2a_serve_at = "%s"\n' % endpoint + manifest = tai.CapManifest.from_toml(text, source="test") + return tai.CapA2aDriver(manifest) + + def run_turn(self, harness, text="hi", timeout=15): + harness.send_text(text) + deadline = time.time() + timeout + seen = [] + while time.time() < deadline and harness.state not in ("done", "failed", "input_required"): + seen += harness.poll(0.2) + seen += harness.poll(0) + return seen + + def test_card_refused_without_extension(self): + server = FakeA2aServer(card={"name": "plain", "extensions": []}) + endpoint = server.start() + try: + harness = self.driver(endpoint) + with self.assertRaises(tai.CapError) as caught: + harness.start(timeout=10) + self.assertEqual(caught.exception.symbol, "cap_binding_unavailable") + finally: + server.stop() + + def test_draft_sse_mapping(self): + payloads = [{"kind": "cap.text_chunk", "message_id": "m1", "text": "raw", "channel": "assistant"}, + {"task": {"id": "t1", "contextId": "c1", + "status": {"state": "working", "message": {"messageId": "m2", "parts": [ + {"kind": "text", "text": "via-task"}]}}}}, + {"message": {"messageId": "m3", "parts": [ + {"kind": "data", "data": {"model_id": "m", "input_tokens": 3, "output_tokens": 4}, + "_meta": {"cap": {"kind": "cap.usage"}}}]}}, + {"task": {"id": "t1", "status": {"state": "completed"}}}] + server = FakeA2aServer(stream_payloads=payloads) + endpoint = server.start() + try: + harness = self.driver(endpoint) + try: + boot = harness.start(timeout=10) + self.assertEqual(harness.state, "ready") + self.assertTrue(any(event["kind"] == "cap.session.ready" for event in boot)) + seen = self.run_turn(harness) + self.assertEqual(harness.state, "done") + self.assertEqual(harness.flavor, "draft") + kinds = [event["kind"] for event in seen] + self.assertEqual(kinds, ["cap.text_chunk", "cap.text_chunk", "cap.usage"]) + self.assertEqual(seen[0]["text"], "raw") + self.assertEqual(seen[1]["text"], "via-task") + self.assertEqual(seen[1]["message_id"], "m2") + self.assertEqual(seen[2]["output_tokens"], 4) + self.assertEqual(harness._context_id, "c1") + posts = [call for call in server.calls if call[0] == "POST"] + self.assertTrue(posts[0][1].endswith("/message/send")) + finally: + harness.close() + finally: + server.stop() + + def test_input_required_and_answer(self): + payloads = [{"task": {"id": "t1", "status": {"state": "input-required", + "message": {"parts": [{"kind": "text", "text": "go?"}]}}}}] + server = FakeA2aServer(stream_payloads=payloads) + endpoint = server.start() + try: + harness = self.driver(endpoint) + try: + harness.start(timeout=10) + seen = self.run_turn(harness) + self.assertEqual(harness.state, "input_required") + asks = [event for event in seen if event["kind"] == "cap.ask_user"] + self.assertEqual(asks[0]["prompt"], "go?") + harness.answer(asks[0]["ask_id"], "yes") + posts = [call for call in server.calls if call[0] == "POST"] + self.assertEqual(len(posts), 2) + answer_part = posts[1][2]["message"]["parts"][0] + self.assertEqual(answer_part["_meta"]["cap"]["kind"], "cap.ask_user.answer") + self.assertEqual(answer_part["data"]["value"], "yes") + self.assertEqual(harness.state, "working") + finally: + harness.close() + finally: + server.stop() + + def test_task_failed(self): + payloads = [{"task": {"id": "t1", "status": {"state": "failed", "message": "blew up"}}}] + server = FakeA2aServer(stream_payloads=payloads) + endpoint = server.start() + try: + harness = self.driver(endpoint) + try: + harness.start(timeout=10) + seen = self.run_turn(harness) + self.assertEqual(harness.state, "done") + errors = [event for event in seen if event["kind"] == "cap.error"] + self.assertEqual(errors[0]["message"], "blew up") + self.assertEqual(errors[0]["details"], {"task_state": "failed"}) + finally: + harness.close() + finally: + server.stop() + + def test_empty_stream_fails(self): + server = FakeA2aServer(stream_payloads=[]) + endpoint = server.start() + try: + harness = self.driver(endpoint) + try: + harness.start(timeout=10) + seen = self.run_turn(harness) + self.assertEqual(harness.state, "failed") + self.assertEqual(seen[0]["kind"], "cap.error") + finally: + harness.close() + finally: + server.stop() + + def test_non_json_sse_ignored(self): + server = FakeA2aServer(stream_payloads=[b"data: nope{\n\n"]) + endpoint = server.start() + try: + harness = self.driver(endpoint) + try: + harness.start(timeout=10) + self.run_turn(harness) + self.assertEqual(harness.ignored_frames, 1) + finally: + harness.close() + finally: + server.stop() + + def test_current_stream_fallback(self): + payloads = [{"kind": "cap.text_chunk", "message_id": "m1", "text": "new", + "channel": "assistant"}] + server = FakeA2aServer(stream_payloads=payloads, fail_paths={"/message/send"}) + endpoint = server.start() + try: + harness = self.driver(endpoint) + try: + harness.start(timeout=10) + seen = self.run_turn(harness) + self.assertEqual(harness.flavor, "stream") + self.assertTrue(any(event.get("text") == "new" for event in seen)) + posts = [call[1] for call in server.calls if call[0] == "POST"] + self.assertIn("/message:stream", posts) + finally: + harness.close() + finally: + server.stop() + + def test_current_blocking_fallback(self): + task = {"task": {"id": "t9", "status": {"state": "completed", + "message": {"parts": [{"kind": "text", "text": "blocked"}]}}}} + server = FakeA2aServer(blocking=task, fail_paths={"/message/send", "/message:stream"}) + endpoint = server.start() + try: + harness = self.driver(endpoint) + try: + harness.start(timeout=10) + seen = self.run_turn(harness) + self.assertEqual(harness.flavor, "send") + self.assertEqual(harness.state, "done") + chunks = [event for event in seen if event["kind"] == "cap.text_chunk"] + self.assertEqual(chunks[0]["text"], "blocked") + self.assertEqual(harness._task_id, "t9") + finally: + harness.close() + finally: + server.stop() + + def test_plain_artifact(self): + payloads = [{"artifact": {"artifactId": "ar1", "name": "out.txt", "parts": [ + {"kind": "text", "text": "file-bytes"}]}}] + server = FakeA2aServer(stream_payloads=payloads) + endpoint = server.start() + try: + harness = self.driver(endpoint) + try: + harness.start(timeout=10) + seen = self.run_turn(harness) + artifacts = [event for event in seen if event["kind"] == "cap.artifact"] + self.assertEqual(artifacts[0]["artifact_id"], "ar1") + self.assertEqual(artifacts[0]["content"], "file-bytes") + finally: + harness.close() + finally: + server.stop() + + def test_cancel_posts_and_readies(self): + server = FakeA2aServer(stream_payloads=[]) + endpoint = server.start() + try: + harness = self.driver(endpoint) + try: + harness.start(timeout=10) + harness.send_text("slow") + harness.cancel("current_turn") + self.assertEqual(harness.state, "ready") + posts = [call[1] for call in server.calls if call[0] == "POST"] + self.assertIn("/tasks/cancel", posts) + finally: + harness.close() + finally: + server.stop() + + +class CapAnswererTests(unittest.TestCase): + def test_policy_lists_win_without_research(self): + answerer = tai.CapAnswerer(allow_tools=["read"], deny_tools=["exec"]) + with mock.patch.object(tai.CapAnswerer, "research", side_effect=AssertionError("called")): + event = {"tool": "exec", "risk_level": "low", "intent": {}} + self.assertEqual(answerer.decide_permission("cap://a", event), "deny") + event = {"tool": "read", "risk_level": "high", "intent": {}} + self.assertEqual(answerer.decide_permission("cap://a", event), "allow_once") + + def test_risk_gate_denies_before_research(self): + answerer = tai.CapAnswerer(max_risk="low") + with mock.patch.object(tai.CapAnswerer, "research", side_effect=AssertionError("called")): + event = {"tool": "mystery", "risk_level": "medium", "intent": {}} + self.assertEqual(answerer.decide_permission("cap://a", event), "deny") + + def test_unknown_tool_researched(self): + answerer = tai.CapAnswerer() + event = {"tool": "mystery", "risk_level": "medium", "intent": {"x": 1}} + with mock.patch.object(tai.CapAnswerer, "research", return_value="allow, it only reads"): + self.assertEqual(answerer.decide_permission("cap://a", event), "allow_once") + with mock.patch.object(tai.CapAnswerer, "research", return_value="deny it"): + self.assertEqual(answerer.decide_permission("cap://a", event), "deny") + with mock.patch.object(tai.CapAnswerer, "research", return_value=None): + self.assertEqual(answerer.decide_permission("cap://a", event), "deny") + + def test_answer_ask_free_text_and_fallback(self): + answerer = tai.CapAnswerer() + event = {"prompt": "what color?", "form": {"type": "string"}} + with mock.patch.object(tai.CapAnswerer, "research", return_value="blue"): + self.assertEqual(answerer.answer_ask("cap://a", event), "blue") + with mock.patch.object(tai.CapAnswerer, "research", return_value=None): + self.assertEqual(answerer.answer_ask("cap://a", event), "unknown (research failed)") + + def test_answer_ask_yes_no(self): + form = {"type": "string", "oneOf": [{"const": "yes"}, {"const": "no"}]} + answerer = tai.CapAnswerer() + event = {"prompt": "go?", "form": form} + with mock.patch.object(tai.CapAnswerer, "research", return_value="yes, proceed"): + self.assertTrue(answerer.answer_ask("cap://a", event)) + with mock.patch.object(tai.CapAnswerer, "research", return_value="yellow submarine"): + self.assertFalse(answerer.answer_ask("cap://a", event)) + with mock.patch.object(tai.CapAnswerer, "research", return_value=None): + self.assertFalse(answerer.answer_ask("cap://a", event)) + + def test_research_marker_parse_and_failures(self): + answerer = tai.CapAnswerer(research_home="/tmp/research-x", tai_binary="/tmp/tai.py", timeout=9) + done = mock.Mock() + done.returncode = 0 + done.stdout = b"thinking hard\nANSWER: 42\n" + with mock.patch("subprocess.run", return_value=done) as runner: + self.assertEqual(answerer.research("q?"), "42") + argv = runner.call_args.args[0] + self.assertEqual(argv[:3], [sys.executable, "/tmp/tai.py", "--auto"]) + self.assertEqual(argv[3], "q?") + self.assertEqual(runner.call_args.kwargs["timeout"], 9) + self.assertEqual(runner.call_args.kwargs["env"]["TAI_HOME"], "/tmp/research-x") + done.returncode = 1 + with mock.patch("subprocess.run", return_value=done): + self.assertIsNone(answerer.research("q?")) + with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("x", 1)): + self.assertIsNone(answerer.research("q?")) + done.returncode = 0 + done.stdout = b"no marker here\n" + with mock.patch("subprocess.run", return_value=done): + self.assertIsNone(answerer.research("q?")) + + def test_handle_dispatches(self): + answerer = tai.CapAnswerer() + session = mock.Mock() + session.urn = "cap://a" + with mock.patch.object(tai.CapAnswerer, "decide_permission", return_value="deny") as decided: + answerer.handle(session, {"kind": "cap.permission.request", "req_id": "p1"}) + decided.assert_called_once() + session.answer.assert_called_with("p1", "deny") + with mock.patch.object(tai.CapAnswerer, "answer_ask", return_value="v") as answered: + answerer.handle(session, {"kind": "cap.ask_user", "ask_id": "a1"}) + answered.assert_called_once() + session.answer.assert_called_with("a1", "v") + + +class CapFleetAnswerTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.fleet = tai.CapFleet(workdir=self.tmp.name) + + def tearDown(self): + self.fleet.close_all() + self.tmp.cleanup() + + def test_pump_hands_open_asks_to_answerer_once(self): + session = make_fleet_session(self.fleet, "a") + session.driver.open_asks["ask_1"] = ("options", None, {}) + gate = threading.Event() + calls = [] + + def slow_handle(_session, _event): + calls.append(1) + gate.wait(timeout=10) + + self.fleet.set_answerer(mock.Mock(handle=slow_handle)) + event = tai.CapEvent.make("cap.ask_user", ask_id="ask_1", prompt="?", form={}) + self.fleet.feed.put(("cap://a", event)) + self.fleet.feed.put(("cap://a", event)) + self.fleet.pump() + deadline = time.time() + 10 + while time.time() < deadline and not calls: + time.sleep(0.05) + self.assertEqual(len(calls), 1) + kinds = [entry["kind"] for entry in self.fleet.audit] + self.assertIn("answer.start", kinds) + gate.set() + deadline = time.time() + 10 + while time.time() < deadline: + with self.fleet._answering_lock: + if not self.fleet._answering: + break + time.sleep(0.05) + with self.fleet._answering_lock: + self.assertEqual(self.fleet._answering, set()) + + def test_no_answerer_and_stale_ask_skip(self): + session = make_fleet_session(self.fleet, "a") + event = tai.CapEvent.make("cap.ask_user", ask_id="ask_9", prompt="?", form={}) + self.fleet.feed.put(("cap://a", event)) + self.fleet.pump() + self.assertEqual([entry["kind"] for entry in self.fleet.audit if entry["kind"].startswith("answer")], []) + self.fleet.set_answerer(mock.Mock()) + self.fleet.feed.put(("cap://a", event)) + self.fleet.pump() + self.fleet.answerer.handle.assert_not_called() + + def test_answer_error_audited(self): + session = make_fleet_session(self.fleet, "a") + session.driver.open_asks["ask_2"] = ("options", None, {}) + failing = mock.Mock(handle=mock.Mock(side_effect=RuntimeError("boom"))) + self.fleet.set_answerer(failing) + self.fleet.feed.put(("cap://a", tai.CapEvent.make("cap.ask_user", ask_id="ask_2", prompt="?", form={}))) + self.fleet.pump() + deadline = time.time() + 10 + while time.time() < deadline: + kinds = [entry["kind"] for entry in self.fleet.audit] + if "answer.error" in kinds: + break + time.sleep(0.05) + kinds = [entry["kind"] for entry in self.fleet.audit] + self.assertIn("answer.error", kinds) + + def test_wait_idle_busy_while_answering(self): + session = make_fleet_session(self.fleet, "a") + session.driver.state = "done" + session.driver.open_asks["ask_3"] = ("options", None, {}) + gate = threading.Event() + self.fleet.set_answerer(mock.Mock(handle=lambda _s, _e: gate.wait(timeout=10))) + self.fleet.feed.put(("cap://a", tai.CapEvent.make("cap.ask_user", ask_id="ask_3", prompt="?", form={}))) + self.fleet.pump() + self.assertFalse(self.fleet.wait_idle(timeout=1)) + gate.set() + + +class CapDriverFactoryTests(unittest.TestCase): + def test_create_dispatches(self): + pty_manifest = tai.CapManifest.from_toml(CAP_PTY_MANIFEST, source="test") + self.assertIsInstance(tai.CapDriver.create(pty_manifest), tai.CapPtyDriver) + stream_manifest = tai.CapManifest.from_toml(CAP_STREAM_MANIFEST, source="test") + self.assertIsInstance(tai.CapDriver.create(stream_manifest), tai.CapStreamDriver) + + def test_create_acp_and_a2a(self): + text = CapManifestTests.MINIMAL + "\n[fast_path]\nacp_stdio = true\n" + manifest = tai.CapManifest.from_toml(text, source="test") + self.assertEqual(manifest.binding(), "acp_stdio") + driver = tai.CapDriver.create(manifest, acp_argv=["agent"]) + self.assertIsInstance(driver, tai.CapAcpDriver) + self.assertEqual(driver.acp_argv, ["agent"]) + text = CapManifestTests.MINIMAL + '\n[fast_path]\na2a_serve_at = "http://x/"\n' + manifest = tai.CapManifest.from_toml(text, source="test") + self.assertEqual(manifest.binding(), "a2a") + self.assertIsInstance(tai.CapDriver.create(manifest), tai.CapA2aDriver) + + +class FakeCapDriver(tai.CapDriver): + def __init__(self, urn="cap://fake"): + manifest = tai.CapManifest.from_toml(CapManifestTests.MINIMAL, source="test") + super().__init__(manifest, urn=urn) + self.sent = [] + self.script = [] + self.end_state = "done" + self.started = False + self.armed = False + self.cancelled = [] + self.proc = mock.Mock() + + def _spawn(self): + self.started = True + + def start(self, timeout=None): + self._spawn() + self.state = "ready" + return [tai.CapEvent.make("cap.session.ready", version=tai.CAP_VERSION, session_id=self.urn)] + + def send_text(self, text): + self._pre_send() + self.sent.append(text) + self.armed = True + self.state = "working" + + def poll(self, timeout=0.0): + if not self.armed: + return [] + events = self.script + self.script = [] + self.armed = False + if events: + self.state = self.end_state + return events + + def cancel(self, scope="current_turn"): + self.cancelled.append(scope) + + def close(self): + self.proc = None + if self.state != "failed": + self.state = "closed" + + +class CapSessionTests(unittest.TestCase): + def test_ask_collects_turn_and_usage_delta(self): + driver = FakeCapDriver() + session = tai.CapSession("cap://a", driver) + driver.script = [tai.CapEvent.make("cap.text_chunk", message_id="m1", text="hi", channel="assistant"), + tai.CapEvent.make("cap.usage", model_id="m", input_tokens=4, output_tokens=6)] + session.start() + try: + result = session.ask("go", timeout=10) + self.assertEqual(driver.sent, ["go"]) + self.assertEqual(result.state, "done") + self.assertEqual(result.text, "hi") + self.assertEqual(result.usage["output_tokens"], 6) + self.assertEqual(session.usage["input_tokens"], 4) + finally: + session.close() + + def test_wait_timeout_raises(self): + driver = FakeCapDriver() + session = tai.CapSession("cap://a", driver) + session.start() + try: + driver.send_text("hang") + with self.assertRaises(TimeoutError): + session.wait_done(timeout=1) + finally: + session.close() + + def test_input_required_returns_partial(self): + driver = FakeCapDriver() + driver.end_state = "input_required" + session = tai.CapSession("cap://a", driver) + driver.script = [tai.CapEvent.make("cap.ask_user", ask_id="a1", prompt="?", form={}, timeout_seconds=None)] + session.start() + try: + result = session.ask("go", timeout=10) + self.assertEqual(result.state, "input_required") + finally: + session.close() + + +CAP_CODING_MANIFEST = """ +[agent] +name = "coder" +binary = "coder" +profiles = ["coding"] + +[startup] +command = ["coder"] +ready_when = { pattern = "ready" } + +[fast_path] +stream_json = ["coder"] + +[pty] +cols = 80 +rows = 24 + +[capabilities] +streaming_output = true +""" + + +def make_fleet_session(fleet, name): + manifest = tai.CapManifest.from_toml(CapManifestTests.MINIMAL, source="test") + driver = FakeCapDriver("cap://%s" % name) + driver.manifest = manifest + session = tai.CapSession("cap://%s" % name, driver) + session.taps.append(fleet.feed.put) + fleet.sessions["cap://%s" % name] = session + fleet.pending_routes["cap://%s" % name] = collections.deque(maxlen=tai.CAP_ROUTE_QUEUE_MAX) + session.start() + return session + + +class CapFleetTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.fleet = tai.CapFleet(workdir=self.tmp.name) + + def tearDown(self): + self.fleet.close_all() + self.tmp.cleanup() + + def fake_session(self, fleet, name): + return make_fleet_session(fleet, name) + + def test_add_duplicate_and_plan_validation(self): + manifest = tai.CapManifest.from_toml(CapManifestTests.MINIMAL, source="test") + self.fleet.add("a", manifest, worktree=False) + with self.assertRaises(ValueError): + self.fleet.add("a", manifest, worktree=False) + plan = tai.CapEvent.make("cap.plan", entries=[ + {"id": "t1", "content": "x", "status": "pending", + "_meta": {"cap.assigned_to": "cap://a"}}]) + self.fleet.publish_plan(plan) + bad = tai.CapEvent.make("cap.plan", entries=[ + {"id": "t1", "content": "x", "status": "pending", + "_meta": {"cap.assigned_to": "cap://ghost"}}]) + with self.assertRaises(ValueError): + self.fleet.publish_plan(bad) + + def test_ask_unknown_and_budget_refusal(self): + with self.assertRaises(ValueError): + self.fleet.ask("cap://ghost", "hi") + session = self.fake_session(self.fleet, "a") + session.usage["cost_usd_estimate"] = 5.0 + self.fleet.budget_usd = 5.0 + with self.assertRaises(tai.CapError) as caught: + self.fleet.ask("cap://a", "hi") + self.assertEqual(caught.exception.symbol, "cap_budget_exceeded") + + def test_routing_mention_queued_and_delivered(self): + first = self.fake_session(self.fleet, "a") + second = self.fake_session(self.fleet, "b") + first._note(tai.CapEvent.make("cap.text_chunk", message_id="m1", + text="hey @cap://b check this", channel="assistant")) + self.assertEqual(self.fleet.pump(), 2) + kinds = [entry["kind"] for entry in self.fleet.audit] + self.assertIn("route.queue", kinds) + self.assertIn("route.send", kinds) + self.assertEqual(len(second.driver.sent), 1) + self.assertIn("[from cap://a]", second.driver.sent[0]) + + def test_routing_drops(self): + first = self.fake_session(self.fleet, "a") + first._note(tai.CapEvent.make("cap.text_chunk", message_id="m1", + text="ping @cap://ghost and @cap://a", channel="assistant")) + self.fleet.pump() + drops = [entry for entry in self.fleet.audit if entry["kind"] == "route.drop"] + self.assertEqual(len(drops), 2) + for _turn in range(tai.CAP_ROUTE_QUEUE_MAX + 2): + self.fleet.feed.put(("cap://a", tai.CapEvent.make( + "cap.text_chunk", message_id="m1", text="x @cap://b", channel="assistant"))) + self.fake_session(self.fleet, "b") + self.fleet.sessions["cap://b"].driver.state = "working" + self.fleet.pump() + drops = [entry for entry in self.fleet.audit if entry["kind"] == "route.drop" + and "queue full" in entry["text"]] + self.assertTrue(drops) + + def test_busy_target_holds_route(self): + first = self.fake_session(self.fleet, "a") + second = self.fake_session(self.fleet, "b") + second.driver.state = "working" + first._note(tai.CapEvent.make("cap.text_chunk", message_id="m1", + text="for @cap://b later", channel="assistant")) + self.fleet.pump() + self.assertEqual(len(self.fleet.pending_routes["cap://b"]), 1) + self.assertEqual(second.driver.sent, []) + second.driver.state = "done" + self.fleet.pump() + self.assertEqual(len(second.driver.sent), 1) + + def test_wait_idle(self): + session = self.fake_session(self.fleet, "a") + session.driver.state = "done" + self.assertTrue(self.fleet.wait_idle(timeout=2)) + session.driver.state = "working" + self.assertFalse(self.fleet.wait_idle(timeout=1)) + + def test_plain_worktree_isolated_and_cleaned(self): + manifest = tai.CapManifest.from_toml(CAP_CODING_MANIFEST, source="test") + with mock.patch.object(tai.CapFleet, "_git_inside", return_value=False): + self.fleet.add("coder", manifest) + path = os.path.join(self.tmp.name, ".cap-worktrees", "coder") + self.assertTrue(os.path.isdir(path)) + kinds = [entry["kind"] for entry in self.fleet.audit] + self.assertIn("worktree.plain", kinds) + self.assertIn("cap://coder", self.fleet.worktrees) + self.fleet.close_all() + self.assertFalse(os.path.exists(path)) + + def test_git_worktree_roundtrip(self): + if not shutil.which("git"): + self.skipTest("no git binary") + subprocess.run(["git", "init", "-q"], cwd=self.tmp.name, check=True, timeout=30) + subprocess.run(["git", "config", "user.email", "t@t"], cwd=self.tmp.name, check=True, timeout=30) + subprocess.run(["git", "config", "user.name", "t"], cwd=self.tmp.name, check=True, timeout=30) + with open(os.path.join(self.tmp.name, "f.txt"), "w", encoding="utf-8") as handle: + handle.write("one\n") + subprocess.run(["git", "add", "."], cwd=self.tmp.name, check=True, timeout=30) + subprocess.run(["git", "commit", "-qm", "init"], cwd=self.tmp.name, check=True, timeout=30) + manifest = tai.CapManifest.from_toml(CAP_CODING_MANIFEST, source="test") + self.fleet.add("coder", manifest) + path = self.fleet.worktrees["cap://coder"] + self.assertTrue(os.path.isfile(os.path.join(path, "f.txt"))) + with open(os.path.join(path, "f.txt"), "w", encoding="utf-8") as handle: + handle.write("two\n") + diffs = self.fleet.collect_diffs() + self.assertIn("-one", diffs["cap://coder"]["diff"]) + self.assertIn("+two", diffs["cap://coder"]["diff"]) + self.fleet.close_all() + self.assertFalse(os.path.exists(path)) + + def test_collect_diffs_no_worktree_note(self): + manifest = tai.CapManifest.from_toml(CapManifestTests.MINIMAL, source="test") + self.fleet.add("a", manifest, worktree=False) + self.assertEqual(self.fleet.collect_diffs()["cap://a"], {"note": "no worktree"}) + + +class CapFleetPlanTests(unittest.TestCase): + def spec(self): + return {"fleet": {"start": "cap://a", "task": "do it", "budget_usd": 2.5}, + "sessions": [{"name": "a", "agent": "echo"}, {"name": "b", "agent": "echo", "model": "m"}], + "routes": [{"when": "cap://a.done", "route_to": "cap://b"}]} + + def test_valid_plan(self): + plan = tai.cap_fleet_plan(self.spec(), None) + self.assertEqual(plan["start"], "cap://a") + self.assertEqual(plan["task"], "do it") + self.assertEqual(plan["budget_usd"], 2.5) + self.assertEqual(plan["routes"], [("cap://a", "cap://b")]) + + def test_task_override(self): + plan = tai.cap_fleet_plan(self.spec(), "other") + self.assertEqual(plan["task"], "other") + + def test_plan_answerer_fields(self): + plan = tai.cap_fleet_plan(self.spec(), None) + self.assertEqual((plan["auto_answer"], plan["allow_tools"], plan["deny_tools"], plan["max_risk"]), + (False, [], [], "medium")) + spec = self.spec() + spec["fleet"]["auto_answer"] = True + spec["fleet"]["allow_tools"] = ["read"] + spec["fleet"]["deny_tools"] = ["exec"] + spec["fleet"]["max_risk"] = "low" + plan = tai.cap_fleet_plan(spec, None) + self.assertEqual((plan["auto_answer"], plan["allow_tools"], plan["deny_tools"], plan["max_risk"]), + (True, ["read"], ["exec"], "low")) + bad = self.spec() + bad["fleet"]["max_risk"] = "extreme" + with self.assertRaises(ValueError): + tai.cap_fleet_plan(bad, None) + bad = self.spec() + bad["fleet"]["allow_tools"] = "read" + with self.assertRaises(ValueError): + tai.cap_fleet_plan(bad, None) + + def test_invalid_specs_rejected(self): + with self.assertRaises(ValueError): + tai.cap_fleet_plan({"fleet": {}, "sessions": []}, None) + bad = self.spec() + bad["fleet"]["start"] = "cap://ghost" + with self.assertRaises(ValueError): + tai.cap_fleet_plan(bad, None) + bad = self.spec() + bad["fleet"]["task"] = "" + with self.assertRaises(ValueError): + tai.cap_fleet_plan(bad, None) + bad = self.spec() + bad["routes"] = [{"when": "cap://a", "route_to": "cap://ghost"}] + with self.assertRaises(ValueError): + tai.cap_fleet_plan(bad, None) + + +ECHO_AGENT_SCRIPT = ("import sys, json\n" + "print(json.dumps({'type': 'system', 'subtype': 'init', 'session_id': 'e1'}), flush=True)\n" + "for line in sys.stdin:\n" + " line = line.strip()\n" + " if not line:\n" + " continue\n" + " msg = json.loads(line).get('message', '')\n" + " print(json.dumps({'type': 'assistant', 'message': {'id': 'm1', 'content': [{'type': 'text', 'text': 'got:' + str(msg)}]}}), flush=True)\n" + " print(json.dumps({'type': 'result', 'subtype': 'success', 'duration_ms': 1, 'usage': {'input_tokens': 1, 'output_tokens': 2}}), flush=True)\n") + + +def write_echo_agent(tmp, name): + script = os.path.join(tmp, "%s_agent.py" % name) + with open(script, "w", encoding="utf-8") as handle: + handle.write(ECHO_AGENT_SCRIPT) + folder = os.path.join(tmp, ".config", "cap", "agents") + os.makedirs(folder, exist_ok=True) + manifest = ('[agent]\nname = "%s"\nbinary = "%s"\nprofiles = []\n\n' + '[startup]\ncommand = ["%s"]\nready_when = { pattern = "init" }\n\n' + '[fast_path]\nstream_json = ["%s", "%s"]\n\n' + '[pty]\ncols = 80\nrows = 24\n\n' + '[capabilities]\nstreaming_output = true\n') % (name, name, name, sys.executable, script) + with open(os.path.join(folder, "%s.toml" % name), "w", encoding="utf-8") as handle: + handle.write(manifest) + return script + + +class CapCliCapTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.home = mock.patch.dict(os.environ, {"HOME": self.tmp.name}) + self.home.start() + + def tearDown(self): + self.home.stop() + self.tmp.cleanup() + + def test_unknown_command_rejected(self): + with mock.patch("sys.stdout", io.StringIO()): + with mock.patch("sys.stderr", io.StringIO()): + self.assertEqual(tai.main(["--cap", "frobnicate"]), 2) + + def test_resolve_unknown_returns_1(self): + with mock.patch("sys.stdout", io.StringIO()): + with mock.patch("sys.stderr", io.StringIO()): + self.assertEqual(tai.main(["--cap", "resolve", "ghost"]), 1) + + def test_resolve_user_dir_manifest(self): + write_echo_agent(self.tmp.name, "echo1") + out = io.StringIO() + with mock.patch("sys.stdout", out): + self.assertEqual(tai.main(["--cap", "resolve", "echo1"]), 0) + self.assertIn("echo1:", out.getvalue()) + self.assertIn("via stream_json", out.getvalue()) + + def test_chat_task_once(self): + write_echo_agent(self.tmp.name, "echo1") + out = io.StringIO() + with mock.patch("sys.stdout", out): + with mock.patch.object(sys, "stdin", io.StringIO()): + code = tai.main(["--cap", "chat", "--driver", "echo1", "--task", "hi", + "--cwd", self.tmp.name]) + self.assertEqual(code, 0) + self.assertIn("got:hi", out.getvalue()) + + def test_run_missing_and_bad_fleet(self): + with mock.patch("sys.stdout", io.StringIO()): + with mock.patch("sys.stderr", io.StringIO()): + self.assertEqual(tai.main(["--cap", "run", os.path.join(self.tmp.name, "nope.toml")]), 2) + bad = os.path.join(self.tmp.name, "bad.toml") + with open(bad, "w", encoding="utf-8") as handle: + handle.write('[fleet]\nstart = "cap://a"\n') + with mock.patch("sys.stdout", io.StringIO()): + with mock.patch("sys.stderr", io.StringIO()): + self.assertEqual(tai.main(["--cap", "run", bad]), 2) + + def test_chat_auto_answer_wiring(self): + write_echo_agent(self.tmp.name, "echo1") + out = io.StringIO() + with mock.patch.object(tai, "CapAnswerer") as answerer_cls: + with mock.patch("sys.stdout", out): + with mock.patch.object(sys, "stdin", io.StringIO()): + code = tai.main(["--cap", "chat", "--driver", "echo1", "--task", "hi", + "--cwd", self.tmp.name, "--auto-answer", + "--allow-tool", "Shell", "--deny-tool", "Exec", + "--max-risk", "high"]) + self.assertEqual(code, 0) + answerer_cls.assert_called_once_with(allow_tools=["Shell"], deny_tools=["Exec"], max_risk="high") + + def test_run_plan_answerer_wiring(self): + write_echo_agent(self.tmp.name, "echo1") + fleet_file = os.path.join(self.tmp.name, "fleet.toml") + with open(fleet_file, "w", encoding="utf-8") as handle: + handle.write('[fleet]\nstart = "cap://coder"\ntask = "go"\nauto_answer = true\n' + 'allow_tools = ["read"]\nmax_risk = "low"\n\n' + '[[sessions]]\nname = "coder"\nagent = "echo1"\ncwd = "%s"\n' % self.tmp.name) + out = io.StringIO() + with mock.patch.object(tai, "CapAnswerer") as answerer_cls: + with mock.patch("sys.stdout", out): + self.assertEqual(tai.main(["--cap", "run", fleet_file]), 0) + answerer_cls.assert_called_once_with(allow_tools=["read"], deny_tools=[], max_risk="low") + + def test_run_two_agent_routing(self): + write_echo_agent(self.tmp.name, "echo1") + write_echo_agent(self.tmp.name, "echo2") + fleet_file = os.path.join(self.tmp.name, "fleet.toml") + with open(fleet_file, "w", encoding="utf-8") as handle: + handle.write('[fleet]\nstart = "cap://coder"\ntask = "build it"\n\n' + '[[sessions]]\nname = "coder"\nagent = "echo1"\ncwd = "%s"\n\n' + '[[sessions]]\nname = "reviewer"\nagent = "echo2"\ncwd = "%s"\n\n' + '[[routes]]\nwhen = "cap://coder.done"\nroute_to = "cap://reviewer"\n' + % (self.tmp.name, self.tmp.name)) + out = io.StringIO() + with mock.patch("sys.stdout", out): + self.assertEqual(tai.main(["--cap", "run", fleet_file]), 0) + printed = out.getvalue() + self.assertIn("== cap://coder [done] ==", printed) + self.assertIn("== route cap://coder -> cap://reviewer ==", printed) + self.assertIn("== cap://reviewer [done] ==", printed) + self.assertIn("got:build it", printed) + self.assertIn("got:got:build it", printed) + + +class CapPtySharedTests(unittest.TestCase): + def test_text_lines(self): + screen = tai.TuiVtScreen(4, 20) + screen.feed(b"hi\r\nbye") + lines = screen.text_lines() + self.assertEqual(lines[0], "hi") + self.assertEqual(lines[1], "bye") + + def test_pty_proc_size_and_resize(self): + if not tai.PtyProc.available(): + self.skipTest("no pty on this platform") + proc = tai.PtyProc(["stty", "size"], False, os.getcwd(), dict(os.environ), 40, 100) + try: + out = b"" + deadline = time.time() + 10 + while time.time() < deadline: + chunk = proc.read(0.5) + if chunk is None: + break + out += chunk + self.assertIn(b"40 100", out) + finally: + proc.close() + + +class CapDocTests(unittest.TestCase): + """cap.md is the CAP contract; it must name everything the code enforces.""" + + @classmethod + def setUpClass(cls): + with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "cap.md"), "r", encoding="utf-8") as handle: + cls.doc = handle.read() + + def test_every_constant_is_documented(self): + for name in ("CAP_VERSION", "CAP_MANIFEST_SCHEMA", "CAP_PROFILE_CODING", + "CAP_MANIFEST_REVISION", "CAP_PROBE_TIMEOUT", + "CAP_ERROR_CODES", "CAP_ERROR_SYMBOLS", "CAP_ERROR_STRINGS", + "CAP_SESSION_CONFIG_ENV", "CAP_SPEAK_MAX_TURNS", "CAP_SPEAK_DIFF_MAX", + "CAP_PTY_SETTLE_MS", "CAP_PTY_IDLE_MS", "CAP_PTY_SUBMIT_GAP_MS", + "CAP_PTY_RESUBMITS_MAX", "CAP_PTY_SCROLLBACK", "CAP_PTY_INIT_TIMEOUT", + "CAP_STREAM_LINE_MAX", "CAP_STREAM_STDERR_KEEP", + "CAP_SESSION_POLL", "CAP_TURN_TIMEOUT", "CAP_WORKTREE_DIR", + "CAP_ROUTE_QUEUE_MAX", "CAP_FLEET_DIFF_MAX", "CAP_MENTION_RE", + "TAI_CAP_MANIFEST", "CapError", "CapEvent", "CapBinding", + "CapManifest", "CapRegistry", "CapSessionConfig", "CapStreamJson", + "CapSpeakSession", "CapDriver", "CapPtyDriver", "CapStreamDriver", + "CapTurnResult", "CapSession", "CapFleet", + "CapPipeReader", "cap_proc_kill", "cap_proc_reap", "cap_stderr_drain", + "CAP_ACP_VERSION", "CAP_ACP_TERMINALS_MAX", "CAP_ACP_TERM_BUF", + "CAP_ACP_TERM_WAIT_MAX", "CAP_ACP_FS_MAX", "CapAcpDriver", + "CAP_A2A_TIMEOUT", "CapA2aDriver", + "CAP_ANSWER_TIMEOUT", "CAP_ANSWER_PARALLEL", "CAP_ANSWER_MARKER", + "CAP_ANSWER_RISKS", "CapAnswerer", + "cap_speak_main", "cap_interrupt", "cap_cli_main", "cap_cli_help", + "cap_resolve_main", "cap_chat_main", "cap_run_main", "cap_fleet_plan", + "cap_print_result", "cap_build_answerer", "cap_answer_flags"): + self.assertIn("`%s`" % name, self.doc, name) + self.assertTrue(hasattr(tai, name), name) + + def test_every_event_kind_is_documented(self): + for kind in tai.CapEvent.KINDS: + self.assertIn("`%s`" % kind, self.doc, kind) + + def test_every_error_symbol_is_documented(self): + for symbol in tai.CAP_ERROR_SYMBOLS: + self.assertIn(symbol, self.doc, symbol) + for code in tai.CAP_ERROR_STRINGS: + self.assertIn("`%s`" % code, self.doc, code) + + def test_contract_words_are_documented(self): + for word in ("--cap-manifest", "--cap-speak", "--cap-cwd", "--cap-model", + "--cap-permission-mode", "--cap-max-turns", "cap-agent.toml", "workspace_only", + "project_root", "unrestricted", "cap://", "assigned_to", "grpc", + "stream_json", "acp_stdio", "a2a", "message/send", "message:send", + "CAP_SESSION_CONFIG", "artifact", "transcript", "cap-transcripts", + "raw_sink", "input_required", "PtyProc", "text_lines", + "fleet.toml", "route_to", "pending", "budget_usd", "ignored_frames", + "--argv", "--auto-answer", "--allow-tool", "--deny-tool", "--max-risk", + "auto_answer", "allow_tools", "deny_tools", "max_risk", "_cap/", + "elicitation", "user_io", "open_asks", "cap-research"): + self.assertIn(word, self.doc, word) + + def test_no_em_dashes(self): + self.assertNotIn("—", self.doc) + + if __name__ == "__main__": unittest.main()