diff --git a/.github/workflows/ci-non-linux.yml b/.github/workflows/ci-non-linux.yml index 937848758..a432b83df 100644 --- a/.github/workflows/ci-non-linux.yml +++ b/.github/workflows/ci-non-linux.yml @@ -60,6 +60,14 @@ jobs: matrix: os: [macos-latest, windows-latest] partition: ["1/8", "2/8", "3/8", "4/8", "5/8", "6/8", "7/8", "8/8"] + # macOS/Windows default per-test thread stacks are smaller than Linux's, so + # Config-heavy async test frames that build more than one Agent/ServeAgentDeps + # in the same frame (e.g. build_agent_factory_gates_trust_state_independently_per_session, + # build_combined_deps_wires_policy_gate_through_to_session_agent) can overflow the + # default stack here even though they pass in Linux's non-instrumented `test` job + # (same root cause as the coverage job's fix in ci.yml, run 29873938646). + env: + RUST_MIN_STACK: "33554432" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index d633a68fa..388206ffb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] + +## [0.22.3] - 2026-07-22 +### Fixed + +- `.github/workflows/ci-non-linux.yml`: the sharded macOS/Windows `Test` jobs intermittently + crashed with `fatal runtime error: stack overflow, aborting` on + `serve::agent_factory::tests::build_agent_factory_gates_trust_state_independently_per_session` + and `build_combined_deps_wires_policy_gate_through_to_session_agent` (run 29873938646) — the + same root cause already fixed for the `coverage` job (Config-heavy async test frames that + build more than one `Agent`/`ServeAgentDeps` at once overflow the smaller default per-test + stack on non-Linux runners even though they pass on Linux). Added `RUST_MIN_STACK: "33554432"` + (32 MiB) to the sharded `test` job's env, scoped to that job only. +- `src/agent_setup.rs`, `src/acp.rs`: `build_tool_setup_wires_risk_chain_and_blocks_exfil_sequence` + and `acp_session_risk_chain_state_does_not_leak_across_sessions` executed `cat /etc/passwd` to + trigger the risk chain's `SensitiveRead` classification and asserted the call succeeds — the + classifier only pattern-matches the command text (`classify()` in `risk_chain.rs`), so the + path never needed to actually exist, but a real `/etc/passwd` read failed on Windows runners + where the path is absent. Swapped to `echo /etc/passwd`, which preserves the classification + match while succeeding on every platform. +- `src/commands/durable.rs`: `durable_db_is_filename_namespaced_sibling_of_sqlite_path` asserted + a hardcoded forward-slash path against `resolve_durable_db_url`'s `Path::join`-built output, + which uses the platform's native separator — a mismatch on Windows (`\` vs `/`). The expected + value is now built via the same `Path::join` the function under test uses. +- `src/acp.rs`: `already_locked_session_log_notifies_client_proactively_without_prompt` drives + genuine `flock(2)` contention through `SessionEventLog::open_exclusive`, whose advisory lock is + a documented no-op on non-Unix targets (`AdvisoryLock`, `zeph-session`'s `log.rs`) — a second + `open_exclusive` never contends on Windows, so the test never exercised its own assertion + there. Gated `#[cfg(unix)]`, matching `zeph-session`'s own tests for the same primitive. + ### Removed - `zeph-core`: removed the `#[cfg(test)]`-only legacy sequential-dispatch harness in @@ -17298,7 +17327,8 @@ let agent = Agent::new(provider, channel, &skills_prompt, executor); [0.16.0]: https://github.com/bug-ops/zeph/compare/v0.15.3...v0.16.0 -[Unreleased]: https://github.com/bug-ops/zeph/compare/v0.22.2...HEAD +[Unreleased]: https://github.com/bug-ops/zeph/compare/v0.22.3...HEAD +[0.22.3]: https://github.com/bug-ops/zeph/compare/v0.22.2...v0.22.3 [0.22.2]: https://github.com/bug-ops/zeph/compare/v0.22.1...v0.22.2 [0.22.1]: https://github.com/bug-ops/zeph/compare/v0.22.0...v0.22.1 [0.22.0]: https://github.com/bug-ops/zeph/compare/v0.21.4...v0.22.0 diff --git a/Cargo.lock b/Cargo.lock index b6de0362f..1c19f06ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11127,7 +11127,7 @@ dependencies = [ [[package]] name = "zeph" -version = "0.22.2" +version = "0.22.3" dependencies = [ "agent-client-protocol", "anyhow", @@ -11212,7 +11212,7 @@ dependencies = [ [[package]] name = "zeph-a2a" -version = "0.22.2" +version = "0.22.3" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -11243,7 +11243,7 @@ dependencies = [ [[package]] name = "zeph-acp" -version = "0.22.2" +version = "0.22.3" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", @@ -11287,7 +11287,7 @@ dependencies = [ [[package]] name = "zeph-agent-context" -version = "0.22.2" +version = "0.22.3" dependencies = [ "chrono", "futures", @@ -11312,7 +11312,7 @@ dependencies = [ [[package]] name = "zeph-agent-feedback" -version = "0.22.2" +version = "0.22.3" dependencies = [ "regex", "schemars 1.2.1", @@ -11327,7 +11327,7 @@ dependencies = [ [[package]] name = "zeph-agent-persistence" -version = "0.22.2" +version = "0.22.3" dependencies = [ "serde", "serde_json", @@ -11346,11 +11346,11 @@ dependencies = [ [[package]] name = "zeph-agent-tools" -version = "0.22.2" +version = "0.22.3" [[package]] name = "zeph-bench" -version = "0.22.2" +version = "0.22.3" dependencies = [ "clap", "schemars 1.2.1", @@ -11372,7 +11372,7 @@ dependencies = [ [[package]] name = "zeph-channels" -version = "0.22.2" +version = "0.22.3" dependencies = [ "axum 0.8.9", "criterion", @@ -11401,7 +11401,7 @@ dependencies = [ [[package]] name = "zeph-commands" -version = "0.22.2" +version = "0.22.3" dependencies = [ "serde", "thiserror 2.0.18", @@ -11412,7 +11412,7 @@ dependencies = [ [[package]] name = "zeph-common" -version = "0.22.2" +version = "0.22.3" dependencies = [ "axum 0.8.9", "blake3", @@ -11451,7 +11451,7 @@ dependencies = [ [[package]] name = "zeph-config" -version = "0.22.2" +version = "0.22.3" dependencies = [ "dirs", "insta", @@ -11472,7 +11472,7 @@ dependencies = [ [[package]] name = "zeph-context" -version = "0.22.2" +version = "0.22.3" dependencies = [ "blake3", "criterion", @@ -11493,7 +11493,7 @@ dependencies = [ [[package]] name = "zeph-core" -version = "0.22.2" +version = "0.22.3" dependencies = [ "age", "base64 0.22.1", @@ -11565,7 +11565,7 @@ dependencies = [ [[package]] name = "zeph-db" -version = "0.22.2" +version = "0.22.3" dependencies = [ "regex", "sqlx", @@ -11582,7 +11582,7 @@ dependencies = [ [[package]] name = "zeph-durable" -version = "0.22.2" +version = "0.22.3" dependencies = [ "blake3", "bytes", @@ -11608,7 +11608,7 @@ dependencies = [ [[package]] name = "zeph-experiments" -version = "0.22.2" +version = "0.22.3" dependencies = [ "futures", "ordered-float 5.3.0", @@ -11632,7 +11632,7 @@ dependencies = [ [[package]] name = "zeph-gateway" -version = "0.22.2" +version = "0.22.3" dependencies = [ "axum 0.8.9", "http-body-util", @@ -11649,7 +11649,7 @@ dependencies = [ [[package]] name = "zeph-index" -version = "0.22.2" +version = "0.22.3" dependencies = [ "futures", "ignore", @@ -11682,7 +11682,7 @@ dependencies = [ [[package]] name = "zeph-llm" -version = "0.22.2" +version = "0.22.3" dependencies = [ "async-stream", "audioadapter-buffers", @@ -11732,7 +11732,7 @@ dependencies = [ [[package]] name = "zeph-mcp" -version = "0.22.2" +version = "0.22.3" dependencies = [ "async-trait", "base64 0.22.1", @@ -11773,7 +11773,7 @@ dependencies = [ [[package]] name = "zeph-memory" -version = "0.22.2" +version = "0.22.3" dependencies = [ "arc-swap", "blake3", @@ -11815,7 +11815,7 @@ dependencies = [ [[package]] name = "zeph-orchestration" -version = "0.22.2" +version = "0.22.3" dependencies = [ "blake3", "chrono", @@ -11846,7 +11846,7 @@ dependencies = [ [[package]] name = "zeph-plugins" -version = "0.22.2" +version = "0.22.3" dependencies = [ "dirs", "flate2", @@ -11873,7 +11873,7 @@ dependencies = [ [[package]] name = "zeph-sanitizer" -version = "0.22.2" +version = "0.22.3" dependencies = [ "image", "parking_lot", @@ -11896,7 +11896,7 @@ dependencies = [ [[package]] name = "zeph-scheduler" -version = "0.22.2" +version = "0.22.3" dependencies = [ "chrono", "cron", @@ -11919,7 +11919,7 @@ dependencies = [ [[package]] name = "zeph-session" -version = "0.22.2" +version = "0.22.3" dependencies = [ "rustix 1.1.4", "serde", @@ -11939,7 +11939,7 @@ dependencies = [ [[package]] name = "zeph-skills" -version = "0.22.2" +version = "0.22.3" dependencies = [ "anyhow", "blake3", @@ -11976,7 +11976,7 @@ dependencies = [ [[package]] name = "zeph-subagent" -version = "0.22.2" +version = "0.22.3" dependencies = [ "dirs", "indoc", @@ -12008,7 +12008,7 @@ dependencies = [ [[package]] name = "zeph-tools" -version = "0.22.2" +version = "0.22.3" dependencies = [ "arc-swap", "dashmap", @@ -12053,7 +12053,7 @@ dependencies = [ [[package]] name = "zeph-tui" -version = "0.22.2" +version = "0.22.3" dependencies = [ "arboard", "base64 0.22.1", @@ -12104,7 +12104,7 @@ dependencies = [ [[package]] name = "zeph-vault" -version = "0.22.2" +version = "0.22.3" dependencies = [ "age", "proptest", @@ -12121,7 +12121,7 @@ dependencies = [ [[package]] name = "zeph-worktree" -version = "0.22.2" +version = "0.22.3" dependencies = [ "parking_lot", "serde", diff --git a/Cargo.toml b/Cargo.toml index cf8d01c6a..5024685d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ resolver = "3" [workspace.package] edition = "2024" rust-version = "1.97" -version = "0.22.2" +version = "0.22.3" authors = ["bug-ops"] license = "MIT OR Apache-2.0" repository = "https://github.com/bug-ops/zeph" @@ -155,38 +155,38 @@ url = "2.5.8" uuid = "1.24.0" walkdir = "2.5" wiremock = "0.6.5" -zeph-a2a = { path = "crates/zeph-a2a", version = "0.22.2" } -zeph-acp = { path = "crates/zeph-acp", default-features = false, version = "0.22.2" } -zeph-agent-context = { path = "crates/zeph-agent-context", default-features = false, version = "0.22.2" } -zeph-agent-feedback = { path = "crates/zeph-agent-feedback", version = "0.22.2" } -zeph-agent-persistence = { path = "crates/zeph-agent-persistence", default-features = false, version = "0.22.2" } -zeph-agent-tools = { path = "crates/zeph-agent-tools", version = "0.22.2" } -zeph-bench = { path = "crates/zeph-bench", default-features = false, version = "0.22.2" } -zeph-channels = { path = "crates/zeph-channels", default-features = false, version = "0.22.2" } -zeph-commands = { path = "crates/zeph-commands", version = "0.22.2" } -zeph-common = { path = "crates/zeph-common", version = "0.22.2" } -zeph-config = { path = "crates/zeph-config", version = "0.22.2" } -zeph-context = { path = "crates/zeph-context", version = "0.22.2" } -zeph-core = { path = "crates/zeph-core", default-features = false, version = "0.22.2" } -zeph-db = { path = "crates/zeph-db", default-features = false, version = "0.22.2" } -zeph-durable = { path = "crates/zeph-durable", default-features = false, version = "0.22.2" } -zeph-experiments = { path = "crates/zeph-experiments", default-features = false, version = "0.22.2" } -zeph-gateway = { path = "crates/zeph-gateway", version = "0.22.2" } -zeph-index = { path = "crates/zeph-index", default-features = false, version = "0.22.2" } -zeph-llm = { path = "crates/zeph-llm", version = "0.22.2" } -zeph-mcp = { path = "crates/zeph-mcp", default-features = false, version = "0.22.2" } -zeph-memory = { path = "crates/zeph-memory", default-features = false, version = "0.22.2" } -zeph-orchestration = { path = "crates/zeph-orchestration", default-features = false, version = "0.22.2" } -zeph-plugins = { path = "crates/zeph-plugins", default-features = false, version = "0.22.2" } -zeph-sanitizer = { path = "crates/zeph-sanitizer", default-features = false, version = "0.22.2" } -zeph-scheduler = { path = "crates/zeph-scheduler", default-features = false, version = "0.22.2" } -zeph-session = { path = "crates/zeph-session", default-features = false, version = "0.22.2" } -zeph-skills = { path = "crates/zeph-skills", default-features = false, version = "0.22.2" } -zeph-subagent = { path = "crates/zeph-subagent", default-features = false, version = "0.22.2" } -zeph-tools = { path = "crates/zeph-tools", default-features = false, version = "0.22.2" } -zeph-tui = { path = "crates/zeph-tui", default-features = false, version = "0.22.2" } -zeph-worktree = { path = "crates/zeph-worktree", version = "0.22.2" } -zeph-vault = { path = "crates/zeph-vault", version = "0.22.2" } +zeph-a2a = { path = "crates/zeph-a2a", version = "0.22.3" } +zeph-acp = { path = "crates/zeph-acp", default-features = false, version = "0.22.3" } +zeph-agent-context = { path = "crates/zeph-agent-context", default-features = false, version = "0.22.3" } +zeph-agent-feedback = { path = "crates/zeph-agent-feedback", version = "0.22.3" } +zeph-agent-persistence = { path = "crates/zeph-agent-persistence", default-features = false, version = "0.22.3" } +zeph-agent-tools = { path = "crates/zeph-agent-tools", version = "0.22.3" } +zeph-bench = { path = "crates/zeph-bench", default-features = false, version = "0.22.3" } +zeph-channels = { path = "crates/zeph-channels", default-features = false, version = "0.22.3" } +zeph-commands = { path = "crates/zeph-commands", version = "0.22.3" } +zeph-common = { path = "crates/zeph-common", version = "0.22.3" } +zeph-config = { path = "crates/zeph-config", version = "0.22.3" } +zeph-context = { path = "crates/zeph-context", version = "0.22.3" } +zeph-core = { path = "crates/zeph-core", default-features = false, version = "0.22.3" } +zeph-db = { path = "crates/zeph-db", default-features = false, version = "0.22.3" } +zeph-durable = { path = "crates/zeph-durable", default-features = false, version = "0.22.3" } +zeph-experiments = { path = "crates/zeph-experiments", default-features = false, version = "0.22.3" } +zeph-gateway = { path = "crates/zeph-gateway", version = "0.22.3" } +zeph-index = { path = "crates/zeph-index", default-features = false, version = "0.22.3" } +zeph-llm = { path = "crates/zeph-llm", version = "0.22.3" } +zeph-mcp = { path = "crates/zeph-mcp", default-features = false, version = "0.22.3" } +zeph-memory = { path = "crates/zeph-memory", default-features = false, version = "0.22.3" } +zeph-orchestration = { path = "crates/zeph-orchestration", default-features = false, version = "0.22.3" } +zeph-plugins = { path = "crates/zeph-plugins", default-features = false, version = "0.22.3" } +zeph-sanitizer = { path = "crates/zeph-sanitizer", default-features = false, version = "0.22.3" } +zeph-scheduler = { path = "crates/zeph-scheduler", default-features = false, version = "0.22.3" } +zeph-session = { path = "crates/zeph-session", default-features = false, version = "0.22.3" } +zeph-skills = { path = "crates/zeph-skills", default-features = false, version = "0.22.3" } +zeph-subagent = { path = "crates/zeph-subagent", default-features = false, version = "0.22.3" } +zeph-tools = { path = "crates/zeph-tools", default-features = false, version = "0.22.3" } +zeph-tui = { path = "crates/zeph-tui", default-features = false, version = "0.22.3" } +zeph-worktree = { path = "crates/zeph-worktree", version = "0.22.3" } +zeph-vault = { path = "crates/zeph-vault", version = "0.22.3" } zeroize = { version = "1.9.0", default-features = false } [workspace.lints.rust] diff --git a/README.md b/README.md index 2a0511444..036ce3974 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/bug-ops/zeph/ci.yml?branch=main&label=CI)](https://github.com/bug-ops/zeph/actions) [![codecov](https://codecov.io/gh/bug-ops/zeph/graph/badge.svg?token=S5O0GR9U6G)](https://codecov.io/gh/bug-ops/zeph) [![MSRV](https://img.shields.io/badge/MSRV-1.97-blue)](https://www.rust-lang.org) - [![Tests](https://img.shields.io/badge/tests-14890-brightgreen)](https://github.com/bug-ops/zeph/actions) + [![Tests](https://img.shields.io/badge/tests-15347-brightgreen)](https://github.com/bug-ops/zeph/actions) [![License: MIT OR Apache-2.0](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-yellow.svg)](LICENSE) @@ -195,6 +195,7 @@ Secrets live in an [age-encrypted](https://github.com/FiloSottile/age) vault, ne - **SSRF defense (5 layers)** — HTTPS-only, pre-DNS blocklist, post-DNS IP validation, pinned-address client (blocks DNS-rebinding), and redirect-chain re-validation (max 3 hops). - **ShadowSentinel** — an optional LLM probe evaluates risky tool calls *before* execution, with every verdict written to an audit table. - **Exfiltration guard** — blocks tracking-pixel image links and suspicious URLs in tool output, and suppresses injection-flagged memory writes. +- **Safe-by-default limits** — a fresh install ships with the per-category tool rate limiter (`[security.rate_limit]`) and a daily LLM cost cap (`[cost].max_daily_cents`, default $25/day) already enabled, so a runaway agent loop can't hammer a tool category or burn unbounded API spend. Local-only Ollama/Candle usage costs `0` and never trips the cap; any config that sets these keys explicitly keeps its own values. - **Tamper-evident, downgrade-resistant history** — sub-agent transcripts and session event logs are keyed-BLAKE3 hash-chained (vault-keyed `ZEPH_HISTORY_KEY`), so an edited, reordered, or partially-stripped entry is detected and fails closed before it's replayed as trusted prior context; a tampered session can be inspected read-only via `zeph sessions resume --print --allow-unverified`. A per-file vault anchor (`[integrity] anchor = "vault"`, the default) additionally closes the whole-file-strip downgrade a chain alone can't catch — a file-write-only attacker cannot forge or delete a vault entry. Durable execution journals use a separate authenticated high-water-mark, vault-sealable via `zeph durable seal-integrity` to close the equivalent whole-row-delete lever. `zeph sessions verify` / `zeph doctor` report status. See the [security model](https://bug-ops.github.io/zeph/reference/security.html). diff --git a/book/src/advanced/gateway.md b/book/src/advanced/gateway.md index dc3aaf979..5e8f4195d 100644 --- a/book/src/advanced/gateway.md +++ b/book/src/advanced/gateway.md @@ -33,7 +33,7 @@ Add the `[gateway]` section to `config/default.toml`: enabled = true bind = "127.0.0.1" port = 8090 -# auth_token = "secret" # optional, from vault ZEPH_GATEWAY_TOKEN +auth_token = "secret" # required; set from vault via ZEPH_GATEWAY_TOKEN rate_limit = 120 # max requests/minute per IP (0 = unlimited) max_body_size = 1048576 # 1 MB ``` @@ -42,7 +42,9 @@ Set `bind = "0.0.0.0"` to accept connections from all interfaces. The gateway lo ### Authentication -When `auth_token` is set (or resolved from vault via `ZEPH_GATEWAY_TOKEN`), all requests to `/webhook` must include a bearer token: +**`auth_token` is required.** The gateway fails to start if `auth_token` is missing or blank, protecting against unauthenticated webhook injection. + +All requests to `/webhook` must include a bearer token: ``` Authorization: Bearer @@ -50,6 +52,27 @@ Authorization: Bearer Token comparison uses constant-time hashing (blake3 + `subtle`) to prevent timing attacks. The `/health` endpoint is always unauthenticated. +To set the token: + +**Option 1: Store in age vault** (recommended): +```bash +zeph vault set ZEPH_GATEWAY_TOKEN "your-secret-token" +``` + +**Option 2: Environment variable** (dev only): +```bash +export ZEPH_GATEWAY_TOKEN="your-secret-token" +zeph --daemon +``` + +**Option 3: Direct config** (not recommended): +```toml +[gateway] +auth_token = "your-secret-token" # prefer vault +``` + +The `--init` wizard now explicitly prompts for the gateway token and instructs you to store it in the vault. + ## Endpoints ### `GET /health` diff --git a/book/src/advanced/observability.md b/book/src/advanced/observability.md index 3e1e57935..8079477fc 100644 --- a/book/src/advanced/observability.md +++ b/book/src/advanced/observability.md @@ -87,6 +87,31 @@ CPS resets daily at UTC midnight alongside the cost budget. Use it to track whet **In code:** access via `MetricsSnapshot.cost_cps_cents` and `MetricsSnapshot.cost_successful_tasks`. +### Per-Message Usage Records + +Every LLM call that contributes to cost tracking is durably recorded in the `usage_records` table, indexed by message ID. This provides a detailed audit trail of per-message token counts, latency, and cost — useful for understanding what drove total spend. + +**Accessing usage data:** + +- **CLI**: `zeph usage ` shows token counts and cost for a specific conversation message +- **SQL query**: + ```sql + SELECT message_id, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + cost_cents, latency_ms, ttft_ms FROM usage_records + WHERE created_at > datetime('now', '-1 day') + ORDER BY cost_cents DESC; + ``` + +Each row records: +- **input_tokens, output_tokens, cache_read_tokens, cache_write_tokens** — token count breakdown +- **cost_cents** — total cost in cents, calculated using the same pricing formula as the daily aggregate +- **latency_ms** — total round-trip time (including retries and backoff) +- **ttft_ms** — time-to-first-token (true value on streaming paths, time-to-first-byte proxy on non-streaming) +- **tokens_per_sec** — throughput (output_tokens / latency_ms * 1000) +- **message_id** — links to conversation message (NULL for background work: planner, aggregator, verifier) + +Usage rows are written inline-awaited alongside cost tracking — no separate toggle or config needed. They honor the same `[cost] enabled` gate: when cost tracking is off, usage records are not written. + ## TaskSupervisor Metrics Zeph uses a `TaskSupervisor` to manage background tasks (embedding, memory consolidation, file watching, etc.). Task metrics provide CPU and wall-time measurement for performance debugging. diff --git a/book/src/advanced/sub-agents.md b/book/src/advanced/sub-agents.md index 85df58fba..0250e7ad7 100644 --- a/book/src/advanced/sub-agents.md +++ b/book/src/advanced/sub-agents.md @@ -215,6 +215,8 @@ Report findings as a structured list with severity (critical/warning/info). | `hooks.PreToolUse` | HookMatcher[] | `[]` | Hooks fired before tool execution (see [Hooks](#hooks)) | | `hooks.PostToolUse` | HookMatcher[] | `[]` | Hooks fired after tool execution (see [Hooks](#hooks)) | +> **Breaking Change (v0.22.3):** Sub-agent frontmatter files with a misspelled key in the `tools:` or `permissions:` sections (e.g., `pemission_mode:`, `alow:`) now fail to load with a parse error instead of silently falling back to defaults. This is intentional to catch configuration errors early. Review and fix any typos in existing sub-agent definitions — keys are case-sensitive. + If neither `tools.allow` nor `tools.deny` is specified, the sub-agent inherits all tools from the main agent. Matched skill bodies are capped by `[skills] subagent_skill_token_budget` (default `12000` tokens, config-wide, not per-agent) — applies only when `skills.include` is empty; an explicit, hand-curated `include` list is never capped — see [Skill Filtering](#skill-filtering) below. @@ -286,6 +288,62 @@ Summarize the provided content in three bullet points. The default is `20`. Set a lower value for narrow, well-defined tasks. +### `delegation_mode` — Autonomous Spawning Control + +`delegation_mode` controls whether the main agent can autonomously spawn this sub-agent via the orchestration scheduler, or whether spawns must be initiated by direct user action. + +Three modes are available: + +| Mode | Description | +|------|-------------| +| `proactive` (default) | The orchestration scheduler can autonomously spawn this agent. This is the default and preserves the subsystem's prior behavior. | +| `explicit_request_only` | The agent can only be spawned via direct user action: `/agent spawn`, `/agent resume`, `/subagent spawn`. The orchestration scheduler cannot autonomously spawn it. Useful for channels where prompt injection is a risk (Telegram, Discord, webhooks). | +| `disabled` | No spawn path is available (read-only operations like `/agent list` still work). Set `enabled: false` in config to disable all sub-agents globally. | + +Example: + +```yaml +--- +name: code-reviewer +description: Reviews code for correctness and style +delegation_mode: explicit_request_only # only spawn via /agent spawn, not via scheduler +--- + +You are a code reviewer... +``` + +Override in `config.toml`: + +```toml +[agents] +delegation_mode = "explicit_request_only" # applies to all agents without explicit override +``` + +Or via CLI: + +```bash +zeph --delegation-mode explicit_request_only +``` + +### Token-Level Transcript Streaming + +Sub-agents can stream response text and thinking output as token-level deltas within a turn, rather than waiting for the turn to complete. This provides faster, more interactive feedback. + +**Requirements:** +- Provider must support streaming with tools (currently Claude) +- Configured via the existing `forward_transcript` setting (no new config needed) + +When enabled, each token appears in real-time: + +``` +Sub-agent 'analyzer' thinking… +Looking at the file… +I can see the issue… +The bug is on line 42… +``` + +Deltas are ephemeral and display-only (dropped under backpressure, just like turn-end summaries). The final accumulated response remains the sole source of truth for the next turn's LLM context. Providers without native streaming support fall back to full per-turn text at completion, with zero behavior change. + ### Definition Locations | Path | Scope | Priority | diff --git a/book/src/reference/configuration.md b/book/src/reference/configuration.md index b2eab1cd4..14ae6dcf0 100644 --- a/book/src/reference/configuration.md +++ b/book/src/reference/configuration.md @@ -487,6 +487,13 @@ edge_history_limit = 100 # Max historical edge versions per source # contradiction_risk_threshold = 0.7 # Graph edge conflict risk (default: 0.7) # Fail-open contract: embed/LLM/graph errors yield neutral defaults. Requires graph memory for contradiction scoring. +[memory.consent_gate] +# Write-time memory consent gate — requires confirmation for untrusted tool outputs before saving +enabled = false # Enable consent gate for memory writes (default: false) +confirm_threshold = "external_untrusted" # Trust level triggering confirmation: "trusted", "local_untrusted", "external_untrusted" (default: "external_untrusted") +disclose_threshold = "local_untrusted" # Trust level triggering a disclosure note: "trusted", "local_untrusted", "external_untrusted" (default: "local_untrusted") +audit_all = true # Log all memory writes to audit trail (default: true) + [session] # Durable, replayable event log per conversation-session — see "Session Persistence and Resume" # in the mdBook guide (advanced/session-persistence.md). @@ -759,6 +766,7 @@ enabled = false # Enable native worktree isolation for backgr [agents] enabled = false # Enable sub-agent system (default: false) max_concurrent = 1 # Max concurrent sub-agents (default: 1) +delegation_mode = "proactive" # Autonomous spawn control: "proactive" (default), "explicit_request_only", or "disabled" extra_dirs = [] # Additional directories to scan for agent definitions # default_memory_scope = "project" # Default memory scope for agents without explicit `memory` field # Valid: "user", "project", "local". Omit to disable. @@ -811,7 +819,7 @@ verify_timeout_secs = 120 # Timeout for post-task completeness ve enabled = false bind = "127.0.0.1" port = 8090 -# auth_token = "secret" # Bearer token for gateway auth (from vault ZEPH_GATEWAY_TOKEN); warn logged at startup if unset +auth_token = "secret" # Required: Bearer token for gateway auth (from vault ZEPH_GATEWAY_TOKEN); gateway fails to start if missing rate_limit = 120 max_body_size = 1048576 # 1 MiB diff --git a/crates/zeph-channels/README.md b/crates/zeph-channels/README.md index beb602b27..c102b6c19 100644 --- a/crates/zeph-channels/README.md +++ b/crates/zeph-channels/README.md @@ -24,7 +24,7 @@ Implements I/O channel adapters that connect the agent to different frontends. S | `discord` | Discord adapter (optional feature) | | `slack` | Slack adapter (optional feature); audio file detection and download with Bearer auth | | `any` | `AnyChannel` — enum dispatch over all channels | -| `markdown` | Markdown rendering helpers | +| `markdown` | `markdown_to_telegram` renders CommonMark to Telegram `MarkdownV2` (Bot API 10.1 rich text); multi-line blockquotes prefix every line with `>`, nested quotes flatten to a single level (MarkdownV2 has no nested-quote grammar), and long quotes render as Bot API 10.1 expandable (collapsed-by-default) blockquotes at or above `expandable_blockquote_min_lines` | **Re-exports:** `AnyChannel`, `CliChannel`, `JsonCliChannel` @@ -38,6 +38,7 @@ Key fields in the `[telegram]` config section: | Field | Type | Default | Description | |-------|------|---------|-------------| | `stream_interval_ms` | u64 | `3000` | Minimum interval between streaming message edits (minimum 500 ms) | +| `expandable_blockquote_min_lines` | u32 | `10` | Blockquotes with at least this many lines render as Bot API 10.1 expandable (collapsed-by-default) quotes; `0` disables the expandable form | | `guest_mode` | bool | `false` | Enable Bot API 10.0 Guest Mode — surfaces guest messages via a local proxy | | `bot_to_bot` | bool | `false` | Enable Bot-to-Bot communication via `setManagedBotAccessSettings` | | `allowed_bots` | `Vec` | `[]` | Telegram user IDs of bots allowed to interact with this agent | @@ -45,11 +46,12 @@ Key fields in the `[telegram]` config section: ```toml [telegram] -stream_interval_ms = 3000 -guest_mode = false -bot_to_bot = false -allowed_bots = [] -max_bot_chain_depth = 3 +stream_interval_ms = 3000 +expandable_blockquote_min_lines = 10 +guest_mode = false +bot_to_bot = false +allowed_bots = [] +max_bot_chain_depth = 3 ``` > [!NOTE] diff --git a/crates/zeph-commands/README.md b/crates/zeph-commands/README.md index c7fc02929..583be17e7 100644 --- a/crates/zeph-commands/README.md +++ b/crates/zeph-commands/README.md @@ -16,7 +16,7 @@ startup. - `sink` — [`ChannelSink`] minimal async I/O trait; replaces the `C: Channel` generic in handlers - `context` — [`CommandContext`] non-generic dispatch context with trait-object fields -- `traits` — sub-trait definitions for subsystem access (`AgentAccess`, `DebugAccess`, etc.) +- `traits` — 15 per-domain subsystem-access sub-traits (`MemoryAccess`, `GraphAccess`, `ModelAccess`, `SkillAccess`, `PolicyAccess`, `SchedulerAccess`, `LspAccess`, `SessionControlAccess`, `McpAccess`, `OrchestrationAccess`, `SubagentAccess`, `IntegrationAccess`, `TrackingAccess`, `WorktreeAccess`, `MiscAccess`); `AgentAccess` is an empty marker supertrait over all 15 (blanket-impl'd) so `dyn AgentAccess` call sites are unchanged - `handlers` — concrete handler implementations (session, debug, skill, mcp, plan, …) - `commands` — static `COMMANDS` metadata table used by `/help` diff --git a/crates/zeph-memory/README.md b/crates/zeph-memory/README.md index d6e11b19c..887d49338 100644 --- a/crates/zeph-memory/README.md +++ b/crates/zeph-memory/README.md @@ -370,6 +370,7 @@ enabled = true max_hops = 2 recall_limit = 10 extraction_timeout_secs = 15 +retrieval_strategy = "synapse" # synapse (default) | bfs | astar | water_circles | beam_search | hybrid use_embedding_resolution = true # semantic entity dedup via Qdrant (default: false) entity_similarity_threshold = 0.85 # auto-merge threshold entity_ambiguous_threshold = 0.70 # LLM disambiguation threshold @@ -387,8 +388,16 @@ timeout_ms = 500 # Activation timeout to prevent runaway trav [memory.graph] recall_timeout_ms = 1000 # Timeout for the full graph recall call (default: 1000) + +[memory.graph.beam_search] +beam_width = 10 # top candidates kept per hop when retrieval_strategy = "beam_search" + +[memory.graph.watercircles] +ring_limit = 0 # facts per ring when retrieval_strategy = "water_circles" (0 = auto: limit / max_hops) ``` +`retrieval_strategy` selects which recall algorithm the live graph-recall path invokes: `synapse` (default; SYNAPSE spreading activation), `bfs` (hop-weighted traversal), `astar` (A* shortest path), `water_circles` (concentric ring-hop expansion, tuned by `[memory.graph.watercircles] ring_limit`), `beam_search` (top-K per hop, tuned by `[memory.graph.beam_search] beam_width`), or `hybrid` (an LLM classifier picks a strategy per query). Setting `[memory.graph.spreading_activation] enabled = true` force-overrides the strategy to `synapse`. `ZoomIn`/`ZoomOut` `recall_view` enrichment is preserved across all six strategies. + ## BeliefMem probabilistic edges `BeliefMem` (`[memory.graph.belief_mem]`) stages candidate graph facts as probabilistic beliefs before committing them as permanent APEX-MEM edges. Evidence from multiple turns accumulates via Noisy-OR, and facts are only committed when confidence exceeds the promotion threshold. @@ -458,6 +467,15 @@ Tags messages with a category derived from the active skill or tool context. The Organises memories as leaf nodes and periodically consolidates similar clusters into parent summaries via a background sweep. Context assembly traverses the tree for complex queries, mixing leaf-level detail with higher-level summaries. Configure via `[memory.tree]`. +## Per-message usage and cost tracking + +The `usage_records` table (migration 115, SQLite and PostgreSQL) durably records token counts, cost, latency, TTFT/TTFB, and tokens-per-second for every LLM call that feeds `CostTracker::record_usage`, joined to the conversational message that produced it. Writes follow the existing `[cost] enabled` gate and are inline-awaited on the same call sites that update `CostTracker` — no new background tasks and no new config surface (additive telemetry). + +- `UsageRecord` / `UsageSource` — the per-call record type and its origin classification. +- `SqliteStore::record_usage_row` — persist one row. +- `SqliteStore::message_usage` / `conversation_usage` — query usage for a single message or a whole conversation. +- `SqliteStore::usage_cost_since` — aggregate cost over a time window. + ## Features | Feature | Default | Description | diff --git a/crates/zeph-subagent/README.md b/crates/zeph-subagent/README.md index 056929528..589fb4a3f 100644 --- a/crates/zeph-subagent/README.md +++ b/crates/zeph-subagent/README.md @@ -82,6 +82,9 @@ You are a research assistant. Use web_scrape to gather information. Always cite your sources. ``` +> [!IMPORTANT] +> The top-level frontmatter and its nested `tools:` and `permissions:` sections all reject unknown keys. A misspelled key (e.g. `pemission_mode:`, `alow:`) fails the definition load instead of silently falling back to defaults, so security-relevant `permission_mode`/`worktree` typos surface immediately. + ## Zero-trust grants Sub-agents receive only what is explicitly granted: @@ -98,6 +101,8 @@ let grants = PermissionGrants::builder() **Important:** Tools not in the grant list are inaccessible to the sub-agent even if they are globally available. Use `tools.except` in the definition to additionally deny specific tools from an inherited grant set. +Both `GrantKind::Tool` and `GrantKind::Secret` grants carry TTL and revocation state. `handle_tool_step` re-checks `PermissionGrants::check_tool_grant` immediately before every tool dispatch, so an expired or revoked tool grant is rejected with an actionable error at call time — grants are shared between `SubAgentManager` and the spawned agent-loop task via `Arc>`. + ## Context propagation Sub-agents inherit context from their parent agent to reduce cold-start latency: diff --git a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_ansi16_full_layout.snap b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_ansi16_full_layout.snap index da2e4a6b1..333a5b786 100644 --- a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_ansi16_full_layout.snap +++ b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_ansi16_full_layout.snap @@ -1,11 +1,10 @@ --- source: crates/zeph-tui/src/widgets/splash.rs -assertion_line: 261 expression: output --- ≈ zeph think further. - v0.22.2 + v0.22.3 / commands @ files ? keys Tab panels diff --git a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_ascii_only_full_layout.snap b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_ascii_only_full_layout.snap index a7b77e209..cbc571332 100644 --- a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_ascii_only_full_layout.snap +++ b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_ascii_only_full_layout.snap @@ -1,11 +1,10 @@ --- source: crates/zeph-tui/src/widgets/splash.rs -assertion_line: 267 expression: output --- ~ zeph think further. - v0.22.2 + v0.22.3 / commands @ files ? keys Tab panels diff --git a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_truecolor_full_layout.snap b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_truecolor_full_layout.snap index 68bcf2c61..333a5b786 100644 --- a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_truecolor_full_layout.snap +++ b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_truecolor_full_layout.snap @@ -1,11 +1,10 @@ --- source: crates/zeph-tui/src/widgets/splash.rs -assertion_line: 255 expression: output --- ≈ zeph think further. - v0.22.2 + v0.22.3 / commands @ files ? keys Tab panels diff --git a/specs/002-agent-loop/spec.md b/specs/002-agent-loop/spec.md index 4df2468df..34b95c24a 100644 --- a/specs/002-agent-loop/spec.md +++ b/specs/002-agent-loop/spec.md @@ -256,6 +256,35 @@ strip_orphaned_tool_results(messages: &mut Vec) --- +## Focus/Compaction Summary Sanitization (SEC-CC-03) + +LLM-generated compaction summaries (`complete_focus_tool`, `handle_compress_context` backing +the `compress_context`/`request_compaction` tools) are appended to the pinned Knowledge block, +which is re-inserted immediately after the system prompt on every subsequent turn. Because the +summary can transitively carry content from compressed tool/web output, it must be sanitized +(`WebScrape`/`ExternalUntrusted` trust level) before the append — otherwise an injection +surviving compaction reaches the model unfiltered on every turn thereafter. `handle_compress_context` +lacked this sanitizer call until #6585; both sinks now share the same sanitize-before-append +step and have regression coverage (#6562, #6572). + +Separately, `build_compression_prompt` truncates each message to 500 chars before handing it to +the compression LLM. Untrusted tool-result content is already spotlight-wrapped +(``/``) at write time by `sanitize_tool_output`; blind truncation +could sever a trailing wrapper's closing tag, leaving an opened-but-never-closed spotlight block +in the compaction prompt. `repair_truncated_spotlight_wrapper` re-closes a severed wrapper by +open/close tag count, checked independently per wrapper kind since one message can bundle +multiple concatenated wrappers from a multi-tool-call turn (#6584, fixed in #6613). + +### Key Invariants + +- Every sink that appends an LLM-generated summary to the pinned Knowledge block MUST sanitize + it first — `complete_focus_tool` and `handle_compress_context` both go through the same + `WebScrape`/`ExternalUntrusted` sanitizer call +- NEVER let a truncated compaction-prompt message leave a spotlight wrapper + (``/``) open without its matching closing tag + +--- + ## Goal Lifecycle (#3567) The agent tracks a per-session *goal state* that reflects whether the current user diff --git a/specs/003-llm-providers/spec.md b/specs/003-llm-providers/spec.md index 92a6c2dca..a26275ab5 100644 --- a/specs/003-llm-providers/spec.md +++ b/specs/003-llm-providers/spec.md @@ -90,7 +90,7 @@ AnyProvider { Ollama, Claude, OpenAi, Gemini, Candle (feature "candle"), |---|---|---| | Claude | `claude.rs` | Anthropic API, prompt caching (4 breakpoints), thinking blocks | | OpenAI | `openai.rs` | OpenAI API + compatible endpoints | -| Ollama | `ollama.rs` | Local via `ollama-rs`, streaming | +| Ollama | `ollama.rs` | Local via `ollama-rs`, streaming; HTTP-status + transport-level retry (#6531) | | Compatible | `compatible.rs` | OpenAI-compatible HTTP (LM Studio, vLLM, etc.); also used for GonkaGate Phase 1 | | Candle | `candle.rs` | Local inference via HuggingFace candle (feature-gated) | | Gemini | `gemini.rs` | Google Gemini API | @@ -167,6 +167,47 @@ default = true Subsystems reference a provider by name via a `*_provider` field. When the field is absent, the subsystem falls back to the default provider. See `.local/specs/024-multi-model-design/spec.md` for the full per-subsystem mapping. +## Ollama Retry: HTTP-Status and Transport-Level (#6531, #6491) + +Unlike Claude/OpenAI/Gemini, `OllamaProvider::chat`/`chat_stream`/`chat_with_tools` originally +sent every request through `ollama-rs` with no retry — `ollama-rs` discards the HTTP status +code on non-2xx responses, so a single 429/503 or transient connection failure failed the whole +turn immediately. `ollama.rs` now posts chat requests directly via a dedicated `reqwest` client +(`ollama-rs`'s internal client is a different major `reqwest` version and not interchangeable +with `retry::send_with_retry`), reusing `ChatMessageRequest`/`ChatMessageResponse` only for +(de)serialization shape. + +Two independent retry layers: + +- **HTTP-status retry**: `retry::send_with_retry` (the same helper Claude/OpenAI/Gemini use) + retries on real HTTP 429/503, honoring `Retry-After`. Max 3 attempts, matching the other + backends' `MAX_RETRIES`. +- **Transport-level retry**: `send_with_transport_retry`, layered underneath, retries transient + transport failures — request timeout, or a connection reset mid-request (`is_request()` + without `is_connect()`) — that fail before a `reqwest::Response` exists to inspect, so they + never reach `send_with_retry`'s status check. Same exponential backoff schedule as + `send_with_retry`. + +Connect-phase failures (`is_connect()`, e.g. no Ollama server running) are deliberately NOT +retried — they are far more likely to be permanent misconfiguration than a transient blip, and +`RouterProvider`'s fallback-exhaustion diagnostics depend on this failing fast. + +The streaming NDJSON parser introduced alongside this change buffers raw bytes across chunk +boundaries and decodes only once a complete line is assembled, rather than decoding each chunk +independently — decoding per-chunk can silently drop a chunk that splits a multi-byte UTF-8 +character. + +### Key Invariants + +- Ollama chat/streaming requests MUST retry on real HTTP 429/503 via `send_with_retry` — same + retry semantics as Claude/OpenAI/Gemini +- Transport-level failures (timeout, mid-request connection reset) MUST be retried by + `send_with_transport_retry` before status-level retry ever sees a response +- Connect-phase failures (e.g. server unreachable) MUST NOT be retried — fail fast to preserve + `RouterProvider` fallback diagnostics +- The NDJSON stream parser MUST buffer across chunk boundaries and decode complete lines only — + NEVER decode a chunk in isolation (risks silently dropping a split multi-byte character) + ## BetaHeaderRejected Retry (Claude) When a Claude request fails with `LlmError::BetaHeaderRejected`, the provider automatically @@ -181,6 +222,35 @@ paths: `chat_with_tools_stream`, `chat_typed`, and `chat_with_tools` (extended i - The retried request uses identical parameters except the beta header is omitted - Retry happens at most once per request — no retry loop +## Tool-Call ID Uniqueness (OpenAI/Compatible) (#6506, #6501) + +`convert_messages_structured` (OpenAI provider; also used by `CompatibleProvider`, which +delegates message conversion to it) runs a `dedupe_tool_call_ids` pass at the end of +conversion. Some OpenAI-compatible backends derive their `tool_call.id` counter relative to +their own perceived context window; after Focus-based context compaction shrinks what Zeph +sends, the backend's counter can reset and re-mint an id that collides with one still present +earlier in the still-serialized history. A colliding `tool_calls[].id` is rejected outright by +the backend (`tool_calls[].id must be unique within the request`), stalling the turn. + +`dedupe_tool_call_ids` walks the outgoing request in order, rewrites any repeated +`tool_calls[].id` to a freshly-minted unique id, and keeps the paired `tool`-role message's +`tool_call_id` in sync (FIFO pairing per original id, so multiple collisions on the same +original id resolve to the correct occurrence). + +### Key Invariants + +- Every `tool_calls[].id` in a single outgoing request MUST be unique before serialization — + this is a hard wire-format requirement of the OpenAI-compatible API, not an optional + cleanliness pass +- Ids that never collide MUST be left untouched — only colliding ids are rewritten +- Rewriting a `tool_calls[].id` MUST update the paired `tool`-role message's `tool_call_id` to + match — an unpaired rewrite corrupts call/response matching +- Dedup applies to both `openai` and `compatible` provider types, since `CompatibleProvider` + delegates message conversion to `OpenAiProvider` +- Zeph does not mint `tool_call.id` values itself — it replays whatever the upstream model + returned as history; the fix is defensive rewriting on the outbound path, not a change to id + generation + ## o-Series Models (OpenAI) OpenAI o-series models (`o1`, `o2-*`, `o3`, `o4-mini`, `o4`, and any model matching the diff --git a/specs/004-memory/004-9-memory-write-gate.md b/specs/004-memory/004-9-memory-write-gate.md index 8905bf82b..341d69193 100644 --- a/specs/004-memory/004-9-memory-write-gate.md +++ b/specs/004-memory/004-9-memory-write-gate.md @@ -17,6 +17,7 @@ related: - "[[004-memory/spec]]" - "[[004-3-admission-control]]" - "[[024-multi-model-design/spec]]" + - "[[083-memory-write-consent-gate/spec]]" --- # Spec: MemReader Write Quality Gate @@ -76,6 +77,10 @@ MVP; LLM-assisted scoring is opt-in via `quality_gate_provider`. - Retrospective cleanup of existing low-quality entries (handled by `#[forgetting]`) - Graph-edge admission (see `[[004-7-memory-apex-magma]]`) - Tool-output admission (always admitted; quality runs only on conversational writes) +- Provenance/consent/trust gating of writes derived from untrusted content — this gate is a + **noise-control** mechanism (redundancy, reference completeness, contradiction), not a + security/consent filter (see NFR-011 below). That concern is a separate, composed gate: + see `[[083-memory-write-consent-gate/spec|MemGhost write-time consent gate]]` --- @@ -328,4 +333,5 @@ AND the LLM timeout counter increments - [[004-3-admission-control]] — upstream A-MAC admission - [[004-7-memory-apex-magma]] — graph-side conflict handling (composes with `contradiction_risk`) - [[024-multi-model-design/spec]] — provider tier guidance +- [[083-memory-write-consent-gate/spec]] — MemGhost write-time consent gate (orthogonal: security/consent, not quality) - [[MOC-specs]] — all specifications diff --git a/specs/004-memory/spec.md b/specs/004-memory/spec.md index 8c01e8b2c..600a1746c 100644 --- a/specs/004-memory/spec.md +++ b/specs/004-memory/spec.md @@ -261,6 +261,55 @@ embed_timeout_secs = 5 # per-embed timeout; 0 = disabled This is separate from `context.fidelity.max_embed_input_tokens` (which limits input size) — `embed_timeout_secs` limits wall-clock duration of the embed call itself. +## Vector Search Limit Clamp (`MAX_SEARCH_LIMIT`, #6553/#6616/#6623) + +`zeph_memory::MAX_SEARCH_LIMIT = 100` bounds every caller-supplied `limit`/`top_k` search +parameter reaching Qdrant, closing an oversized-result-set DoS: an unbounded `limit` (e.g. a +misconfigured `memory.retrieval.depth` or a caller passing `usize::MAX`) would otherwise make +`zeph-memory` allocate/deserialize an arbitrarily large Qdrant result set in one call. There is +deliberately no config knob to raise this ceiling. + +The enforcement point evolved across three PRs on the same invariant — only the final +(current) state below reflects live behavior: + +1. **#6553/PR #6615** (`e0af1f70c`, breaking commit shared with A2A — see + [[014-a2a/spec]]) added the clamp only at the wrapper layer: `EmbeddingStore::search`/ + `search_collection`, `EmbeddingRegistry::search_raw`, `ReasoningMemory::retrieve_by_embedding`. + A caller reaching a `VectorStore` implementor directly (e.g. `zeph-index`'s `CodeStore::search` + or a generic `RetrievalStep` pipeline step) bypassed these wrappers entirely. +2. **#6622** (closes #6616) added a `clamp_search_limit` call at the top of `search()` in each of + the three production implementors (`QdrantOps`, `DbVectorStore`, `InMemoryVectorStore`) — + but this relied on every implementor remembering to call the helper by convention. +3. **#6627** (closes #6623, current state) converts `VectorStore::search` into a **template + method**: it is now trait-provided, clamps `limit` to `[1, MAX_SEARCH_LIMIT]` unconditionally, + and delegates to a new required `search_clamped()` method that implementors supply instead of + `search()` itself. This closes the gap where a 4th implementor (a test mock) had silently + skipped the by-convention clamp in #6622 — the bound is now structurally reached by every + call path regardless of implementor. + +A one-shot `tracing::warn!` fires the first time a requested limit is actually reduced (each +clamping site warns independently, at most once per process). + +### Key Invariants + +- `limit`/`top_k` reaching Qdrant is always in `[1, MAX_SEARCH_LIMIT]`, regardless of call path — + enforced structurally by `VectorStore::search`'s template method, not by per-implementor convention +- Implementors of `VectorStore` MUST implement `search_clamped`, NEVER override `search` — overriding + `search` bypasses the clamp entirely +- `search_clamped` MUST NOT re-clamp `limit` — it is guaranteed already within bounds by `search` +- NEVER add a config field to raise `MAX_SEARCH_LIMIT` — this reopens the oversized-result-set DoS + the constant exists to close; a config-driven candidate pool larger than 100 is silently truncated + (with a one-shot warning), by design + +### Related: Qdrant Endpoint Hardening Warning + +Same PR (#6553/#6615) added a non-fatal `tracing::warn!` in `Config::validate` when +`memory.qdrant_url` points at a non-loopback host without TLS (`https://`) or an API key +(`memory.qdrant_api_key`) configured — memory content would otherwise travel in plaintext with +no server authentication. Loopback targets (`localhost`, `127.0.0.1`, `::1`) are exempt. This is +a warning, not a hard validation failure, since a remote Qdrant reachable only over an +already-trusted internal network is a legitimate deployment. + ## Benna-Fusi Multi-Timescale SYNAPSE Edges (#3709, #3710, #3994) ### Fast/Slow Synaptic Variables (#3709) @@ -318,6 +367,26 @@ deep_reasoning_query_conditioned = false # opt-in - Migration 096 is append-only — existing rows get `NULL` fast/slow until first reassertion; read code handles `NULL` gracefully - `deep_reasoning_query_conditioned = true` must be fail-open — if `recall_graph_hela` errors, fall back to static-weight path +### `GraphConfig::retrieval_strategy` Wired to the Live Recall Path (#6597, BREAKING) + +`retrieval_strategy` (`[memory.graph]`, values `synapse`/`bfs`/`astar`/`watercircles`/ +`beam_search`/`hybrid`) was parsed, validated, and documented, but **not consulted** by the +live graph-recall path used by every real entry point (CLI/ACP/daemon/serve) — that path +(`SemanticMemoryBackend::recall_graph_facts`) only switched on `spreading_activation.enabled` +(SYNAPSE vs. plain BFS). The correct 6-way dispatch existed only in +`zeph-agent-context::helpers`, which had zero production callers. #6597 ports the dispatch into +the live path (`fetch_graph_facts` in `zeph-context` → `recall_graph_facts` in +`zeph-agent-context`) and removes the dead duplicate chain. Full strategy details, the variant +table, and the WaterCircles ring-hop fix live in [[012-graph-memory/spec]]; see that spec's +"Graph Retrieval Strategy Dispatch" section. + +**BREAKING**: deployments on the default config (`spreading_activation.enabled = false`, +`retrieval_strategy` unset) now get SYNAPSE spreading-activation recall instead of the +previous — unintentional — plain-BFS fallback, since `retrieval_strategy`'s documented default +(`synapse`) now actually takes effect. Set `retrieval_strategy = "bfs"` under `[memory.graph]` +to keep the prior BFS-only behavior; benchmark before upgrading if graph-recall latency/cost +is a concern. + --- ## JoinSet and CancellationToken Fixes diff --git a/specs/005-skills/spec.md b/specs/005-skills/spec.md index 9f6eb9615..c3c39dc72 100644 --- a/specs/005-skills/spec.md +++ b/specs/005-skills/spec.md @@ -270,6 +270,50 @@ emits a `DEBUG` log noting absent provenance. --- +## Self-Learning Auto-Improved Skill Trust Cap (#6619, #6568) + +Self-learning paths that rewrite a skill's active body (not just newly-created skills, see +"NL Skill Generation and GitHub Repo Mining" above) previously bypassed both the injection scan +and trust demotion: `store_improved_version` wrote LLM-regenerated bodies straight to the live +`SKILL.md` with no `scan_skill_body` call, and the new `"auto"`-sourced version silently +inherited the parent skill's trust tier — including `Trusted`/`Verified` — instead of being +demoted for machine-evolved content. + +A shared `scan_and_cap_for_activation` gate is now wired into all three points that can activate +a skill version: + +- `store_improved_version` — the failure-driven self-learning path +- `activate_version_and_write` — the choke point shared by `/skill activate`, `/skill approve`, + and `/skill reset`, run unconditionally regardless of the target version's `source` +- `arise_store_version` — the ARISE success-trace-driven path + +The gate runs `scan_skill_body` and hard-blocks activation on a match (the version row is still +saved for forensics; the previously-active body stays live), and caps the newly-activated +version's trust at `Quarantined` via `min_trust` (or the parent's level, if already worse than +Quarantined) before the live file write, failing closed (skipping activation) if the trust write +itself errors. + +Retroactive re-scan of pre-existing auto versions and per-version (rather than per-skill-name) +trust restoration on rollback are deferred follow-ups — trust is stored per skill name, not per +version, so a demoted trust level is not automatically restored when rolling back to an +earlier, previously-vetted version. + +### Key Invariants + +- Every code path that can activate a skill version (self-learning improvement, ARISE + trace-driven storage, or manual `/skill activate`/`approve`/`reset`) MUST run the same + injection scan as manually-created skills — single-path scanning is incomplete +- An auto-improved or auto-generated version MUST NOT silently inherit the parent skill's trust + level — cap at `Quarantined` (or lower, if the parent is already below it) before the live + write +- The trust cap MUST be written and confirmed BEFORE the live SKILL.md file is written — writing + the file first and the trust cap second would reopen the vulnerability this fix closes +- A scan match MUST hard-block activation — the version row is saved for forensics but the live + body and file are never touched +- NEVER activate a version when the trust-cap write itself fails — fail closed, not open + +--- + ## Skill Category System @@ -448,6 +492,33 @@ Install-time filtering: At startup and on hot-reload, `build_registry()` assigns `Trusted` trust to all skills that carry a `.bundled` marker file. This initialization is unconditional — it does not wait for feedback accumulation. +### Trust Snapshot Must Not Fail Open on Transient Load Failure (#6513, #6482) + +`Agent::build_skill_trust_map` distinguishes a genuine `load_all_skill_trust()` read error from +a legitimate empty result via `SkillTrustMapLoad::{Fresh, LoadFailed}`. Previously both cases +collapsed into the same empty `HashMap`, so a transient DB read failure (lock contention, disk +I/O hiccup) made every active skill resolve to `SkillTrustLevel::Trusted` for the turn — +including skills an operator had explicitly `Blocked` or left `Quarantined` — unblocking +`QUARANTINE_DENIED` tools and skipping body sanitization. + +On `LoadFailed`, both call sites (`apply_skill_trust_and_gating` and `reload_skills`) reuse the +previous turn's `trust_snapshot` instead of overwriting it with an empty map — a transient +failure degrades to stale-but-real trust data, never to maximum trust. The one residual gap: +on the very first turn ever, `trust_snapshot` still holds its construction-time empty value, so +a load failure on that narrow bootstrap window still yields an empty map (no prior state +exists to fall back to). + +#### Key Invariants + +- A genuine `load_all_skill_trust()` read error MUST be distinguishable from "no rows yet" — + NEVER collapse both into the same empty map +- On a load failure, callers MUST reuse the previous turn's `trust_snapshot` — NEVER fail open + to `SkillTrustLevel::Trusted` +- Memory being unconfigured (`None`) is a permanent, expected condition, not a load failure — it + legitimately yields an empty `Fresh` map +- The first-turn bootstrap window (no prior snapshot exists) is an accepted residual gap, not a + regression — there is no "previous" state to protect before the first turn + ### Key Invariants (Hub Install) - `.bundled` marker is write-once at install time — never added post-install by the agent diff --git a/specs/007-channels/007-3-telegram-rich-text.md b/specs/007-channels/007-3-telegram-rich-text.md index f3f002a56..6f722efd7 100644 --- a/specs/007-channels/007-3-telegram-rich-text.md +++ b/specs/007-channels/007-3-telegram-rich-text.md @@ -10,7 +10,7 @@ tags: - telegram - bot-api-10 created: 2026-07-20 -status: draft +status: implemented related: - "[[007-channels/spec]]" - "[[007-channels/007-1-telegram-guest-mode]]" @@ -30,6 +30,11 @@ related: > as Phase 1 MVP, defines the phased roadmap for the new formatting surface (Phase 2: > spoiler/underline/custom-emoji via markup), and defers structured `sendRichMessage` > blocks to a separate epic (Phase 3). Closes #6541. +> +> **Phase 1 MVP landed** in commit `fdd887fbf` (#6604): guest-mode escaping fix, per-line +> multi-line/nested blockquote flattening, `expandable_blockquote_min_lines` config +> (wired into `--init`/`--migrate-config`), all matching this spec's FR-001..009. Phase 2/3 +> (§8) remain roadmap-only — not implemented. ## Sources @@ -338,6 +343,12 @@ on BlockQuote end: > for other multi-event constructs (e.g. lists), as long as the per-line `>` prefix invariant > (FR-005) and the length-triggered expandable form (FR-006/FR-007) hold. +**As implemented (#6604):** nested `BlockQuote` events are flattened to a single `>`-per-line +level via a bounded mark stack, `MAX_BLOCKQUOTE_NESTING_DEPTH = 512` (same pattern as +`MAX_CHUNK_DEPTH`, #6595) — input nesting past the cap does not grow tracked-mark memory +unboundedly; only the outermost mark within the cap is recorded, and flattening still applies +beyond it. + --- ## 8. Phase 2 / Phase 3 Roadmap (Not Committed in This Spec's PR) diff --git a/specs/007-channels/spec.md b/specs/007-channels/spec.md index 47568bcf7..edd18303d 100644 --- a/specs/007-channels/spec.md +++ b/specs/007-channels/spec.md @@ -328,6 +328,42 @@ A shared `StreamingBuffer` abstraction was extracted from channel-specific code. and Slack adapters now use the shared buffer for streaming chunk accumulation. Stub `elicit()` methods were added to Discord and Slack channels for future elicitation support. +### Fail-Closed Allowlist Gate — Discord/Slack Parity (#6502, #6472, #6498) + +`TelegramChannel::start()` already refused to start with an empty allowed-users list. +Discord (`allowed_user_ids`/`allowed_role_ids`) and Slack (`allowed_user_ids`) had no +equivalent check: `DiscordChannel::new` and `SlackChannel::new`/`new_with_supervisor` +started successfully with an empty allowlist and silently accepted messages from any +sender — a fail-open default. Slack's recv-time filter in `slack/events.rs` (the actual +per-message enforcement point, since `SlackChannel::recv`/`try_recv` do not re-check the +allowlist) was a separate hand-rolled fail-open check with the same gap. + +A new `zeph_channels::auth` module centralizes both responsibilities so a future channel +adapter cannot silently diverge from the fail-closed policy: + +- `require_configured_allowlist` — the **startup gate**, called before any listener is + spawned or network call made; refuses to start when every relevant allowlist is empty +- `is_identity_allowed` / `all_lists_empty` — the **per-message check**, reused by every + adapter's `is_authorized` path and by `ConfirmLoop::confirm_accepts` + +An empty list is treated as "unrestricted" only inside the per-message check, which is +safe *only* because the startup gate guarantees the list is never actually empty at call +time in a correctly constructed adapter — the two must not be used independently. + +The `--init` wizard's Discord and Slack steps now prompt for an allowlist, mirroring +Telegram's existing prompt, so a freshly initialized config is startable under the new +fail-closed check. + +#### Key Invariants + +- Discord and Slack MUST refuse to start (fail closed) when their identity allowlist(s) + are entirely empty — mirroring Telegram's pre-existing policy; NEVER silently run open +- Telegram, Discord, and Slack MUST share one authorization primitive + (`zeph_channels::auth`) for both the startup gate and the per-message check — no adapter + may hand-roll its own allowlist check +- Breaking-but-safe: existing Discord/Slack configs with an empty allowlist fail to start + until an allowlist is configured; there is no opt-out config field + ### Discord API 429 Rate-Limit Retry (#4746) `DiscordChannel` now handles HTTP 429 responses from the Discord API by reading the @@ -339,3 +375,25 @@ HTTP 429 responses were propagated as `ChannelError::Send` and the message was d - On HTTP 429, the channel MUST wait `Retry-After` seconds before retrying — never drop the message - If `Retry-After` is absent or unparseable, fall back to a fixed 1-second delay - Retry applies to `send()` and `send_chunk()` only — `send_typing()` is non-critical and may be dropped on 429 + +### Retry-After Clamp (#6516, #6496) + +`crates/zeph-channels/src/common/http_retry.rs`'s `send_with_retry` is the shared 429-retry +dispatch path used by Discord, Slack, and Telegram alike. `f64::min` only guards against `NaN`, +not negative values, so a malicious or misbehaving upstream 429 response with a negative or +non-finite (`NaN`/`±inf`) `Retry-After` header or JSON body value reached +`Duration::from_secs_f64` unclamped — which panics on such inputs, crashing the shared dispatch +path for all three channels. + +`valid_retry_secs()` filters out non-finite and negative values from both the header and body +sources, before the existing header → body → 1 s-default fallback and `MAX_RETRY_SECS` clamp — +treating a hostile/malformed value exactly like an absent or unparseable one. + +#### Key Invariants + +- A `Retry-After` value (header or JSON body) MUST be validated finite and non-negative before + reaching `Duration::from_secs_f64` — an unclamped hostile value panics the shared dispatch path +- A negative, `NaN`, or infinite `Retry-After` value MUST be treated the same as an absent or + unparseable one — fall through to the next source, then the 1 s default +- This validation applies uniformly to Discord, Slack, and Telegram, since all three share + `send_with_retry` diff --git a/specs/008-mcp/spec.md b/specs/008-mcp/spec.md index 7f9223b66..8e5ead60f 100644 --- a/specs/008-mcp/spec.md +++ b/specs/008-mcp/spec.md @@ -106,6 +106,14 @@ Config migration step 50 adds these fields. Both live under `[[mcp.servers]]`. - `ToolRefreshEvent` channel is bounded to 16 slots (commit #4488) — prevents unbounded memory growth in high-churn registries - Cancellation token plumbed through retry loop (commit #4609) — shutdown is clean even during retry wait +- **Breaking change (#6614)**: `McpClient::connect` no longer takes two adjacent positional + `bool`s (`suppress_stderr`, `env_isolation`) — it takes `StderrPolicy` (`Forward`/`Suppress`) + and `EnvPolicy` (`InheritAll`/`Isolated`) enums instead, so a future call-site edit can no + longer silently swap the two and flip security-relevant behavior (full parent-env + inheritance) without a compiler error. `connect_url`/`connect_url_with_headers`/ + `connect_url_oauth` now take `McpTrustLevel` directly instead of a pre-collapsed + `trusted: bool`. No runtime behavior change — call sites in `manager/retry.rs` and + `manager/connect.rs` convert at the boundary. ## See Also diff --git a/specs/010-security/spec.md b/specs/010-security/spec.md index e32b06557..ca5c9d868 100644 --- a/specs/010-security/spec.md +++ b/specs/010-security/spec.md @@ -573,14 +573,25 @@ call risk signals. ### Integration -`RiskChainAccumulator` is reset at `begin_turn()` via `SecurityState` and accumulates signals -from `ShellExecutor` and `NetworkEgress` risk classifications during the tool loop. Signals -are pushed to `RiskSignalQueue`. When a complete chain is detected, a `SecurityEvent::RiskChain` -is emitted before the offending tool is executed. +`RiskChainAccumulator::advance_turn()` (#6602) is called at `begin_turn()` via `SecurityState` +and accumulates signals from `ShellExecutor` and `NetworkEgress` risk classifications during the +tool loop. Signals are pushed to `RiskSignalQueue`. When a complete chain is detected, a +`SecurityEvent::RiskChain` is emitted before the offending tool is executed. `advance_turn()` +does NOT fully clear state — it prunes only calls older than a bounded window +(`CROSS_TURN_WINDOW_TURNS`, 3 turns) and recomputes the cumulative score from what remains, so a +chain deliberately split across turns (sensitive read in turn N, network egress in turn N+1) is +still caught. Each detection pushes its signal code once (deduped while the same chain stays +live) to avoid flooding `TrajectorySentinel` with duplicates from one logical attack. ### Key Invariants -- `RiskChainAccumulator` MUST be reset at the start of every turn — accumulated chains never span across turns +- `RiskChainAccumulator` is scoped to one agent **session** (constructed once per + session/connection, never shared across concurrent sessions via a common `Arc`) — a + per-connection-shared instance lets one session's turn boundary wipe or cross-contaminate + another session's chain state +- `advance_turn()` prunes calls older than the bounded cross-turn window — it never fully + resets state, so a chain split across turns within the window is still detected; a call + that ages out of the window no longer contributes to a later detection - A `RiskChain` event blocks the triggering tool call — not just logs it - NEVER accumulate signals from subagent tool calls into the parent session's chain accumulator diff --git a/specs/012-graph-memory/spec.md b/specs/012-graph-memory/spec.md index 06aef7caf..30fff38e2 100644 --- a/specs/012-graph-memory/spec.md +++ b/specs/012-graph-memory/spec.md @@ -335,6 +335,102 @@ link_weight_decay_interval_secs = 86400 --- +## Graph Retrieval Strategy Dispatch (`GraphConfig::retrieval_strategy`, #6597, BREAKING) + +### Overview + +`[memory.graph] retrieval_strategy` selects which recall algorithm the live graph-recall path +uses. Six variants, dispatched from `SemanticMemoryBackend::recall_graph_facts` +(`crates/zeph-agent-context/src/memory_backend.rs`) to the matching `SemanticMemory` method: + +| `retrieval_strategy` value | `SemanticMemory` method | Description | +|---|---|---| +| `synapse` (default) | `recall_graph_activated` | SYNAPSE spreading activation — see above | +| `bfs` | `recall_graph` | Hop-limited BFS (pre-SYNAPSE behavior) | +| `astar` | `recall_graph_astar` | A* shortest-path via petgraph — see PRISM below | +| `watercircles` | `recall_graph_watercircles` | Concentric ring BFS — see below | +| `beam_search` | `recall_graph_beam` | Beam search, top-K candidates per hop (`beam_search.beam_width`) | +| `hybrid` | classifies the query first via `classify_graph_strategy`, then dispatches to the matching method above | Dynamic per-query strategy selection | + +`spreading_activation.enabled = true` force-overrides the resolved strategy to `synapse` +regardless of `retrieval_strategy`'s configured value. + +### Before #6597: Dead-on-Arrival Config Field + +`retrieval_strategy` was parsed, validated, and documented in `zeph-config`, but the live +recall path used by every real entry point (CLI/ACP/daemon/serve) never read it — it only +switched on the `spreading_activation.enabled` boolean (SYNAPSE vs. plain BFS). The correct +6-way dispatch logic existed in `zeph-agent-context::helpers`, but that module had zero +production callers — a "wired in isolation, never connected" defect. #6597 ports the dispatch +into the live path (`zeph-context`'s `fetch_graph_facts` → `zeph-agent-context`'s +`SemanticMemoryBackend::recall_graph_facts`) and removes the now-fully-dead duplicate chain +from `zeph-agent-context::helpers`. + +`ZoomIn`/`ZoomOut` `recall_view` enrichment (source-message provenance, 1-hop neighbor +expansion) — previously implemented only inside `SemanticMemory::recall_graph_view` — is +preserved across all 6 strategies via a shared `SemanticMemory::enrich_recall_view` post-dispatch +pass, run after whichever concrete method produced the base fact set. + +### Config + +```toml +[memory.graph] +retrieval_strategy = "synapse" # synapse | bfs | astar | watercircles | beam_search | hybrid + +[memory.graph.beam_search] +beam_width = 10 + +[memory.graph.watercircles] +ring_limit = 0 # 0 = auto (recall_limit / max_hops) +``` + +### Key Invariants + +- `spreading_activation.enabled = true` always overrides `retrieval_strategy` to `synapse` — never let a configured `bfs`/`astar`/etc. silently win over an explicitly enabled spreading-activation config +- `enrich_recall_view` (ZoomIn/ZoomOut) MUST run after every strategy's base recall, not just BFS's — `memcot_config.recall_view` must keep working regardless of `retrieval_strategy` +- `hybrid` classifies once per `recall_graph_facts` call, then dispatches — never re-classifies per candidate + +### BREAKING CHANGE (#6597) + +Deployments running the default graph-memory config (`spreading_activation.enabled = false`, +`retrieval_strategy` left unset) now use SYNAPSE spreading-activation recall +(`recall_graph_activated`) instead of the previous, unintentional plain-BFS fallback +(`recall_graph`) — before this fix, `retrieval_strategy` was never read at all, and the live +path fell back to BFS purely because `spreading_activation`'s own params were only built when +`enabled = true`. Set `retrieval_strategy = "bfs"` under `[memory.graph]` to keep the prior +BFS-only behavior. Spreading activation is more expensive per-recall than plain BFS — +deployments sensitive to graph-recall latency/cost should benchmark before upgrading or pin +`retrieval_strategy = "bfs"`. + +--- + +## WaterCircles: Concentric Ring BFS. `crates/zeph-memory/src/graph/retrieval_watercircles.rs`. + +### Overview + +`graph_recall_watercircles` (`retrieval_strategy = "watercircles"`) buckets BFS-discovered +edges into concentric rings by hop distance from the seed entities, returning results ring by +ring up to `max_hops`. `[memory.graph.watercircles] ring_limit` caps facts returned per ring +(`0` = auto: `recall_limit / max_hops`). + +### Ring-Hop Distance Fix (#6600) + +`hop_dist` for an edge must be the edge's **farther** endpoint from the seed — i.e. +`max(source_depth, target_depth)` — not simply "prefer source, fall back to target". BFS +discovers an edge regardless of traversal direction, so an edge's source-node depth is almost +always present in the depth map but one ring shallower than the edge's true ring. Before +this fix, `hop_dist` preferred source depth whenever present, so the `dist != hop` ring filter +never matched for any typical seed-outward graph — every edge was silently dropped regardless +of `ring_limit`, for any `ring_limit`. + +### Key Invariants + +- `hop_dist = max(source_depth, target_depth)` — NEVER prefer one endpoint's depth over the + other; a "prefer source, fallback to target" rule misclassifies the ring for edges discovered + in the opposite BFS orientation and silently drops them from every ring's results + +--- + ## PRISM: Query-Sensitive A* Edge Costing (#4079, #4360) Opt-in traversal enhancement that weights BFS graph recall by cosine similarity between the diff --git a/specs/013-acp/spec.md b/specs/013-acp/spec.md index e834ae5fa..439baff3b 100644 --- a/specs/013-acp/spec.md +++ b/specs/013-acp/spec.md @@ -8,7 +8,7 @@ tags: - protocol - acp created: 2026-04-08 -updated: 2026-07-17 +updated: 2026-07-21 status: approved related: - "[[MOC-specs]]" @@ -34,6 +34,7 @@ related: | 1.6 | 2026-07-01 | developer | Review fix pass on #5361/#5362: (S-C1) `default_temperature_preset` is now primed into the effective `provider_override` at session creation (`do_new_session`/`do_load_session`/`do_fork_session`/`do_resume_session`), not just advertised in the IDE dropdown; (S-C2) the `$/cancel_request` watcher select is now `biased` with prompt completion checked first, and `drain_agent_events` drains a stale `cancel_signal` permit before its main loop, so a cancellation resolving at/after prompt completion can no longer leak into the next, unrelated prompt (also hardens the pre-existing `session/cancel` no-active-prompt race — `cancel_before_prompt_returns_cancelled` renamed to `cancel_before_prompt_is_a_no_op` to reflect the corrected semantics). Documented fork/resume reset behavior for `temperature_preset` (#5373 tracking issue filed); filed #5374 for the untested `resume_session` store-backed path. | | 1.7 | 2026-07-01 | developer | Fixed #5379: `zeph acp model-config show` now loads the resolved config and marks the active `[acp.model_config].default_temperature_preset` in its output, instead of only printing the static preset table. Resolved #5373: `session/fork` and `session/resume` now inherit `model`/`temperature_preset`/`thinking_enabled`/`auto_approve_level` from the source session (live in-memory state, falling back to a persisted close-time snapshot, falling back to configured defaults) instead of always resetting to defaults — new `acp_sessions` columns via migration `105_acp_session_config`; see "Fork/Resume Config Inheritance" below. | | 1.8 | 2026-07-17 | developer | Renovate `rust-minor-patch` bundle bumped core `1.0.1`→`1.2.0`, schema `=1.1.0`→`=1.4.0`. Core `1.1.0` stabilized `$/cancel_request` unconditionally and dropped its `unstable_cancel_request` forward — `unstable-cancel-request` is now a local-only opt-in gate (Cargo feature unchanged, but no longer maps to an upstream feature); `unstable-boolean-config` tombstoned the same way after schema `1.1.0` made `SessionConfigOptionValue::Boolean` unconditional. Schema `1.4.0` renamed `SetProviderRequest`/`DisableProviderRequest`/`ProviderInfo`'s `id: String` field to `provider_id: ProviderId` (`Arc` newtype) — updated all `providers.rs` call sites and tests. No handler/transport/builder-chain logic changed otherwise. | +| 1.9 | 2026-07-21 | developer | Mechanical decomposition (#6624): `ZephAcpAgentState`'s god-object `agent/mod.rs` (4424 lines, six unrelated responsibilities) split into eight sibling files under `crates/zeph-acp/src/agent/`. No public API or behavior change. See "Implementation Structure" below. | > [!warning] Body narrative lags the 1.8 changelog entry — 2026-07 audit > Only the top summary (line 22) and the changelog table above were updated for the 1.8 bump. @@ -98,6 +99,33 @@ AcpSessionManager - Session fork: create a new session branching from an existing session at a given turn - Session resume: reconnect to an existing session by ID +## Implementation Structure (`agent/` module decomposition, #6624) + +`ZephAcpAgentState` (the ACP session coordinator struct) had accumulated six unrelated +responsibilities spanning ~2955 lines of impl blocks inside a 4424-line `agent/mod.rs`: +builder wiring, idle-session reaping, LSP diagnostics, session lifecycle, slash-command/model +dispatch, and MCP extension handling. This increased merge-conflict surface on every ACP +feature PR and made it hard to reason about which methods were safe to test in isolation. + +Each cluster was extracted into a sibling file under `crates/zeph-acp/src/agent/`, following +the existing `providers.rs`/`usage.rs` precedent (file-local `impl ZephAcpAgentState` blocks): + +| Module | Responsibility | +|---|---| +| `builder.rs` | Dependency wiring / construction | +| `reaper.rs` | Idle-session reaping | +| `lsp_events.rs` | LSP diagnostics forwarding | +| `mcp_ext.rs` | MCP extension-method handling | +| `model.rs` | Model switching / model_config dispatch | +| `slash.rs` | Slash-command dispatch | +| `turn.rs` | Turn/prompt execution | +| `session.rs` | Session lifecycle (new/load/fork/resume/close) | + +`mod.rs` shrank from 4424 to 1408 lines, retaining the struct definition, type aliases, and +thin protocol dispatch methods. Mechanical extraction only: all 25 fields stay on the +coordinator struct, no behavior change, no signature changes beyond the necessary +private-to-`pub(crate)` visibility bumps required for cross-file calls within the crate. + ### Agent Spawner Contract (1.0.1) Agent sessions use the `Agent.builder()` / `run_agent()` pattern. Session state is `Arc`-wrapped. @@ -130,6 +158,35 @@ AcpPermissionGate (TOML-backed, SQLite-persisted) - Async request queue: async lookup with oneshot reply channels — agent blocked until user answers - Tool call lifecycle: `proposed → approved/denied → persisted → executed → result` +### Shell Interpreter Permission Cache Identity (#6511, #6485) + +For ordinary binaries, the "Allow always"/"Reject always" cache key is the extracted +binary name (`build_permission_title` in `crates/zeph-acp/src/terminal.rs`), preserving +per-binary granularity. For shell interpreters (`SHELL_INTERPRETERS`: `bash`, `sh`, `zsh`, +`fish`, `dash`) the binary name alone does not determine what the command does — `bash -c +