From 9ff935d9189b9f1cf98e5a9d785356c43ac8cbbf Mon Sep 17 00:00:00 2001 From: Christoforus Yoga Haryanto Date: Sun, 24 May 2026 19:13:57 +1000 Subject: [PATCH 1/2] Incorporate internal research architecture into the open-source prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pull cognition parsing out of the kernel by introducing strong-typed adapter outputs (`ActionHint`, `DecisionLine`, `JudgeOutput`), a shared `runtime::canonicalize` utility for cross-vendor canonicalization (markdown fences, prose-around-JSON, balanced-brace extraction), and renaming `CognitionResponse.text` → `content` with role-specific typed fields populated by adapters. - Give the Actor an agentic inner loop bounded by `DEFAULT_INNER_STEP_BUDGET` so pure-reasoning replies continue the turn while structured `ActionHint` responses commit, replacing the prior single-turn JSON-parse-or-fail surface. - Replace the findings-specific Tool with a generic record substrate by removing `record_finding`, renaming `Finding`/`*FindingsBackend` to `Record`/`*RecordStore`, routing record appends through `modify_artifact` with `kind=record-store`, and letting the Charter define record content shape while the kernel injects `_*` provenance. - Add the Skills primitive as Actor-side cognition instrumentation by adding `core::skill::Skill` (content-hashed, SKILL.md convention), folding skills into the Snapshot identity hash, loading them from `/skills/*.md`, and composing them into the Actor system prompt under grounding. - Drop filesystem IO from the kernel by splitting `charter_loader` into pure parsers (`parse_charter_def`, `parse_role_context_def`) inside `core/` and deployment-side IO (`load_charter_def`, `load_role_context_def`, `load_skills`) inside `runtime::charter_loader`. - Unify durable streams on one async append-only primitive by adding `chartered_dispatch::JsonlSink` serving Receipts, the cognition log, and the record store, making `ReceiptStore::append` async, and codifying the discipline in a new spec §Persistence. - Make Snapshots persisted and addressable by adding `chartered_dispatch::snapshot_store` (one immutable file per `/snapshots/.json`), persisting on Snapshot creation, and adding spec §Snapshot Lifecycle so every Receipt's `snapshot_id` resolves to a record on disk. - Rework the v1 protocol by giving `Receipt` `receipt_id`/`task_id`/`attempt_id`/`steward_id`, replacing `EnforcementLevel` with structured `GovernanceMode` (grounding+evaluation toggles), promoting `Verdict.frame_id` to a Steward-scoped `FrameRef`, renaming `ToolCall.tool_name` → `tool` while dropping its timestamp, and removing `Metrics`. - Add the Gemini cognition backend as a sibling adapter to OpenAI by introducing `runtime::gemini_backend` with lazy per-model `outputTokenLimit`/`thinking` caching, native role mapping (System → `systemInstruction`, Assistant → `model`), and an explicit non-forwarding policy for LLM-native tools (search/codeExec/urlContext are cognition, not governed Tools). - Rename the OpenAI environment variables from `LLM_*` to `OPEN_AI_*` across `.env`, runtime, dashboard launcher, docs, and tests so per-vendor configuration is decoupled from the abstract LLM name now that Gemini exists alongside OpenAI. - Make the dashboard read its Charter view from the kernel by adding `chartered-runtime --print-charter` (JSON emit of the parsed Charter), replacing the dashboard's parallel TOML+markdown parser in `local-api.mjs` with a subprocess call, so the Charter parser has one source of truth. - Codify three-tier test discipline by formalizing unit (stateless, CI) / integration (vertical cut, tempdir-isolated, CI) / e2e (real LLM via production transport, local-only, `#[ignore]`d, no soft-skip) in `AGENTS.md §Verification`, adding `docs/IMPLEMENTATION_CHECKLIST.md §Test Tier Discipline`, renaming `tests/e2e.rs` → `tests/binary_integration.rs`, adding `tests/persistence.rs`, and gating `llm_e2e.rs` behind `#[ignore]`. - Expand the specification to cover the newly-incorporated subsystems by adding §Persistence, §Snapshot Lifecycle, §Skills, §Subagents, §User-Facing Integration Boundary, the "Cognition is commodity" framing for §Cognition Layer, and the "LLM-side native tools are cognition, not Tools" clarification under §Tools. - Add Transcription Fidelity discipline to `AGENTS.md` requiring user-intent artifacts to contain only the user's stated words, prohibiting silent assistant extrapolation into mandates and briefs. --- .env.default | 6 +- .env.dev | 6 +- .github/workflows/ci.yml | 4 +- AGENTS.md | 12 +- CONTEXT.md | 4 +- core/CONTEXT.md | 23 +- core/Cargo.lock | 312 ++++++++++- core/Cargo.toml | 1 + core/src/actor.rs | 262 +++++---- core/src/artifact.rs | 437 ++++++--------- core/src/charter_loader.rs | 107 ++-- core/src/cognition.rs | 190 +++++-- core/src/lib.rs | 18 +- core/src/loop_runner.rs | 17 +- core/src/prompt.rs | 67 ++- core/src/receipt.rs | 7 +- core/src/scenario.rs | 18 +- core/src/skill.rs | 105 ++++ core/src/snapshot.rs | 26 +- core/src/steward.rs | 1 + core/src/verdict.rs | 89 +-- core/tests/artifact_tools.rs | 66 ++- core/tests/common/mod.rs | 86 ++- core/tests/gate_invariants.rs | 80 ++- core/tests/multi_steward.rs | 2 +- core/tests/passthrough.rs | 2 +- core/tests/reconciliation.rs | 8 +- dashboard/app.js | 2 - dashboard/local-api.mjs | 166 +----- dispatch/CONTEXT.md | 13 +- dispatch/Cargo.lock | 1 + dispatch/Cargo.toml | 1 + dispatch/src/artifact.rs | 134 ++--- dispatch/src/lib.rs | 6 +- dispatch/src/paths.rs | 2 +- dispatch/src/persistence.rs | 72 +++ dispatch/src/registry.rs | 41 +- dispatch/src/snapshot_store.rs | 141 +++++ dispatch/tests/artifact_executors.rs | 82 +-- dispatch/tests/registry.rs | 20 +- dispatch/tests/snapshot_store.rs | 77 +++ docs/IMPLEMENTATION_CHECKLIST.md | 28 + docs/SPECIFICATION.md | 48 +- examples/charters/CONTEXT.md | 2 +- examples/charters/coding-agent/frames.toml | 2 +- .../charters/customer-service/frames.toml | 2 +- .../charters/medical-reception/frames.toml | 2 +- .../.chartered/charter/behavioral_spec.md | 27 +- examples/demo/.chartered/charter/frames.toml | 8 +- examples/demo/.chartered/steward.toml | 12 +- .../demo/.chartered/tools/record_finding.toml | 2 - examples/demo/README.md | 29 +- proto/CONTEXT.md | 8 +- proto/v1/evaluate.proto | 2 +- proto/v1/receipt.proto | 37 +- proto/v1/tool_call.proto | 11 +- proto/v1/verdict.proto | 17 +- runtime/CONTEXT.md | 52 +- runtime/Cargo.lock | 1 + runtime/src/canonicalize.rs | 275 +++++++++ runtime/src/charter_loader.rs | 86 +++ runtime/src/config.rs | 25 +- runtime/src/gemini_backend.rs | 527 ++++++++++++++++++ runtime/src/lib.rs | 4 + runtime/src/main.rs | 13 +- runtime/src/openai_backend.rs | 248 ++++----- runtime/src/persistence.rs | 182 +----- runtime/src/print_charter.rs | 379 +++++++++++++ runtime/src/run.rs | 123 +++- .../tests/{e2e.rs => binary_integration.rs} | 70 +-- {core => runtime}/tests/charter_loader.rs | 91 ++- runtime/tests/config.rs | 5 +- runtime/tests/llm_e2e.rs | 357 +++++++----- runtime/tests/persistence.rs | 114 ++++ 74 files changed, 3906 insertions(+), 1597 deletions(-) create mode 100644 core/src/skill.rs create mode 100644 dispatch/src/persistence.rs create mode 100644 dispatch/src/snapshot_store.rs create mode 100644 dispatch/tests/snapshot_store.rs delete mode 100644 examples/demo/.chartered/tools/record_finding.toml create mode 100644 runtime/src/canonicalize.rs create mode 100644 runtime/src/charter_loader.rs create mode 100644 runtime/src/gemini_backend.rs create mode 100644 runtime/src/print_charter.rs rename runtime/tests/{e2e.rs => binary_integration.rs} (95%) rename {core => runtime}/tests/charter_loader.rs (61%) create mode 100644 runtime/tests/persistence.rs diff --git a/.env.default b/.env.default index bd84c12..c40e56e 100644 --- a/.env.default +++ b/.env.default @@ -1,6 +1,6 @@ -LLM_BASE_URL=https://api.openai.com -LLM_MODEL=gpt-4o-mini -LLM_API_KEY= +OPEN_AI_BASE_URL=https://api.openai.com +OPEN_AI_MODEL=gpt-4o-mini +OPEN_AI_API_KEY= EMBEDDER_BASE_URL=https://api.openai.com EMBEDDER_MODEL=text-embedding-3-small EMBEDDER_API_KEY= diff --git a/.env.dev b/.env.dev index 2b66d4b..57dba01 100644 --- a/.env.dev +++ b/.env.dev @@ -1,5 +1,5 @@ -LLM_BASE_URL=http://localhost:1234 -LLM_MODEL=openai/gpt-oss-20b -EMBEDDER_BASE_URL=http://localhost:1234 +OPEN_AI_BASE_URL=http://localhost:1234/v1 +OPEN_AI_MODEL=google/gemma-4-26b-a4b +EMBEDDER_BASE_URL=http://localhost:1234/v1 EMBEDDER_MODEL=text-embedding-qwen3-embedding-4b DATABASE_URL=postgres://:@localhost:5432/chartered_dev \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 638bdd9..9cb9038 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [master] pull_request: - branches: [main] + branches: [master] env: CARGO_TERM_COLOR: always diff --git a/AGENTS.md b/AGENTS.md index 6b9ef9e..9b59133 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,8 @@ Forbidden: uninstructed truncation guards, fallback behaviors (see Error Discipl **Domain logic in kernel.** The kernel provides mechanisms; domain config provides policy. Hardcoded pattern-matching rules (regex for prices, substring matching for names) that encode domain concepts as kernel code = domain logic in kernel, regardless of how it's dispatched. The kernel may implement generic mechanisms (LLM judgment, registry dispatch). It MUST NOT implement domain-specific algorithms. Test: "Would this code need to change if we switched from medical CX to e-commerce?" Yes → domain logic, belongs in config or domain layer. +**Transcription fidelity.** User-intent artifacts (goals, briefs, mandates, requirements) MUST contain only what the user stated. Assistant contributions during dialogue — analogies, explanations, meta-commentary, proposed extrapolations — MUST NOT enter the artifact, even when they appear to follow. Diagnostic per clause: "Did the user say this, or did I?" Latter → strike. Compression and re-ordering permitted; addition forbidden. Artifact authority comes from being the user's words, not from assistant synthesis. + ### Writing Discipline **Co-location.** Concept and mechanism belong together — in specs, code, comments, docs. Separating them forces readers to hold cross-references across distant locations; readers reliably fail at this. Diagnostic: "To understand this mechanism, how many other locations must the reader hold?" More than one → co-location violation. Applies universally: spec sections co-locate concept with mechanism; code co-locates type with validation; comments co-locate with the code they govern; CONTEXT.md co-locates design intent with the subtree it governs. When modifying any artifact, if co-location violations exist in the touched scope → propose fixes. @@ -259,9 +261,15 @@ Each status report: exact commands, exit codes, failing tests + output (quoted), Verification answers: what proposition? what source? what measurement? how does verdict follow? Any unanswered → not verification. -Implementations include: static checks (lint, type checks, forbidden imports), unit tests for pure deterministic logic, integration tests with real infrastructure, assembly tests wiring several components. +Implementations include static checks (lint, type checks, forbidden imports) and tests across three tiers — unit, integration, e2e. + +**Unit tests.** Literally stateless. No filesystem, no env mutation, no IO, no shared in-process state across test cases. Live under `#[cfg(test)] mod tests` in the source file they exercise. Run in CI. + +**Integration tests.** Exercise vertical cuts across trait interface boundaries — several components wired together, observed through their declared contracts. May read or write files, but each test run owns its own isolated directory (one `tempfile::tempdir()` per test; no shared paths, no cleanup races). Live under `/tests/`. Run in CI. + +**E2E tests.** Exercise the actual full agent loop, require an actual LLM, and call over the production API via its proper transport protocol. Local-only — never run in CI. Each e2e test MUST be `#[ignore]`d so default `cargo test` skips it; opt-in via `cargo test -- --ignored`. The soft-skip pattern (`if !precondition { return; }`) is forbidden — it masks failure as passing (see "Tests MUST fail when preconditions absent" below). -Mocks only for external services. Internal components → real or test-grade implementations. Every test path requiring external dependency MUST have complementary test using substitute validating same contract. +Mocks only for external services. Internal components → real or test-grade implementations (`InMemoryReceiptStore`, `FakeCognitionBackend`, …). Every test path requiring an LLM MUST have both a fake-LLM variant (queued responses, in-process) and a real-LLM variant (e2e against the production transport). The fake side covers all logic paths; the real side validates the transport, serde, and adapter canonicalization. Before any test: what requirement? what violation signal? what bugs caught? what bugs missed? diff --git a/CONTEXT.md b/CONTEXT.md index 0f4375f..1338787 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -53,8 +53,8 @@ Real-backend impl: `runtime::openai_backend::OpenAiCompatibleBackend`. Same impl serves real OpenAI (`https://api.openai.com/v1` + API key) and local OpenAI-compatible servers (LM Studio, llama.cpp, vLLM, SGLang; key optional). Picked per-role via `steward.toml` `backend = -"openai"` + env (`LLM_BASE_URL`, `LLM_MODEL`, `LLM_API_KEY`); -`LLM_BASE_URL` is the versioned API base. +"openai"` + env (`OPEN_AI_BASE_URL`, `OPEN_AI_MODEL`, `OPEN_AI_API_KEY`); +`OPEN_AI_BASE_URL` is the versioned API base. Frames are Steward-owned weak entities. Any serialized cross-boundary reference uses `FrameRef { steward_id, frame_id }`; bare `FrameId` diff --git a/core/CONTEXT.md b/core/CONTEXT.md index 00f20aa..36c384f 100644 --- a/core/CONTEXT.md +++ b/core/CONTEXT.md @@ -44,20 +44,23 @@ The components that close the negative-feedback loop: - **Test-grade Tool implementations**: `harness` module. In-memory only — `MessageLog`, `InMemoryFs`, `ReviewQueue`. Used by kernel tests and the Runtime binary's scenario mode. -- **Charter loader**: `charter_loader` (charter_loader.rs). Two-stage - construction — `load_charter_def` / `load_role_context_def` parse - `frames.toml` + `scopes.md` / `role_context.md` into data-only - shapes; `build_charter` / `build_role_context` materialize the - runtime types by attaching Evaluator instances and version numbers. - Markdown sections become slugified scope names. Format documented +- **Charter parsers**: `charter_loader` (charter_loader.rs). Two-stage + construction — `parse_charter_def` / `parse_role_context_def` accept + already-loaded text (no filesystem dependency) and produce data-only + shapes; `build_charter` / `build_role_context` materialize the runtime + types by attaching Evaluator instances and version numbers. Markdown + sections become slugified scope names. Filesystem IO that reads + `frames.toml` / `scopes.md` / `behavioral_spec.md` / `role_context.md` + / `skills/*.md` lives in `runtime::charter_loader`. Format documented in `examples/charters/CONTEXT.md`. ## What does NOT live here -- Stdlib effect surfaces (`std::fs`, `std::process`, `tokio::net`). - This crate is in-memory only. OS-touching ToolExecutors live in - `dispatch/`. The file-backed Receipt store and the cognition log - live in `runtime/src/persistence.rs`. +- Stdlib effect surfaces (`std::fs`, `std::process`, `tokio::fs`, + `tokio::process`, `tokio::net`). This crate is in-memory only; + filesystem IO for Charter loading lives in `runtime::charter_loader`; + OS-touching ToolExecutors live in `dispatch/`; the file-backed + Receipt store and the cognition log live in `runtime/src/persistence.rs`. - Real LLM backends. Step 2 adds OpenAI / Anthropic / vLLM / SGLang as sibling implementations of `CognitionBackend`. - E2E scenario tests. Those live in `runtime/tests/` and target the diff --git a/core/Cargo.lock b/core/Cargo.lock index 892dc4a..41c3ec3 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "async-trait" version = "0.1.89" @@ -13,6 +19,12 @@ dependencies = [ "syn", ] +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + [[package]] name = "block-buffer" version = "0.10.4" @@ -37,6 +49,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "tempfile", "tokio", "toml", ] @@ -76,6 +89,28 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "generic-array" version = "0.14.7" @@ -86,18 +121,52 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "indexmap" version = "2.14.0" @@ -105,7 +174,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.0", + "serde", + "serde_core", ] [[package]] @@ -114,24 +185,58 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -150,6 +255,31 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -224,6 +354,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "tokio" version = "1.52.1" @@ -298,12 +441,85 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "winnow" version = "0.7.15" @@ -313,6 +529,100 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/core/Cargo.toml b/core/Cargo.toml index 42496d8..26ad628 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -16,3 +16,4 @@ toml = "0.8" [dev-dependencies] tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "time", "sync", "test-util"] } +tempfile = "3" diff --git a/core/src/actor.rs b/core/src/actor.rs index 4a90a9e..9d9107f 100644 --- a/core/src/actor.rs +++ b/core/src/actor.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use async_trait::async_trait; -use crate::cognition::{CognitionBackend, CognitionRequest, CognitionResponse, Message, ToolCallHint}; +use crate::cognition::{ActionHint, CognitionBackend, CognitionRequest, CognitionResponse, Message}; use crate::receipt::RefinementSignal; use crate::tool::{ToolCall, ToolId, ToolParams, ToolResult}; @@ -10,16 +10,16 @@ use crate::tool::{ToolCall, ToolId, ToolParams, ToolResult}; /// Tool call; Halt signals the Task is complete. /// /// `Fail` is the structural failure signal: the Actor's cognition could -/// not produce a valid Action (parse error, backend error, malformed -/// output). The LoopRunner emits a Receipt with `intercept_complete=false` -/// and Outcome::Escalated so the operator sees the failure in the -/// Receipt trail. CHECKLIST §Risk Register > Silent Failure: every -/// partial-coverage condition (cognitive failure prevents the Steward's -/// output from reaching the Gate) flips `intercept_complete=false`; -/// silent halt would leave operators unable to distinguish "task -/// complete" from "Actor failed." The LoopRunner sources the -/// identifiers (workspace_id, actor.id) when it forges the failure -/// Receipt — the Actor only contributes the reason. +/// not produce a valid Action (the adapter never delivered an action_hint +/// within the inner step budget). The LoopRunner emits a Receipt with +/// `intercept_complete=false` and Outcome::Escalated so the operator +/// sees the failure in the Receipt trail. CHECKLIST §Risk Register > +/// Silent Failure: every partial-coverage condition (cognitive failure +/// prevents the Steward's output from reaching the Gate) flips +/// `intercept_complete=false`; silent halt would leave operators unable +/// to distinguish "task complete" from "Actor failed." The LoopRunner +/// sources the identifiers (workspace_id, actor.id) when it forges the +/// failure Receipt — the Actor only contributes the reason. #[derive(Debug, Clone)] pub enum Action { Propose(ToolCall), @@ -43,20 +43,15 @@ pub trait Actor: Send + Sync { async fn step(&mut self, observation: Option) -> Action; } -/// The canonical Actor implementation: maintains a per-turn -/// conversation history with the CognitionBackend, parses the LLM's -/// canonical response into an Action. -/// -/// Two response shapes the Actor consumes from the backend: -/// 1. `tool_call_hint` is set — the adapter has already extracted -/// `(tool, params)` from a structured tool-use wire format -/// (gpt-oss harmony, OpenAI native tool_calls, etc.). The Actor -/// builds a `ToolCall` from the hint without re-parsing `text`. -/// 2. `tool_call_hint` is `None` — the Actor parses `text` as JSON: -/// `{"tool":"", "params":{...}}` → `Action::Propose`, -/// `{"halt": true}` → `Action::Halt`. Anything else → -/// `Action::Fail` (operator-visible, recorded in trail with -/// `intercept_complete=false`, never silent). +/// The canonical Actor implementation. Maintains a per-turn +/// conversation history with the CognitionBackend; commits on the +/// strong-typed `action_hint` the adapter produces. The kernel does +/// not parse JSON — the adapter converts whatever wire shape the +/// vendor used (harmony envelope, plain prose with embedded JSON, +/// OpenAI native `tool_calls`, Gemini `functionCall`, etc.) into an +/// `ActionHint`. When the response has no `action_hint`, the Actor +/// treats it as a reasoning step and continues the inner loop until +/// either an action commits or the step budget exhausts. pub struct LlmActor { id: String, backend: Arc, @@ -64,8 +59,15 @@ pub struct LlmActor { history: Vec, context_id: Arc, source_id: Arc, + inner_step_budget: usize, } +/// Default ceiling on LLM calls inside one `Actor::step` invocation. +/// Each pure-reasoning response burns one step; structured (tool-call +/// or halt) responses commit immediately. Exhaustion → `Action::Fail` +/// with operator-visible diagnostic. +pub const DEFAULT_INNER_STEP_BUDGET: usize = 8; + impl LlmActor { pub fn new( id: impl Into, @@ -82,6 +84,7 @@ impl LlmActor { history: Vec::new(), context_id: context_id.into(), source_id, + inner_step_budget: DEFAULT_INNER_STEP_BUDGET, } } @@ -90,6 +93,16 @@ impl LlmActor { self } + /// Cap the LLM calls inside one outer step. The Actor's + /// agentic inner loop walks up to `budget` LLM exchanges before + /// returning `Action::Fail`. Spec §The Loop and §Cognition Layer: + /// every externally-observable tool call still crosses the Gate; + /// the inner loop is bounded planning, not bounded effect. + pub fn with_inner_step_budget(mut self, budget: usize) -> Self { + self.inner_step_budget = budget; + self + } + fn append_observation(&mut self, observation: Observation) { let formatted = match observation { Observation::Accepted(Ok(v)) => { @@ -115,25 +128,15 @@ impl LlmActor { } } - fn action_from_hint(&self, hint: ToolCallHint) -> Action { - Action::Propose(ToolCall { - tool: ToolId::new(hint.tool), - params: ToolParams(hint.params), - context_id: self.context_id.clone(), - source_id: self.source_id.clone(), - }) - } - - fn action_from_text(&self, text: &str) -> Action { - match parse_canonical_action(text) { - ParsedAction::Propose { tool, params } => Action::Propose(ToolCall { + fn action_from_hint(&self, hint: ActionHint) -> Action { + match hint { + ActionHint::Halt => Action::Halt, + ActionHint::Propose { tool, params } => Action::Propose(ToolCall { tool: ToolId::new(tool), params: ToolParams(params), context_id: self.context_id.clone(), source_id: self.source_id.clone(), }), - ParsedAction::Halt => Action::Halt, - ParsedAction::Unparseable(reason) => self.fail(reason), } } } @@ -149,113 +152,132 @@ impl Actor for LlmActor { self.append_observation(obs); } - let mut messages = Vec::with_capacity(self.history.len() + 1); - messages.push(Message::system(self.system_prompt.clone())); - messages.extend(self.history.iter().cloned()); + // Agentic inner loop. Each iteration either commits (action_hint + // present → returned to the outer LoopRunner, which crosses the + // Gate) or treats the response as reasoning and continues. + // Bounded by `inner_step_budget` so a non-committing model can't + // spin indefinitely. + for _ in 0..self.inner_step_budget { + let mut messages = Vec::with_capacity(self.history.len() + 1); + messages.push(Message::system(self.system_prompt.clone())); + messages.extend(self.history.iter().cloned()); - let request = CognitionRequest { - messages, - max_output_tokens: Some(1024), - }; - let response: CognitionResponse = match self.backend.complete(&request).await { - Ok(r) => r, - Err(e) => return self.fail(format!("backend error: {e}")), - }; + let request = CognitionRequest { + messages, + max_output_tokens: Some(1024), + }; + let response: CognitionResponse = match self.backend.complete(&request).await { + Ok(r) => r, + Err(e) => return self.fail(format!("backend error: {e}")), + }; - self.history.push(Message::assistant(response.text.clone())); - if let Some(hint) = response.tool_call_hint { - return self.action_from_hint(hint); + self.history.push(Message::assistant(response.content.clone())); + if let Some(hint) = response.action_hint { + return self.action_from_hint(hint); + } + // No action_hint → adapter classified the response as pure + // reasoning. Keep it in history (already pushed above) and + // continue the inner loop. } - self.action_from_text(&response.text) + self.fail(format!( + "inner step budget exhausted ({} steps) without the adapter producing an action_hint", + self.inner_step_budget + )) } } -#[derive(Debug)] -enum ParsedAction { - Propose { - tool: String, - params: serde_json::Value, - }, - Halt, - Unparseable(String), -} +#[cfg(test)] +mod tests { + use super::*; + use crate::cognition::FakeCognitionBackend; -/// Parse the canonical Actor response: a JSON object of shape -/// `{"tool": "...", "params": {...}}` or `{"halt": true}`. -/// Adapters strip vendor envelopes (markdown fences, harmony, etc.) -/// and populate `tool_call_hint` for tool-use formats; this parser -/// only sees canonical JSON text. -fn parse_canonical_action(text: &str) -> ParsedAction { - let value: serde_json::Value = match serde_json::from_str(text.trim()) { - Ok(v) => v, - Err(e) => { - return ParsedAction::Unparseable(format!( - "response is not a JSON object: {e}" - )); + fn propose(tool: &str, params: serde_json::Value) -> ActionHint { + ActionHint::Propose { + tool: tool.to_string(), + params, } - }; - if value.get("halt").and_then(serde_json::Value::as_bool) == Some(true) { - return ParsedAction::Halt; } - let tool = match value.get("tool").and_then(serde_json::Value::as_str) { - Some(t) => t.to_string(), - None => { - return ParsedAction::Unparseable( - "response JSON has no `tool` field and no `halt: true`".into(), - ); - } - }; - let params = value - .get("params") - .cloned() - .unwrap_or(serde_json::Value::Null); - ParsedAction::Propose { tool, params } -} -#[cfg(test)] -mod tests { - use super::*; + #[tokio::test] + async fn commits_on_first_action_hint() { + let backend = Arc::new(FakeCognitionBackend::new("a1")); + backend.enqueue_action(propose( + "write_file", + serde_json::json!({"path": "out.md"}), + )); + let mut actor = LlmActor::new("a", backend, "sys", "ctx"); - #[test] - fn parses_raw_json_propose() { - match parse_canonical_action(r#"{"tool":"write_file","params":{"path":"x"}}"#) { - ParsedAction::Propose { tool, .. } => assert_eq!(tool, "write_file"), + match actor.step(None).await { + Action::Propose(tc) => { + assert_eq!(tc.tool.0, "write_file"); + assert_eq!(tc.params.0["path"].as_str(), Some("out.md")); + } other => panic!("expected Propose, got {other:?}"), } } - #[test] - fn parses_raw_json_halt() { - match parse_canonical_action(r#"{"halt": true}"#) { - ParsedAction::Halt => {} + #[tokio::test] + async fn returns_halt_on_halt_action_hint() { + let backend = Arc::new(FakeCognitionBackend::new("a2")); + backend.enqueue_action(ActionHint::Halt); + let mut actor = LlmActor::new("a", backend, "sys", "ctx"); + match actor.step(None).await { + Action::Halt => {} other => panic!("expected Halt, got {other:?}"), } } - #[test] - fn unparseable_garbage_is_reported() { - match parse_canonical_action("just some prose with no json") { - ParsedAction::Unparseable(_) => {} - other => panic!("expected Unparseable, got {other:?}"), + #[tokio::test] + async fn reasoning_responses_continue_inner_loop_until_commit() { + let backend = Arc::new(FakeCognitionBackend::new("a3")); + backend + .enqueue("First I reason about the request.") + .enqueue("Now I plan: I'll write the file.") + .enqueue_action(propose( + "write_file", + serde_json::json!({"path": "out.md"}), + )); + let mut actor = LlmActor::new("a", backend, "sys", "ctx"); + + match actor.step(None).await { + Action::Propose(tc) => { + assert_eq!(tc.tool.0, "write_file"); + assert_eq!(tc.params.0["path"].as_str(), Some("out.md")); + } + other => panic!("expected Propose after reasoning, got {other:?}"), } } - #[test] - fn propose_with_no_params_field_yields_null_params() { - match parse_canonical_action(r#"{"tool":"halt_check"}"#) { - ParsedAction::Propose { tool, params } => { - assert_eq!(tool, "halt_check"); - assert_eq!(params, serde_json::Value::Null); + #[tokio::test] + async fn budget_exhaustion_fails_with_diagnostic() { + let backend = Arc::new(FakeCognitionBackend::new("a4")); + for _ in 0..DEFAULT_INNER_STEP_BUDGET { + backend.enqueue("more reasoning, no commit"); + } + let mut actor = LlmActor::new("a", backend, "sys", "ctx"); + + match actor.step(None).await { + Action::Fail { reason } => { + assert!( + reason.contains("inner step budget exhausted"), + "reason: {reason}" + ); } - other => panic!("expected Propose, got {other:?}"), + other => panic!("expected Fail, got {other:?}"), } } - #[test] - fn missing_tool_and_no_halt_is_unparseable() { - match parse_canonical_action(r#"{"params":{"x":1}}"#) { - ParsedAction::Unparseable(_) => {} - other => panic!("expected Unparseable, got {other:?}"), + #[tokio::test] + async fn custom_budget_is_respected() { + let backend = Arc::new(FakeCognitionBackend::new("a5")); + backend.enqueue("just reasoning"); // one response only + let mut actor = + LlmActor::new("a", backend, "sys", "ctx").with_inner_step_budget(1); + match actor.step(None).await { + Action::Fail { reason } => { + assert!(reason.contains("inner step budget exhausted (1 steps)")); + } + other => panic!("expected Fail with budget=1, got {other:?}"), } } } diff --git a/core/src/artifact.rs b/core/src/artifact.rs index 2a4e313..9195e33 100644 --- a/core/src/artifact.rs +++ b/core/src/artifact.rs @@ -60,11 +60,13 @@ pub fn kind_text() -> ArtifactKindId { ArtifactKindId::new("text") } -/// Reference kind: an append-only collection of `Finding` records, queried -/// through `ArtifactBackend::read` and appended through -/// `ArtifactBackend::modify`. -pub fn kind_findings_store() -> ArtifactKindId { - ArtifactKindId::new("findings-store") +/// Reference kind: an append-only store of structured records. The +/// kernel injects runtime provenance (`_task_id`, `_steward_id`, +/// `_snapshot_id`, `_receipt_id`) into the Edit; the content fields +/// under `edit.append` are opaque to the kernel — Charters define +/// whatever shape suits the deployment. +pub fn kind_record_store() -> ArtifactKindId { + ArtifactKindId::new("record-store") } // ============================================================ @@ -81,8 +83,9 @@ pub struct Artifact { /// Kind-discriminated Selector. Each Backend deserializes against its own /// schema. For `kind=text`, contains an optional `range` field naming a -/// byte-coordinate substring. For `kind=findings-store`, contains an -/// optional `filter` field naming a query (severity, frame, steward, etc.). +/// byte-coordinate substring. For `kind=record-store`, contains an +/// optional `filter` object whose entries are field=value equality +/// checks against the persisted record (any field; metadata or content). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Selector(pub serde_json::Value); @@ -97,8 +100,11 @@ impl Selector { } /// Kind-discriminated Edit. For `kind=text`, names a `range` and a -/// `replacement` substring. For `kind=findings-store`, names an `append` -/// of one Finding record. +/// `replacement` substring. For `kind=record-store`, names an `append` +/// object whose fields are opaque content (the Charter defines the +/// shape); runtime provenance flows in via `_*`-prefixed metadata at +/// the Edit top level (injected by the kernel at `ModifyArtifact` +/// dispatch). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Edit(pub serde_json::Value); @@ -124,19 +130,22 @@ pub struct ArtifactRange { pub end_line: usize, } -/// A structured record stored in a `kind=findings-store` artifact. +/// A record persisted by a `kind=record-store` Backend. Carries the +/// kernel-injected runtime provenance plus the Steward-supplied content +/// (opaque to the kernel — the Charter defines the shape). Serializes +/// flat: provenance fields plus every content field at top level. #[derive(Debug, Clone, Serialize)] -pub struct Finding { +pub struct Record { pub id: String, pub task_id: TaskId, - pub author_steward_id: StewardId, + pub steward_id: StewardId, pub snapshot_id: SnapshotId, - pub artifact_id: ArtifactId, - pub range: ArtifactRange, - pub concern: String, - pub severity: String, - pub detail: String, - pub admitting_receipt_id: ReceiptId, + pub receipt_id: ReceiptId, + /// Opaque content fields. Serialized into the persisted record at + /// top level (flattened) so query filters can match either + /// provenance or content uniformly. + #[serde(flatten)] + pub content: serde_json::Map, } // ============================================================ @@ -361,33 +370,33 @@ impl ArtifactBackend for InMemoryTextBackend { } } -/// `kind=findings-store` Backend backed by an in-memory `Vec`. -/// Exposes one artifact (typically `findings`); `read` returns the filtered -/// list, `modify` appends one Finding. -pub struct InMemoryFindingsBackend { +/// `kind=record-store` Backend backed by an in-memory `Vec`. +/// Exposes one artifact whose id the constructor takes; `read` returns +/// the filtered list, `modify` appends one Record. +pub struct InMemoryRecordStore { kind: ArtifactKindId, artifact_id: ArtifactId, - findings: Mutex>, + records: Mutex>, counter: AtomicU64, } -impl InMemoryFindingsBackend { +impl InMemoryRecordStore { pub fn new(artifact_id: ArtifactId) -> Self { Self { - kind: kind_findings_store(), + kind: kind_record_store(), artifact_id, - findings: Mutex::new(Vec::new()), + records: Mutex::new(Vec::new()), counter: AtomicU64::new(0), } } - pub fn findings(&self) -> Vec { - self.findings.lock().unwrap().clone() + pub fn records(&self) -> Vec { + self.records.lock().unwrap().clone() } } #[async_trait] -impl ArtifactBackend for InMemoryFindingsBackend { +impl ArtifactBackend for InMemoryRecordStore { fn kind(&self) -> &ArtifactKindId { &self.kind } @@ -406,94 +415,95 @@ impl ArtifactBackend for InMemoryFindingsBackend { ) -> Result { if artifact_id != &self.artifact_id { return Err(format!( - "findings-store has no artifact `{artifact_id}` (only `{}` exists)", + "record-store has no artifact `{artifact_id}` (only `{}` exists)", self.artifact_id )); } - let filter = parse_findings_filter(selector)?; - let findings = self.findings.lock().unwrap(); - let filtered: Vec<&Finding> = findings.iter().filter(|f| filter.matches(f)).collect(); + let filter = parse_record_filter(selector)?; + let records = self.records.lock().unwrap(); + let filtered: Vec<&Record> = records + .iter() + .filter(|r| { + let v = serde_json::to_value(r).unwrap_or(serde_json::Value::Null); + filter.matches_value(&v) + }) + .collect(); Ok(Projection(serde_json::json!({ - "findings": filtered, + "records": filtered, }))) } async fn modify(&self, artifact_id: &ArtifactId, edit: &Edit) -> Result { if artifact_id != &self.artifact_id { return Err(format!( - "findings-store has no artifact `{artifact_id}` (only `{}` exists)", + "record-store has no artifact `{artifact_id}` (only `{}` exists)", self.artifact_id )); } - let append = parse_findings_append(edit)?; + let append = parse_record_append(edit)?; let n = self.counter.fetch_add(1, Ordering::Relaxed) + 1; - let finding = Finding { - id: format!("F-{n:03}"), + let record = Record { + id: format!("record-{n:03}"), task_id: append.task_id, - author_steward_id: append.author_steward_id, + steward_id: append.steward_id, snapshot_id: append.snapshot_id, - artifact_id: append.artifact_id, - range: append.range, - concern: append.concern, - severity: append.severity, - detail: append.detail, - admitting_receipt_id: append.receipt_id, + receipt_id: append.receipt_id, + content: append.content, }; - let id = finding.id.clone(); - self.findings.lock().unwrap().push(finding); - Ok(Projection(serde_json::json!({ "finding_id": id }))) + let id = record.id.clone(); + self.records.lock().unwrap().push(record); + Ok(Projection(serde_json::json!({ "record_id": id }))) } } // ============================================================ -// Backward-compat façade: InMemoryArtifactStore +// In-memory façade: InMemoryArtifactStore // ============================================================ /// Façade that bundles an in-memory text Backend + an in-memory -/// findings-store Backend behind one ArtifactStore. Preserves the legacy -/// API for tests and the dashboard's in-process mode while the kernel -/// dispatches through the OS-contract substrate beneath. +/// record-store Backend behind one ArtifactStore. Used by kernel tests +/// and by the dashboard's in-process mode. pub struct InMemoryArtifactStore { pub store: std::sync::Arc, text: std::sync::Arc, - findings: std::sync::Arc, + records: std::sync::Arc, } impl InMemoryArtifactStore { pub fn new(artifacts: I) -> Self where - I: IntoIterator, + I: IntoIterator, { let entries = artifacts.into_iter().map(|a| (a.id, a.content)); let text = std::sync::Arc::new(InMemoryTextBackend::new(entries)); - let findings = - std::sync::Arc::new(InMemoryFindingsBackend::new(ArtifactId::new("findings"))); + let records = + std::sync::Arc::new(InMemoryRecordStore::new(ArtifactId::new("records"))); // One Backend per kind; the ArtifactStore enforces this on // registration. Order is not load-bearing. let store = std::sync::Arc::new( ArtifactStore::new() - .with_backend(findings.clone()) + .with_backend(records.clone()) .with_backend(text.clone()), ); Self { store, text, - findings, + records, } } - /// Legacy accessor returning text artifacts as `LegacyArtifact { id, content }`. - pub fn artifacts(&self) -> Vec { + /// Accessor returning text artifacts as `TextArtifactSeed { id, content }`. + pub fn artifacts(&self) -> Vec { self.text .entries() .into_iter() - .map(|(id, content)| LegacyArtifact { id, content }) + .map(|(id, content)| TextArtifactSeed { id, content }) .collect() } - /// Legacy accessor returning the findings collection. - pub fn findings(&self) -> Vec { - self.findings.findings() + /// Accessor returning the records collection. + pub fn records(&self) -> Vec { + self.records.records() } pub fn artifact_store(&self) -> std::sync::Arc { @@ -501,10 +511,10 @@ impl InMemoryArtifactStore { } } -/// Legacy text-only Artifact shape preserved for tests and the in-memory +/// Test-seed shape: a `(id, content)` pair preserved for tests and the in-memory /// seeding API. New code uses `Artifact` (the kind-typed handle). #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LegacyArtifact { +pub struct TextArtifactSeed { pub id: ArtifactId, pub content: String, } @@ -598,152 +608,96 @@ pub fn apply_text_edit( Ok(next) } -/// Selector shape for `kind=findings-store` artifacts. Single source of -/// truth — both `InMemoryFindingsBackend` (kernel typed records) and -/// `FilesystemFindingsBackend` (JSONL records on disk) parse through -/// `parse_findings_filter` and match through this struct. +/// Selector shape for `kind=record-store` artifacts. Generic +/// field-equality filter: every string-valued entry in `selector.filter` +/// matches a top-level field of the persisted record (metadata or +/// content). Records that don't carry a queried field, or carry a +/// different value, fall out of the projection. #[derive(Debug, Default)] -pub struct FindingsFilter { - pub severity: Option, - pub artifact_id: Option, - pub task_id: Option, - pub author_steward_id: Option, -} - -impl FindingsFilter { - pub fn matches(&self, f: &Finding) -> bool { - if let Some(s) = &self.severity - && &f.severity != s - { - return false; - } - if let Some(a) = &self.artifact_id - && &f.artifact_id.0 != a - { - return false; - } - if let Some(task_id) = &self.task_id - && &f.task_id.0 != task_id - { - return false; - } - if let Some(s) = &self.author_steward_id - && &f.author_steward_id.0 != s - { - return false; - } - true - } +pub struct RecordFilter { + pub fields: std::collections::BTreeMap, +} - /// Match against a JSON record (used by the disk-backed Findings - /// Backend, where each line is a `serde_json::Value` parsed lazily - /// from JSONL). Mirrors `matches(&Finding)` field-for-field on the - /// same kernel-defined Selector schema. +impl RecordFilter { + /// Match against any JSON value with top-level fields — both the + /// in-memory `Record` (after serializing) and disk-backed JSONL + /// lines parsed back into a `serde_json::Value` use this path. pub fn matches_value(&self, v: &serde_json::Value) -> bool { - let str_field = |name: &str| v.get(name).and_then(|x| x.as_str()); - if let Some(s) = &self.severity - && str_field("severity") != Some(s) - { - return false; - } - if let Some(a) = &self.artifact_id - && str_field("artifact_id") != Some(a) - { - return false; - } - if let Some(task_id) = &self.task_id - && str_field("task_id") != Some(task_id) - { - return false; - } - if let Some(s) = &self.author_steward_id - && str_field("author_steward_id") != Some(s) - { - return false; + for (name, expected) in &self.fields { + let actual = v.get(name).and_then(|x| x.as_str()); + if actual != Some(expected.as_str()) { + return false; + } } true } } -pub fn parse_findings_filter(selector: &Selector) -> Result { +pub fn parse_record_filter(selector: &Selector) -> Result { if selector.0.is_null() { - return Ok(FindingsFilter::default()); + return Ok(RecordFilter::default()); } let obj = selector .0 .as_object() - .ok_or_else(|| "findings selector must be a JSON object".to_string())?; + .ok_or_else(|| "record selector must be a JSON object".to_string())?; let filter = obj.get("filter").and_then(|v| v.as_object()); - let mut out = FindingsFilter::default(); + let mut fields = std::collections::BTreeMap::new(); if let Some(filter) = filter { - out.severity = filter - .get("severity") - .and_then(|v| v.as_str()) - .map(str::to_owned); - out.artifact_id = filter - .get("artifact_id") - .and_then(|v| v.as_str()) - .map(str::to_owned); - out.task_id = filter - .get("task_id") - .and_then(|v| v.as_str()) - .map(str::to_owned); - out.author_steward_id = filter - .get("author_steward_id") - .or_else(|| filter.get("steward_id")) - .and_then(|v| v.as_str()) - .map(str::to_owned); - } - Ok(out) -} - -/// Edit shape for `kind=findings-store` `append`. Single source of -/// truth — both `InMemoryFindingsBackend` and the disk-backed -/// `FilesystemFindingsBackend` parse through `parse_findings_append`. -/// Each backend chooses its own `Finding::id` scheme (sequential for -/// in-memory test fixtures, receipt-derived for the durable disk -/// backend); only the parse contract is shared. -pub struct FindingsAppend { + for (k, val) in filter { + if let Some(s) = val.as_str() { + fields.insert(k.clone(), s.to_string()); + } + } + } + Ok(RecordFilter { fields }) +} + +/// Append shape for `kind=record-store`. Runtime provenance comes from +/// `_*` fields the kernel injected into the Edit at `ModifyArtifact` +/// dispatch; the Steward-supplied content lives under `edit.append` and +/// is opaque to the kernel — every field there flows through into the +/// persisted record as-is, with the kernel adding `id`, `task_id`, +/// `steward_id`, `snapshot_id`, `receipt_id` at the top level. +pub struct RecordAppend { pub task_id: TaskId, - pub author_steward_id: StewardId, + pub steward_id: StewardId, pub snapshot_id: SnapshotId, - pub artifact_id: ArtifactId, - pub range: ArtifactRange, - pub concern: String, - pub severity: String, - pub detail: String, pub receipt_id: ReceiptId, + pub content: serde_json::Map, +} + +/// Read a kernel-injected `_*` metadata field at the top of the Edit +/// object. The runtime injects `_receipt_id`, `_task_id`, +/// `_attempt_id`, `_steward_id`, `_snapshot_id` into the Edit at the +/// `ModifyArtifact` dispatch boundary (`edit_from_params`); Backends +/// that need provenance read them from here. Steward-supplied payload +/// lives under the kind-specific key (e.g. `append`); the kernel +/// metadata namespace (`_`-prefix) does not collide with it. +fn runtime_metadata_field<'a>( + obj: &'a serde_json::Map, + name: &str, +) -> Result<&'a str, String> { + obj.get(name) + .and_then(|v| v.as_str()) + .ok_or_else(|| format!("record edit missing runtime metadata field: {name}")) } -pub fn parse_findings_append(edit: &Edit) -> Result { +pub fn parse_record_append(edit: &Edit) -> Result { let obj = edit .0 .as_object() - .ok_or_else(|| "findings edit must be a JSON object".to_string())?; + .ok_or_else(|| "record edit must be a JSON object".to_string())?; let append = obj .get("append") .and_then(|v| v.as_object()) - .ok_or_else(|| "findings edit missing required field: append".to_string())?; - let str_field = |name: &str| -> Result { - append - .get(name) - .and_then(|v| v.as_str()) - .map(str::to_owned) - .ok_or_else(|| format!("findings append missing required field: {name}")) - }; - let range_value = append - .get("range") - .ok_or_else(|| "findings append missing required field: range".to_string())?; - Ok(FindingsAppend { - task_id: TaskId::new(str_field("task_id")?), - author_steward_id: StewardId::new(str_field("author_steward_id")?), - snapshot_id: SnapshotId(str_field("snapshot_id")?), - artifact_id: ArtifactId::new(str_field("artifact_id")?), - range: parse_artifact_range(range_value)?, - concern: str_field("concern")?, - severity: str_field("severity")?, - detail: str_field("detail")?, - receipt_id: ReceiptId(str_field("receipt_id")?), + .ok_or_else(|| "record edit missing required field: append".to_string())?; + Ok(RecordAppend { + task_id: TaskId::new(runtime_metadata_field(obj, "_task_id")?), + steward_id: StewardId::new(runtime_metadata_field(obj, "_steward_id")?), + snapshot_id: SnapshotId(runtime_metadata_field(obj, "_snapshot_id")?.to_string()), + receipt_id: ReceiptId(runtime_metadata_field(obj, "_receipt_id")?.to_string()), + content: append.clone(), }) } @@ -774,13 +728,16 @@ fn selector_from_params(kind: &ArtifactKindId, params: &ToolParams) -> Selector } /// Edit ABI: the `edit` field carries any JSON Value (kind-shaped). For -/// convenience, top-level `range` + `replacement` fields on the Tool params -/// (without `edit`) are promoted to a text Edit — only when `kind=text`. +/// convenience, top-level `range` + `replacement` fields on the Tool +/// params (without `edit`) are promoted to a text Edit — only when +/// `kind=text`. Runtime-injected `_*` metadata at the top of params is +/// propagated into the Edit at the same level, so Backends that need +/// provenance (record-store and any future tamper-evident kind) read +/// it from the Edit they are handed. Backends that don't care ignore it. fn edit_from_params(kind: &ArtifactKindId, params: &ToolParams) -> Result { - if let Some(edit) = params.0.get("edit") { - return Ok(Edit::from_value(edit.clone())); - } - if kind == &kind_text() { + let mut value = if let Some(edit) = params.0.get("edit") { + edit.clone() + } else if kind == &kind_text() { let range = params .0 .get("range") @@ -791,14 +748,28 @@ fn edit_from_params(kind: &ArtifactKindId, params: &ToolParams) -> Result, - findings_artifact_id: ArtifactId, -} - -impl RecordFinding { - pub fn new(id: ToolId, store: std::sync::Arc) -> Self { - Self::with_artifact_id(id, store, ArtifactId::new("findings")) - } - - pub fn with_artifact_id( - id: ToolId, - store: std::sync::Arc, - findings_artifact_id: ArtifactId, - ) -> Self { - Self { - id, - store, - findings_artifact_id, - } - } -} - -#[async_trait] -impl ToolExecutor for RecordFinding { - fn id(&self) -> &ToolId { - &self.id - } - - async fn execute(&self, params: &ToolParams) -> ToolResult { - // Sugar over modify_artifact against kind=findings-store. Kind and - // findings-store artifact_id are fixed by the Tool's identity; - // the params shape carries the finding's content fields. - let p = ¶ms.0; - let str_field = |name: &str| -> Result<&str, String> { - p.get(name) - .and_then(|v| v.as_str()) - .ok_or_else(|| format!("missing required field: {name}")) - }; - let range_value = p - .get("range") - .ok_or_else(|| "missing required field: range".to_string())?; - let append = serde_json::json!({ - "append": { - "task_id": str_field("_task_id")?, - "author_steward_id": str_field("_steward_id")?, - "snapshot_id": str_field("_snapshot_id")?, - "receipt_id": str_field("_receipt_id")?, - "artifact_id": str_field("artifact_id")?, - "range": range_value, - "concern": str_field("concern")?, - "severity": str_field("severity")?, - "detail": str_field("detail")?, - } - }); - let edit = Edit::from_value(append); - let projection = self - .store - .modify(&kind_findings_store(), &self.findings_artifact_id, &edit) - .await?; - Ok(projection.0) - } -} diff --git a/core/src/charter_loader.rs b/core/src/charter_loader.rs index 1773803..feea493 100644 --- a/core/src/charter_loader.rs +++ b/core/src/charter_loader.rs @@ -1,25 +1,33 @@ -//! Load Charter and Role context from filesystem artifacts. Spec -//! §The Charter, §Role Context, §Reference Charters. +//! Charter and Role context parsing — pure data shapes, no IO. //! -//! Inputs: -//! - `/frames.toml` — `permitted_tools` + `[[frames]]` -//! tables with `id`, `concern`, `applies_to_tools`, typed -//! `declared_scopes`, optional `prior_receipt_queries`. -//! - `/scopes.md` — Charter Scopes; `## Heading` lines -//! become scope names via slugify (lowercase, non-alphanumeric runs -//! collapsed to `_`); content between headings becomes scope text. -//! - `/role_context.md` — Role context Scopes; same -//! markdown format as scopes.md but parsed by `load_role_context_def` -//! into `RoleContextDef`. +//! Spec §The Charter, §Role Context, §Reference Charters. //! -//! Two-stage construction: parser → `CharterDef`/`RoleContextDef` +//! The kernel parses already-loaded text into the data shapes +//! `CharterDef` / `RoleContextDef`; deployment-side IO (reading +//! `frames.toml`, `scopes.md`, `behavioral_spec.md`, `role_context.md`, +//! `skills/*.md` from disk or any other substrate) lives outside the +//! kernel. The kernel has no `std::fs` dependency; substrate choice is +//! a deployment concern. +//! +//! Input shapes: +//! - `frames.toml` text — `permitted_tools` + `[[frames]]` tables with +//! `id`, `concern`, `applies_to_tools`, typed `declared_scopes`, +//! optional `prior_receipt_queries`. +//! - `scopes.md` text — Charter Scopes; `## Heading` lines become scope +//! names via slugify (lowercase, non-alphanumeric runs collapsed to +//! `_`); content between headings becomes scope text. +//! - `behavioral_spec.md` text — conduct prose composed verbatim into +//! the Actor's system prompt. +//! - `role_context.md` text — Role context Scopes (same markdown format +//! as `scopes.md`). +//! +//! Two-stage construction: pure parser → `CharterDef`/`RoleContextDef` //! (data only, no Evaluator instances), then `build_charter` / -//! `build_role_context` materialize the runtime Charter/RoleContext -//! by attaching an Evaluator (caller chooses backend) and a version -//! number (caller supplies from `charter.toml` / `role_context.md` -//! deployment metadata). +//! `build_role_context` materialize the runtime Charter/RoleContext by +//! attaching an Evaluator (caller chooses backend) and a version number +//! (caller supplies from `charter.toml` / `role_context.md` deployment +//! metadata). -use std::path::Path; use std::sync::Arc; use serde::Deserialize; @@ -68,33 +76,27 @@ pub struct RoleContextDef { pub role_context_content_hash: String, } -/// Load a Charter from a directory containing `frames.toml`, -/// `scopes.md`, and `behavioral_spec.md`. Returns `CharterDef` (data +/// Parse a Charter from already-loaded text. Returns `CharterDef` (data /// only); use `build_charter` to attach Evaluators. /// -/// `behavioral_spec.md` is the conduct prose the Runtime composes into -/// the Actor's system prompt. Per spec §The Charter, this lives on the +/// `source_name` is a substrate-agnostic identifier used only in error +/// messages (e.g. the file path the caller read from). The parser has +/// no opinion on where the text came from. +/// +/// `behavioral_spec` is the conduct prose composed verbatim into the +/// Actor's system prompt. Per spec §The Charter, this lives on the /// Charter (per-Steward), not in deployment-side configuration. -pub fn load_charter_def(charter_dir: &Path) -> Result { - let frames_path = charter_dir.join("frames.toml"); - let scopes_path = charter_dir.join("scopes.md"); - let behavioral_spec_path = charter_dir.join("behavioral_spec.md"); - - let frames_text = std::fs::read_to_string(&frames_path).map_err(|e| { - CharterLoadError(format!("reading {}: {e}", frames_path.display())) - })?; - let scopes_text = std::fs::read_to_string(&scopes_path).map_err(|e| { - CharterLoadError(format!("reading {}: {e}", scopes_path.display())) - })?; - let behavioral_spec = std::fs::read_to_string(&behavioral_spec_path).map_err(|e| { - CharterLoadError(format!("reading {}: {e}", behavioral_spec_path.display())) - })?; - - let doc: FramesDocToml = toml::from_str(&frames_text).map_err(|e| { - CharterLoadError(format!("parsing {}: {e}", frames_path.display())) +pub fn parse_charter_def( + frames_toml: &str, + scopes_md: &str, + behavioral_spec: &str, + source_name: &str, +) -> Result { + let doc: FramesDocToml = toml::from_str(frames_toml).map_err(|e| { + CharterLoadError(format!("parsing {source_name}: {e}")) })?; - let charter_scopes = parse_named_sections(&scopes_text); + let charter_scopes = parse_named_sections(scopes_md); let mut frames = Vec::with_capacity(doc.frames.len()); for ft in doc.frames { @@ -105,9 +107,8 @@ pub fn load_charter_def(charter_dir: &Path) -> Result ScopeKind::RoleContext, other => { return Err(CharterLoadError(format!( - "unknown ScopeKind `{other}` in frame `{}` of {}", - ft.id, - frames_path.display() + "unknown ScopeKind `{other}` in frame `{}` of {source_name}", + ft.id ))); } }; @@ -130,9 +131,9 @@ pub fn load_charter_def(charter_dir: &Path) -> Result Result Result { - let text = std::fs::read_to_string(path) - .map_err(|e| CharterLoadError(format!("reading {}: {e}", path.display())))?; - let scopes = parse_named_sections(&text); +/// Parse Role context from already-loaded markdown text using the same +/// `## Heading` → slugified-name + content convention as `scopes.md`. +pub fn parse_role_context_def(text: &str) -> RoleContextDef { + let scopes = parse_named_sections(text); let mut hasher = Sha256::new(); hasher.update(text.as_bytes()); let role_context_content_hash = hex::encode(hasher.finalize()); - Ok(RoleContextDef { + RoleContextDef { scopes, role_context_content_hash, - }) + } } pub fn build_role_context(def: RoleContextDef, role_context_version: u64) -> RoleContext { diff --git a/core/src/cognition.rs b/core/src/cognition.rs index bf8dc8c..3bed999 100644 --- a/core/src/cognition.rs +++ b/core/src/cognition.rs @@ -71,29 +71,68 @@ pub struct CognitionRequest { pub max_output_tokens: Option, } -/// Adapter-canonicalized assistant response. `text` is the bare body -/// the role consumes for line-based or JSON parsing — adapters strip -/// vendor envelopes (gpt-oss harmony, markdown fences, etc.) before -/// returning. `tool_call_hint` is set when the wire format separated -/// the tool name from its params (harmony `to=` header + JSON body, -/// OpenAI native `tool_calls`, etc.) — the Actor consumes the hint -/// directly without re-parsing `text`. +/// Adapter-canonicalized assistant response. Distinct fields by +/// ontology, not duplication: +/// - `content` is the verbatim assistant output (whatever the model +/// emitted as its message). Kept for `cognition.jsonl` so operators +/// see what the model actually said, and appended to the Actor's +/// history so the LLM sees its own prior reasoning on the next +/// inner step. The kernel does NOT parse it. +/// - `action_hint`, `verdict_lines`, `judge_output` are the role- +/// specific strong types the adapter extracted from whichever wire +/// shape the vendor used (harmony envelope, plain prose, vendor- +/// native tool-call fields, etc.). Each role-consumer (Actor, +/// Evaluator, Judge) reads its relevant field directly — the +/// kernel performs no text or JSON parsing. +/// +/// Adapters populate the fields they have evidence for; consumers that +/// don't see their field treat it as "the response was content-only +/// for this role" (Actor: continue inner loop; Evaluator: no verdict +/// produced — Gate's empty-trace fallback applies; Judge: same). #[derive(Debug, Clone, Serialize)] pub struct CognitionResponse { - pub text: String, + pub content: String, pub input_tokens: u32, pub output_tokens: u32, pub cache_hit_tokens: u32, #[serde(skip_serializing_if = "Option::is_none")] - pub tool_call_hint: Option, + pub action_hint: Option, + /// Strong-typed Evaluator output the adapter extracted from + /// content. One entry per decision the model emitted. Empty when + /// the adapter found none (pure prose, no recognizable decision). + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub verdict_lines: Vec, + /// Strong-typed Judge output the adapter extracted from content. + /// `None` when the adapter found no parseable Judge output. + #[serde(skip_serializing_if = "Option::is_none")] + pub judge_output: Option, +} + +/// One adapter-extracted Evaluator decision line. The adapter produces +/// these without knowing which Evaluator role is consuming them; the +/// LlmEvaluator stamps its own `evaluator_id` when wrapping these +/// into `EvaluatorEntry` for the Gate. +#[derive(Debug, Clone, Serialize)] +pub struct DecisionLine { + pub decision: crate::verdict::Decision, + pub observation: String, } -/// Adapter-extracted structured tool call. When present, the Actor -/// builds a `ToolCall` from this directly without parsing `text`. +/// Strong-typed Action surface that the adapter produces from a vendor +/// response. The Actor consumes this directly — there is no JSON-from- +/// string fallback in the kernel. +/// +/// `Propose.params` is `serde_json::Value` because params are dynamic +/// per Tool (the kernel doesn't know all Tools' schemas — the executor +/// deserializes into its specific shape). Everything else is typed. #[derive(Debug, Clone, Serialize)] -pub struct ToolCallHint { - pub tool: String, - pub params: serde_json::Value, +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ActionHint { + Propose { + tool: String, + params: serde_json::Value, + }, + Halt, } #[derive(Debug, Clone)] @@ -143,48 +182,85 @@ impl FakeCognitionBackend { } } - /// Enqueue a canonical text response. - pub fn enqueue(&self, text: impl Into) -> &Self { - self.queue.lock().unwrap().push_back(CognitionResponse { - text: text.into(), - input_tokens: 0, - output_tokens: 0, - cache_hit_tokens: 0, - tool_call_hint: None, - }); + /// Enqueue a pure-reasoning response — `action_hint` is `None`. + /// The Actor treats this as an inner-loop reasoning step and + /// continues without committing an Action. + pub fn enqueue(&self, content: impl Into) -> &Self { + self.queue.lock().unwrap().push_back(content_only(content.into())); + self + } + + /// Enqueue a structured Action response — what the adapter would + /// have canonicalized from a vendor wire shape. The Actor commits + /// immediately on this. `content` is auto-derived for + /// `cognition.jsonl` visibility (and so the Actor's history shows + /// the action the model emitted). + pub fn enqueue_action(&self, action: ActionHint) -> &Self { + let content = match &action { + ActionHint::Halt => r#"{"halt":true}"#.to_string(), + ActionHint::Propose { tool, params } => { + serde_json::json!({ "tool": tool, "params": params }).to_string() + } + }; + let mut r = content_only(content); + r.action_hint = Some(action); + self.queue.lock().unwrap().push_back(r); + self + } + + /// Enqueue an explicit (content, action_hint) pair. Used by the + /// runtime when configuring fake backends from TOML + /// `fake_responses` — the strings stay JSON-shaped for operator + /// ergonomics, and the runtime canonicalizes each into an + /// `ActionHint` before enqueueing. + pub fn enqueue_with_action( + &self, + content: impl Into, + action_hint: Option, + ) -> &Self { + let mut r = content_only(content.into()); + r.action_hint = action_hint; + self.queue.lock().unwrap().push_back(r); + self + } + + /// Enqueue a strong-typed Evaluator response — the adapter + /// equivalent for fake-LLM tests. `content` auto-derived so the + /// cognition.jsonl record reflects what a real model would have + /// emitted (line-shaped DECISION text). + pub fn enqueue_verdict_lines(&self, lines: Vec) -> &Self { + let content = lines + .iter() + .map(|l| format!("{}: {}", decision_to_keyword(l.decision), l.observation)) + .collect::>() + .join("\n"); + let mut r = content_only(content); + r.verdict_lines = lines; + self.queue.lock().unwrap().push_back(r); self } - /// Enqueue a structured tool-call hint. Used to simulate adapters - /// that pre-extract `(tool, params)` from a tool-use wire format. - pub fn enqueue_hint(&self, tool: impl Into, params: serde_json::Value) -> &Self { - self.queue.lock().unwrap().push_back(CognitionResponse { - text: String::new(), - input_tokens: 0, - output_tokens: 0, - cache_hit_tokens: 0, - tool_call_hint: Some(ToolCallHint { - tool: tool.into(), - params, - }), - }); + /// Enqueue a strong-typed Judge response. + pub fn enqueue_judge_output(&self, output: crate::scenario::JudgeOutput) -> &Self { + let content = serde_json::to_string(&output).unwrap_or_default(); + let mut r = content_only(content); + r.judge_output = Some(output); + self.queue.lock().unwrap().push_back(r); self } pub fn enqueue_with_tokens( &self, - text: impl Into, + content: impl Into, input_tokens: u32, output_tokens: u32, cache_hit_tokens: u32, ) -> &Self { - self.queue.lock().unwrap().push_back(CognitionResponse { - text: text.into(), - input_tokens, - output_tokens, - cache_hit_tokens, - tool_call_hint: None, - }); + let mut r = content_only(content.into()); + r.input_tokens = input_tokens; + r.output_tokens = output_tokens; + r.cache_hit_tokens = cache_hit_tokens; + self.queue.lock().unwrap().push_back(r); self } @@ -199,6 +275,32 @@ impl FakeCognitionBackend { } } +/// Construct a `CognitionResponse` that carries only verbatim content +/// — no role-specific strong types. The Actor's inner loop treats this +/// as a reasoning step; the Evaluator's empty-trace path applies; the +/// Judge sees no parseable output. Used by every `enqueue_*` variant +/// as the base before stamping the relevant role's strong type. +fn content_only(content: String) -> CognitionResponse { + CognitionResponse { + content, + input_tokens: 0, + output_tokens: 0, + cache_hit_tokens: 0, + action_hint: None, + verdict_lines: Vec::new(), + judge_output: None, + } +} + +fn decision_to_keyword(d: crate::verdict::Decision) -> &'static str { + match d { + crate::verdict::Decision::Allow => "ALLOW", + crate::verdict::Decision::Deny => "DENY", + crate::verdict::Decision::Escalate => "ESCALATE", + crate::verdict::Decision::Defer => "DEFER", + } +} + #[async_trait] impl CognitionBackend for FakeCognitionBackend { fn id(&self) -> &str { diff --git a/core/src/lib.rs b/core/src/lib.rs index 7437b73..a3c68d4 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -20,6 +20,7 @@ mod loop_runner; mod prompt; mod receipt; mod scenario; +mod skill; mod snapshot; mod steward; mod task; @@ -27,23 +28,23 @@ mod tool; mod verdict; mod workspace; -pub use actor::{Action, Actor, LlmActor, Observation}; +pub use actor::{Action, Actor, DEFAULT_INNER_STEP_BUDGET, LlmActor, Observation}; pub use artifact::{ Artifact, ArtifactBackend, ArtifactId, ArtifactKindId, ArtifactRange, ArtifactStore, - BackendConflict, Edit, Finding, FindingsAppend, FindingsFilter, InMemoryArtifactStore, - InMemoryFindingsBackend, InMemoryTextBackend, LegacyArtifact, ListArtifacts, ModifyArtifact, - Projection, ReadArtifact, RecordFinding, Selector, apply_text_edit, kind_findings_store, - kind_text, parse_artifact_range, parse_findings_append, parse_findings_filter, parse_text_edit, + BackendConflict, Edit, InMemoryArtifactStore, InMemoryRecordStore, InMemoryTextBackend, + ListArtifacts, ModifyArtifact, Projection, ReadArtifact, Record, RecordAppend, RecordFilter, + Selector, TextArtifactSeed, apply_text_edit, kind_record_store, kind_text, + parse_artifact_range, parse_record_append, parse_record_filter, parse_text_edit, parse_text_range_from_selector, slice_range, validate_range, }; pub use charter::{Charter, DeclaredScope, RoleContext, Scope, ScopeKind}; pub use charter_loader::{ CharterDef, CharterLoadError, FrameDef, RoleContextDef, build_charter, build_role_context, - load_charter_def, load_role_context_def, + parse_charter_def, parse_role_context_def, }; pub use cognition::{ - CognitionBackend, CognitionError, CognitionRequest, CognitionResponse, FakeCognitionBackend, - Message, MessageRole, ToolCallHint, + ActionHint, CognitionBackend, CognitionError, CognitionRequest, CognitionResponse, + DecisionLine, FakeCognitionBackend, Message, MessageRole, }; pub use frame::{Frame, FrameId, FrameRef, PriorReceiptQuery}; pub use gate::Gate; @@ -58,6 +59,7 @@ pub use scenario::{ ActorFactory, Judge, JudgeError, JudgeOutput, JudgeReport, LlmJudge, LlmTester, ScenarioResult, ScenarioRunner, SelectionAction, SelectionActionKind, Tester, TesterError, Trigger, }; +pub use skill::{Skill, skills_content_hash}; pub use snapshot::{Snapshot, SnapshotId}; pub use steward::{Steward, StewardId}; pub use task::{AttemptId, AttemptRecord, TaskId, TaskRecord, TaskStatus, TaskTrigger}; diff --git a/core/src/loop_runner.rs b/core/src/loop_runner.rs index fa1e893..bbc9ca1 100644 --- a/core/src/loop_runner.rs +++ b/core/src/loop_runner.rs @@ -150,7 +150,8 @@ impl LoopRunner { params: serde_json::json!({}), intercept_complete: true, }, - ); + ) + .await; task.status = TaskStatus::Halted; return LoopOutcome::Halted { task, @@ -173,7 +174,8 @@ impl LoopRunner { params: serde_json::json!({ "reason": reason }), intercept_complete: false, }, - ); + ) + .await; task.status = TaskStatus::Escalated; return LoopOutcome::Escalated { task, @@ -192,7 +194,7 @@ impl LoopRunner { let signal = receipt.refinement_signal(); let receipt_id = receipt.receipt_id.clone(); - if let Err(e) = self.workspace.receipt_store.append(&receipt) { + if let Err(e) = self.workspace.receipt_store.append(&receipt).await { receipt.outcome = Outcome::Escalated; receipt.intercept_complete = false; let _ = e; @@ -267,7 +269,8 @@ impl LoopRunner { }), intercept_complete: true, }, - ); + ) + .await; task.status = TaskStatus::Escalated; return LoopOutcome::Escalated { task, @@ -294,11 +297,11 @@ impl LoopRunner { /// Append a kernel-emitted Receipt (Halt, Fail, BudgetExhausted) to /// both the durable store and the in-memory trail. The same Receipt /// crosses both surfaces — never mutate one and not the other. - fn append_kernel_event( + async fn append_kernel_event( &self, trail: &mut Vec, task_id: &TaskId, - event: KernelEvent, + event: KernelEvent<'_>, ) { let snapshot = &self.steward.snapshot; let receipt = Receipt { @@ -321,7 +324,7 @@ impl LoopRunner { role_context_version: snapshot.role_context.role_context_version, snapshot_id: snapshot.id.clone(), }; - if let Err(_e) = self.workspace.receipt_store.append(&receipt) { + if let Err(_e) = self.workspace.receipt_store.append(&receipt).await { // The kernel event still reaches the caller's in-memory // trail below; failure visibility is represented by the // event's intercept_complete flag chosen by the caller. diff --git a/core/src/prompt.rs b/core/src/prompt.rs index 9ccc119..2e7821a 100644 --- a/core/src/prompt.rs +++ b/core/src/prompt.rs @@ -34,6 +34,7 @@ use crate::governance::GovernanceMode; pub fn assemble_actor_system_prompt( charter: &Charter, role_context: &RoleContext, + skills: &[crate::skill::Skill], mode: GovernanceMode, ) -> String { let mut s = String::new(); @@ -44,13 +45,17 @@ pub fn assemble_actor_system_prompt( // ungoverned. The base line is invariant across Charters. s.push_str( "You are a chartered Steward in the CharteredOS runtime. Every \ - external effect you take is a Tool call. Reply with one Action \ - JSON object per turn: \ + external effect you take is a Tool call. \ + You may think across several replies before committing — reply \ + with free-form reasoning to think more, and reply with one Action \ + JSON object when you are ready to act: \ `{\"tool\":\"\",\"params\":{...}}` to invoke a Tool, or \ - `{\"halt\":true}` when the Task is complete. The Gate evaluates \ - every Tool call against the active Charter before any effect \ - takes place; on rejection you receive a Refinement signal naming \ - the failing Frame and a one-sentence reason.\n\n", + `{\"halt\":true}` when the Task is complete. \ + Only Action JSON commits; reasoning replies stay internal to your \ + turn. The Gate evaluates every Tool call against the active \ + Charter before any effect takes place; on rejection you receive a \ + Refinement signal naming the failing Frame and a one-sentence \ + reason.\n\n", ); // Charter behavioral spec — how this particular Steward @@ -85,6 +90,18 @@ pub fn assemble_actor_system_prompt( s.push_str(&format!("[{name}]\n{}\n\n", content.trim())); } } + + // Skills are Actor-side cognition instrumentation (spec + // §Skills): they steer how the Actor approaches the task. + // They do NOT carry authority (no expansion of permitted_tools, + // no Gate bypass) — every tool call the Actor emits under a + // Skill's influence still crosses the Gate. + if !skills.is_empty() { + s.push_str("--- SKILLS (Actor-side guidance; tool calls still cross the Gate) ---\n"); + for skill in skills { + s.push_str(&format!("[{}]\n{}\n\n", skill.id, skill.content.trim())); + } + } } s.push_str(&format!("--- GOVERNANCE MODE ---\n{mode}\n")); @@ -114,6 +131,7 @@ mod tests { let prompt = assemble_actor_system_prompt( &charter, &RoleContext::empty(), + &[], GovernanceMode::EVALUATION_ONLY, ); assert!(prompt.contains("BEHAVIORAL SPECIFICATION")); @@ -128,6 +146,7 @@ mod tests { let with_grounding = assemble_actor_system_prompt( &charter, &RoleContext::empty(), + &[], GovernanceMode::FULL, ); assert!(with_grounding.contains("AUTHORITY SCOPES")); @@ -136,6 +155,7 @@ mod tests { let without_grounding = assemble_actor_system_prompt( &charter, &RoleContext::empty(), + &[], GovernanceMode::EVALUATION_ONLY, ); assert!(!without_grounding.contains("AUTHORITY SCOPES")); @@ -151,6 +171,7 @@ mod tests { let with_grounding = assemble_actor_system_prompt( &charter, &role_context, + &[], GovernanceMode::FULL, ); assert!(with_grounding.contains("ROLE CONTEXT")); @@ -159,6 +180,7 @@ mod tests { let without_grounding = assemble_actor_system_prompt( &charter, &role_context, + &[], GovernanceMode::NEITHER, ); assert!(!without_grounding.contains("ROLE CONTEXT")); @@ -171,8 +193,41 @@ mod tests { let prompt = assemble_actor_system_prompt( &charter, &RoleContext::empty(), + &[], GovernanceMode::GROUNDING_ONLY, ); assert!(prompt.contains("grounding-only")); } + + #[test] + fn skills_appear_under_grounding_with_skill_section() { + let charter = empty_charter_with_behavioral(""); + let skills = vec![crate::skill::Skill::new( + "billing-triage", + "When pricing is ambiguous, ask for clarification before quoting.", + )]; + let prompt = assemble_actor_system_prompt( + &charter, + &RoleContext::empty(), + &skills, + GovernanceMode::FULL, + ); + assert!(prompt.contains("SKILLS")); + assert!(prompt.contains("[billing-triage]")); + assert!(prompt.contains("ask for clarification")); + } + + #[test] + fn skills_absent_when_grounding_off() { + let charter = empty_charter_with_behavioral(""); + let skills = vec![crate::skill::Skill::new("s", "guidance content")]; + let prompt = assemble_actor_system_prompt( + &charter, + &RoleContext::empty(), + &skills, + GovernanceMode::EVALUATION_ONLY, + ); + assert!(!prompt.contains("SKILLS")); + assert!(!prompt.contains("guidance content")); + } } diff --git a/core/src/receipt.rs b/core/src/receipt.rs index b2bf275..f3115f1 100644 --- a/core/src/receipt.rs +++ b/core/src/receipt.rs @@ -2,6 +2,7 @@ use std::sync::Mutex; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use async_trait::async_trait; use serde::{Serialize, Serializer}; use sha2::{Digest, Sha256}; @@ -138,8 +139,9 @@ impl std::error::Error for ReceiptStoreError {} /// Stewards' Receipts. Cross-Steward queries (when a Charter declares /// they're needed) are an explicit opt-in; the default scoping is /// per-Steward. +#[async_trait] pub trait ReceiptStore: Send + Sync { - fn append(&self, receipt: &Receipt) -> Result<(), ReceiptStoreError>; + async fn append(&self, receipt: &Receipt) -> Result<(), ReceiptStoreError>; fn query( &self, context_id: &str, @@ -193,8 +195,9 @@ impl Default for InMemoryReceiptStore { } } +#[async_trait] impl ReceiptStore for InMemoryReceiptStore { - fn append(&self, receipt: &Receipt) -> Result<(), ReceiptStoreError> { + async fn append(&self, receipt: &Receipt) -> Result<(), ReceiptStoreError> { self.receipts.lock().unwrap().push(receipt.clone()); Ok(()) } diff --git a/core/src/scenario.rs b/core/src/scenario.rs index fbe10e0..c6fa2c3 100644 --- a/core/src/scenario.rs +++ b/core/src/scenario.rs @@ -207,7 +207,7 @@ impl Tester for LlmTester { .await .map_err(|e| TesterError(format!("backend error: {e}")))?; - let text = response.text.trim().to_string(); + let text = response.content.trim().to_string(); self.history.push(Message::assistant(text.clone())); Ok(text) } @@ -279,15 +279,19 @@ impl Judge for LlmJudge { .complete(&request) .await .map_err(|e| JudgeError(format!("backend error: {e}")))?; - parse_judge_response(&response.text) + // Adapter-produced `judge_output` is the strong-typed Judge + // verdict — the kernel performs no JSON parsing here (see + // `AGENTS.md §Verification`). `None` means the adapter found no + // parseable Judge output in the model's response. + response.judge_output.ok_or_else(|| { + JudgeError(format!( + "adapter produced no judge_output; verbatim content was: {}", + response.content + )) + }) } } -pub(crate) fn parse_judge_response(text: &str) -> Result { - serde_json::from_str::(text.trim()) - .map_err(|e| JudgeError(format!("response parse failure: {e}"))) -} - #[derive(Debug)] pub struct ScenarioResult { pub tasks: Vec, diff --git a/core/src/skill.rs b/core/src/skill.rs new file mode 100644 index 0000000..d841500 --- /dev/null +++ b/core/src/skill.rs @@ -0,0 +1,105 @@ +//! Skills: Actor-side cognition instrumentation. +//! +//! Spec §Skills: "Actor-side cognition instrumentation following the +//! SKILL.md convention. The Actor consults Skills during cognition; any +//! tool call produced under a Skill's guidance crosses the Gate. Skills +//! do not constitute a new Tool category and do not bypass the +//! Charter's `permitted_tools`." +//! +//! A Skill is operator-curated text (typically markdown). It enters the +//! Actor's system prompt as a labeled section. It carries no authority +//! to expand the permitted-tools set or to bypass Frame evaluation — +//! every tool call the Actor emits under a Skill's influence still +//! crosses the Gate. + +use sha2::{Digest, Sha256}; + +/// One Skill — a named, content-hashed piece of operator-curated text. +/// Identity is `id`; `content_hash` detects drift between source file +/// and loaded content. Skills are unversioned by design — drift surfaces +/// as a changed content_hash, which propagates into a new Snapshot ID +/// (see *Snapshot Lifecycle*). +#[derive(Debug, Clone)] +pub struct Skill { + pub id: String, + pub content: String, + pub content_hash: String, +} + +impl Skill { + /// Construct a Skill from raw content. `content_hash` is computed + /// from the content; callers MUST NOT pre-compute it (single source + /// of truth for the hash function). + pub fn new(id: impl Into, content: impl Into) -> Self { + let id = id.into(); + let content = content.into(); + let content_hash = hash_content(&content); + Self { + id, + content, + content_hash, + } + } +} + +/// Single source of truth for the Skill content-hash function. Shared +/// across construction (`Skill::new`) and aggregate hashing +/// (`skills_content_hash`). +fn hash_content(content: &str) -> String { + let mut h = Sha256::new(); + h.update(content.as_bytes()); + hex::encode(h.finalize()) +} + +/// Combined content hash over a set of Skills, used in Snapshot +/// identity composition and in the persisted SnapshotManifest. Order- +/// stable: callers MUST sort by Skill id before hashing so the same +/// set of Skills yields the same hash regardless of load order. +pub fn skills_content_hash(skills: &[Skill]) -> String { + let mut sorted: Vec<&Skill> = skills.iter().collect(); + sorted.sort_by(|a, b| a.id.cmp(&b.id)); + let mut h = Sha256::new(); + for s in sorted { + h.update(s.id.as_bytes()); + h.update(b":"); + h.update(s.content_hash.as_bytes()); + h.update(b"\n"); + } + hex::encode(h.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn content_hash_is_stable_and_content_addressed() { + let a = Skill::new("s1", "hello"); + let b = Skill::new("s1", "hello"); + assert_eq!(a.content_hash, b.content_hash); + let c = Skill::new("s1", "hello!"); + assert_ne!(a.content_hash, c.content_hash); + } + + #[test] + fn aggregate_hash_is_order_independent() { + let s1 = Skill::new("billing", "rules"); + let s2 = Skill::new("triage", "steps"); + let h_ab = skills_content_hash(&[s1.clone(), s2.clone()]); + let h_ba = skills_content_hash(&[s2, s1]); + assert_eq!(h_ab, h_ba); + } + + #[test] + fn aggregate_hash_changes_with_content() { + let h1 = skills_content_hash(&[Skill::new("s", "v1")]); + let h2 = skills_content_hash(&[Skill::new("s", "v2")]); + assert_ne!(h1, h2); + } + + #[test] + fn aggregate_hash_of_empty_is_stable() { + let h = skills_content_hash(&[]); + assert_eq!(h.len(), 64); // sha256 hex + } +} diff --git a/core/src/snapshot.rs b/core/src/snapshot.rs index f7aea57..6a9e7e4 100644 --- a/core/src/snapshot.rs +++ b/core/src/snapshot.rs @@ -4,8 +4,9 @@ use std::time::SystemTime; use sha2::{Digest, Sha256}; use crate::charter::{Charter, RoleContext}; +use crate::skill::{Skill, skills_content_hash}; -#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize)] +#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct SnapshotId(pub String); impl std::fmt::Display for SnapshotId { @@ -14,30 +15,41 @@ impl std::fmt::Display for SnapshotId { } } -/// Frozen Charter and Role context at Task creation. Spec §Vocabulary > -/// Snapshot, §Receipts. +/// Frozen Charter, Role context, and Skills at Task creation. Spec +/// §Vocabulary > Snapshot, §Snapshot Lifecycle, §Skills. /// -/// Identity is content-addressed: identical content yields identical -/// SnapshotId. The freeze timestamp lives in `frozen_at`, never in -/// the identity hash. +/// Identity is content-addressed over all three: identical Charter + +/// Role-context + Skills content yields the identical SnapshotId. The +/// freeze timestamp lives in `frozen_at`, never in the identity hash. pub struct Snapshot { pub id: SnapshotId, pub charter: Charter, pub role_context: RoleContext, + pub skills: Vec, pub frozen_at: SystemTime, } impl Snapshot { - pub fn new(charter: Charter, role_context: RoleContext) -> Arc { + /// Construct a Snapshot from Charter, Role context, and Skills. + /// The Snapshot ID is content-addressed over all three; pass + /// `Vec::new()` for skills when none are bound. + pub fn new( + charter: Charter, + role_context: RoleContext, + skills: Vec, + ) -> Arc { let mut hasher = Sha256::new(); hasher.update(charter.charter_content_hash.as_bytes()); hasher.update(b":"); hasher.update(role_context.role_context_content_hash.as_bytes()); + hasher.update(b":"); + hasher.update(skills_content_hash(&skills).as_bytes()); let id = SnapshotId(hex::encode(hasher.finalize())); Arc::new(Self { id, charter, role_context, + skills, frozen_at: SystemTime::now(), }) } diff --git a/core/src/steward.rs b/core/src/steward.rs index 0996dac..ab07a9b 100644 --- a/core/src/steward.rs +++ b/core/src/steward.rs @@ -78,6 +78,7 @@ impl Steward { assemble_actor_system_prompt( &self.snapshot.charter, &self.snapshot.role_context, + &self.snapshot.skills, self.governance_mode, ) } diff --git a/core/src/verdict.rs b/core/src/verdict.rs index fe96110..2bde43e 100644 --- a/core/src/verdict.rs +++ b/core/src/verdict.rs @@ -241,65 +241,23 @@ impl Evaluator for LlmEvaluator { .complete(&request) .await .map_err(|e| EvaluatorError(format!("backend error: {e}")))?; - Ok(parse_evaluator_response(&self.id, &response.text)) + // Adapter-produced `verdict_lines` are role-agnostic + // (decision + observation); the LlmEvaluator stamps its own id + // when wrapping into EvaluatorEntry. The kernel performs no + // text parsing — see `AGENTS.md §Verification` (all parsing at + // the interface boundary, internally strongly-typed). + Ok(response + .verdict_lines + .into_iter() + .map(|line| EvaluatorEntry { + evaluator_id: self.id.clone(), + decision: line.decision, + observation: line.observation, + }) + .collect()) } } -pub(crate) fn parse_evaluator_response(evaluator_id: &str, text: &str) -> Vec { - text.lines() - .map(str::trim) - .filter(|l| !l.is_empty()) - .filter_map(|line| parse_decision_line(evaluator_id, line)) - .collect() -} - -/// Parse one line into an EvaluatorEntry. Recognized shapes: -/// `ALLOW: reason` — canonical -/// `DECISION: ALLOW, REASON: reason` — common LLM paraphrase -/// `DECISION: ALLOW` — degenerate paraphrase, no reason -/// Returns None for any line that does not start with a recognized -/// keyword somewhere in the leading tokens; the Gate's empty-trace -/// fallback (UNGROUNDED) catches genuine garbage. -fn parse_decision_line(evaluator_id: &str, line: &str) -> Option { - let (head, tail) = line.split_once(':')?; - let head_token = head.trim().to_uppercase(); - if let Some(decision) = decision_from_token(&head_token) { - return Some(EvaluatorEntry { - evaluator_id: evaluator_id.to_string(), - decision, - observation: tail.trim().to_string(), - }); - } - // Tolerate "DECISION: [, REASON: ...]" paraphrases. The - // tail begins with the keyword; everything after the next ',' or - // 'REASON:' is the reason text. - if head_token == "DECISION" { - let tail = tail.trim(); - // Split into keyword and remainder. Keyword runs to the first - // ',' or whitespace. - let kw_end = tail - .find(|c: char| c == ',' || c.is_whitespace()) - .unwrap_or(tail.len()); - let kw = tail[..kw_end].trim().to_uppercase(); - let decision = decision_from_token(&kw)?; - let mut reason_part = tail[kw_end..].trim().trim_start_matches(',').trim(); - // Drop a leading "REASON:" if present. - if let Some(rest) = reason_part - .strip_prefix("REASON:") - .or_else(|| reason_part.strip_prefix("Reason:")) - .or_else(|| reason_part.strip_prefix("reason:")) - { - reason_part = rest.trim(); - } - return Some(EvaluatorEntry { - evaluator_id: evaluator_id.to_string(), - decision, - observation: reason_part.to_string(), - }); - } - None -} - fn build_evaluator_system_prompt(frame_id: &FrameId, concern: &str) -> String { format!( "You are a Frame Evaluator.\n\nFrame: {frame_id}\nConcern: {concern}\n\n\ @@ -322,13 +280,9 @@ fn build_evaluator_system_prompt(frame_id: &FrameId, concern: &str) -> String { Reply with one or more lines. Each line MUST start with one \ of the keywords ALLOW, DENY, ESCALATE, or DEFER (uppercase, \ literal — not a placeholder), followed by a colon and a \ - one-sentence reason. Do not prefix the line with the literal \ - word \"DECISION\"; start with the keyword itself.\n\n\ - EXAMPLE valid lines:\n\ - ALLOW: the proposal matches the policy on path containment.\n\ - ALLOW: the response declines to disclose without identity \ - verification, satisfying the privacy concern.\n\ - DENY: the path escapes the workspace root.\n\n\ + one-sentence reason grounded in this Frame's concern and \ + scopes. Do not prefix the line with the literal word \ + \"DECISION\"; start with the keyword itself.\n\n\ Keyword meanings:\n\ ALLOW = proposal satisfies the concern (including by policy-\ compliant refusal/redirect).\n\ @@ -338,12 +292,3 @@ fn build_evaluator_system_prompt(frame_id: &FrameId, concern: &str) -> String { ) } -fn decision_from_token(tok: &str) -> Option { - match tok { - "ALLOW" => Some(Decision::Allow), - "DENY" => Some(Decision::Deny), - "ESCALATE" => Some(Decision::Escalate), - "DEFER" => Some(Decision::Defer), - _ => None, - } -} diff --git a/core/tests/artifact_tools.rs b/core/tests/artifact_tools.rs index fc31671..e23b7ab 100644 --- a/core/tests/artifact_tools.rs +++ b/core/tests/artifact_tools.rs @@ -1,18 +1,17 @@ use std::sync::Arc; use chartered_core::{ - ArtifactId, Finding, InMemoryArtifactStore, LegacyArtifact, ListArtifacts, LoopOutcome, - LoopRunner, ModifyArtifact, Outcome, ReadArtifact, Receipt, ReceiptStore, ReceiptStoreError, - RecordFinding, StewardId, ToolExecutor, ToolId, ToolParams, ToolRegistry, Workspace, - WorkspaceId, + ArtifactId, InMemoryArtifactStore, ListArtifacts, LoopOutcome, LoopRunner, ModifyArtifact, + Outcome, ReadArtifact, Receipt, ReceiptStore, ReceiptStoreError, Record, StewardId, + TextArtifactSeed, ToolExecutor, ToolId, ToolParams, ToolRegistry, Workspace, WorkspaceId, }; mod common; use common::{make_frame, make_llm_actor, make_snapshot, make_steward}; #[tokio::test] -async fn artifact_tools_read_modify_list_and_record_findings() { - let store = Arc::new(InMemoryArtifactStore::new([LegacyArtifact { +async fn artifact_tools_read_modify_list_and_records_via_modify_artifact() { + let store = Arc::new(InMemoryArtifactStore::new([TextArtifactSeed { id: ArtifactId::new("deal.md"), content: "vendor liability cap".into(), }])); @@ -21,7 +20,6 @@ async fn artifact_tools_read_modify_list_and_record_findings() { let read = ReadArtifact::new(ToolId::new("read_artifact"), artifact_store.clone()); let modify = ModifyArtifact::new(ToolId::new("modify_artifact"), artifact_store.clone()); let list = ListArtifacts::new(ToolId::new("list_artifacts"), artifact_store.clone()); - let record = RecordFinding::new(ToolId::new("record_finding"), artifact_store.clone()); let selected = read .execute(&ToolParams(serde_json::json!({ @@ -58,34 +56,56 @@ async fn artifact_tools_read_modify_list_and_record_findings() { .map(|v| v["artifact_id"].as_str().unwrap()) .collect(); assert!(ids.contains(&"deal.md"), "list missing deal.md: {ids:?}"); - assert!(ids.contains(&"findings"), "list missing findings: {ids:?}"); - - record + assert!(ids.contains(&"records"), "list missing records: {ids:?}"); + + // Records are appended by calling `modify_artifact` with + // `kind=record-store`. The runtime injects `_*` provenance keys + // (receipt_id, task_id, steward_id, snapshot_id) at the top of + // params; `ModifyArtifact::execute` propagates them into the Edit + // so the record Backend records provenance without a domain- + // specific Tool wrapper. Content fields under `edit.append` are + // opaque to the kernel — the Charter defines whatever shape suits + // the deployment. + modify .execute(&ToolParams(serde_json::json!({ - "artifact_id": "deal.md", - "range": { "start": 0, "end": 6, "start_line": 1, "end_line": 1 }, - "concern": "Confidentiality", - "severity": "high", - "detail": "Disclosure risk", + "kind": "record-store", + "artifact_id": "records", + "edit": { + "append": { + "artifact_id": "deal.md", + "range": { "start": 0, "end": 6, "start_line": 1, "end_line": 1 }, + "concern": "Confidentiality", + "severity": "high", + "detail": "Disclosure risk" + } + }, "_task_id": "task-1", "_steward_id": "reviewer", "_snapshot_id": "snapshot-1", "_receipt_id": "receipt-1" }))) .await - .expect("record finding"); + .expect("modify_artifact append record"); - let findings: Vec = store.findings(); - assert_eq!(findings.len(), 1); - assert_eq!(findings[0].artifact_id, ArtifactId::new("deal.md")); - assert_eq!(findings[0].author_steward_id, StewardId::new("reviewer")); - assert_eq!(findings[0].admitting_receipt_id.0, "receipt-1"); + let records: Vec = store.records(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].steward_id, StewardId::new("reviewer")); + assert_eq!(records[0].receipt_id.0, "receipt-1"); + assert_eq!( + records[0].content.get("artifact_id").and_then(|v| v.as_str()), + Some("deal.md") + ); + assert_eq!( + records[0].content.get("severity").and_then(|v| v.as_str()), + Some("high") + ); } struct FailingReceiptStore; +#[async_trait::async_trait] impl ReceiptStore for FailingReceiptStore { - fn append(&self, _receipt: &Receipt) -> Result<(), ReceiptStoreError> { + async fn append(&self, _receipt: &Receipt) -> Result<(), ReceiptStoreError> { Err(ReceiptStoreError("forced append failure".into())) } @@ -106,7 +126,7 @@ impl ReceiptStore for FailingReceiptStore { #[tokio::test] async fn artifact_modification_stops_when_receipt_append_fails() { - let artifact_store = Arc::new(InMemoryArtifactStore::new([LegacyArtifact { + let artifact_store = Arc::new(InMemoryArtifactStore::new([TextArtifactSeed { id: ArtifactId::new("architecture.md"), content: "public gateway uses shared cache".into(), }])); diff --git a/core/tests/common/mod.rs b/core/tests/common/mod.rs index 8e4258c..c6f5053 100644 --- a/core/tests/common/mod.rs +++ b/core/tests/common/mod.rs @@ -18,18 +18,31 @@ use async_trait::async_trait; use serde_json::json; use chartered_core::{ - Charter, FakeCognitionBackend, Frame, FrameId, GovernanceMode, LlmActor, LlmEvaluator, - RoleContext, Snapshot, Steward, StewardId, ToolExecutor, ToolId, ToolParams, ToolRegistry, - ToolResult, Workspace, WorkspaceId, + ActionHint, Charter, Decision, DecisionLine, FakeCognitionBackend, Frame, FrameId, + GovernanceMode, LlmActor, LlmEvaluator, RoleContext, Snapshot, Steward, StewardId, + ToolExecutor, ToolId, ToolParams, ToolRegistry, ToolResult, Workspace, WorkspaceId, }; -/// Build a Frame backed by an `LlmEvaluator` and a `FakeCognitionBackend` -/// pre-loaded with `responses`. The Frame has empty declared_scopes — -/// kernel tests don't exercise scope plumbing through this helper. +/// Build a Frame backed by an `LlmEvaluator` and a `FakeCognitionBackend`. +/// Test inputs are line-shaped Evaluator responses like `"ALLOW: ok"` — +/// this helper translates each into a `Vec` (mimicking +/// what a real adapter does on the LLM output) and enqueues via the +/// fake backend's strong-typed `enqueue_verdict_lines`. The kernel +/// never parses these lines (see `AGENTS.md §Verification`); this +/// helper is the test-fixture analogue of a production adapter. +/// +/// Strings that contain no recognized decision keyword are enqueued as +/// pure content (no `verdict_lines`) — used by tests that want to +/// exercise the Gate's empty-trace fallback (UNGROUNDED). pub fn make_frame(id: &str, applies: &[&str], responses: &[&str]) -> Frame { let backend = Arc::new(FakeCognitionBackend::new(format!("eval-{id}"))); for r in responses { - backend.enqueue(r.to_string()); + let lines = verdict_lines_from_test_string(r); + if lines.is_empty() { + backend.enqueue(r.to_string()); + } else { + backend.enqueue_verdict_lines(lines); + } } let evaluator = Arc::new(LlmEvaluator::new( format!("llm-eval-{id}"), @@ -47,18 +60,65 @@ pub fn make_frame(id: &str, applies: &[&str], responses: &[&str]) -> Frame { } } -/// Build an `LlmActor` whose backend dequeues `responses` (each -/// JSON-serialized) on each `step()`. The Actor's system prompt and -/// context_id are uniform across kernel tests because they don't shape -/// the assertions — backend behavior is what's under test. +/// Parse Evaluator-shaped test fixture strings into `DecisionLine`s. +/// One line per line of input that starts with a recognized keyword. +/// Lines without a recognized keyword produce no DecisionLine (the +/// caller can decide whether to fall back to plain content). +fn verdict_lines_from_test_string(text: &str) -> Vec { + text.lines() + .filter_map(|line| { + let (head, tail) = line.split_once(':')?; + let decision = match head.trim().to_uppercase().as_str() { + "ALLOW" => Decision::Allow, + "DENY" => Decision::Deny, + "ESCALATE" => Decision::Escalate, + "DEFER" => Decision::Defer, + _ => return None, + }; + Some(DecisionLine { + decision, + observation: tail.trim().to_string(), + }) + }) + .collect() +} + +/// Build an `LlmActor` whose backend dequeues one Action per `step()`. +/// Test inputs are `serde_json::Value` shaped as the canonical Action +/// envelope (`{"tool":"...","params":{...}}` or `{"halt":true}`) — this +/// helper translates each into an `ActionHint` and enqueues it via the +/// fake backend's strong-typed surface. The kernel never parses JSON +/// (spec §Cognition Layer: kernel consumes the strong-typed +/// `action_hint`); this helper is the test-fixture analogue of what +/// real adapters do in production. pub fn make_llm_actor(id: &str, responses: Vec) -> LlmActor { let backend = Arc::new(FakeCognitionBackend::new(format!("actor-{id}"))); for r in responses { - backend.enqueue(serde_json::to_string(&r).unwrap()); + let action = action_hint_from_value(&r).unwrap_or_else(|| { + panic!( + "make_llm_actor: response value is not a canonical Action envelope: {r}" + ) + }); + backend.enqueue_action(action); } LlmActor::new(id, backend, "test actor", "ctx-test") } +fn action_hint_from_value(v: &serde_json::Value) -> Option { + if v.get("halt").and_then(serde_json::Value::as_bool) == Some(true) { + return Some(ActionHint::Halt); + } + let tool = v.get("tool").and_then(serde_json::Value::as_str)?; + let params = v + .get("params") + .cloned() + .unwrap_or(serde_json::Value::Null); + Some(ActionHint::Propose { + tool: tool.to_string(), + params, + }) +} + /// Build a Snapshot from frames + permitted tool ids. pub fn make_snapshot(frames: Vec, permitted: &[&str]) -> Arc { let charter = Charter { @@ -69,7 +129,7 @@ pub fn make_snapshot(frames: Vec, permitted: &[&str]) -> Arc { charter_content_hash: "test-charter".into(), behavioral_spec: String::new(), }; - Snapshot::new(charter, RoleContext::empty()) + Snapshot::new(charter, RoleContext::empty(), Vec::new()) } /// Build a Steward with the given id, snapshot, and registry. Wraps the diff --git a/core/tests/gate_invariants.rs b/core/tests/gate_invariants.rs index 62a66b0..7b14a46 100644 --- a/core/tests/gate_invariants.rs +++ b/core/tests/gate_invariants.rs @@ -306,7 +306,7 @@ async fn evaluator_timeout_flips_intercept_complete() { charter_content_hash: "slow".into(), behavioral_spec: String::new(), }; - let snap = Snapshot::new(charter, RoleContext::empty()); + let snap = Snapshot::new(charter, RoleContext::empty(), Vec::new()); let gate = Gate::new(snap, StewardId::new("test-steward"), GovernanceMode::FULL).with_frame_timeout(Duration::from_millis(50)); let r = gate.evaluate(proposal("t")).await; @@ -339,7 +339,7 @@ async fn backend_error_yields_ungrounded() { charter_content_hash: "x".into(), behavioral_spec: String::new(), }; - let snap = Snapshot::new(charter, RoleContext::empty()); + let snap = Snapshot::new(charter, RoleContext::empty(), Vec::new()); let gate = Gate::new(snap, StewardId::new("test-steward"), GovernanceMode::FULL); let r = gate.evaluate(proposal("t")).await; assert_eq!(r.outcome, Outcome::Denied); @@ -355,19 +355,31 @@ async fn backend_error_yields_ungrounded() { ); } -/// Actor cognitive failure (parse error) surfaces as a Receipt with -/// `outcome: Escalated` and `intercept_complete=false`. CHECKLIST §Risk -/// Register > Silent Failure: every partial-coverage condition must be -/// visible in the Receipt trail; silent halt would leave operators -/// unable to distinguish "task complete" from "Actor failed." +/// Actor persistent cognitive failure (every inner step produces +/// unparseable output → inner-loop budget exhausts) surfaces as a +/// Receipt with `outcome: Escalated` and `intercept_complete=false`. +/// CHECKLIST §Risk Register > Silent Failure: every partial-coverage +/// condition must be visible in the Receipt trail; silent halt would +/// leave operators unable to distinguish "task complete" from "Actor +/// failed." +/// +/// Under the agentic inner loop (spec §Cognition Layer), a single +/// non-structured response is treated as a reasoning step and the loop +/// continues — only sustained inability to emit a structured action +/// escalates. The test exhausts the inner budget by enqueueing budget+ +/// non-JSON responses. #[tokio::test] -async fn actor_malformed_response_escalates_with_intercept_incomplete() { +async fn actor_persistent_malformed_responses_escalate_via_budget_exhaustion() { + use chartered_core::DEFAULT_INNER_STEP_BUDGET; let snap = snapshot(vec![frame("f", &["t"], &[])], &["t"]); let ws = workspace(snap, registry_with_nops(&["t"])); - let steward = sole_steward(&ws); let runner = LoopRunner::new(ws.clone(), steward); + let steward = sole_steward(&ws); + let runner = LoopRunner::new(ws.clone(), steward); let backend = Arc::new(FakeCognitionBackend::new("malformed-actor")); - backend.enqueue("not valid JSON"); + for _ in 0..DEFAULT_INNER_STEP_BUDGET { + backend.enqueue("not valid JSON"); + } let mut actor = LlmActor::new("malformed", backend, "system prompt", "ctx-1"); let result = runner.run(&mut actor).await; @@ -380,9 +392,51 @@ async fn actor_malformed_response_escalates_with_intercept_incomplete() { assert!(!r.intercept_complete); assert_eq!(r.tool_call.tool.0, ""); assert!(r.verdicts.is_empty()); + let reason = r.tool_call.params.0["reason"].as_str().unwrap(); + assert!( + reason.contains("inner step budget exhausted"), + "expected budget exhaustion in reason, got: {reason}" + ); assert_eq!(ws.receipt_store.all().len(), 1); } +/// Inner-loop reasoning + commit: the Actor emits free-form reasoning +/// for several inner steps, then commits to a tool call. The outer +/// LoopRunner sees one Allowed Receipt (one externally-observable +/// effect) regardless of how many inner reasoning steps preceded it. +/// Spec §Cognition Layer: the inner loop is bounded planning, not +/// bounded effect. +#[tokio::test] +async fn actor_inner_loop_reasoning_then_tool_call_yields_one_allowed_receipt() { + let snap = snapshot(vec![frame("f", &["t"], &["ALLOW: ok"])], &["t"]); + let ws = workspace(snap, registry_with_nops(&["t"])); + let steward = sole_steward(&ws); + let runner = LoopRunner::new(ws.clone(), steward); + + let backend = Arc::new(FakeCognitionBackend::new("reasoning-actor")); + backend + .enqueue("Step 1: read the request.") + .enqueue("Step 2: plan to call tool t.") + .enqueue_action(chartered_core::ActionHint::Propose { + tool: "t".into(), + params: serde_json::json!({}), + }) + .enqueue_action(chartered_core::ActionHint::Halt); + let mut actor = LlmActor::new("reasoner", backend, "system prompt", "ctx-1"); + + let result = runner.run(&mut actor).await; + let LoopOutcome::Halted { trail, .. } = result else { + panic!("expected Halted, got {result:?}"); + }; + // Three Receipts: the Allowed tool call, then the kernel-emitted + // Receipt. Two reasoning steps did NOT produce Receipts — + // only externally-observable effects do. + assert_eq!(trail.len(), 2, "one Allowed + one Receipt"); + assert_eq!(trail[0].outcome, Outcome::Allowed); + assert_eq!(trail[0].tool_call.tool.0, "t"); + assert_eq!(trail[1].tool_call.tool.0, ""); +} + /// Actor backend error surfaces as the same kind of Receipt. #[tokio::test] async fn actor_backend_error_escalates_with_intercept_incomplete() { @@ -447,7 +501,7 @@ async fn workspace_rejects_missing_charter_scope() { charter_content_hash: "x".into(), behavioral_spec: String::new(), }; - let snap = Snapshot::new(charter, RoleContext::empty()); + let snap = Snapshot::new(charter, RoleContext::empty(), Vec::new()); let mut reg = ToolRegistry::new(); reg.register(Arc::new(NopTool::new("t"))); let steward = make_steward("sut", snap, reg); @@ -485,7 +539,7 @@ async fn workspace_rejects_missing_role_context_scope() { charter_content_hash: "x".into(), behavioral_spec: String::new(), }; - let snap = Snapshot::new(charter, RoleContext::empty()); + let snap = Snapshot::new(charter, RoleContext::empty(), Vec::new()); let mut reg = ToolRegistry::new(); reg.register(Arc::new(NopTool::new("t"))); let steward = make_steward("sut", snap, reg); @@ -535,7 +589,7 @@ async fn scopes_reach_evaluator_with_provenance_separated() { let mut role_context = RoleContext::empty(); role_context.scopes = vec![("schedule".into(), "FACTS_FROM_PROFESSIONAL".into())]; role_context.role_context_content_hash = "rc-x".into(); - let snap = Snapshot::new(charter, role_context); + let snap = Snapshot::new(charter, role_context, Vec::new()); let mut reg = ToolRegistry::new(); reg.register(Arc::new(NopTool::new("t"))); diff --git a/core/tests/multi_steward.rs b/core/tests/multi_steward.rs index 2e7e2c6..2f2807a 100644 --- a/core/tests/multi_steward.rs +++ b/core/tests/multi_steward.rs @@ -178,7 +178,7 @@ async fn prior_receipt_queries_do_not_leak_across_stewards() { charter_content_hash: "char-b".into(), behavioral_spec: String::new(), }; - let snap_b = Snapshot::new(charter_b, RoleContext::empty()); + let snap_b = Snapshot::new(charter_b, RoleContext::empty(), Vec::new()); let mut reg_b = ToolRegistry::new(); reg_b.register(Arc::new(NopTool::new("tool-y"))); let steward_b = Steward::new(StewardId::new("steward-b"), snap_b, Arc::new(reg_b)); diff --git a/core/tests/passthrough.rs b/core/tests/passthrough.rs index 328a2fd..427ba3d 100644 --- a/core/tests/passthrough.rs +++ b/core/tests/passthrough.rs @@ -34,7 +34,7 @@ fn snap_with_deny_frame(content_hash: &str) -> Arc { charter_content_hash: content_hash.into(), behavioral_spec: String::new(), }; - Snapshot::new(charter, RoleContext::empty()) + Snapshot::new(charter, RoleContext::empty(), Vec::new()) } fn send_proposal() -> serde_json::Value { diff --git a/core/tests/reconciliation.rs b/core/tests/reconciliation.rs index 409f244..7309d0e 100644 --- a/core/tests/reconciliation.rs +++ b/core/tests/reconciliation.rs @@ -35,7 +35,7 @@ async fn allowed_proposal_dispatches_send_message() { charter_content_hash: "recon-allow".into(), behavioral_spec: String::new(), }; - let snap = Snapshot::new(charter, RoleContext::empty()); + let snap = Snapshot::new(charter, RoleContext::empty(), Vec::new()); let steward = make_steward("sut", snap, registry); let ws = Arc::new( Workspace::single(WorkspaceId::new("ws-allow"), steward).expect("workspace validates"), @@ -88,7 +88,7 @@ async fn denied_proposal_does_not_dispatch() { charter_content_hash: "recon-deny".into(), behavioral_spec: String::new(), }; - let snap = Snapshot::new(charter, RoleContext::empty()); + let snap = Snapshot::new(charter, RoleContext::empty(), Vec::new()); let steward = make_steward("sut", snap, registry); let ws = Arc::new( Workspace::single(WorkspaceId::new("ws-deny"), steward).expect("workspace validates"), @@ -136,7 +136,7 @@ async fn capability_denial_does_not_dispatch_or_produce_verdicts() { charter_content_hash: "recon-cap".into(), behavioral_spec: String::new(), }; - let snap = Snapshot::new(charter, RoleContext::empty()); + let snap = Snapshot::new(charter, RoleContext::empty(), Vec::new()); let steward = make_steward("sut", snap, registry); let ws = Arc::new( Workspace::single(WorkspaceId::new("ws-cap"), steward).expect("workspace validates"), @@ -192,7 +192,7 @@ async fn refinement_sequence_dispatches_only_accepted_proposal() { charter_content_hash: "recon-refine".into(), behavioral_spec: String::new(), }; - let snap = Snapshot::new(charter, RoleContext::empty()); + let snap = Snapshot::new(charter, RoleContext::empty(), Vec::new()); let steward = make_steward("sut", snap, registry); let ws = Arc::new( Workspace::single(WorkspaceId::new("ws-refine"), steward).expect("workspace validates"), diff --git a/dashboard/app.js b/dashboard/app.js index 3f03483..bdc3e28 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -871,8 +871,6 @@ function CharterSource() {
${() => charter()?.behavioral_spec ?? ""}

Frames

${() => JSON.stringify(charter()?.frames ?? [], null, 2)}
-

Reviewers

-
${() => JSON.stringify(charter()?.reviewers ?? [], null, 2)}

Actions

${() => JSON.stringify(charter()?.actions ?? [], null, 2)}
diff --git a/dashboard/local-api.mjs b/dashboard/local-api.mjs index 5ee3e4b..2475140 100644 --- a/dashboard/local-api.mjs +++ b/dashboard/local-api.mjs @@ -72,159 +72,14 @@ async function workspaceState() { } async function charterState() { - const charterRefRaw = await readOptional(path.join(charteredDir, "charter.toml")); - const charterRef = parseCharterRef(charterRefRaw); - const charterDir = path.resolve(charteredDir, charterRef.path ?? "./charter"); - const framesToml = await readOptional(path.join(charterDir, "frames.toml")); - const scopesMd = await readOptional(path.join(charterDir, "scopes.md")); - const behavioralSpec = await readOptional(path.join(charterDir, "behavioral_spec.md")); - const roleContextMd = await readOptional(path.join(charteredDir, "role_context.md")); - const charteredToml = await readOptional(path.join(charteredDir, "chartered.toml")); - - const frames = parseFrames(framesToml); - const scopes = parseHeadingSections(scopesMd, 2); - const sections = splitTopLevelSections(behavioralSpec); - const actionsBlock = sections.find((s) => /actions?/i.test(s.title))?.body ?? ""; - const reviewersBlock = sections.find((s) => /reviewers?/i.test(s.title))?.body ?? ""; - const behavioralBlock = - sections.find((s) => /behavioral spec/i.test(s.title))?.body ?? behavioralSpec; - const actions = parseActions(actionsBlock); - const reviewers = parseReviewers(reviewersBlock); - const governance = parseGovernanceMode(charteredToml); - const stewardId = "sut"; - const stewards = [ - { - id: stewardId, - role: "reviewer", - display_name: "Project Citadel Steward", - frames, - authoring_notes: reviewers, - }, - ]; - - return { - charter_ref: charterRef, - charter_dir: charterDir, - behavioral_spec: behavioralBlock.trim(), - scopes, - frames, - stewards, - actions, - reviewers, - role_context_present: roleContextMd.length > 0, - governance_mode: governance, - }; -} - -function parseCharterRef(text) { - const path = (text.match(/path\s*=\s*"([^"]*)"/) ?? [])[1]; - const versionRaw = (text.match(/version\s*=\s*(\d+)/) ?? [])[1]; - return { - path: path ?? null, - version: versionRaw ? Number(versionRaw) : 1, - }; -} - -function parseFrames(toml) { - const frames = []; - if (!toml) return frames; - const blocks = toml.split(/^\s*\[\[frames\]\]\s*$/m).slice(1); - for (const block of blocks) { - const id = (block.match(/^\s*id\s*=\s*"([^"]+)"/m) ?? [])[1] ?? ""; - const concern = (block.match(/^\s*concern\s*=\s*"([\s\S]*?)"\s*$/m) ?? [])[1] ?? ""; - const appliesRaw = (block.match(/^\s*applies_to_tools\s*=\s*\[([\s\S]*?)\]/m) ?? [])[1] ?? ""; - const applies = appliesRaw - .split(",") - .map((s) => (s.match(/"([^"]+)"/) ?? [])[1]) - .filter(Boolean); - const declared = []; - const declaredBlock = (block.match(/declared_scopes\s*=\s*\[([\s\S]*?)\]/m) ?? [])[1] ?? ""; - for (const m of declaredBlock.matchAll(/\{[^}]*name\s*=\s*"([^"]+)"[^}]*kind\s*=\s*"([^"]+)"[^}]*\}/g)) { - declared.push({ name: m[1], kind: m[2] }); - } - frames.push({ id, concern, applies_to_tools: applies, declared_scopes: declared }); - } - return frames; -} - -function parseHeadingSections(text, level) { - const out = []; - if (!text) return out; - const re = new RegExp(`^#{${level}}\\s+(.+)$`, "gm"); - const indices = []; - let m; - while ((m = re.exec(text))) { - indices.push({ index: m.index, end: m.index + m[0].length, title: m[1].trim() }); - } - for (let i = 0; i < indices.length; i++) { - const start = indices[i].end; - const stop = i + 1 < indices.length ? indices[i + 1].index : text.length; - out.push({ name: indices[i].title, text: text.slice(start, stop).trim() }); - } - return out; -} - -function splitTopLevelSections(text) { - const out = []; - if (!text) return out; - const re = /^#\s+(.+)$/gm; - const heads = []; - let m; - while ((m = re.exec(text))) heads.push({ index: m.index, end: m.index + m[0].length, title: m[1].trim() }); - if (!heads.length) return [{ title: "", body: text }]; - for (let i = 0; i < heads.length; i++) { - const start = heads[i].end; - const stop = i + 1 < heads.length ? heads[i + 1].index : text.length; - out.push({ title: heads[i].title, body: text.slice(start, stop).trim() }); + const { command, args } = runtimeCommand(["--print-charter"]); + const result = await spawnCapture(command, args); + if (result.code !== 0) { + throw new Error( + `chartered-runtime --print-charter exited ${result.code}: ${result.stderr || result.stdout}`, + ); } - return out; -} - -function parseActions(block) { - const subs = parseHeadingSections(block, 2); - return subs.map((sub) => { - let kind = "generative"; - let prompt = ""; - let inPrompt = false; - for (const line of sub.text.split("\n")) { - if (/^\s*Type:\s*/i.test(line)) { - kind = line.replace(/^\s*Type:\s*/i, "").trim().toLowerCase(); - inPrompt = false; - } else if (/^\s*Prompt:\s*/i.test(line)) { - prompt = line.replace(/^\s*Prompt:\s*/i, "").trim(); - inPrompt = true; - } else if (inPrompt) { - prompt += "\n" + line; - } - } - return { name: sub.name, kind, prompt: prompt.trim() }; - }); -} - -function parseReviewers(block) { - const subs = parseHeadingSections(block, 2); - return subs.map((sub) => { - let concern = ""; - let scopes = []; - for (const line of sub.text.split("\n")) { - if (/^\s*Concern:\s*/i.test(line)) concern = line.replace(/^\s*Concern:\s*/i, "").trim(); - else if (/^\s*Scopes:\s*/i.test(line)) { - scopes = line - .replace(/^\s*Scopes:\s*/i, "") - .trim() - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - } - } - return { name: sub.name, concern, scopes }; - }); -} - -function parseGovernanceMode(charteredToml) { - const grounding = /grounding\s*=\s*true/i.test(charteredToml); - const evaluation = /evaluation\s*=\s*true/i.test(charteredToml); - return { grounding, evaluation }; + return JSON.parse(result.stdout); } async function listArtifacts(root) { @@ -261,7 +116,12 @@ function isArtifactPath(file) { } async function readFindings() { - const file = path.join(charteredDir, "findings.jsonl"); + // The kernel persists `kind=record-store` records to + // `/.jsonl`. The default record store + // registers artifact_id `records`; the dashboard's "Findings" left- + // rail node renders those records (which, in the demo, happen to + // carry concern/severity/detail fields). + const file = path.join(charteredDir, "records.jsonl"); const text = await readOptional(file); return text .split("\n") diff --git a/dispatch/CONTEXT.md b/dispatch/CONTEXT.md index 8e8f1a8..2ccf6a6 100644 --- a/dispatch/CONTEXT.md +++ b/dispatch/CONTEXT.md @@ -22,9 +22,11 @@ operating system. - `NativeFsWrite` (`fs.rs`) — `write_file(path, content)`. Creates intermediate directories within the workspace root. - Native artifact executors (`artifact.rs`) — `read_artifact`, - `modify_artifact`, `record_finding`, `list_artifacts`. Artifact ids - are workspace-relative UTF-8 file paths; findings append to - `.chartered/findings.jsonl`. + `modify_artifact`, `list_artifacts`. Artifact ids are workspace-relative + UTF-8 file paths; findings are recorded by Charters as + `modify_artifact` calls with `kind=record-store`, appending to + `.chartered/.jsonl` (the default record store registers + artifact_id `records`, persisting at `.chartered/records.jsonl`). - `NativeExec` (`exec.rs`) — `exec_command(cmd, args)`. Spawns, captures stdout/stderr, reports exit code. - `ExecutorRegistry` (`registry.rs`) — maps the deployment config's @@ -43,8 +45,9 @@ denials surface as `ToolResult::Err`, never silently allow. - Tools that are not in-Runtime-native: peer-process Adapters and contained-subprocess executors live in their own crates (future). -- Charter / RoleContext loading: `core::charter_loader` and - `runtime::config`. +- Charter / RoleContext loading: `runtime::charter_loader` (deployment + IO) + `chartered_core::parse_charter_def` / `parse_role_context_def` + (pure kernel parsers); deployment-config loading: `runtime::config`. - Network tools: future. ## Tests diff --git a/dispatch/Cargo.lock b/dispatch/Cargo.lock index 22dcce6..b19bdcb 100644 --- a/dispatch/Cargo.lock +++ b/dispatch/Cargo.lock @@ -65,6 +65,7 @@ version = "0.1.0" dependencies = [ "async-trait", "chartered-core", + "serde", "serde_json", "tempfile", "tokio", diff --git a/dispatch/Cargo.toml b/dispatch/Cargo.toml index 2ee814a..90b6ee1 100644 --- a/dispatch/Cargo.toml +++ b/dispatch/Cargo.toml @@ -13,6 +13,7 @@ path = "src/lib.rs" chartered-core = { path = "../core" } async-trait = "0.1" tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "fs", "process", "io-util"] } +serde = "1" serde_json = "1" [dev-dependencies] diff --git a/dispatch/src/artifact.rs b/dispatch/src/artifact.rs index 53efd63..4001279 100644 --- a/dispatch/src/artifact.rs +++ b/dispatch/src/artifact.rs @@ -6,10 +6,10 @@ //! files; reads return content (optionally sliced by Selector range); //! modifies splice content at a byte range under a containment-checked //! path. -//! - `FilesystemFindingsBackend` (`kind=findings-store`) — backed by -//! `/.chartered/findings.jsonl`; reads return the -//! filtered list (optionally narrowed by a Selector `filter`); modifies -//! append one Finding record. +//! - `FilesystemRecordStore` (`kind=record-store`) — backed by +//! `/.jsonl`; reads return the filtered +//! list (optionally narrowed by a Selector `filter`); modifies append +//! one Record with kernel-injected provenance. //! //! Both Backends are constructed with the canonical workspace root and //! reject any path that resolves outside it (after canonicalization, so @@ -19,9 +19,9 @@ use std::path::{Path, PathBuf}; use async_trait::async_trait; use chartered_core::{ - Artifact, ArtifactBackend, ArtifactId, ArtifactKindId, Edit, Finding, Projection, Selector, - apply_text_edit, kind_findings_store, kind_text, parse_findings_append, - parse_findings_filter, parse_text_edit, parse_text_range_from_selector, slice_range, + Artifact, ArtifactBackend, ArtifactId, ArtifactKindId, Edit, Projection, Record, Selector, + apply_text_edit, kind_record_store, kind_text, parse_record_append, parse_record_filter, + parse_text_edit, parse_text_range_from_selector, slice_range, }; use crate::fs::{ensure_within_root, resolve_existing, resolve_for_write}; @@ -109,38 +109,47 @@ impl ArtifactBackend for FilesystemTextBackend { } // ============================================================ -// kind=findings-store — `.chartered/findings.jsonl` +// kind=record-store — `.chartered/.jsonl` // ============================================================ -/// `kind=findings-store` Backend backed by -/// `/.chartered/findings.jsonl`. Exposes one artifact -/// (`findings`); `read` returns the filtered list, `modify` appends one -/// Finding record (durably synced before returning). -pub struct FilesystemFindingsBackend { +/// `kind=record-store` Backend backed by a JSONL file under +/// `/.jsonl`. Exposes one artifact whose id +/// the constructor takes; `read` returns the filtered list of records +/// (each line one JSON object), `modify` appends one Record (durably +/// synced before returning). The kernel injects runtime provenance into +/// the Edit; the Steward-supplied content fields are persisted at the +/// top level alongside provenance for uniform filtering. +pub struct FilesystemRecordStore { kind: ArtifactKindId, artifact_id: ArtifactId, - findings_path: PathBuf, + file_path: PathBuf, + sink: crate::persistence::JsonlSink, } -impl FilesystemFindingsBackend { - /// Built from the central `DeploymentPaths`. Findings live in - /// `paths.chartered_dir()` alongside other per-deployment audit - /// state (per-run receipts, role-context versions, etc.), not - /// under `workspace_root` — deployments that point - /// `--workspace-root` and `--chartered-dir` at independent paths - /// still produce a single findings stream. - pub fn new(paths: &DeploymentPaths) -> Self { - let findings_path = paths.chartered_dir().join("findings.jsonl"); - Self { - kind: kind_findings_store(), - artifact_id: ArtifactId::new("findings"), - findings_path, - } +impl FilesystemRecordStore { + /// Built from the central `DeploymentPaths`. The store's file lives + /// at `paths.chartered_dir()/.jsonl` so deployments + /// that point `--workspace-root` and `--chartered-dir` at + /// independent paths still produce a single record stream per + /// artifact id. Each ArtifactId gets its own file; multiple + /// record-store Backends can coexist in one deployment. + pub async fn new( + paths: &DeploymentPaths, + artifact_id: ArtifactId, + ) -> std::io::Result { + let file_path = paths.chartered_dir().join(format!("{artifact_id}.jsonl")); + let sink = crate::persistence::JsonlSink::create(&file_path).await?; + Ok(Self { + kind: kind_record_store(), + artifact_id, + file_path, + sink, + }) } } #[async_trait] -impl ArtifactBackend for FilesystemFindingsBackend { +impl ArtifactBackend for FilesystemRecordStore { fn kind(&self) -> &ArtifactKindId { &self.kind } @@ -159,18 +168,18 @@ impl ArtifactBackend for FilesystemFindingsBackend { ) -> Result { if artifact_id != &self.artifact_id { return Err(format!( - "findings-store has no artifact `{artifact_id}` (only `{}` exists)", + "record-store has no artifact `{artifact_id}` (only `{}` exists)", self.artifact_id )); } - let filter = parse_findings_filter(selector)?; - let raw = match tokio::fs::read_to_string(&self.findings_path).await { + let filter = parse_record_filter(selector)?; + let raw = match tokio::fs::read_to_string(&self.file_path).await { Ok(s) => s, Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), Err(e) => { return Err(format!( "read failed for {}: {e}", - self.findings_path.display() + self.file_path.display() )); } }; @@ -182,7 +191,7 @@ impl ArtifactBackend for FilesystemFindingsBackend { let v: serde_json::Value = serde_json::from_str(line).map_err(|e| { format!( "parse failed for {} line {}: {e}", - self.findings_path.display(), + self.file_path.display(), lineno + 1 ) })?; @@ -191,64 +200,37 @@ impl ArtifactBackend for FilesystemFindingsBackend { } } Ok(Projection(serde_json::json!({ - "findings": records, + "records": records, }))) } async fn modify(&self, artifact_id: &ArtifactId, edit: &Edit) -> Result { if artifact_id != &self.artifact_id { return Err(format!( - "findings-store has no artifact `{artifact_id}` (only `{}` exists)", + "record-store has no artifact `{artifact_id}` (only `{}` exists)", self.artifact_id )); } - let append = parse_findings_append(edit)?; - // Finding IDs are derived from the admitting receipt — durable + let append = parse_record_append(edit)?; + // Record IDs are derived from the admitting receipt — durable // and unique across runs without an in-process counter. - let finding = Finding { - id: format!("finding-{}", append.receipt_id), + let record = Record { + id: format!("record-{}", append.receipt_id), task_id: append.task_id, - author_steward_id: append.author_steward_id, + steward_id: append.steward_id, snapshot_id: append.snapshot_id, - artifact_id: append.artifact_id, - range: append.range, - concern: append.concern, - severity: append.severity, - detail: append.detail, - admitting_receipt_id: append.receipt_id, + receipt_id: append.receipt_id, + content: append.content, }; - let finding_id = finding.id.clone(); - if let Some(parent) = self.findings_path.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|e| format!("creating parent {}: {e}", parent.display()))?; - } - let mut line = serde_json::to_string(&finding) - .map_err(|e| format!("serialize finding {finding_id}: {e}"))?; - line.push('\n'); - append_utf8(&self.findings_path, &line).await?; - Ok(Projection(serde_json::json!({ "finding_id": finding_id }))) + let record_id = record.id.clone(); + self.sink + .append(&record) + .await + .map_err(|e| format!("append record {record_id} to {}: {e}", self.file_path.display()))?; + Ok(Projection(serde_json::json!({ "record_id": record_id }))) } } -async fn append_utf8(path: &Path, line: &str) -> Result<(), String> { - use tokio::io::AsyncWriteExt; - - let mut file = tokio::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - .await - .map_err(|e| format!("open {}: {e}", path.display()))?; - file.write_all(line.as_bytes()) - .await - .map_err(|e| format!("append {}: {e}", path.display()))?; - file.sync_data() - .await - .map_err(|e| format!("sync {}: {e}", path.display()))?; - Ok(()) -} - fn collect_artifacts(root: &Path, dir: &Path, out: &mut Vec) -> Result<(), String> { for entry in std::fs::read_dir(dir).map_err(|e| format!("read_dir {}: {e}", dir.display()))? { let entry = entry.map_err(|e| format!("read_dir entry {}: {e}", dir.display()))?; diff --git a/dispatch/src/lib.rs b/dispatch/src/lib.rs index baed592..8fbaccb 100644 --- a/dispatch/src/lib.rs +++ b/dispatch/src/lib.rs @@ -16,10 +16,14 @@ pub mod artifact; pub mod exec; pub mod fs; pub mod paths; +pub mod persistence; pub mod registry; +pub mod snapshot_store; -pub use artifact::{FilesystemFindingsBackend, FilesystemTextBackend}; +pub use artifact::{FilesystemRecordStore, FilesystemTextBackend}; pub use exec::NativeExec; pub use fs::{NativeFsRead, NativeFsWrite}; pub use paths::DeploymentPaths; +pub use persistence::JsonlSink; pub use registry::{ExecutorBuildError, ExecutorRegistry}; +pub use snapshot_store::{SnapshotManifest, persist as persist_snapshot, prune as prune_snapshots}; diff --git a/dispatch/src/paths.rs b/dispatch/src/paths.rs index 38a21cb..eeabe35 100644 --- a/dispatch/src/paths.rs +++ b/dispatch/src/paths.rs @@ -19,7 +19,7 @@ pub struct DeploymentPaths { pub workspace_root: PathBuf, /// Deployment configuration + audit state (`charter/`, `tools/` / /// `backends/`, `runs//{receipts,cognition}.jsonl`, - /// `findings.jsonl`, `role_context.md`). Distinct from + /// `records.jsonl`, `role_context.md`). Distinct from /// `workspace_root`: deployments may point them at independent paths. pub chartered_dir: PathBuf, } diff --git a/dispatch/src/persistence.rs b/dispatch/src/persistence.rs new file mode 100644 index 0000000..1d0c13e --- /dev/null +++ b/dispatch/src/persistence.rs @@ -0,0 +1,72 @@ +//! Unified persistence primitive for every durable append-only stream. +//! +//! Spec §Persistence: "One async append-only primitive serves every +//! durable stream — Receipt log, Cognition log, Findings. One +//! open/append/fsync discipline. One serialization-failure contract." +//! +//! Lives in `dispatch/` (not `core/`) because it touches `tokio::fs`; +//! the kernel stays in-memory only. Runtime and dispatch both reach for +//! it via `chartered_dispatch::JsonlSink`. + +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use tokio::fs::{File, OpenOptions}; +use tokio::io::AsyncWriteExt; +use tokio::sync::Mutex; + +/// JSON-Lines append sink: serializes one record per line, fsyncs each +/// write so an operator-visible trail survives process death. Mutex +/// serializes concurrent writers — log lines never interleave. +/// +/// Serialization failure is reported via `eprintln!` and returned as +/// `ErrorKind::InvalidData`; the line is dropped rather than silently +/// rewritten to `"{}"`. AGENTS.md §Error Discipline §Semantic Integrity +/// Under Failure: a dropped record with operator-visible diagnostic +/// beats a fake-shape record that masks the failure. +pub struct JsonlSink { + path: PathBuf, + file: Mutex, +} + +impl JsonlSink { + pub async fn create(path: impl Into) -> std::io::Result { + let path = path.into(); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let file = OpenOptions::new() + .append(true) + .create(true) + .open(&path) + .await?; + Ok(Self { + path, + file: Mutex::new(file), + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Serialize `value` to one JSON line, write+fsync. Returns the IO + /// error verbatim on write/fsync failure; serializer failure is + /// reported to stderr and returned as `ErrorKind::InvalidData`. + /// Callers MUST honor the result — silently writing to disk while + /// updating an in-memory mirror diverges the two surfaces. + pub async fn append(&self, value: &T) -> std::io::Result<()> { + let mut line = serde_json::to_string(value).map_err(|e| { + eprintln!( + "JsonlSink({}): serialize failure: {e}", + self.path.display() + ); + std::io::Error::new(std::io::ErrorKind::InvalidData, e) + })?; + line.push('\n'); + let mut file = self.file.lock().await; + file.write_all(line.as_bytes()).await?; + file.sync_data().await?; + Ok(()) + } +} diff --git a/dispatch/src/registry.rs b/dispatch/src/registry.rs index e955d4f..30f6247 100644 --- a/dispatch/src/registry.rs +++ b/dispatch/src/registry.rs @@ -13,24 +13,24 @@ //! without rippling through constructor signatures. //! //! **Artifact-substrate executors** (`artifact_read`, `artifact_modify`, -//! `artifact_list`, `record_finding`) share a single `ArtifactStore` -//! constructed at `with_native_defaults` time, populated with the -//! deployment's filesystem-backed Backends (`FilesystemTextBackend` -//! for `kind=text`, `FilesystemFindingsBackend` for `kind=findings-store`). -//! All four executors dispatch through that store, which routes by kind -//! to the owning Backend. The Tool surface is fixed; substrate -//! variation lives in Backends. +//! `artifact_list`) share a single `ArtifactStore` constructed at +//! `with_native_defaults` time, populated with the deployment's +//! filesystem-backed Backends (`FilesystemTextBackend` for `kind=text`, +//! `FilesystemRecordStore` for `kind=record-store`). All three +//! executors dispatch through that store, which routes by kind to the +//! owning Backend. The Tool surface is fixed at the spec's uniform +//! verbs; substrate variation lives in Backends. use std::collections::HashMap; use std::sync::Arc; use chartered_core::{ - ArtifactStore, ListArtifacts, ModifyArtifact, ReadArtifact, RecordFinding, ToolExecutor, ToolId, + ArtifactId, ArtifactStore, ListArtifacts, ModifyArtifact, ReadArtifact, ToolExecutor, ToolId, }; use crate::paths::DeploymentPaths; use crate::{ - FilesystemFindingsBackend, FilesystemTextBackend, NativeExec, NativeFsRead, NativeFsWrite, + FilesystemRecordStore, FilesystemTextBackend, NativeExec, NativeFsRead, NativeFsWrite, }; #[derive(Debug, Clone)] @@ -79,8 +79,12 @@ impl ExecutorRegistry { /// (`native_fs_read`, `native_fs_write`, `native_exec`, and the /// artifact-substrate executors backed by filesystem Backends). /// Takes ownership of the central `DeploymentPaths`. - pub fn new(paths: DeploymentPaths) -> Self { - Self::empty(paths).with_native_defaults() + /// + /// Async because `FilesystemRecordStore::new` opens its + /// JsonlSink at construction (spec §Persistence — one async + /// primitive serves every durable stream). + pub async fn new(paths: DeploymentPaths) -> std::io::Result { + Self::empty(paths).with_native_defaults().await } /// Construct a registry without the native built-ins. Tests that @@ -98,19 +102,21 @@ impl ExecutorRegistry { /// Pre-populate the artifact substrate with the two reference /// filesystem Backends (`kind=text` against - /// `paths.workspace_root()`, `kind=findings-store` against + /// `paths.workspace_root()`, `kind=record-store` against /// `paths.chartered_dir()`) and register the corresponding executor /// builders. - pub fn with_native_defaults(mut self) -> Self { + pub async fn with_native_defaults(mut self) -> std::io::Result { let text_backend = Arc::new(FilesystemTextBackend::new(&self.paths)); - let findings_backend = Arc::new(FilesystemFindingsBackend::new(&self.paths)); + let records_backend = Arc::new( + FilesystemRecordStore::new(&self.paths, ArtifactId::new("records")).await?, + ); // ArtifactStore registers Backends keyed by ArtifactKindId; one // Backend per kind is enforced at registration. Tool calls carry // `kind` as a first-class param and dispatch lands deterministically // — no fall-through, no ownership heuristic. let store = Arc::new( ArtifactStore::new() - .with_backend(findings_backend) + .with_backend(records_backend) .with_backend(text_backend), ); self.artifact_store = store; @@ -146,11 +152,8 @@ impl ExecutorRegistry { self.register("native_artifact_list", |_paths, id, store| { Ok(Arc::new(ListArtifacts::new(id.clone(), store.clone()))) }); - self.register("native_artifact_record_finding", |_paths, id, store| { - Ok(Arc::new(RecordFinding::new(id.clone(), store.clone()))) - }); - self + Ok(self) } /// Register a builder for one executor name. Later registrations diff --git a/dispatch/src/snapshot_store.rs b/dispatch/src/snapshot_store.rs new file mode 100644 index 0000000..650e7f3 --- /dev/null +++ b/dispatch/src/snapshot_store.rs @@ -0,0 +1,141 @@ +//! Snapshot persistence — one immutable file per Snapshot, keyed by ID. +//! +//! Spec §Snapshot Lifecycle: "Snapshots are content-addressed and +//! persist via the unified primitive. Stable references: the Snapshot +//! ID embedded in every Receipt resolves to a persisted Snapshot +//! record. Append-only: new Snapshots are added; existing Snapshots +//! are not mutated. Pruning: old versions are trimmed when no longer +//! referenced." +//! +//! Layout: `/snapshots/.json` per +//! Snapshot. Single-shot writes (not appends) — each file is the whole +//! manifest for one Snapshot, content-immutable for the Snapshot's +//! lifetime. Deletion is the prune operation. +//! +//! `SnapshotManifest` carries the durable identity (id, frozen-at, +//! Charter and Role-context versions and content hashes). Full +//! Snapshot reconstruction from a manifest requires the matching +//! Charter and Role-context source materials and is left to higher +//! layers; this module owns the manifest write/read/list/prune +//! lifecycle. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use chartered_core::{Snapshot, SnapshotId, skills_content_hash}; +use serde::{Deserialize, Serialize}; +use tokio::io::AsyncWriteExt; + +/// Durable identity record for one Snapshot. Content-addressed by `id`; +/// the file path is derived from `id` so lookup is O(1) without a +/// directory scan. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SnapshotManifest { + pub id: SnapshotId, + pub frozen_at_unix_nanos: u128, + pub charter_version: u64, + pub charter_content_hash: String, + pub role_context_version: u64, + pub role_context_content_hash: String, + /// Aggregate content hash over the Skills bound to this Snapshot + /// (sha256 over sorted `[id, content_hash]` pairs). Empty Skills + /// hash to the sha256 of the empty input so a Skill-less Snapshot + /// still has a defined value. + pub skills_content_hash: String, +} + +impl SnapshotManifest { + pub fn from_snapshot(snapshot: &Snapshot) -> Self { + let frozen_at_unix_nanos = snapshot + .frozen_at + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + Self { + id: snapshot.id.clone(), + frozen_at_unix_nanos, + charter_version: snapshot.charter.charter_version, + charter_content_hash: snapshot.charter.charter_content_hash.clone(), + role_context_version: snapshot.role_context.role_context_version, + role_context_content_hash: snapshot.role_context.role_context_content_hash.clone(), + skills_content_hash: skills_content_hash(&snapshot.skills), + } + } +} + +/// Resolve the on-disk path for a Snapshot ID under `dir`. +pub fn manifest_path(dir: &Path, id: &SnapshotId) -> PathBuf { + dir.join(format!("{}.json", id.0)) +} + +/// Persist a Snapshot manifest as `/.json`. Idempotent — the +/// content-addressed ID means a re-persist of an unchanged Snapshot +/// overwrites with identical bytes. Write+fsync discipline matches +/// `JsonlSink` (durable before return; serialization failure +/// surfaces). +pub async fn persist(snapshot: &Snapshot, dir: &Path) -> std::io::Result<()> { + let manifest = SnapshotManifest::from_snapshot(snapshot); + let bytes = serde_json::to_vec(&manifest).map_err(|e| { + eprintln!( + "snapshot_store::persist({}): serialize failure: {e}", + snapshot.id + ); + std::io::Error::new(std::io::ErrorKind::InvalidData, e) + })?; + tokio::fs::create_dir_all(dir).await?; + let path = manifest_path(dir, &snapshot.id); + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&path) + .await?; + file.write_all(&bytes).await?; + file.sync_data().await?; + Ok(()) +} + +/// Enumerate persisted Snapshot IDs under `dir`. Reads no manifest +/// content — only filenames. Missing `dir` returns an empty list (no +/// snapshots persisted yet is not an error). +pub async fn list_persisted_ids(dir: &Path) -> std::io::Result> { + let mut ids = Vec::new(); + let mut entries = match tokio::fs::read_dir(dir).await { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ids), + Err(e) => return Err(e), + }; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + ids.push(SnapshotId(stem.to_string())); + } + } + Ok(ids) +} + +/// Delete persisted Snapshot manifests under `dir` whose IDs are not in +/// `retain`. Returns the count of deleted manifests. +/// +/// Caller composes `retain` from the live reference set — in-flight +/// Tasks' pinned Snapshots and Receipts within the audit window. This +/// function does not enforce any retention policy; it executes the +/// caller's decision. +pub async fn prune(dir: &Path, retain: &HashSet) -> std::io::Result { + let ids = list_persisted_ids(dir).await?; + let mut deleted = 0; + for id in ids { + if retain.contains(&id) { + continue; + } + let path = manifest_path(dir, &id); + tokio::fs::remove_file(&path).await?; + deleted += 1; + } + Ok(deleted) +} + diff --git a/dispatch/tests/artifact_executors.rs b/dispatch/tests/artifact_executors.rs index 8eddfdf..dd5d4c2 100644 --- a/dispatch/tests/artifact_executors.rs +++ b/dispatch/tests/artifact_executors.rs @@ -3,15 +3,15 @@ //! These exercise the substrate-blind kernel `ToolExecutor`s wired through //! an `ArtifactStore` that contains both filesystem Backends — proving //! that one Tool ABI dispatches polymorphically across `kind=text` -//! (workspace files) and `kind=findings-store` (`.chartered/findings.jsonl`). +//! (workspace files) and `kind=record-store` (`.chartered/.jsonl`). use std::sync::Arc; use chartered_core::{ - ArtifactStore, ListArtifacts, ModifyArtifact, ReadArtifact, RecordFinding, ToolExecutor, - ToolId, ToolParams, + ArtifactStore, ListArtifacts, ModifyArtifact, ReadArtifact, ToolExecutor, ToolId, ToolParams, }; -use chartered_dispatch::{DeploymentPaths, FilesystemFindingsBackend, FilesystemTextBackend}; +use chartered_core::ArtifactId as CoreArtifactId; +use chartered_dispatch::{DeploymentPaths, FilesystemRecordStore, FilesystemTextBackend}; fn json(v: serde_json::Value) -> ToolParams { ToolParams(v) @@ -26,7 +26,7 @@ fn range(start: usize, end: usize) -> serde_json::Value { }) } -fn make_store(workspace: &std::path::Path) -> Arc { +async fn make_store(workspace: &std::path::Path) -> Arc { // Tests put both workspace and chartered_dir under the same tempdir // for simplicity. In production they may be independent paths; // DeploymentPaths is the single source of truth either way. @@ -34,26 +34,29 @@ fn make_store(workspace: &std::path::Path) -> Arc { std::fs::create_dir_all(&chartered).unwrap(); let paths = DeploymentPaths::canonicalize(workspace, &chartered).unwrap(); let text = Arc::new(FilesystemTextBackend::new(&paths)); - let findings = Arc::new(FilesystemFindingsBackend::new(&paths)); + let records = Arc::new( + FilesystemRecordStore::new(&paths, CoreArtifactId::new("records")) + .await + .unwrap(), + ); Arc::new( ArtifactStore::new() - .with_backend(findings) + .with_backend(records) .with_backend(text), ) } #[tokio::test] -async fn read_modify_record_and_list_artifacts() { +async fn read_modify_and_list_artifacts() { let dir = tempfile::tempdir().unwrap(); let artifact_path = dir.path().join("docs").join("deal.md"); std::fs::create_dir_all(artifact_path.parent().unwrap()).unwrap(); std::fs::write(&artifact_path, "vendor liability cap").unwrap(); std::fs::write(dir.path().join("notes.txt"), "side note").unwrap(); - let store = make_store(dir.path()); + let store = make_store(dir.path()).await; let read = ReadArtifact::new(ToolId::new("read_artifact"), store.clone()); let modify = ModifyArtifact::new(ToolId::new("modify_artifact"), store.clone()); - let record = RecordFinding::new(ToolId::new("record_finding"), store.clone()); let list = ListArtifacts::new(ToolId::new("list_artifacts"), store.clone()); let selection = read @@ -81,30 +84,35 @@ async fn read_modify_record_and_list_artifacts() { "vendor liability carve-out" ); - let finding = record + let appended = modify .execute(&json(serde_json::json!({ - "artifact_id": "docs/deal.md", - "range": range(0, 6), - "concern": "Confidentiality", - "severity": "high", - "detail": "Disclosure risk", + "kind": "record-store", + "artifact_id": "records", + "edit": { + "append": { + "artifact_id": "docs/deal.md", + "range": range(0, 6), + "concern": "Confidentiality", + "severity": "high", + "detail": "Disclosure risk" + } + }, "_task_id": "task-1", "_steward_id": "reviewer", "_snapshot_id": "snapshot-1", "_receipt_id": "receipt-1" }))) .await - .expect("record finding"); - assert_eq!(finding["finding_id"], "finding-receipt-1"); + .expect("modify_artifact append record"); + assert_eq!(appended["record_id"], "record-receipt-1"); - let finding_line = - std::fs::read_to_string(dir.path().join(".chartered").join("findings.jsonl")).unwrap(); - let stored: serde_json::Value = serde_json::from_str(finding_line.trim()).unwrap(); + let record_line = + std::fs::read_to_string(dir.path().join(".chartered").join("records.jsonl")).unwrap(); + let stored: serde_json::Value = serde_json::from_str(record_line.trim()).unwrap(); assert_eq!(stored["artifact_id"], "docs/deal.md"); assert_eq!(stored["task_id"], "task-1"); - assert_eq!(stored["author_steward_id"], "reviewer"); - assert_eq!(stored["admitting_receipt_id"], "receipt-1"); - assert!(stored.get("frame_id").is_none()); + assert_eq!(stored["steward_id"], "reviewer"); + assert_eq!(stored["receipt_id"], "receipt-1"); let listed = list.execute(&json(serde_json::json!({}))).await.unwrap(); let ids: Vec<_> = listed["artifacts"] @@ -113,32 +121,32 @@ async fn read_modify_record_and_list_artifacts() { .iter() .map(|v| v["artifact_id"].as_str().unwrap()) .collect(); - // Now includes the kind=findings-store artifact alongside kind=text files. + // Now includes the kind=record-store artifact alongside kind=text files. assert!(ids.contains(&"docs/deal.md")); assert!(ids.contains(&"notes.txt")); - assert!(ids.contains(&"findings")); + assert!(ids.contains(&"records")); - // Read the findings store through the same Tool ABI — proving polymorphism. - let findings_view = read + // Read the record store through the same Tool ABI — proving polymorphism. + let records_view = read .execute(&json(serde_json::json!({ - "kind": "findings-store", - "artifact_id": "findings", + "kind": "record-store", + "artifact_id": "records", "selector": { "filter": { "severity": "high" } } }))) .await - .expect("read findings store"); - let arr = findings_view["findings"] + .expect("read record store"); + let arr = records_view["records"] .as_array() - .expect("findings array"); + .expect("records array"); assert_eq!(arr.len(), 1); assert_eq!(arr[0]["concern"], "Confidentiality"); - assert_eq!(arr[0]["admitting_receipt_id"], "receipt-1"); + assert_eq!(arr[0]["receipt_id"], "receipt-1"); } #[tokio::test] async fn read_rejects_artifact_path_traversal() { let dir = tempfile::tempdir().unwrap(); - let store = make_store(dir.path()); + let store = make_store(dir.path()).await; let read = ReadArtifact::new(ToolId::new("read_artifact"), store); let r = read .execute(&json(serde_json::json!({ @@ -161,7 +169,7 @@ async fn read_rejects_unregistered_kind_with_specific_error() { // surface a specific "kind not registered" error before any Backend // runs — no fall-through, no heuristic. let dir = tempfile::tempdir().unwrap(); - let store = make_store(dir.path()); + let store = make_store(dir.path()).await; let read = ReadArtifact::new(ToolId::new("read_artifact"), store); let r = read .execute(&json(serde_json::json!({ @@ -183,7 +191,7 @@ async fn read_rejects_missing_kind_field() { // Tool calls without `kind` are malformed — surface a specific // missing-field error rather than defaulting to text. let dir = tempfile::tempdir().unwrap(); - let store = make_store(dir.path()); + let store = make_store(dir.path()).await; let read = ReadArtifact::new(ToolId::new("read_artifact"), store); let r = read .execute(&json(serde_json::json!({ diff --git a/dispatch/tests/registry.rs b/dispatch/tests/registry.rs index 81a06ea..9f15ff2 100644 --- a/dispatch/tests/registry.rs +++ b/dispatch/tests/registry.rs @@ -11,10 +11,10 @@ fn make_paths(workspace: &std::path::Path) -> DeploymentPaths { DeploymentPaths::canonicalize(workspace, &chartered).unwrap() } -#[test] -fn registry_builds_known_executors() { +#[tokio::test] +async fn registry_builds_known_executors() { let dir = tempfile::tempdir().unwrap(); - let reg = ExecutorRegistry::new(make_paths(dir.path())); + let reg = ExecutorRegistry::new(make_paths(dir.path())).await.unwrap(); let r = reg .build("native_fs_read", &ToolId::new("read_file")) @@ -41,24 +41,16 @@ fn registry_builds_known_executors() { .expect("native_artifact_modify builds"); assert_eq!(am.id().0, "modify_artifact"); - let af = reg - .build( - "native_artifact_record_finding", - &ToolId::new("record_finding"), - ) - .expect("native_artifact_record_finding builds"); - assert_eq!(af.id().0, "record_finding"); - let al = reg .build("native_artifact_list", &ToolId::new("list_artifacts")) .expect("native_artifact_list builds"); assert_eq!(al.id().0, "list_artifacts"); } -#[test] -fn registry_rejects_unknown_executor() { +#[tokio::test] +async fn registry_rejects_unknown_executor() { let dir = tempfile::tempdir().unwrap(); - let reg = ExecutorRegistry::new(make_paths(dir.path())); + let reg = ExecutorRegistry::new(make_paths(dir.path())).await.unwrap(); let err = match reg.build("nonexistent_executor", &ToolId::new("x")) { Ok(_) => panic!("expected unknown-executor error"), Err(e) => e, diff --git a/dispatch/tests/snapshot_store.rs b/dispatch/tests/snapshot_store.rs new file mode 100644 index 0000000..43a9387 --- /dev/null +++ b/dispatch/tests/snapshot_store.rs @@ -0,0 +1,77 @@ +//! Integration tests for the Snapshot manifest store. These exercise +//! the disk-touching surface (`persist`, `list_persisted_ids`, `prune`) +//! with folder-isolated tempdirs per run. They live under `tests/` +//! rather than in `#[cfg(test)] mod tests` because they are not unit +//! tests by `AGENTS.md §Verification` — unit tests are literally +//! stateless. + +use std::collections::HashSet; +use std::sync::Arc; + +use chartered_core::{Charter, RoleContext, Snapshot, SnapshotId}; +use chartered_dispatch::snapshot_store::{ + list_persisted_ids, manifest_path, persist, prune, SnapshotManifest, +}; + +fn mk_snapshot(charter_hash: &str, role_hash: &str) -> Arc { + let charter = Charter { + frames: Vec::new(), + permitted_tools: Vec::new(), + charter_scopes: Vec::new(), + behavioral_spec: String::new(), + charter_version: 1, + charter_content_hash: charter_hash.to_string(), + }; + let role_context = RoleContext { + scopes: Vec::new(), + role_context_version: 1, + role_context_content_hash: role_hash.to_string(), + }; + Snapshot::new(charter, role_context, Vec::new()) +} + +#[tokio::test] +async fn persist_and_list_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let snapshot = mk_snapshot("c-hash-1", "r-hash-1"); + + persist(&snapshot, dir.path()).await.unwrap(); + + let path = manifest_path(dir.path(), &snapshot.id); + assert!(path.exists(), "manifest file written"); + + let ids = list_persisted_ids(dir.path()).await.unwrap(); + assert_eq!(ids.len(), 1); + assert_eq!(ids[0], snapshot.id); + + let bytes = tokio::fs::read(&path).await.unwrap(); + let manifest: SnapshotManifest = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(manifest.id, snapshot.id); + assert_eq!(manifest.charter_content_hash, "c-hash-1"); + assert_eq!(manifest.role_context_content_hash, "r-hash-1"); +} + +#[tokio::test] +async fn prune_deletes_unreferenced_keeps_retained() { + let dir = tempfile::tempdir().unwrap(); + let keep = mk_snapshot("c-keep", "r-keep"); + let drop = mk_snapshot("c-drop", "r-drop"); + persist(&keep, dir.path()).await.unwrap(); + persist(&drop, dir.path()).await.unwrap(); + + let retain: HashSet = [keep.id.clone()].into_iter().collect(); + let deleted = prune(dir.path(), &retain).await.unwrap(); + assert_eq!(deleted, 1); + + let remaining = list_persisted_ids(dir.path()).await.unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0], keep.id); +} + +#[tokio::test] +async fn list_on_missing_dir_returns_empty() { + let dir = tempfile::tempdir().unwrap(); + let nonexistent = dir.path().join("snapshots"); + let ids = list_persisted_ids(&nonexistent).await.unwrap(); + assert!(ids.is_empty()); +} diff --git a/docs/IMPLEMENTATION_CHECKLIST.md b/docs/IMPLEMENTATION_CHECKLIST.md index 8876b99..5808f75 100644 --- a/docs/IMPLEMENTATION_CHECKLIST.md +++ b/docs/IMPLEMENTATION_CHECKLIST.md @@ -324,6 +324,34 @@ Diagnostic: "Same Frame definitions for capability evaluation (authoring) and re --- +## Test Tier Discipline + +Three tiers per `AGENTS.md §Verification`: unit (stateless, CI), integration (vertical cut, folder-isolated IO, CI), e2e (real LLM via production transport, local-only). + +### Unit Tests Are Literally Stateless + +Diagnostic: "For each `#[cfg(test)] mod tests` block, does any test touch the filesystem, mutate env, or share in-process state with another test case?" Any yes → tier mislabel; the test belongs under `/tests/` as integration. + +### Integration Tests Isolate Per Run + +Diagnostic: "For each test under `/tests/` that reads or writes files, does it own its own `tempfile::tempdir()`?" No → cleanup races, cross-test contamination, non-deterministic ordering. Shared `target/`-relative paths or fixed `/tmp/...` paths → violation. + +### E2E Tests Are Local-Only + +Diagnostic: "Does every test that requires a real LLM, a network call to a production endpoint, or any environment-dependent resource carry `#[ignore]`?" No → CI will execute it and fail on the missing precondition, or worse, pass silently via a soft-skip masking failure as success. + +Soft-skip pattern (`if !out.status.success() { return; }`, `if std::env::var("…").is_err() { return; }`) inside a non-`#[ignore]`d test → violation. `#[ignore]` is the only correct gate; soft-skip = fabricated green (`AGENTS.md §Verification`). + +### LLM-Using Paths Have Both Fake and Real Variants + +Diagnostic: "For each kernel logic path that consumes an LLM (LlmActor inner loop, LlmEvaluator decision, LlmJudge scoring, LlmTester turn), is there a fake-LLM test exercising the path AND a real-LLM e2e test validating the same shape against the production transport?" No fake side → no CI coverage of the path. No real side → transport, serde, or adapter canonicalization can break invisibly. + +### Mock Prohibition Inside the Codebase + +Diagnostic: "Does any non-vendor file under `core/`, `dispatch/`, `runtime/` define a mock of an internal trait?" Yes → violation. Internal traits get real implementations or kernel-resident test-grade implementations (`InMemoryReceiptStore`, `InMemoryArtifactStore`, `InMemoryTextBackend`, …). The only fake-of-external-service in this codebase is `FakeCognitionBackend` (the LLM is external). + +--- + ## Vocabulary Discipline Diagnostic: grep the codebase for `reject`, `block`, `refuse`, `wrapper`, `shim`, `hook` (in Runtime surface), `command` (in abstract context), `log entry` (for Receipts), `result` (for Verdicts), `guard` (for evaluator). Any hit outside kernel-mechanism context → violation. Approved terms: `SPECIFICATION.md > Vocabulary`. diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md index 29aa11b..1d0101e 100644 --- a/docs/SPECIFICATION.md +++ b/docs/SPECIFICATION.md @@ -133,7 +133,7 @@ block-beta The cognition layer may know the rules — behavioral spec, governance Scopes (when grounding is on), Role context, rejection feedback. What it cannot do is enforce them. Only the Runtime gates tool calls, writes Receipts, and dispatches effects. Cognition cannot influence the Evaluator except by producing a proposal — see *Structural Separation* (the persuasive-context-exclusion invariant). -The Runtime manages cognition as infrastructure — assembling prompts, managing prefix caching, dispatching to the preconfigured model backend, enforcing resource limits. The interface between Runtime and cognition handles multiple response formats so that any model backend works without special configuration. +Cognition is commodity; governance is the value proposition. The Runtime manages cognition as infrastructure — assembling prompts, managing prefix caching, dispatching to the preconfigured model backend, enforcing resource limits. The interface between Runtime and cognition handles multiple response formats so that any cognition implementation satisfies the kernel's contract without special configuration. --- @@ -255,7 +255,11 @@ Frames are immutable once deployed. A new version is a new Frame ID. The Snapsho ## Tools -Tools are the Runtime's typed action vocabulary. The Steward has no raw access; every action is a tool call. Reference Tools form an artifact-substrate ABI of eight uniform operations: +Tools are the Runtime's typed action vocabulary. The Steward has no raw access; every action is a tool call. + +**LLM-side native tools are cognition, not Tools.** Model APIs expose "native" tools the LLM may invoke as part of producing its output — web search, code execution in the vendor's sandbox, URL fetching, vector-store lookup. These run inside the model vendor's surface; their results enter the LLM's response as cognition input. The Runtime treats them as part of cognition (see *Where Governance Applies and Where It Does Not*) — the LLM has no path to act on the Steward's behalf outside what the Charter exposes as Tools (see *Tool Registry Is the Only Path* in CHECKLIST). If a capability needs governance, the Charter exposes it as a Tool; LLM-native tools are not a substitute. + +Reference Tools form an artifact-substrate ABI of eight uniform operations: - **`read_artifact(artifact_id, selector)`** — project current state through a Selector. - **`list_artifacts(parent_id, filter)`** — enumerate sub-artifacts. @@ -337,13 +341,13 @@ For multi-deployment use, a per-host or per-organization daemon owns the Receipt Every governed proposal produces a Receipt — the append-only record of one Gate step. Receipts feed three downstream consumers: the Actor (via the *Refinement signal* projected from a denied Receipt), the Professional (via the Receipt trail), and offline regression and incident analysis (via stored Receipt sets). -Receipt content: `task_id`, `attempt_id` (absent for controller events), `tool_call`, `verdicts` (one Verdict per applicable Steward-owned Frame), `outcome`, `timestamp`, `enforcement_level`, `intercept_complete` (false on any partial-coverage condition — Evaluator unreachable, durability degraded, peer-process timeout), `charter_version`, `role_context_version`, `snapshot_id`. +Receipt content: `receipt_id`, `task_id`, `attempt_id` (absent for controller events), `steward_id`, `tool_call`, `verdicts` (one Verdict per applicable Steward-owned Frame), `outcome`, `timestamp`, `intercept_complete` (false on any partial-coverage condition — Evaluator unreachable, durability degraded, peer-process timeout), `governance_mode` (the (grounding, evaluation) toggle pair active for this evaluation), `charter_version`, `role_context_version`, `snapshot_id`. **Durability.** Two tiers. **Critical** (denials, plus any Verdict against a Frame marked `critical: true`): the Receipt is `fdatasync`-persisted before the next Actor step. Persistence failure → denial. **Buffered** (allowed actions under non-critical Frames): append to a ring buffer; background flusher persists at configured cadence. Loss bound: flush interval × throughput. **Confidentiality.** Receipts may carry argv, paths, message bodies, query strings, headers. The store applies access control on query, per-field redaction (declared-sensitive Frame fields), retention with rotation, tamper-evidence. -**Storage.** Append-only. SQLite default with per-day partitioning, or append-only file. WAL. No ORM. +**Storage.** Via the unified primitive (see *Persistence*). **Refinement signal.** When the Gate denies, the Runtime projects each UNGROUNDED Verdict into a `{frame_ref, reason}` pair and returns the set to the Actor. The Receipt is the durable record; the Refinement signal is the live message back into the loop. Same source, different consumers, different shapes. @@ -351,13 +355,43 @@ Receipt content: `task_id`, `attempt_id` (absent for controller events), `tool_c --- +## Persistence + +One async append-only primitive serves every durable stream — Receipt log, Cognition log, Findings. One open/append/fsync discipline. One serialization-failure contract: write failure surfaces to the caller; silent rewrite to placeholder is forbidden (`AGENTS.md §Error Discipline > Semantic Integrity Under Failure`). In-memory mirrors are derived from the on-disk record and update only after durable success. + +--- + +## Snapshot Lifecycle + +Snapshots are content-addressed (see *Vocabulary > Snapshot*). Each Snapshot persists as one content-immutable file under `/snapshots/.json` — the same write+fsync discipline as the unified streaming primitive (see *Persistence*), applied to a single-shot object write rather than an append. Stable references: the Snapshot ID embedded in every Receipt resolves to a persisted Snapshot record. Append-only: new Snapshots are added as new files; existing files are never mutated. Pruning: files for old Snapshots are deleted when no longer referenced. + +--- + +## Skills + +Actor-side cognition instrumentation following the SKILL.md convention. The Actor consults Skills during cognition; any tool call produced under a Skill's guidance crosses the Gate (see *The Loop*). Skills do not constitute a new Tool category and do not bypass the Charter's `permitted_tools`. + +--- + +## Subagents + +A Steward may spawn a subagent via a tool call. The spawn proposal crosses the Gate like any other tool call; the spawned subagent runs its own loop with its own Snapshot, and every tool call inside the subagent's loop crosses its own Gate. Subagents do not constitute a new governance surface — they are recursive instances of the existing one. + +--- + +## User-Facing Integration Boundary + +The agent is never the primary UI. Product UIs that consume CharteredOS deployments interact with the Runtime, not with the Actor's cognition. The user does not see the agent; the user sees product surfaces backed by Receipt-governed effects. + +--- + ## The Protocol The Runtime's contract is a typed proposal-and-Verdict protocol. Definitions live in `proto/v1/`: -- `tool_call.proto` — the `ToolCall` packet (`tool_name`, `tool_params`, `context_id`, `source_id`, `timestamp`). -- `verdict.proto` — `Verdict` (per-Frame Ruling + within-Frame trace), `EvaluatorEntry`, `Metrics`. -- `receipt.proto` — `Receipt`, `Outcome` enum, `EnforcementLevel` enum. +- `tool_call.proto` — the `ToolCall` packet (`tool`, `tool_params`, `context_id`, `source_id`). +- `verdict.proto` — `FrameRef` (Steward-scoped Frame identity), `Verdict` (Ruling + reason + within-Frame `EvaluatorEntry` trace). +- `receipt.proto` — `Receipt`, `Outcome` enum, `GovernanceMode` (the (grounding, evaluation) toggle pair). Versioned by field numbering. New fields ignored by old readers; absent fields default. diff --git a/examples/charters/CONTEXT.md b/examples/charters/CONTEXT.md index 70b0608..712f8c8 100644 --- a/examples/charters/CONTEXT.md +++ b/examples/charters/CONTEXT.md @@ -2,7 +2,7 @@ Concrete Charter templates per domain. Spec §Tools and §Reference Charters point here. Inherits root `CONTEXT.md`. Loaded by -`core::charter_loader::load_charter_def`. +`runtime::charter_loader::load_charter_def`. ## Domains diff --git a/examples/charters/coding-agent/frames.toml b/examples/charters/coding-agent/frames.toml index 009524b..7558571 100644 --- a/examples/charters/coding-agent/frames.toml +++ b/examples/charters/coding-agent/frames.toml @@ -1,5 +1,5 @@ # Charter: coding-agent -# Spec §Reference Charters; loaded by core::charter_loader::load_charter_def. +# Spec §Reference Charters; loaded by runtime::charter_loader::load_charter_def. permitted_tools = ["read_file", "write_file", "exec_command"] diff --git a/examples/charters/customer-service/frames.toml b/examples/charters/customer-service/frames.toml index 6cfdc62..8129fba 100644 --- a/examples/charters/customer-service/frames.toml +++ b/examples/charters/customer-service/frames.toml @@ -1,5 +1,5 @@ # Charter: customer-service -# Spec §Reference Charters; loaded by core::charter_loader::load_charter_def. +# Spec §Reference Charters; loaded by runtime::charter_loader::load_charter_def. # # All declared scopes here are RoleContext: the operator supplies the # pricing schedule, returns/warranty policy, and service scope as diff --git a/examples/charters/medical-reception/frames.toml b/examples/charters/medical-reception/frames.toml index 9838875..2723194 100644 --- a/examples/charters/medical-reception/frames.toml +++ b/examples/charters/medical-reception/frames.toml @@ -1,5 +1,5 @@ # Charter: medical-reception -# Spec §Reference Charters; loaded by core::charter_loader::load_charter_def. +# Spec §Reference Charters; loaded by runtime::charter_loader::load_charter_def. # # Worked example throughout the spec (§The Loop, §Structural # Separation). All declared scopes here are RoleContext: the practice diff --git a/examples/demo/.chartered/charter/behavioral_spec.md b/examples/demo/.chartered/charter/behavioral_spec.md index 75d7811..a2e6093 100644 --- a/examples/demo/.chartered/charter/behavioral_spec.md +++ b/examples/demo/.chartered/charter/behavioral_spec.md @@ -8,18 +8,21 @@ trigger naming an artifact, a range, and a professional action. Reply with exactly one JSON Action object per turn — never prose, never multiple actions. After one allowed action, halt. -Generative actions modify the artifact via `modify_artifact`. The Tool -params carry `kind: "text"`, the `artifact_id`, the `range`, the -`replacement`, and a `summary` field stating, in diligence-professional -terms, what the substantive change accomplishes ("Tightens MAC carve-out -to exclude pandemic and climate events explicitly", not "Improves the -clause"). - -Evaluative actions record one finding via `record_finding`. The finding's -`concern` is a single-line statement of the diligence issue; `severity` -is `low`, `medium`, or `high` per the Charter's calibration scope; -`detail` quotes the relevant text from the selected range and explains -the deal-impact in one or two sentences. +Generative actions modify the artifact via `modify_artifact` with +`kind: "text"`. The Tool params carry the source `artifact_id`, the +`range`, the `replacement`, and a `summary` field stating, in +diligence-professional terms, what the substantive change accomplishes +("Tightens MAC carve-out to exclude pandemic and climate events +explicitly", not "Improves the clause"). + +Evaluative actions record one finding via `modify_artifact` with +`kind: "record-store"`. The Tool params carry the record-store +artifact_id (always `"records"`) and an `edit.append` object whose +fields are: the source `artifact_id` (the document under review), the +`range` inside it, a single-line `concern`, a `severity` of `low`, +`medium`, or `high` per the Charter's calibration scope, and a `detail` +that quotes the relevant text from the selected range and explains the +deal-impact in one or two sentences. Use the exact `artifact_id` and `range` from the trigger. Do not invent ranges. Do not contact Seller's counsel. Do not draft text for Seller. diff --git a/examples/demo/.chartered/charter/frames.toml b/examples/demo/.chartered/charter/frames.toml index 0f97765..20211c9 100644 --- a/examples/demo/.chartered/charter/frames.toml +++ b/examples/demo/.chartered/charter/frames.toml @@ -1,9 +1,9 @@ -permitted_tools = ["modify_artifact", "record_finding", "read_artifact", "list_artifacts"] +permitted_tools = ["modify_artifact", "read_artifact", "list_artifacts"] [[frames]] id = "citation_grounding" concern = "The Tool call's claim is grounded in quoted text from the selected artifact range. Findings cite a substring or an adjacent clause of the selected Section; refinements summarize the substantive change rather than the act of editing." -applies_to_tools = ["modify_artifact", "record_finding"] +applies_to_tools = ["modify_artifact"] declared_scopes = [ { name = "citation_grounding", kind = "Charter" }, ] @@ -11,7 +11,7 @@ declared_scopes = [ [[frames]] id = "diligence_discipline" concern = "Review findings identify a concrete deal-relevant issue (change-of-control trigger, unenforceable covenant, unfavorable indemnity term, stale reference date, overbroad carve-out, asymmetric right). Refine modifications preserve the artifact's professional register and the section's substantive subject." -applies_to_tools = ["modify_artifact", "record_finding"] +applies_to_tools = ["modify_artifact"] declared_scopes = [ { name = "diligence_discipline", kind = "Charter" }, ] @@ -19,7 +19,7 @@ declared_scopes = [ [[frames]] id = "severity_calibration" concern = "Severity reflects deal impact under the calibration in the Charter scope: high for signing-gating issues, medium for negotiation moves, low for cleanup." -applies_to_tools = ["record_finding"] +applies_to_tools = ["modify_artifact"] declared_scopes = [ { name = "severity_calibration", kind = "Charter" }, ] diff --git a/examples/demo/.chartered/steward.toml b/examples/demo/.chartered/steward.toml index 518ef8b..41c56e4 100644 --- a/examples/demo/.chartered/steward.toml +++ b/examples/demo/.chartered/steward.toml @@ -8,14 +8,14 @@ For a Refine action (generative), respond with: {"tool":"modify_artifact","params":{"kind":"text","artifact_id":"","range":{"start":,"end":,"start_line":,"end_line":},"replacement":"","summary":""}} For a Review action (evaluative), respond with: - {"tool":"record_finding","params":{"artifact_id":"","range":{"start":,"end":,"start_line":,"end_line":},"concern":"","severity":"low|medium|high","detail":""}} + {"tool":"modify_artifact","params":{"kind":"record-store","artifact_id":"records","edit":{"append":{"artifact_id":"","range":{"start":,"end":,"start_line":,"end_line":},"concern":"","severity":"low|medium|high","detail":""}}}} -Use the exact artifact_id and range from the selection trigger. Carry -the summary or detail as professional diligence language ("Section 8.1 -MAC carve-outs exclude pandemic and climate events", not "I noticed -something"). +Use the exact source `artifact_id` and `range` from the selection +trigger. Carry the summary or detail as professional diligence +language ("Section 8.1 MAC carve-outs exclude pandemic and climate +events", not "I noticed something"). """ -tool_registry = ["modify_artifact.toml", "record_finding.toml", "read_artifact.toml", "list_artifacts.toml"] +tool_registry = ["modify_artifact.toml", "read_artifact.toml", "list_artifacts.toml"] [actor] backend = "openai" diff --git a/examples/demo/.chartered/tools/record_finding.toml b/examples/demo/.chartered/tools/record_finding.toml deleted file mode 100644 index 43246ab..0000000 --- a/examples/demo/.chartered/tools/record_finding.toml +++ /dev/null @@ -1,2 +0,0 @@ -id = "record_finding" -executor = "native_artifact_record_finding" diff --git a/examples/demo/README.md b/examples/demo/README.md index 70b0bb6..29f8fc7 100644 --- a/examples/demo/README.md +++ b/examples/demo/README.md @@ -13,10 +13,11 @@ persona will. - A real-shape persona workspace (M&A) is just `(directory + Charter + Steward graph)` running on the unchanged kernel. - The Tool ABI is fixed at `read_artifact` / `modify_artifact` / - `list_artifacts` / `record_finding`; substrate variation is below - the line, in ArtifactBackends. The same dashboard, same runtime, - same Receipts trail as the existing text-blob demo. -- Findings flow into a `kind=findings-store` artifact; text artifacts + `list_artifacts`; substrate variation is below the line, in + ArtifactBackends (`kind=text` for memos and data-room files, + `kind=record-store` for the audit log). The same dashboard, same + runtime, same Receipts trail as the existing text-blob demo. +- Findings flow into a `kind=record-store` artifact; text artifacts are `kind=text`; both reached through the same Tools, dispatched by kind explicitly, no ownership heuristic. - The Charter's Steward-owned Frames make the diligence Steward auditable: every @@ -48,15 +49,15 @@ Then open http://127.0.0.1:5177. Environment overrides: ```bash PORT=5180 ./examples/demo/run.sh # alternate port -LLM_BASE_URL=http://:1234/v1 ./examples/demo/run.sh # dynamic host endpoint -LLM_BASE_URL=https://api.openai.com/v1 \ - LLM_MODEL=gpt-4o-mini \ - LLM_API_KEY=sk-... \ +OPEN_AI_BASE_URL=http://:1234/v1 ./examples/demo/run.sh # dynamic host endpoint +OPEN_AI_BASE_URL=https://api.openai.com/v1 \ + OPEN_AI_MODEL=gpt-4o-mini \ + OPEN_AI_API_KEY=sk-... \ ./examples/demo/run.sh # OpenAI hosted PROFILE=release ./examples/demo/run.sh # release build ``` -The launcher resets `findings.jsonl` and the per-run Receipt trail on +The launcher resets `records.jsonl` and the per-run Receipt trail on each invocation so the demo starts from a clean state. ## Walkthrough @@ -88,9 +89,10 @@ The memo updates in place if the Outcome is `Allowed`. 1. Open `data-room/customer-northstar.md`. 2. Select Section 11.1 (the change-of-control assignment clause). 3. Click **Review**. -4. The Steward proposes a `record_finding` Tool call. The Gate evaluates - citation grounding, diligence discipline, and severity calibration. - On `Allowed`, a Finding is appended to the `findings-store` artifact +4. The Steward proposes a `modify_artifact` Tool call with + `kind=record-store`. The Gate evaluates citation grounding, + diligence discipline, and severity calibration. On `Allowed`, the + appended finding lands in the `record-store` artifact `records` (visible in the left-rail Findings node). Try the same on `data-room/apa-draft-v3.md` Section 8.1 (the MAC @@ -168,7 +170,6 @@ examples/demo/ │ └── scopes.md Charter scopes the Frames evaluate against └── tools/ ├── modify_artifact.toml - ├── record_finding.toml ├── read_artifact.toml └── list_artifacts.toml ``` @@ -179,4 +180,4 @@ examples/demo/ - `core/src/artifact.rs` (`ArtifactStore`, `ArtifactBackend`, kind-typed dispatch). - `dispatch/src/artifact.rs` (`FilesystemTextBackend`, - `FilesystemFindingsBackend`). + `FilesystemRecordStore`). diff --git a/proto/CONTEXT.md b/proto/CONTEXT.md index 19d492d..981ab2a 100644 --- a/proto/CONTEXT.md +++ b/proto/CONTEXT.md @@ -9,10 +9,10 @@ Versioned by field numbering. Inherits root `CONTEXT.md`. ## Layout - `v1/tool_call.proto` — the proposal packet. -- `v1/verdict.proto` — per-Frame Ruling + within-chain trace, - `EvaluatorEntry`, `Metrics`. -- `v1/receipt.proto` — append-only Gate-step record, `Outcome` and - `EnforcementLevel` enums. +- `v1/verdict.proto` — `FrameRef` (Steward-scoped Frame identity), + `Verdict` (Ruling + reason + within-chain `EvaluatorEntry` trace). +- `v1/receipt.proto` — append-only Gate-step record, `Outcome` enum, + `GovernanceMode` (grounding + evaluation toggles). - `v1/evaluate.proto` — Runtime ↔ Daemon ↔ Adapter messages. Future versions ship under `v2/`, `v3/`, etc. Renumbering or removing diff --git a/proto/v1/evaluate.proto b/proto/v1/evaluate.proto index 914b7da..bdc2f37 100644 --- a/proto/v1/evaluate.proto +++ b/proto/v1/evaluate.proto @@ -8,7 +8,7 @@ import "v1/receipt.proto"; // Runtime ↔ Gate ↔ Daemon messages. See docs/SPECIFICATION.md > The Loop, > Receipts. message EvaluateRequest { ToolCall tool_call = 1; // tool_call.source_id identifies the Steward - string charter_instance_id = 2; + string snapshot_id = 2; // the content-addressed Snapshot the loop is running under } message EvaluateResponse { diff --git a/proto/v1/receipt.proto b/proto/v1/receipt.proto index 8fc13a9..724b7e1 100644 --- a/proto/v1/receipt.proto +++ b/proto/v1/receipt.proto @@ -11,27 +11,30 @@ enum Outcome { ALLOWED = 1; // every Frame GROUNDED DENIED = 2; // at least one Frame UNGROUNDED ESCALATED = 3; // refinement budget exhausted - PASSTHROUGH = 4; // no evaluation performed (passthrough enforcement) + PASSTHROUGH = 4; // no evaluation performed (governance_mode.evaluation = false) } -// See docs/SPECIFICATION.md > Requisite Variety for why no intermediate level exists. -enum EnforcementLevel { - ENFORCEMENT_LEVEL_UNSPECIFIED = 0; - PASSTHROUGH_LEVEL = 1; - FULL_LEVEL = 2; +// Independent toggles per docs/SPECIFICATION.md > The Runtime > Governance mode. +// Four named combinations: (true, true) full, (true, false) grounding-only, +// (false, true) evaluation-only, (false, false) neither. Every Receipt records +// which mode produced it. +message GovernanceMode { + bool grounding = 1; // Charter and Role context Scopes injected into the Actor's prompt + bool evaluation = 2; // Frames evaluate proposals before dispatch } message Receipt { string receipt_id = 1; - ToolCall tool_call = 2; - repeated Verdict verdicts = 3; // one per applicable Frame; empty under PASSTHROUGH - Outcome outcome = 4; - google.protobuf.Timestamp timestamp = 5; - Metrics metrics = 6; - EnforcementLevel enforcement_level = 7; - bool intercept_complete = 8; // false on any partial-coverage condition (timeout, evaluator unreachable, durability degraded) - string adapter_id = 9; // Runtime or Adapter identifier - uint64 charter_version = 10; - uint64 role_context_version = 11; - string snapshot_id = 12; + string task_id = 2; + string attempt_id = 3; // empty for controller events (halt, fail, budget_exhausted) + string steward_id = 4; // the Steward that emitted this Receipt + ToolCall tool_call = 5; + repeated Verdict verdicts = 6; // one per applicable Frame; empty under PASSTHROUGH + Outcome outcome = 7; + google.protobuf.Timestamp timestamp = 8; + bool intercept_complete = 9; // false on any partial-coverage condition (timeout, evaluator unreachable, durability degraded) + GovernanceMode governance_mode = 10; + uint64 charter_version = 11; + uint64 role_context_version = 12; + string snapshot_id = 13; } diff --git a/proto/v1/tool_call.proto b/proto/v1/tool_call.proto index 22c2a5d..e28e8e7 100644 --- a/proto/v1/tool_call.proto +++ b/proto/v1/tool_call.proto @@ -1,13 +1,14 @@ syntax = "proto3"; package chartered.v1; -import "google/protobuf/timestamp.proto"; - // The Steward's proposed action. See docs/SPECIFICATION.md > The Loop, > Tools. +// +// Timestamps live on the Receipt (one per Gate step), not on the ToolCall itself. +// A ToolCall is the proposal; the Receipt that aggregates Verdicts over it +// owns the temporal axis. message ToolCall { - string tool_name = 1; // registered Tool identifier - bytes tool_params = 2; // typed shape per Tool registry; serialized + string tool = 1; // registered Tool identifier + bytes tool_params = 2; // typed shape per Tool registry; serialized JSON in v1 string context_id = 3; // session / conversation / task identifier string source_id = 4; // which Steward - google.protobuf.Timestamp timestamp = 5; } diff --git a/proto/v1/verdict.proto b/proto/v1/verdict.proto index c6ba904..5e6baa9 100644 --- a/proto/v1/verdict.proto +++ b/proto/v1/verdict.proto @@ -1,9 +1,16 @@ syntax = "proto3"; package chartered.v1; +// Frames are weak entities under their owning Steward; cross-boundary identity +// is always the FrameRef pair. See docs/SPECIFICATION.md > Vocabulary > Frame. +message FrameRef { + string steward_id = 1; + string frame_id = 2; +} + // Per-Frame decision. See docs/SPECIFICATION.md > Frames. message Verdict { - string frame_id = 1; + FrameRef frame_ref = 1; Ruling ruling = 2; string reason = 3; // one sentence; surfaces to Steward on denial repeated EvaluatorEntry evaluator_trace = 4; @@ -21,7 +28,6 @@ message EvaluatorEntry { string evaluator_id = 1; Decision decision = 2; string observation = 3; - Metrics metrics = 4; // DEFER ≠ Outcome.PASSTHROUGH (receipt.proto). DEFER = chain-step "next evaluator"; PASSTHROUGH = "not evaluated". enum Decision { @@ -32,10 +38,3 @@ message EvaluatorEntry { DEFER = 4; } } - -message Metrics { - uint32 latency_ms = 1; - uint32 input_tokens = 2; // LLM evaluator only - uint32 output_tokens = 3; // LLM evaluator only - uint32 cache_hit_tokens = 4; // LLM evaluator only (prompt-cache hits) -} diff --git a/runtime/CONTEXT.md b/runtime/CONTEXT.md index b73a58a..1549ac6 100644 --- a/runtime/CONTEXT.md +++ b/runtime/CONTEXT.md @@ -31,7 +31,9 @@ at every level." - **Charter + Role context**: loaded from the Charter directory that `.chartered/charter.toml::path` points at, via - `core::charter_loader::load_charter_def` and `load_role_context_def`. + `runtime::charter_loader::load_charter_def` and `load_role_context_def` + (deployment-side filesystem IO; the kernel parses pre-loaded text + through `chartered_core::parse_charter_def` / `parse_role_context_def`). - **Per-role CognitionBackend**: per-role tables in `steward.toml` (`[actor]`, `[evaluator]`, `[tester]?`, `[judge]?`) declare `backend = "fake"` or `backend = "openai"`. For `fake`, inline @@ -58,13 +60,13 @@ failure, 64 on usage error. `fake_responses` in the same TOML table. The only test path; CI runs entirely on this. - `backend = "openai"` — `OpenAiCompatibleBackend` (HTTP). Reads - `LLM_BASE_URL`, `LLM_MODEL`, `LLM_API_KEY` from environment + `OPEN_AI_BASE_URL`, `OPEN_AI_MODEL`, `OPEN_AI_API_KEY` from environment (loaded by `dotenvy::dotenv()` from `.env` in CWD or any ancestor on binary startup). Per-role `model = "..."` in `steward.toml` - overrides `LLM_MODEL`. Same impl serves both real OpenAI - (`LLM_BASE_URL=https://api.openai.com/v1`, key required) and local + overrides `OPEN_AI_MODEL`. Same impl serves both real OpenAI + (`OPEN_AI_BASE_URL=https://api.openai.com/v1`, key required) and local OpenAI-compatible servers (LM Studio, llama.cpp, vLLM, SGLang; - key optional). `LLM_BASE_URL` is the versioned API base; the backend + key optional). `OPEN_AI_BASE_URL` is the versioned API base; the backend appends `/chat/completions`. The `OpenAiCompatibleBackend` does not differ between them — only the env values do. @@ -108,14 +110,18 @@ end up in the repo. `target/` (also gitignored) holds tempdirs from the test suite indirectly via `tempfile::TempDir`, which uses system temp by default. -## E2E targets THIS binary +## Binary-level integration targets THIS binary -`tests/e2e.rs` constructs a complete `.chartered/` deployment in an -isolated tempdir per test and runs the binary against it. Asserts on -both the stdout JSON AND on real filesystem state in the workspace. -The deployments are real production deployments — same loader, same -loop, same `dispatch::*` Tools. The only distinction from a real-LLM -deployment is `backend = "fake"` in the per-role config. +`tests/binary_integration.rs` constructs a complete `.chartered/` +deployment in an isolated tempdir per test and runs the binary against +it. Asserts on both the stdout JSON AND on real filesystem state in the +workspace. The deployments are real production deployments — same +loader, same loop, same `dispatch::*` Tools. The only distinction from +the e2e LLM deployments is `backend = "fake"` in the per-role config. + +Per `AGENTS.md §Verification`, this file is integration (vertical cut +across the binary boundary, fake-LLM side of the test pair). E2e +requires an actual LLM and lives in `tests/llm_e2e.rs`, local-only. Tempdirs are system temp (outside the repo, naturally gitignored). Each test gets its own tempdir for isolation. @@ -137,15 +143,19 @@ subprocess. - `run` — the single execution path. Tests: -- `tests/config.rs` exercises the loader directly. -- `tests/e2e.rs` (18 tests) exercises the binary with `backend = - "fake"` deployments. -- `tests/llm_e2e.rs` (2 tests) exercises the binary with `backend = - "openai"` deployments. Skips cleanly when `LLM_BASE_URL` is unset. - -Module unit tests inside `persistence`, `openai_backend`. The binary -entry (`src/main.rs`) consumes the lib the same way external -consumers would. +- `tests/config.rs` — loader exercised directly (integration). +- `tests/persistence.rs` — disk-backed Receipt store + Cognition log + (integration; one tempdir per test). +- `tests/binary_integration.rs` — binary exercised with `backend = + "fake"` deployments (integration). +- `tests/llm_e2e.rs` — binary exercised with real LLM (`backend = + "openai"` or `"openai"`-over-local-LM). E2e per + `AGENTS.md §Verification`: every test `#[ignore]`d, opt-in via + `cargo test -- --ignored`. Local-only. + +Module unit tests inside `openai_backend` (stateless canonicalization +and URL building). The binary entry (`src/main.rs`) consumes the lib +the same way external consumers would. ## Example deployment diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index 02105a4..1b0e943 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -99,6 +99,7 @@ version = "0.1.0" dependencies = [ "async-trait", "chartered-core", + "serde", "serde_json", "tokio", ] diff --git a/runtime/src/canonicalize.rs b/runtime/src/canonicalize.rs new file mode 100644 index 0000000..ec07367 --- /dev/null +++ b/runtime/src/canonicalize.rs @@ -0,0 +1,275 @@ +//! Shared adapter utility: convert a raw assistant content string into +//! an `Option`. +//! +//! Why here, not in the kernel: the kernel's contract is strong-typed +//! (`ActionHint`). The kernel does NOT parse JSON or scan for code +//! fences — that's the adapter's job. Adapters that need the same +//! generic canonicalization (universal LLM idioms: markdown fences +//! around JSON, reasoning prose followed by JSON) share this utility. +//! Vendor-specific envelopes (gpt-oss harmony, OpenAI native +//! `tool_calls` field, Gemini `functionCall`) stay in the adapter that +//! owns them. +//! +//! Spec correspondence: `SPECIFICATION.md §Cognition Layer` ("the +//! interface handles multiple response formats so that any cognition +//! implementation satisfies the kernel's contract") + the +//! kernel-doesn't-parse-JSON principle. + +use chartered_core::{ActionHint, Decision, DecisionLine, JudgeOutput}; + +/// Try to extract an `ActionHint` from the assistant's raw content. +/// Strips markdown code fences, then scans for the first balanced +/// `{...}` JSON object and interprets it as the canonical Action +/// envelope: `{"halt": true}` → `Halt`; `{"tool": "...", "params": +/// {...}}` → `Propose`. Returns `None` for pure-reasoning responses +/// (no JSON, or JSON without the expected fields) — the Actor's inner +/// loop continues. +pub fn canonicalize_action_hint(raw: &str) -> Option { + let stripped = strip_code_fences(raw); + let json_text = extract_first_json_object(stripped).unwrap_or(stripped); + let value: serde_json::Value = serde_json::from_str(json_text.trim()).ok()?; + parse_action_value(&value) +} + +/// Parse a `serde_json::Value` as the canonical Action envelope. Public +/// because adapters that have already deserialized vendor-native +/// tool-call JSON (e.g. OpenAI native `tool_calls`, Gemini +/// `functionCall`) can call this directly to wrap into `ActionHint`. +pub fn parse_action_value(value: &serde_json::Value) -> Option { + if value.get("halt").and_then(serde_json::Value::as_bool) == Some(true) { + return Some(ActionHint::Halt); + } + let tool = value.get("tool").and_then(serde_json::Value::as_str)?; + let params = value + .get("params") + .cloned() + .unwrap_or(serde_json::Value::Null); + Some(ActionHint::Propose { + tool: tool.to_string(), + params, + }) +} + +/// Strip a single layer of leading/trailing markdown code fence markers +/// (```json, ```JSON, or plain ```). Whitespace-tolerant. +pub fn strip_code_fences(s: &str) -> &str { + let s = s.trim(); + let s = s + .strip_prefix("```json") + .or_else(|| s.strip_prefix("```JSON")) + .or_else(|| s.strip_prefix("```")) + .map(str::trim) + .unwrap_or(s); + s.trim_end_matches("```").trim() +} + +/// Scan the raw assistant content for Evaluator decision lines. Each +/// recognized line yields one `DecisionLine` (`{decision, observation}`). +/// Empty vec when nothing matches. +/// +/// The scan is lenient — it locates the first `ALLOW` / `DENY` / +/// `ESCALATE` / `DEFER` token (uppercase, word-boundary-aware) +/// anywhere in a line, including after reasoning prose, and reads +/// the text after the next `:` as the observation. Lines that +/// contain the legacy `DECISION: [, REASON: ...]` paraphrase are +/// also handled. Lines without a recognized token are dropped. +pub fn canonicalize_verdict_lines(raw: &str) -> Vec { + raw.lines() + .filter_map(canonicalize_one_decision_line) + .collect() +} + +fn canonicalize_one_decision_line(line: &str) -> Option { + // Locate the latest keyword occurrence in the line, then read the + // observation from after the next `:`. Latest, not earliest, so + // prose containing the word "allow" earlier in the line doesn't + // shadow the actual ALLOW: token at the end (the LLM's emitted + // decision is typically near the end after the reasoning prose). + const KEYWORDS: &[(&str, Decision)] = &[ + ("ALLOW", Decision::Allow), + ("DENY", Decision::Deny), + ("ESCALATE", Decision::Escalate), + ("DEFER", Decision::Defer), + ]; + + let upper = line.to_uppercase(); + let mut best: Option<(usize, Decision, usize)> = None; + for (kw, dec) in KEYWORDS { + let mut search_from = 0usize; + while let Some(rel) = upper[search_from..].find(kw) { + let abs = search_from + rel; + let before_ok = abs == 0 + || !upper.as_bytes()[abs - 1].is_ascii_alphanumeric(); + let after = abs + kw.len(); + let after_ok = after >= upper.len() + || !upper.as_bytes()[after].is_ascii_alphanumeric(); + if before_ok && after_ok && best.is_none_or(|(pos, _, _)| abs > pos) { + best = Some((abs, *dec, kw.len())); + } + search_from = abs + kw.len(); + } + } + let (kw_pos, decision, kw_len) = best?; + + // Two shapes: `DECISION: , ...` (the keyword appears in the + // tail of a DECISION:-prefixed line) and `: reason` (the + // keyword is directly followed by `:`). Both produce the same + // DecisionLine; we differ only on where to read the observation. + let after_kw = &line[kw_pos + kw_len..]; + let observation = if let Some((_, tail)) = after_kw.split_once(':') { + tail.trim().trim_start_matches(',').trim().to_string() + } else { + // `DECISION: ALLOW` with no reason — degenerate paraphrase. + // Drop a leading comma + optional "REASON:" prefix. + let mut rest = after_kw.trim().trim_start_matches(',').trim(); + if let Some(stripped) = rest + .strip_prefix("REASON:") + .or_else(|| rest.strip_prefix("Reason:")) + .or_else(|| rest.strip_prefix("reason:")) + { + rest = stripped.trim(); + } + rest.to_string() + }; + + Some(DecisionLine { + decision, + observation, + }) +} + +/// Try to extract the strong-typed Judge output from raw assistant +/// content. Strips fences, extracts the first balanced JSON object, +/// deserializes via serde. Returns `None` when nothing parses — the +/// Judge layer surfaces this as a `JudgeError` ("adapter produced no +/// judge_output"). +pub fn canonicalize_judge_output(raw: &str) -> Option { + let stripped = strip_code_fences(raw); + let json_text = extract_first_json_object(stripped).unwrap_or(stripped); + serde_json::from_str::(json_text.trim()).ok() +} + +/// Walk `text` and return the first balanced top-level JSON object +/// substring. String/escape state is tracked so braces inside JSON +/// strings do not confuse depth tracking. Returns None when no +/// balanced object is present. +pub fn extract_first_json_object(text: &str) -> Option<&str> { + let bytes = text.as_bytes(); + let mut depth: i32 = 0; + let mut start: Option = None; + let mut in_string = false; + let mut escape = false; + for (i, &b) in bytes.iter().enumerate() { + if in_string { + if escape { + escape = false; + continue; + } + match b { + b'\\' => escape = true, + b'"' => in_string = false, + _ => {} + } + continue; + } + match b { + b'{' => { + if depth == 0 { + start = Some(i); + } + depth += 1; + } + b'"' => in_string = true, + b'}' => { + depth -= 1; + if depth == 0 + && let Some(s) = start + { + return Some(&text[s..=i]); + } + } + _ => {} + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pure_prose_yields_none() { + assert!(canonicalize_action_hint("just thinking out loud").is_none()); + } + + #[test] + fn raw_propose_json_yields_propose() { + let h = canonicalize_action_hint(r#"{"tool":"write_file","params":{"path":"x"}}"#).unwrap(); + match h { + ActionHint::Propose { tool, params } => { + assert_eq!(tool, "write_file"); + assert_eq!(params["path"].as_str(), Some("x")); + } + other => panic!("expected Propose, got {other:?}"), + } + } + + #[test] + fn raw_halt_json_yields_halt() { + let h = canonicalize_action_hint(r#"{"halt": true}"#).unwrap(); + assert!(matches!(h, ActionHint::Halt)); + } + + #[test] + fn propose_with_no_params_field_yields_null_params() { + let h = canonicalize_action_hint(r#"{"tool":"halt_check"}"#).unwrap(); + match h { + ActionHint::Propose { tool, params } => { + assert_eq!(tool, "halt_check"); + assert_eq!(params, serde_json::Value::Null); + } + other => panic!("expected Propose, got {other:?}"), + } + } + + #[test] + fn missing_tool_and_no_halt_yields_none() { + assert!(canonicalize_action_hint(r#"{"params":{"x":1}}"#).is_none()); + } + + #[test] + fn markdown_fenced_json_is_unwrapped() { + let h = canonicalize_action_hint("```json\n{\"halt\":true}\n```").unwrap(); + assert!(matches!(h, ActionHint::Halt)); + } + + #[test] + fn prose_prefix_before_json_is_extracted() { + // Gemini-shaped output: reasoning prose followed by the action. + let s = "I'll write a file. {\"tool\":\"write_file\",\"params\":{\"path\":\"x\"}}"; + let h = canonicalize_action_hint(s).unwrap(); + match h { + ActionHint::Propose { tool, .. } => assert_eq!(tool, "write_file"), + other => panic!("expected Propose, got {other:?}"), + } + } + + #[test] + fn braces_inside_strings_do_not_terminate_extraction() { + let s = r#"prose {"k":"a }{ b","n":1}"#; + assert_eq!( + extract_first_json_object(s), + Some(r#"{"k":"a }{ b","n":1}"#) + ); + } + + #[test] + fn escaped_quotes_inside_strings_dont_break_state() { + let s = r#"prefix {"k":"with \"quote\"","n":1}"#; + assert_eq!( + extract_first_json_object(s), + Some(r#"{"k":"with \"quote\"","n":1}"#) + ); + } +} diff --git a/runtime/src/charter_loader.rs b/runtime/src/charter_loader.rs new file mode 100644 index 0000000..3579f79 --- /dev/null +++ b/runtime/src/charter_loader.rs @@ -0,0 +1,86 @@ +//! Deployment-side Charter and Role context loading from disk. +//! +//! Reads files from the deployment's Charter directory and the +//! deployment's `.chartered/` directory, then hands the text to the +//! kernel's pure parsers (`chartered_core::parse_charter_def`, +//! `parse_role_context_def`). The kernel has no filesystem dependency; +//! substrate choice (local disk here, future daemon-distributed +//! Charter delivery elsewhere) is owned at this layer. +//! +//! Spec §The Charter, §Role Context, §Reference Charters. + +use std::path::Path; + +use chartered_core::{ + CharterDef, CharterLoadError, RoleContextDef, Skill, parse_charter_def, parse_role_context_def, +}; + +/// Load a Charter from a directory containing `frames.toml`, +/// `scopes.md`, and `behavioral_spec.md`. Returns `CharterDef` (data +/// only); use `chartered_core::build_charter` to attach Evaluators. +pub fn load_charter_def(charter_dir: &Path) -> Result { + let frames_path = charter_dir.join("frames.toml"); + let scopes_path = charter_dir.join("scopes.md"); + let behavioral_spec_path = charter_dir.join("behavioral_spec.md"); + + let frames_text = std::fs::read_to_string(&frames_path).map_err(|e| { + CharterLoadError(format!("reading {}: {e}", frames_path.display())) + })?; + let scopes_text = std::fs::read_to_string(&scopes_path).map_err(|e| { + CharterLoadError(format!("reading {}: {e}", scopes_path.display())) + })?; + let behavioral_spec = std::fs::read_to_string(&behavioral_spec_path).map_err(|e| { + CharterLoadError(format!("reading {}: {e}", behavioral_spec_path.display())) + })?; + + parse_charter_def( + &frames_text, + &scopes_text, + &behavioral_spec, + &frames_path.display().to_string(), + ) +} + +/// Load Role context from a markdown file (same `## Heading` → +/// slugified-name + content convention as `scopes.md`). +pub fn load_role_context_def(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .map_err(|e| CharterLoadError(format!("reading {}: {e}", path.display())))?; + Ok(parse_role_context_def(&text)) +} + +/// Load Skills from `/skills/*.md`. Each `.md` file is one +/// Skill: id = filename stem, content = file body. Missing directory +/// returns an empty Vec (Skills are optional — the rest of the Charter +/// loads regardless). Spec §Skills: Actor-side cognition instrumentation. +pub fn load_skills(charter_dir: &Path) -> Result, CharterLoadError> { + let skills_dir = charter_dir.join("skills"); + if !skills_dir.is_dir() { + return Ok(Vec::new()); + } + let entries = std::fs::read_dir(&skills_dir).map_err(|e| { + CharterLoadError(format!("reading {}: {e}", skills_dir.display())) + })?; + let mut skills = Vec::new(); + for entry in entries { + let entry = entry.map_err(|e| { + CharterLoadError(format!("read_dir entry in {}: {e}", skills_dir.display())) + })?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("md") { + continue; + } + let id = match path.file_stem().and_then(|s| s.to_str()) { + Some(s) => s.to_string(), + None => continue, + }; + let content = std::fs::read_to_string(&path).map_err(|e| { + CharterLoadError(format!("reading {}: {e}", path.display())) + })?; + skills.push(Skill::new(id, content)); + } + // Stable order across loads — sort by id so skills_content_hash is + // deterministic without relying on filesystem iteration order. + skills.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(skills) +} diff --git a/runtime/src/config.rs b/runtime/src/config.rs index c0f7924..15a55fd 100644 --- a/runtime/src/config.rs +++ b/runtime/src/config.rs @@ -9,11 +9,13 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use chartered_core::{ - build_charter, build_role_context, load_charter_def, load_role_context_def, CharterDef, - CharterLoadError, GovernanceMode, RoleContextDef, + build_charter, build_role_context, CharterDef, CharterLoadError, GovernanceMode, + RoleContextDef, Skill, }; use serde::Deserialize; +use crate::charter_loader::{load_charter_def, load_role_context_def, load_skills}; + #[derive(Debug, Clone)] pub struct ConfigError(pub String); @@ -40,6 +42,7 @@ pub enum BackendKind { Fake, #[serde(rename = "openai")] OpenAi, + Gemini, } /// Per-deployment runtime configuration. The governance mode is the @@ -157,10 +160,22 @@ pub struct DeploymentConfig { pub role_context_def: Option, pub tools: Vec, pub role_context_version: u64, + /// Skills loaded from `/skills/*.md`. Empty when the + /// directory is absent. Spec §Skills. + pub skills: Vec, } impl DeploymentConfig { - pub fn build_charter(self, evaluator_factory: F) -> (chartered_core::Charter, chartered_core::RoleContext) + /// Build Charter, Role context, and the Skills bound to the Snapshot. + /// Returns a tuple so the caller can compose a `Snapshot::new`. + pub fn build_charter( + self, + evaluator_factory: F, + ) -> ( + chartered_core::Charter, + chartered_core::RoleContext, + Vec, + ) where F: FnMut(&chartered_core::FrameDef) -> std::sync::Arc, { @@ -169,7 +184,7 @@ impl DeploymentConfig { Some(def) => build_role_context(def, self.role_context_version), None => chartered_core::RoleContext::empty(), }; - (charter, role_context) + (charter, role_context, self.skills) } } @@ -208,6 +223,7 @@ pub fn load(chartered_dir: &Path) -> Result { let charter_dir = resolve_charter_path(chartered_dir, &charter_ref.path)?; let charter_def = load_charter_def(&charter_dir)?; + let skills = load_skills(&charter_dir).map_err(|e| ConfigError(e.to_string()))?; let role_context_path = chartered_dir.join("role_context.md"); let role_context_def = if role_context_path.is_file() { @@ -230,6 +246,7 @@ pub fn load(chartered_dir: &Path) -> Result { role_context_def, tools, role_context_version, + skills, }) } diff --git a/runtime/src/gemini_backend.rs b/runtime/src/gemini_backend.rs new file mode 100644 index 0000000..0887df3 --- /dev/null +++ b/runtime/src/gemini_backend.rs @@ -0,0 +1,527 @@ +//! Gemini cognition backend — Google's native generativelanguage API. +//! +//! Spec correspondence: `SPECIFICATION.md §Cognition Layer` ("Cognition +//! is commodity") — this is a parallel implementation to +//! `OpenAiCompatibleBackend`, swapping at the `CognitionBackend` trait. +//! The kernel sees the same `CognitionRequest` / `CognitionResponse`; +//! the wire-shape translation lives here. +//! +//! Endpoint: `https://generativelanguage.googleapis.com/v1beta/models/:generateContent?key=`. +//! Auth: query-parameter `key=`, not Bearer header. +//! +//! Role mapping: +//! `Message::System` → top-level `systemInstruction.parts[].text` +//! (concatenated when multiple System messages +//! appear; Gemini accepts only one). +//! `Message::User` → `contents[].role = "user"`. +//! `Message::Assistant` → `contents[].role = "model"`. +//! +//! Native tools (`googleSearch`, `codeExecution`, `urlContext`) are +//! intentionally NOT forwarded. Per `SPECIFICATION.md §Tools`, LLM-side +//! native tools are cognition, not Tools — any capability that needs +//! governance is exposed as a registered Charter Tool. This backend +//! ships the message body only. +//! +//! `GEMINI_GENERATE_CONTENT_API` env var is read for informational +//! purposes but ignored — this backend always uses `generateContent` +//! (single response, not the streaming variant). Streaming support is +//! deferred. + +use std::env; +use std::time::Duration; + +use std::sync::Arc; + +use async_trait::async_trait; +use chartered_core::{ + CognitionBackend, CognitionError, CognitionRequest, CognitionResponse, Message, MessageRole, +}; +use serde::{Deserialize, Serialize}; +use tokio::sync::OnceCell; + +use crate::canonicalize; +use crate::openai_backend::BackendBuildError; + +const DEFAULT_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta"; +const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(120); + +/// Per-deployment Gemini backend factory. Reads `GEMINI_*` env once, +/// shares the `reqwest::Client` across roles (one connection pool, one +/// TLS session cache). +pub struct GeminiBackendFactory { + base_url: String, + default_model: Option, + api_key: String, + client: reqwest::Client, +} + +impl GeminiBackendFactory { + /// Read `GEMINI_API_KEY`, `GEMINI_MODEL_ID` (optional default + /// model), and `GEMINI_BASE_URL` (optional override of the default + /// `generativelanguage.googleapis.com/v1beta`) from env. + pub fn from_env() -> Result { + let api_key = env::var("GEMINI_API_KEY") + .map_err(|_| BackendBuildError::MissingEnv("GEMINI_API_KEY"))?; + if api_key.is_empty() { + return Err(BackendBuildError::MissingEnv("GEMINI_API_KEY (empty)")); + } + let base_url = env::var("GEMINI_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()); + let default_model = env::var("GEMINI_MODEL_ID").ok().filter(|s| !s.is_empty()); + let client = reqwest::Client::builder() + .timeout(DEFAULT_REQUEST_TIMEOUT) + .build() + .map_err(|e| BackendBuildError::HttpClient(e.to_string()))?; + Ok(Self { + base_url, + default_model, + api_key, + client, + }) + } + + pub fn build( + &self, + id: impl Into, + model_override: Option, + ) -> Result { + let model = model_override.or_else(|| self.default_model.clone()).ok_or( + BackendBuildError::MissingEnv("GEMINI_MODEL_ID (set env or steward.toml [] model)"), + )?; + Ok(GeminiBackend { + id: id.into(), + base_url: self.base_url.clone(), + model, + api_key: self.api_key.clone(), + client: self.client.clone(), + model_info: Arc::new(OnceCell::new()), + }) + } +} + +pub struct GeminiBackend { + id: String, + base_url: String, + model: String, + api_key: String, + client: reqwest::Client, + /// Lazily-fetched per-model token limits and capabilities, from + /// `GET /v1beta/models/`. Cached after the first call so we + /// don't pay the metadata round-trip on every `complete()`. + /// `outputTokenLimit` is the API's hard cap; for thinking models + /// the kernel's generic `max_output_tokens` hint (typically 1024) + /// is too small to leave room for both reasoning and answer, so + /// the adapter ignores the hint and uses the model's full limit. + model_info: Arc>, +} + +#[derive(Clone, Debug)] +struct ModelInfo { + output_token_limit: u32, + thinking: bool, +} + +#[derive(Deserialize)] +struct ModelInfoResponse { + /// Required per ai.google.dev/api/models > Resource: Model. Absence + /// from a 200 response is a schema deviation; serde decode fails + /// and the adapter surfaces it as a CognitionError rather than + /// substituting a fallback (AGENTS.md §Engineering Law > Error + /// Discipline > Fallback prohibition). + #[serde(rename = "outputTokenLimit")] + output_token_limit: u32, + /// Required per ai.google.dev/api/models. Same handling as + /// `output_token_limit` — absence is a decode failure. + thinking: bool, +} + +impl GeminiBackend { + pub fn base_url(&self) -> &str { + &self.base_url + } + + pub fn model(&self) -> &str { + &self.model + } + + /// Lazily fetch (and cache) the model's metadata from + /// `GET {base}/models/{model}?key=...`. Subsequent calls hit the + /// `OnceCell` cache. Returns `CognitionError` if the metadata + /// endpoint is unreachable or the response can't be decoded — the + /// kernel surfaces this as an actor failure with operator-visible + /// reason, never silently substituting defaults. + async fn model_info(&self) -> Result { + self.model_info + .get_or_try_init(|| async { self.fetch_model_info().await }) + .await + .cloned() + } + + async fn fetch_model_info(&self) -> Result { + let url = format!( + "{}/models/{}", + self.base_url.trim_end_matches('/'), + self.model + ); + let url_display = format!("{url}?key="); + let resp = self + .client + .get(&url) + .query(&[("key", &self.api_key)]) + .send() + .await + .map_err(|e| CognitionError(format!("HTTP request to {url_display}: {e}")))?; + + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(CognitionError(format!( + "HTTP {status} from {url_display}: {body}" + ))); + } + + let parsed: ModelInfoResponse = resp + .json() + .await + .map_err(|e| CognitionError(format!("decoding JSON from {url_display}: {e}")))?; + + Ok(ModelInfo { + output_token_limit: parsed.output_token_limit, + thinking: parsed.thinking, + }) + } +} + +/// Choose the actual `maxOutputTokens` to send to Gemini. Thinking +/// models need their full output budget for reasoning + answer; the +/// kernel's generic 1024 hint isn't meaningful for them, so the +/// adapter overrides with `outputTokenLimit`. Non-thinking models +/// honor the kernel's hint (capped at `outputTokenLimit`). +fn effective_max_output_tokens(info: &ModelInfo, kernel_hint: Option) -> u32 { + if info.thinking { + return info.output_token_limit; + } + match kernel_hint { + Some(h) => h.min(info.output_token_limit), + None => info.output_token_limit, + } +} + +#[async_trait] +impl CognitionBackend for GeminiBackend { + fn id(&self) -> &str { + &self.id + } + + async fn complete( + &self, + request: &CognitionRequest, + ) -> Result { + // Effective max-output-tokens comes from the model's metadata + // (per ai.google.dev/api/models > Model.outputTokenLimit), not + // the kernel's generic hint. Thinking models in particular need + // most of their budget for the reasoning phase; 1024 leaves no + // room for the answer. Metadata is fetched lazily once and + // cached. + let info = self.model_info().await?; + let effective_max = effective_max_output_tokens(&info, request.max_output_tokens); + let body = build_request_body(&request.messages, Some(effective_max)); + let url = generate_content_url(&self.base_url, &self.model); + + let resp = self + .client + .post(&url) + .query(&[("key", &self.api_key)]) + .json(&body) + .send() + .await + .map_err(|e| CognitionError(format!("HTTP request to {url}: {e}")))?; + + let status = resp.status(); + let url_display = redact_key(&url); + if !status.is_success() { + let body_text = resp.text().await.unwrap_or_default(); + return Err(CognitionError(format!( + "HTTP {status} from {url_display}: {body_text}" + ))); + } + + let parsed: GeminiResponse = resp.json().await.map_err(|e| { + CognitionError(format!("decoding JSON from {url_display}: {e}")) + })?; + + let content = parsed + .candidates + .first() + .and_then(|c| c.content.as_ref()) + .map(|inner| { + inner + .parts + .iter() + .filter_map(|p| p.text.as_deref()) + .collect::>() + .join("") + }) + .unwrap_or_default(); + // Gemini commonly emits reasoning prose followed by the action + // JSON ("I'll write the file. {...}"). Universal extraction + // lives in the shared canonicalize util (markdown fences, + // prose-around-JSON). Gemini-native function-calling structures + // aren't yet wired — when we add them, parse here and return an + // ActionHint directly without going through the shared util. + let action_hint = canonicalize::canonicalize_action_hint(&content); + let verdict_lines = canonicalize::canonicalize_verdict_lines(&content); + let judge_output = canonicalize::canonicalize_judge_output(&content); + + let usage = parsed.usage_metadata.unwrap_or_default(); + + Ok(CognitionResponse { + content, + input_tokens: usage.prompt_token_count, + output_tokens: usage.candidates_token_count, + cache_hit_tokens: usage.cached_content_token_count, + action_hint, + verdict_lines, + judge_output, + }) + } +} + +fn generate_content_url(base_url: &str, model: &str) -> String { + format!( + "{}/models/{}:generateContent", + base_url.trim_end_matches('/'), + model + ) +} + +/// Strip the `key=` query parameter from a URL for error messages so +/// the API key never lands in cognition.jsonl or operator logs. +fn redact_key(url: &str) -> String { + if let Some(idx) = url.find("?key=") { + format!("{}?key=", &url[..idx]) + } else if let Some(idx) = url.find("&key=") { + format!("{}&key=", &url[..idx]) + } else { + url.to_string() + } +} + +/// Translate kernel `Vec` → Gemini request body. System +/// messages collapse into `systemInstruction`; user/assistant pairs map +/// to `contents[].role = "user" | "model"`. Model identity travels in +/// the URL path (`models/:generateContent`), not in the body — +/// Gemini's schema is strict and rejects extra fields. +fn build_request_body( + messages: &[Message], + max_output_tokens: Option, +) -> GeminiRequest { + let mut system_text = String::new(); + let mut contents: Vec = Vec::with_capacity(messages.len()); + + for msg in messages { + match msg.role { + MessageRole::System => { + if !system_text.is_empty() { + system_text.push('\n'); + } + system_text.push_str(&msg.content); + } + MessageRole::User => contents.push(GeminiContent { + role: "user", + parts: vec![GeminiPart { + text: Some(msg.content.clone()), + }], + }), + MessageRole::Assistant => contents.push(GeminiContent { + role: "model", + parts: vec![GeminiPart { + text: Some(msg.content.clone()), + }], + }), + } + } + + let system_instruction = if system_text.is_empty() { + None + } else { + Some(GeminiContent { + role: "system", + parts: vec![GeminiPart { + text: Some(system_text), + }], + }) + }; + + GeminiRequest { + contents, + system_instruction, + generation_config: max_output_tokens.map(|n| GenerationConfig { + max_output_tokens: Some(n), + }), + // Native tools (googleSearch, codeExecution, urlContext) + // intentionally omitted — see module header. + } +} + +#[derive(Serialize)] +struct GeminiRequest { + contents: Vec, + #[serde(rename = "systemInstruction", skip_serializing_if = "Option::is_none")] + system_instruction: Option, + #[serde(rename = "generationConfig", skip_serializing_if = "Option::is_none")] + generation_config: Option, +} + +#[derive(Serialize)] +struct GeminiContent { + role: &'static str, + parts: Vec, +} + +#[derive(Serialize)] +struct GeminiPart { + #[serde(skip_serializing_if = "Option::is_none")] + text: Option, +} + +#[derive(Serialize)] +struct GenerationConfig { + #[serde(rename = "maxOutputTokens", skip_serializing_if = "Option::is_none")] + max_output_tokens: Option, +} + +#[derive(Deserialize)] +struct GeminiResponse { + #[serde(default)] + candidates: Vec, + #[serde(rename = "usageMetadata", default)] + usage_metadata: Option, +} + +#[derive(Deserialize)] +struct Candidate { + #[serde(default)] + content: Option, +} + +#[derive(Deserialize)] +struct RespContent { + #[serde(default)] + parts: Vec, +} + +#[derive(Deserialize)] +struct RespPart { + #[serde(default)] + text: Option, +} + +#[derive(Deserialize, Default)] +struct UsageMetadata { + #[serde(rename = "promptTokenCount", default)] + prompt_token_count: u32, + #[serde(rename = "candidatesTokenCount", default)] + candidates_token_count: u32, + #[serde(rename = "cachedContentTokenCount", default)] + cached_content_token_count: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_content_url_appends_model_and_method() { + assert_eq!( + generate_content_url("https://generativelanguage.googleapis.com/v1beta", "gemma-3-27b-it"), + "https://generativelanguage.googleapis.com/v1beta/models/gemma-3-27b-it:generateContent" + ); + assert_eq!( + generate_content_url("https://example.com/v1beta/", "m"), + "https://example.com/v1beta/models/m:generateContent" + ); + } + + #[test] + fn effective_max_overrides_kernel_hint_for_thinking_models() { + let info = ModelInfo { + output_token_limit: 8192, + thinking: true, + }; + // Kernel hint of 1024 is ignored; thinking model needs the full budget. + assert_eq!(effective_max_output_tokens(&info, Some(1024)), 8192); + assert_eq!(effective_max_output_tokens(&info, None), 8192); + } + + #[test] + fn effective_max_honors_kernel_hint_for_non_thinking_models() { + let info = ModelInfo { + output_token_limit: 8192, + thinking: false, + }; + // Kernel cap of 1024 is honored. + assert_eq!(effective_max_output_tokens(&info, Some(1024)), 1024); + // None → use model max. + assert_eq!(effective_max_output_tokens(&info, None), 8192); + // Kernel hint above model max is clamped down. + assert_eq!(effective_max_output_tokens(&info, Some(16384)), 8192); + } + + #[test] + fn redact_key_hides_query_param() { + assert_eq!( + redact_key("https://x/models/y:generateContent?key=AIza-secret"), + "https://x/models/y:generateContent?key=" + ); + assert_eq!( + redact_key("https://x/models/y:generateContent?foo=1&key=AIza-secret"), + "https://x/models/y:generateContent?foo=1&key=" + ); + assert_eq!( + redact_key("https://x/models/y:generateContent"), + "https://x/models/y:generateContent" + ); + } + + #[test] + fn build_request_body_collapses_system_messages_and_maps_roles() { + let msgs = vec![ + Message::system("first system line"), + Message::user("hello"), + Message::system("second system line"), + Message::assistant("hi"), + Message::user("how are you"), + ]; + let req = build_request_body(&msgs, Some(64)); + + // Both System messages join into one systemInstruction. + let si = req.system_instruction.expect("system instruction set"); + assert_eq!(si.role, "system"); + let si_text = si.parts[0].text.as_deref().unwrap_or(""); + assert!(si_text.contains("first system line")); + assert!(si_text.contains("second system line")); + assert!(si_text.contains('\n')); + + // Assistant → "model", User → "user". + assert_eq!(req.contents.len(), 3); + assert_eq!(req.contents[0].role, "user"); + assert_eq!(req.contents[1].role, "model"); + assert_eq!(req.contents[2].role, "user"); + + // generationConfig honored. + assert_eq!( + req.generation_config + .as_ref() + .and_then(|c| c.max_output_tokens), + Some(64) + ); + } + + #[test] + fn build_request_body_no_system_omits_field() { + let msgs = vec![Message::user("hello")]; + let req = build_request_body(&msgs, None); + assert!(req.system_instruction.is_none()); + assert!(req.generation_config.is_none()); + } +} diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 7aceb85..844ed1a 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -7,7 +7,11 @@ //! `tools/*.toml`). Nothing else differs between a test deployment //! and a production deployment. +pub mod canonicalize; +pub mod charter_loader; pub mod config; +pub mod gemini_backend; pub mod openai_backend; pub mod persistence; +pub mod print_charter; pub mod run; diff --git a/runtime/src/main.rs b/runtime/src/main.rs index e6fcd3a..7029682 100644 --- a/runtime/src/main.rs +++ b/runtime/src/main.rs @@ -14,7 +14,7 @@ use std::path::PathBuf; use chartered_core::{ArtifactId, ArtifactRange, SelectionAction, SelectionActionKind}; -use chartered_runtime::run; +use chartered_runtime::{print_charter, run}; #[tokio::main] async fn main() { @@ -31,6 +31,7 @@ async fn main() { async fn dispatch(args: &[String]) -> Result<(), Box> { let mut opts = run::Options::default(); let mut selection = SelectionArgs::default(); + let mut print_charter_mode = false; let mut i = 1; while i < args.len() { let arg = &args[i]; @@ -39,6 +40,10 @@ async fn dispatch(args: &[String]) -> Result<(), Box> { .ok_or_else(|| format!("missing value for {arg}").into()) }; match arg.as_str() { + "--print-charter" => { + print_charter_mode = true; + i += 1; + } "--user-message" => { opts.user_message = Some(value()?.clone()); i += 2; @@ -111,6 +116,9 @@ async fn dispatch(args: &[String]) -> Result<(), Box> { } } opts.selection_trigger = selection.finish()?; + if print_charter_mode { + return print_charter::print(opts.chartered_dir); + } run::run(opts).await } @@ -180,4 +188,7 @@ fn usage() { eprintln!(" --selection-action action label, e.g. Refine or Review"); eprintln!(" --selection-kind generative or evaluative"); eprintln!(" --refinement-budget default: 3"); + eprintln!( + " --print-charter emit parsed Charter as JSON to stdout and exit" + ); } diff --git a/runtime/src/openai_backend.rs b/runtime/src/openai_backend.rs index f0926d9..3d35cf3 100644 --- a/runtime/src/openai_backend.rs +++ b/runtime/src/openai_backend.rs @@ -1,38 +1,42 @@ //! OpenAI-compatible HTTP CognitionBackend. //! //! ONE backend implementation serves both: -//! - Real OpenAI (`LLM_BASE_URL=https://api.openai.com/v1`, -//! `LLM_API_KEY=sk-...`) +//! - Real OpenAI (`OPEN_AI_BASE_URL=https://api.openai.com/v1`, +//! `OPEN_AI_API_KEY=sk-...`) //! - Local OpenAI-compatible servers, e.g. LM Studio, llama.cpp, -//! vLLM, SGLang (`LLM_BASE_URL=http://localhost:1234/v1`, key +//! vLLM, SGLang (`OPEN_AI_BASE_URL=http://localhost:1234/v1`, key //! optional) //! //! The wire format is the OpenAI Chat Completions API -//! (POST `/chat/completions`). When `LLM_API_KEY` is set +//! (POST `/chat/completions`). When `OPEN_AI_API_KEY` is set //! and non-empty, the request carries `Authorization: Bearer ...`; //! otherwise no auth header (local servers usually don't need one). //! //! Configuration order: env via `dotenvy` (`.env` in CWD or any //! ancestor) → process env. Per-role `steward.toml` `model` overrides -//! the env's `LLM_MODEL`. The base URL and the API key are always +//! the env's `OPEN_AI_MODEL`. The base URL and the API key are always //! taken from env — secrets and host endpoints don't belong in TOML //! that may be committed. //! -//! Vendor wire-format adaptation lives here. Roles consume the -//! canonical `CognitionResponse { text, tool_call_hint }`; the kernel -//! never sees gpt-oss harmony envelopes, markdown code fences around -//! JSON, or `to=tool.` recipient prefixes. `canonicalize_assistant_response` -//! strips those before returning. +//! Vendor wire-format adaptation lives here. The Actor consumes +//! `CognitionResponse { content, action_hint }`; the kernel never sees +//! gpt-oss harmony envelopes, markdown code fences, or `to=tool.` +//! recipient prefixes. Harmony-envelope unwrapping is local to this +//! file (gpt-oss-specific); markdown-fence stripping and JSON-from-prose +//! extraction live in `crate::canonicalize` because every adapter needs +//! them. use std::env; use std::time::Duration; use async_trait::async_trait; use chartered_core::{ - CognitionBackend, CognitionError, CognitionRequest, CognitionResponse, Message, ToolCallHint, + ActionHint, CognitionBackend, CognitionError, CognitionRequest, CognitionResponse, Message, }; use serde::{Deserialize, Serialize}; +use crate::canonicalize; + #[derive(Debug, Clone)] pub enum BackendBuildError { MissingEnv(&'static str), @@ -59,7 +63,7 @@ const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(120); /// invocation, and each role (Actor, Evaluator-per-Frame, Tester, /// Judge) gets a backend through it. Sharing the client means one /// connection pool, one TLS session cache, one keep-alive set across -/// every role hitting the same `LLM_BASE_URL`. +/// every role hitting the same `OPEN_AI_BASE_URL`. pub struct OpenAiBackendFactory { base_url: String, default_model: Option, @@ -68,15 +72,15 @@ pub struct OpenAiBackendFactory { } impl OpenAiBackendFactory { - /// Read `LLM_BASE_URL`, `LLM_MODEL`, `LLM_API_KEY` from env once. - /// `LLM_BASE_URL` is mandatory; `LLM_MODEL` provides the default - /// model for backends whose per-role `model` is unset; `LLM_API_KEY` + /// Read `OPEN_AI_BASE_URL`, `OPEN_AI_MODEL`, `OPEN_AI_API_KEY` from env once. + /// `OPEN_AI_BASE_URL` is mandatory; `OPEN_AI_MODEL` provides the default + /// model for backends whose per-role `model` is unset; `OPEN_AI_API_KEY` /// is optional. pub fn from_env() -> Result { let base_url = - env::var("LLM_BASE_URL").map_err(|_| BackendBuildError::MissingEnv("LLM_BASE_URL"))?; - let default_model = env::var("LLM_MODEL").ok(); - let api_key = env::var("LLM_API_KEY").ok().filter(|s| !s.is_empty()); + env::var("OPEN_AI_BASE_URL").map_err(|_| BackendBuildError::MissingEnv("OPEN_AI_BASE_URL"))?; + let default_model = env::var("OPEN_AI_MODEL").ok(); + let api_key = env::var("OPEN_AI_API_KEY").ok().filter(|s| !s.is_empty()); let client = reqwest::Client::builder() .timeout(DEFAULT_REQUEST_TIMEOUT) .build() @@ -90,7 +94,7 @@ impl OpenAiBackendFactory { } /// Build a backend for one role. `model_override` is the per-role - /// `model` from `steward.toml`; falls back to `LLM_MODEL` env when + /// `model` from `steward.toml`; falls back to `OPEN_AI_MODEL` env when /// unset. pub fn build( &self, @@ -100,7 +104,7 @@ impl OpenAiBackendFactory { let model = model_override .or_else(|| self.default_model.clone()) .ok_or(BackendBuildError::MissingEnv( - "LLM_MODEL (set env or steward.toml [] model)", + "OPEN_AI_MODEL (set env or steward.toml [] model)", ))?; Ok(OpenAiCompatibleBackend { id: id.into(), @@ -170,20 +174,24 @@ impl CognitionBackend for OpenAiCompatibleBackend { .await .map_err(|e| CognitionError(format!("decoding JSON from {url}: {e}")))?; - let raw_text = parsed + let raw_content = parsed .choices .first() .map(|c| c.message.content.clone()) .unwrap_or_default(); - let canonical = canonicalize_assistant_response(&raw_text); + let (content, action_hint) = canonicalize_openai_content(&raw_content); + let verdict_lines = canonicalize::canonicalize_verdict_lines(&content); + let judge_output = canonicalize::canonicalize_judge_output(&content); let usage = parsed.usage.unwrap_or_default(); Ok(CognitionResponse { - text: canonical.text, + content, input_tokens: usage.prompt_tokens, output_tokens: usage.completion_tokens, cache_hit_tokens: usage.cached_tokens, - tool_call_hint: canonical.tool_call_hint, + action_hint, + verdict_lines, + judge_output, }) } } @@ -247,47 +255,50 @@ struct Usage { // name from its params (harmony `to=` header + JSON body). // --------------------------------------------------------------------- -struct CanonicalAssistantResponse { - text: String, - tool_call_hint: Option, -} - -fn canonicalize_assistant_response(raw: &str) -> CanonicalAssistantResponse { +/// Convert an OpenAI-compatible assistant message into the kernel's +/// `(content, action_hint)` pair. Three vendor shapes handled: +/// - Plain text (or markdown-fenced JSON / prose-around-JSON): +/// delegate to `canonicalize::canonicalize_action_hint`. +/// - gpt-oss harmony envelope without `to=` recipient: same as plain +/// text, after stripping the envelope. +/// - gpt-oss harmony envelope WITH `to=` recipient: the recipient +/// names the tool; the body is the params JSON. Produce +/// `ActionHint::Propose` directly, bypassing the body's lack of +/// `"tool"` field. (gpt-oss specific.) +fn canonicalize_openai_content(raw: &str) -> (String, Option) { if let Some(harmony) = parse_harmony_assistant_block(raw) { - let body = strip_code_fences(harmony.body); - let json_text = extract_json_object(body).unwrap_or(body); + let body = canonicalize::strip_code_fences(harmony.body); + let json_text = canonicalize::extract_first_json_object(body).unwrap_or(body); if let Ok(value) = serde_json::from_str::(json_text) { + // Recipient-named tool: the tool name lives in the header, + // the body is the params. Build Propose from both. if let Some(recipient) = harmony.recipient && value.get("tool").is_none() && value.get("halt").is_none() { let tool_name = strip_harmony_recipient_namespace(recipient).to_string(); if !tool_name.is_empty() { - return CanonicalAssistantResponse { - text: json_text.to_string(), - tool_call_hint: Some(ToolCallHint { + return ( + json_text.to_string(), + Some(ActionHint::Propose { tool: tool_name, params: value, }), - }; + ); } } - return CanonicalAssistantResponse { - text: json_text.to_string(), - tool_call_hint: None, - }; + // Body carries the full Action envelope. + let hint = canonicalize::parse_action_value(&value); + return (json_text.to_string(), hint); } - return CanonicalAssistantResponse { - text: body.to_string(), - tool_call_hint: None, - }; - } - let cleaned = strip_code_fences(raw); - let extracted = extract_json_object(cleaned).unwrap_or(cleaned); - CanonicalAssistantResponse { - text: extracted.to_string(), - tool_call_hint: None, + // Harmony body wasn't JSON — pure reasoning. + return (body.to_string(), None); } + // No harmony envelope — fall through to the shared util. + let stripped = canonicalize::strip_code_fences(raw); + let extracted = canonicalize::extract_first_json_object(stripped).unwrap_or(stripped); + let hint = canonicalize::canonicalize_action_hint(raw); + (extracted.to_string(), hint) } /// gpt-oss harmony assistant block parts. @@ -321,62 +332,6 @@ fn strip_harmony_recipient_namespace(s: &str) -> &str { .trim_start_matches("tools.") } -fn strip_code_fences(s: &str) -> &str { - let s = s.trim(); - let s = s - .strip_prefix("```json") - .or_else(|| s.strip_prefix("```JSON")) - .or_else(|| s.strip_prefix("```")) - .map(str::trim) - .unwrap_or(s); - s.trim_end_matches("```").trim() -} - -/// Walk `text` and return the first balanced top-level JSON object -/// substring. String/escape state is tracked so braces inside JSON -/// strings do not confuse depth tracking. Returns None when no -/// balanced object is present. -fn extract_json_object(text: &str) -> Option<&str> { - let bytes = text.as_bytes(); - let mut depth: i32 = 0; - let mut start: Option = None; - let mut in_string = false; - let mut escape = false; - for (i, &b) in bytes.iter().enumerate() { - if in_string { - if escape { - escape = false; - continue; - } - match b { - b'\\' => escape = true, - b'"' => in_string = false, - _ => {} - } - continue; - } - match b { - b'{' => { - if depth == 0 { - start = Some(i); - } - depth += 1; - } - b'"' => in_string = true, - b'}' => { - depth -= 1; - if depth == 0 - && let Some(s) = start - { - return Some(&text[s..=i]); - } - } - _ => {} - } - } - None -} - #[cfg(test)] mod tests { use super::*; @@ -385,13 +340,13 @@ mod tests { fn factory_from_env_requires_base_url() { // Use unsafe block since set/remove env are unsafe in newer Rust. unsafe { - env::remove_var("LLM_BASE_URL"); + env::remove_var("OPEN_AI_BASE_URL"); } let err = match OpenAiBackendFactory::from_env() { Ok(_) => panic!("expected MissingEnv error"), Err(e) => e, }; - assert!(matches!(err, BackendBuildError::MissingEnv("LLM_BASE_URL"))); + assert!(matches!(err, BackendBuildError::MissingEnv("OPEN_AI_BASE_URL"))); } #[test] @@ -407,63 +362,66 @@ mod tests { } #[test] - fn canonicalize_plain_text_passes_through() { - let c = canonicalize_assistant_response("ALLOW: ok\nDENY: no"); - assert_eq!(c.text, "ALLOW: ok\nDENY: no"); - assert!(c.tool_call_hint.is_none()); + fn canonicalize_plain_text_yields_no_action_hint() { + let (content, hint) = canonicalize_openai_content("ALLOW: ok\nDENY: no"); + assert_eq!(content, "ALLOW: ok\nDENY: no"); + assert!(hint.is_none()); } #[test] - fn canonicalize_markdown_fenced_json_strips_fences() { - let c = canonicalize_assistant_response( - "```json\n{\"tool\":\"write_file\",\"params\":{}}\n```", - ); - assert_eq!(c.text, r#"{"tool":"write_file","params":{}}"#); - assert!(c.tool_call_hint.is_none()); + fn canonicalize_markdown_fenced_action_yields_propose() { + let (_content, hint) = + canonicalize_openai_content("```json\n{\"tool\":\"write_file\",\"params\":{}}\n```"); + match hint { + Some(ActionHint::Propose { tool, .. }) => assert_eq!(tool, "write_file"), + other => panic!("expected Propose, got {other:?}"), + } } #[test] - fn canonicalize_harmony_envelope_with_full_action_returns_text_no_hint() { + fn canonicalize_harmony_envelope_with_full_action_yields_propose() { let raw = "<|channel|>commentary <|message|>{\"tool\":\"write_file\",\"params\":{\"path\":\"x\"}}<|return|>"; - let c = canonicalize_assistant_response(raw); - assert_eq!(c.text, r#"{"tool":"write_file","params":{"path":"x"}}"#); - assert!(c.tool_call_hint.is_none()); + let (_content, hint) = canonicalize_openai_content(raw); + match hint { + Some(ActionHint::Propose { tool, params }) => { + assert_eq!(tool, "write_file"); + assert_eq!(params["path"].as_str(), Some("x")); + } + other => panic!("expected Propose, got {other:?}"), + } } #[test] - fn canonicalize_harmony_with_tool_recipient_extracts_hint() { + fn canonicalize_harmony_with_tool_recipient_extracts_propose() { // gpt-oss harmony: tool name in the header (`to=...`), - // params in the JSON body. Adapter populates the hint. + // params in the JSON body. The recipient names the tool. let raw = "<|channel|>commentary to=tool.write_file <|constrain|>json<|message|>{\"path\":\"hello.txt\",\"content\":\"hi\"}"; - let c = canonicalize_assistant_response(raw); - let hint = c.tool_call_hint.expect("expected hint"); - assert_eq!(hint.tool, "write_file"); - assert_eq!(hint.params["path"].as_str(), Some("hello.txt")); - assert_eq!(hint.params["content"].as_str(), Some("hi")); + let (_content, hint) = canonicalize_openai_content(raw); + match hint { + Some(ActionHint::Propose { tool, params }) => { + assert_eq!(tool, "write_file"); + assert_eq!(params["path"].as_str(), Some("hello.txt")); + assert_eq!(params["content"].as_str(), Some("hi")); + } + other => panic!("expected Propose, got {other:?}"), + } } #[test] fn canonicalize_harmony_with_functions_recipient() { let raw = "<|start|>assistant<|channel|>commentary to=functions.write_file<|constrain|>json<|message|>{\"path\":\"x\"}<|call|>"; - let c = canonicalize_assistant_response(raw); - let hint = c.tool_call_hint.expect("expected hint"); - assert_eq!(hint.tool, "write_file"); + let (_content, hint) = canonicalize_openai_content(raw); + match hint { + Some(ActionHint::Propose { tool, .. }) => assert_eq!(tool, "write_file"), + other => panic!("expected Propose, got {other:?}"), + } } #[test] - fn canonicalize_harmony_evaluator_lines_no_recipient_no_json_returns_body() { + fn canonicalize_harmony_evaluator_lines_no_action_hint() { let raw = "<|channel|>analysis<|message|>ALLOW: looks fine\nDENY: but suspicious<|end|>"; - let c = canonicalize_assistant_response(raw); - assert_eq!(c.text, "ALLOW: looks fine\nDENY: but suspicious"); - assert!(c.tool_call_hint.is_none()); - } - - #[test] - fn extract_json_object_handles_braces_inside_strings() { - let raw = r#"prefix {"a":"} not done","b":1} suffix"#; - assert_eq!( - extract_json_object(raw), - Some(r#"{"a":"} not done","b":1}"#) - ); + let (content, hint) = canonicalize_openai_content(raw); + assert_eq!(content, "ALLOW: looks fine\nDENY: but suspicious"); + assert!(hint.is_none()); } } diff --git a/runtime/src/persistence.rs b/runtime/src/persistence.rs index c9d707d..059b4c0 100644 --- a/runtime/src/persistence.rs +++ b/runtime/src/persistence.rs @@ -19,83 +19,31 @@ //! `core::ReceiptStore` and `core::CognitionBackend` traits stay //! in-memory only. -use std::fs::{File, OpenOptions}; -use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex as StdMutex}; use async_trait::async_trait; use chartered_core::{ CognitionBackend, CognitionError, CognitionRequest, CognitionResponse, FrameId, Receipt, ReceiptStore, ReceiptStoreError, receipt_matches_query, }; -use serde::Serialize; - -/// JSON-Lines append sink: serializes one record per line, fsyncs each -/// write so an operator-visible trail survives process death. Mutex -/// serializes concurrent writers — log lines never interleave. -/// -/// Serialization failure is reported via `eprintln!` and the line is -/// dropped rather than silently rewritten to `"{}"`. AGENTS.md -/// §Error Discipline §Semantic Integrity Under Failure: a dropped -/// record with operator-visible diagnostic beats a fake-shape record -/// that masks the failure. -pub struct JsonlSink { - path: PathBuf, - file: Mutex, -} - -impl JsonlSink { - pub fn create(path: impl Into) -> std::io::Result { - let path = path.into(); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let file = OpenOptions::new().append(true).create(true).open(&path)?; - Ok(Self { - path, - file: Mutex::new(file), - }) - } - - pub fn path(&self) -> &Path { - &self.path - } - - /// Serialize `value` to one JSON line, write+fsync. Returns the IO - /// error verbatim on write/fsync failure; serializer failure is - /// reported to stderr and returned as `ErrorKind::InvalidData`. - /// Callers MUST honor the result — silently writing to disk while - /// updating an in-memory mirror diverges the two surfaces. - pub fn append(&self, value: &T) -> std::io::Result<()> { - let line = serde_json::to_string(value).map_err(|e| { - eprintln!( - "JsonlSink({}): serialize failure: {e}", - self.path.display() - ); - std::io::Error::new(std::io::ErrorKind::InvalidData, e) - })?; - let mut file = self.file.lock().unwrap(); - writeln!(file, "{line}")?; - file.sync_data()?; - Ok(()) - } -} +use chartered_dispatch::JsonlSink; /// Append-only file-backed Receipt store. Each Receipt is serialized -/// to one JSON line. Reads (`query`, `all`) are served from an -/// in-memory mirror so Frames with `prior_receipt_queries` do not pay -/// disk I/O on every Gate evaluation. +/// to one JSON line via the shared `JsonlSink` primitive. Reads +/// (`query`, `all`) are served from an in-memory mirror so Frames with +/// `prior_receipt_queries` do not pay disk I/O on every Gate +/// evaluation. pub struct AppendOnlyFileReceiptStore { sink: JsonlSink, - in_memory: Mutex>, + in_memory: StdMutex>, } impl AppendOnlyFileReceiptStore { - pub fn create(path: impl Into) -> std::io::Result { + pub async fn create(path: impl Into) -> std::io::Result { Ok(Self { - sink: JsonlSink::create(path)?, - in_memory: Mutex::new(Vec::new()), + sink: JsonlSink::create(path).await?, + in_memory: StdMutex::new(Vec::new()), }) } @@ -104,14 +52,15 @@ impl AppendOnlyFileReceiptStore { } } +#[async_trait] impl ReceiptStore for AppendOnlyFileReceiptStore { - fn append(&self, receipt: &Receipt) -> Result<(), ReceiptStoreError> { + async fn append(&self, receipt: &Receipt) -> Result<(), ReceiptStoreError> { // Disk-first: only mirror the Receipt in-memory if the durable // write succeeded. Otherwise the on-disk trail and the in-memory // index would diverge — query() callers (Frame // prior_receipt_queries) would see Receipts that aren't in the // operator-visible JSONL. - match self.sink.append(receipt) { + match self.sink.append(receipt).await { Ok(()) => { self.in_memory.lock().unwrap().push(receipt.clone()); Ok(()) @@ -184,7 +133,7 @@ impl CognitionBackend for LoggingBackend { "response": result.as_ref().ok(), "error": result.as_ref().err().map(|e| e.to_string()), }); - if let Err(e) = self.log.append(&entry) { + if let Err(e) = self.log.append(&entry).await { eprintln!( "LoggingBackend({}): cognition log write failure: {e}", self.inner.id() @@ -215,106 +164,3 @@ pub fn make_run_id() -> String { format!("r-{nanos}-{pid}") } -#[cfg(test)] -mod tests { - use super::*; - use chartered_core::FakeCognitionBackend; - - #[tokio::test] - async fn receipt_store_writes_jsonl_and_caches_in_memory() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("receipts.jsonl"); - let store = AppendOnlyFileReceiptStore::create(&path).unwrap(); - - let receipt = mk_receipt("ctx-1"); - store.append(&receipt).unwrap(); - - // In-memory mirror reflects the write immediately. - assert_eq!(store.all().len(), 1); - - // The file on disk is one valid JSON line. - let text = std::fs::read_to_string(&path).unwrap(); - let lines: Vec<&str> = text.lines().collect(); - assert_eq!(lines.len(), 1); - let v: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); - assert_eq!(v["tool_call"]["context_id"].as_str(), Some("ctx-1")); - } - - #[tokio::test] - async fn cognition_log_records_request_and_response() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("cognition.jsonl"); - let log: Arc = Arc::new(JsonlSink::create(&path).unwrap()); - - let inner = Arc::new(FakeCognitionBackend::new("eval-test")); - inner.enqueue("ALLOW: ok"); - let wrapped = LoggingBackend::new(inner, log.clone()); - - let req = CognitionRequest { - messages: vec![chartered_core::Message::user("hello")], - max_output_tokens: Some(64), - }; - let resp = wrapped.complete(&req).await.unwrap(); - assert_eq!(resp.text, "ALLOW: ok"); - - let text = std::fs::read_to_string(&path).unwrap(); - let lines: Vec<&str> = text.lines().collect(); - assert_eq!(lines.len(), 1); - let v: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); - assert_eq!(v["backend_id"].as_str(), Some("eval-test")); - assert_eq!(v["response"]["text"].as_str(), Some("ALLOW: ok")); - assert!( - v["request"]["messages"][0]["content"] - .as_str() - .unwrap() - .contains("hello") - ); - } - - #[tokio::test] - async fn cognition_log_records_backend_error() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("cognition.jsonl"); - let log: Arc = Arc::new(JsonlSink::create(&path).unwrap()); - - // Empty queue → error on first call. - let inner = Arc::new(FakeCognitionBackend::new("evaluator")); - let wrapped = LoggingBackend::new(inner, log.clone()); - - let req = CognitionRequest { - messages: vec![chartered_core::Message::user("x")], - max_output_tokens: None, - }; - let result = wrapped.complete(&req).await; - assert!(result.is_err()); - - let text = std::fs::read_to_string(&path).unwrap(); - let v: serde_json::Value = serde_json::from_str(text.lines().next().unwrap()).unwrap(); - assert!(v["response"].is_null()); - assert!(v["error"].as_str().unwrap().contains("queue empty")); - } - - fn mk_receipt(context_id: &str) -> Receipt { - use chartered_core::*; - Receipt { - receipt_id: ReceiptId("test-id".into()), - task_id: TaskId::new("task-test"), - attempt_id: Some(AttemptId::new("attempt-test")), - steward_id: StewardId::new("test-steward"), - governance_mode: GovernanceMode::FULL, - tool_call: ToolCall { - tool: ToolId::new("noop"), - params: ToolParams(serde_json::json!({})), - context_id: Arc::from(context_id), - source_id: Arc::from("steward"), - }, - verdicts: vec![], - outcome: Outcome::Allowed, - timestamp: std::time::SystemTime::now(), - intercept_complete: true, - charter_version: 1, - role_context_version: 1, - snapshot_id: SnapshotId("snap-test".into()), - } - } -} diff --git a/runtime/src/print_charter.rs b/runtime/src/print_charter.rs new file mode 100644 index 0000000..9b0d758 --- /dev/null +++ b/runtime/src/print_charter.rs @@ -0,0 +1,379 @@ +//! `chartered-runtime --print-charter` — emit the deployment's parsed +//! Charter as JSON to stdout. The dashboard consumes this to render the +//! left-rail Charter tree; no parallel parser in the harness. +//! +//! Single source of truth: this module reuses the runtime's existing +//! Charter and config loaders (the same code paths `run::run` walks at +//! the start of every Selection trigger), then serializes the resulting +//! shape. + +use std::path::PathBuf; + +use chartered_core::FrameDef; +use serde::Serialize; + +use crate::config::{self, ConfigError}; + +#[derive(Debug, Serialize)] +pub struct PrintedCharter { + pub charter_ref: CharterRefView, + pub charter_dir: String, + pub behavioral_spec: String, + pub scopes: Vec, + pub frames: Vec, + pub stewards: Vec, + pub actions: Vec, + pub role_context_present: bool, + pub governance_mode: GovernanceModeView, +} + +#[derive(Debug, Serialize)] +pub struct CharterRefView { + pub path: String, + pub version: u64, +} + +#[derive(Debug, Serialize)] +pub struct NamedSection { + pub name: String, + pub text: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FrameView { + pub id: String, + pub concern: String, + pub applies_to_tools: Vec, + pub declared_scopes: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DeclaredScopeView { + pub name: String, + pub kind: &'static str, +} + +#[derive(Debug, Serialize)] +pub struct StewardView { + pub id: String, + pub display_name: String, + pub frames: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ActionView { + pub name: String, + pub kind: String, + pub prompt: String, +} + +#[derive(Debug, Serialize)] +pub struct GovernanceModeView { + pub grounding: bool, + pub evaluation: bool, +} + +pub fn print(chartered_dir: Option) -> Result<(), Box> { + let cwd = std::env::current_dir().map_err(|e| ConfigError(format!("cwd: {e}")))?; + let chartered_dir = match chartered_dir { + Some(d) => d, + None => config::find_chartered_dir(&cwd) + .ok_or_else(|| ConfigError("no .chartered/ directory found by walk-up search".into()))?, + }; + let cfg = config::load(&chartered_dir)?; + + let charter_dir = resolve_charter_dir(&cfg)?; + let frames: Vec = cfg.charter_def.frames.iter().map(frame_view).collect(); + + let stewards = vec![StewardView { + id: "sut".into(), + display_name: "sut".into(), + frames: frames.clone(), + }]; + + let actions = parse_actions_from_behavioral_spec(&cfg.charter_def.behavioral_spec); + + let scopes: Vec = cfg + .charter_def + .charter_scopes + .iter() + .map(|(name, text)| NamedSection { + name: name.clone(), + text: text.clone(), + }) + .collect(); + + let role_context_present = cfg.role_context_def.is_some(); + let governance_mode = GovernanceModeView { + grounding: cfg.runtime.governance.grounding, + evaluation: cfg.runtime.governance.evaluation, + }; + + let printed = PrintedCharter { + charter_ref: CharterRefView { + path: cfg.charter_ref.path.clone(), + version: cfg.charter_ref.version, + }, + charter_dir: charter_dir.display().to_string(), + behavioral_spec: extract_non_action_body(&cfg.charter_def.behavioral_spec), + scopes, + frames, + stewards, + actions, + role_context_present, + governance_mode, + }; + + let json = serde_json::to_string_pretty(&printed)?; + println!("{json}"); + Ok(()) +} + +fn resolve_charter_dir(cfg: &config::DeploymentConfig) -> Result { + let raw = std::path::Path::new(&cfg.charter_ref.path); + let resolved = if raw.is_absolute() { + raw.to_path_buf() + } else { + cfg.chartered_dir.join(raw) + }; + resolved + .canonicalize() + .map_err(|e| ConfigError(format!("resolving charter dir {}: {e}", resolved.display()))) +} + +fn frame_view(fd: &FrameDef) -> FrameView { + FrameView { + id: fd.id.0.clone(), + concern: fd.concern.clone(), + applies_to_tools: fd + .applies_to_tools + .iter() + .map(|t| t.0.clone()) + .collect(), + declared_scopes: fd + .declared_scopes + .iter() + .map(|ds| DeclaredScopeView { + name: ds.name.clone(), + kind: match ds.kind { + chartered_core::ScopeKind::Charter => "Charter", + chartered_core::ScopeKind::RoleContext => "RoleContext", + }, + }) + .collect(), + } +} + +// `behavioral_spec.md` may declare structured Actions under a top-level +// `# Actions` section. Each `## ` subsection carries `Type:` and +// `Prompt:` lines that drive the dashboard's selection-trigger button +// bar. The Steward's behavioral prose lives outside that section and +// goes verbatim into the Actor's system prompt. +// +// `parse_actions_from_behavioral_spec` extracts the structured Actions; +// `extract_non_action_body` returns the prose body with the Actions +// section stripped so it can be shown to operators as conduct prose +// distinct from the Action declarations. + +fn parse_actions_from_behavioral_spec(behavioral_spec: &str) -> Vec { + let actions_block = match find_top_level_section(behavioral_spec, "Actions") { + Some(block) => block, + None => return Vec::new(), + }; + let subsections = parse_sub_sections(actions_block); + subsections + .into_iter() + .filter_map(|sub| { + let mut kind = "generative".to_string(); + let mut prompt_lines: Vec<&str> = Vec::new(); + let mut in_prompt = false; + for line in sub.text.lines() { + if let Some(rest) = strip_label_prefix(line, "Type:") { + kind = rest.trim().to_lowercase(); + in_prompt = false; + } else if let Some(rest) = strip_label_prefix(line, "Prompt:") { + prompt_lines.clear(); + prompt_lines.push(rest.trim_start()); + in_prompt = true; + } else if in_prompt { + prompt_lines.push(line); + } + } + let prompt = prompt_lines.join("\n").trim().to_string(); + if prompt.is_empty() && kind == "generative" { + return None; + } + Some(ActionView { + name: sub.name, + kind, + prompt, + }) + }) + .collect() +} + +fn extract_non_action_body(behavioral_spec: &str) -> String { + let mut out = String::new(); + let mut skipping = false; + let mut skipping_depth: usize = 0; + for line in behavioral_spec.lines() { + if let Some(heading) = parse_heading(line) { + if heading.depth == 1 { + skipping = matches!(heading.title.trim(), "Actions" | "Reviewers"); + skipping_depth = if skipping { 1 } else { 0 }; + } else if skipping && heading.depth > skipping_depth { + // still inside the skipped section + } else if heading.depth <= skipping_depth { + skipping = false; + } + } + if !skipping { + out.push_str(line); + out.push('\n'); + } + } + out.trim().to_string() +} + +struct Heading<'a> { + depth: usize, + title: &'a str, +} + +fn parse_heading(line: &str) -> Option> { + let trimmed = line.trim_start(); + let depth = trimmed.chars().take_while(|&c| c == '#').count(); + if depth == 0 { + return None; + } + let rest = &trimmed[depth..]; + if !rest.starts_with(' ') { + return None; + } + Some(Heading { + depth, + title: rest.trim(), + }) +} + +fn find_top_level_section<'a>(text: &'a str, title: &str) -> Option<&'a str> { + let lines: Vec<&str> = text.lines().collect(); + let mut start: Option = None; + for (i, line) in lines.iter().enumerate() { + if let Some(h) = parse_heading(line) + && h.depth == 1 + && h.title == title + { + start = Some(i + 1); + break; + } + } + let start = start?; + let mut end = lines.len(); + for (offset, line) in lines[start..].iter().enumerate() { + if let Some(h) = parse_heading(line) + && h.depth == 1 + { + end = start + offset; + break; + } + } + let byte_start = byte_offset_of_line(text, start); + let byte_end = byte_offset_of_line(text, end); + Some(&text[byte_start..byte_end]) +} + +fn byte_offset_of_line(text: &str, line_index: usize) -> usize { + if line_index == 0 { + return 0; + } + let mut current_line = 0; + for (i, c) in text.char_indices() { + if c == '\n' { + current_line += 1; + if current_line == line_index { + return i + 1; + } + } + } + text.len() +} + +struct SubSection { + name: String, + text: String, +} + +fn parse_sub_sections(block: &str) -> Vec { + let mut out = Vec::new(); + let mut current_name: Option = None; + let mut current_text = String::new(); + for line in block.lines() { + if let Some(h) = parse_heading(line) + && h.depth == 2 + { + if let Some(name) = current_name.take() { + out.push(SubSection { + name, + text: current_text.trim().to_string(), + }); + current_text.clear(); + } + current_name = Some(h.title.to_string()); + } else if current_name.is_some() { + current_text.push_str(line); + current_text.push('\n'); + } + } + if let Some(name) = current_name { + out.push(SubSection { + name, + text: current_text.trim().to_string(), + }); + } + out +} + +fn strip_label_prefix<'a>(line: &'a str, label: &str) -> Option<&'a str> { + let trimmed = line.trim_start(); + let l_label = label.to_lowercase(); + let l_line = trimmed.to_lowercase(); + if l_line.starts_with(&l_label) { + Some(&trimmed[label.len()..]) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_actions_with_type_and_prompt() { + let body = "# Actions\n\n## Refine\nType: generative\nPrompt: Tighten the selected text.\n\n## Review\nType: evaluative\nPrompt: Surface a concrete issue.\n"; + let actions = parse_actions_from_behavioral_spec(body); + assert_eq!(actions.len(), 2); + assert_eq!(actions[0].name, "Refine"); + assert_eq!(actions[0].kind, "generative"); + assert!(actions[0].prompt.contains("Tighten")); + assert_eq!(actions[1].name, "Review"); + assert_eq!(actions[1].kind, "evaluative"); + } + + #[test] + fn non_action_body_strips_actions_and_reviewers() { + let body = "Conduct prose here.\n\n# Actions\n## A\nType: generative\nPrompt: x\n\n# Reviewers\n## R\nConcern: y\n\n# Tail\nmore prose\n"; + let stripped = extract_non_action_body(body); + assert!(stripped.contains("Conduct prose here")); + assert!(stripped.contains("# Tail")); + assert!(!stripped.contains("Actions")); + assert!(!stripped.contains("Reviewers")); + } + + #[test] + fn empty_actions_section_yields_empty_list() { + let actions = parse_actions_from_behavioral_spec("Pure conduct prose, no sections."); + assert!(actions.is_empty()); + } +} diff --git a/runtime/src/run.rs b/runtime/src/run.rs index 0acefb8..214d71f 100644 --- a/runtime/src/run.rs +++ b/runtime/src/run.rs @@ -27,8 +27,11 @@ use chartered_dispatch::ExecutorRegistry; use serde::Serialize; use crate::config::{self, BackendKind, TesterConfig}; +use crate::gemini_backend::GeminiBackendFactory; use crate::openai_backend::OpenAiBackendFactory; -use crate::persistence::{self, AppendOnlyFileReceiptStore, JsonlSink, LoggingBackend}; +use chartered_dispatch::JsonlSink; + +use crate::persistence::{self, AppendOnlyFileReceiptStore, LoggingBackend}; #[derive(Debug, Default)] pub struct Options { @@ -110,10 +113,12 @@ pub async fn run(opts: Options) -> Result<(), Box> { let receipt_store: Arc = Arc::new( AppendOnlyFileReceiptStore::create(&receipts_path) + .await .map_err(|e| RunError(format!("opening {}: {e}", receipts_path.display())))?, ); let cognition_log: Arc = Arc::new( JsonlSink::create(&cognition_path) + .await .map_err(|e| RunError(format!("opening {}: {e}", cognition_path.display())))?, ); @@ -128,9 +133,11 @@ pub async fn run(opts: Options) -> Result<(), Box> { // across all OpenAI-backed roles. Constructed lazily — fake-only // deployments never read env or build an HTTP client. let openai_factory = build_openai_factory_if_needed(&cfg)?; + let gemini_factory = build_gemini_factory_if_needed(&cfg)?; let backends = BackendCtx { log: cognition_log.clone(), openai: openai_factory.as_ref(), + gemini: gemini_factory.as_ref(), }; let actor_backend = build_backend( @@ -192,8 +199,21 @@ pub async fn run(opts: Options) -> Result<(), Box> { // (which `build_charter` moves). Single source of truth, injected // into the executor registry and from there into every Backend. let chartered_dir_for_paths = cfg.chartered_dir.clone(); - let (charter, role_context) = cfg.build_charter(evaluator_factory); - let snapshot = Snapshot::new(charter, role_context); + let (charter, role_context, skills) = cfg.build_charter(evaluator_factory); + let snapshot = Snapshot::new(charter, role_context, skills); + + // Spec §Snapshot Lifecycle: persist on creation so the Snapshot ID + // embedded in every Receipt resolves to a record on disk. + let snapshots_dir = chartered_dir_for_paths.join("snapshots"); + chartered_dispatch::persist_snapshot(&snapshot, &snapshots_dir) + .await + .map_err(|e| { + RunError(format!( + "persist Snapshot {} to {}: {e}", + snapshot.id, + snapshots_dir.display() + )) + })?; let deployment_paths = chartered_dispatch::DeploymentPaths::canonicalize( &workspace_root, @@ -206,7 +226,9 @@ pub async fn run(opts: Options) -> Result<(), Box> { chartered_dir_for_paths.display(), )) })?; - let executor_registry = ExecutorRegistry::new(deployment_paths); + let executor_registry = ExecutorRegistry::new(deployment_paths) + .await + .map_err(|e| RunError(format!("constructing executor registry: {e}")))?; let mut registry = ToolRegistry::new(); for tr in &tools_for_registry { let exec: Arc = executor_registry @@ -340,20 +362,16 @@ fn selection_message( let selected_text = read_selection_text(workspace_root, &selection.artifact_id, selection.range)?; let action_kind = selection.action.kind.as_wire_str(); - let required_tool = match selection.action.kind { - SelectionActionKind::Generative => "modify_artifact", - SelectionActionKind::Evaluative => "record_finding", - }; let response_template = match selection.action.kind { SelectionActionKind::Generative => { r#"{"tool":"modify_artifact","params":{"kind":"text","artifact_id":"ARTIFACT_ID","range":{"start":START,"end":END,"start_line":START_LINE,"end_line":END_LINE},"replacement":"REPLACEMENT_TEXT","summary":"PROFESSIONAL_SUMMARY"}}"# } SelectionActionKind::Evaluative => { - r#"{"tool":"record_finding","params":{"artifact_id":"ARTIFACT_ID","range":{"start":START,"end":END,"start_line":START_LINE,"end_line":END_LINE},"concern":"CONCERN","severity":"medium","detail":"DETAIL"}}"# + r#"{"tool":"modify_artifact","params":{"kind":"record-store","artifact_id":"records","edit":{"append":{"artifact_id":"ARTIFACT_ID","range":{"start":START,"end":END,"start_line":START_LINE,"end_line":END_LINE},"concern":"CONCERN","severity":"medium","detail":"DETAIL"}}}}"# } }; Ok(format!( - "Selection trigger:\ntrigger_json: {}\naction_name: {}\naction_kind: {}\nartifact_id: {}\nrange: start={}, end={}, start_line={}, end_line={}\nselected_text:\n{}\n\nReturn one raw JSON object and no other text. Propose exactly one `{}` Tool call for this action. Generative actions modify artifacts only through `modify_artifact` with kind=\"text\", artifact_id, range, replacement, and summary. Evaluative actions record findings only through `record_finding` with artifact_id, range, concern, severity, and detail (the Tool resolves the findings store internally).\nJSON shape:\n{}", + "Selection trigger:\ntrigger_json: {}\naction_name: {}\naction_kind: {}\nartifact_id: {}\nrange: start={}, end={}, start_line={}, end_line={}\nselected_text:\n{}\n\nReturn one raw JSON object and no other text. Propose exactly one `modify_artifact` Tool call for this action. Generative actions use `kind=\"text\"` with artifact_id, range, replacement, and summary. Evaluative actions use `kind=\"record-store\"` against the `records` artifact, with `edit.append` carrying artifact_id (source), range, concern, severity, and detail.\nJSON shape:\n{}", serde_json::to_string(&trigger) .map_err(|e| RunError(format!("serialize selection trigger: {e}")))?, selection.action.name, @@ -364,7 +382,6 @@ fn selection_message( selection.range.start_line, selection.range.end_line, selected_text, - required_tool, response_template, )) } @@ -458,6 +475,7 @@ enum FakeCorpus<'a> { struct BackendCtx<'a> { log: Arc, openai: Option<&'a OpenAiBackendFactory>, + gemini: Option<&'a GeminiBackendFactory>, } /// Single backend-construction helper: matches on `BackendKind`, @@ -475,6 +493,41 @@ fn build_backend( let inner: Arc = match kind { BackendKind::Fake => { let backend = Arc::new(FakeCognitionBackend::new(id.clone())); + // Per role, the runtime canonicalizes TOML `fake_responses` + // strings into the strong-typed surface the kernel reads — + // the kernel itself performs no text parsing (see + // `AGENTS.md §Verification`). TOML strings remain shaped + // like real LLM output for operator ergonomics: + // Actor: JSON `{"tool":"...","params":{...}}` → ActionHint. + // Evaluator: `ALLOW: reason` line(s) → Vec. + // Judge: JSON `{"score":...,...}` → JudgeOutput. + // Tester: plain user-message text — no strong type needed. + let enqueue_role = |b: &FakeCognitionBackend, r: &str| { + match role { + BackendRole::Actor => { + let hint = crate::canonicalize::canonicalize_action_hint(r); + b.enqueue_with_action(r.to_string(), hint); + } + BackendRole::Evaluator { .. } => { + let lines = crate::canonicalize::canonicalize_verdict_lines(r); + if lines.is_empty() { + b.enqueue(r); + } else { + b.enqueue_verdict_lines(lines); + } + } + BackendRole::Judge => { + if let Some(out) = crate::canonicalize::canonicalize_judge_output(r) { + b.enqueue_judge_output(out); + } else { + b.enqueue(r); + } + } + BackendRole::Tester => { + b.enqueue(r); + } + } + }; match fakes { FakeCorpus::Sequence(rs) => { if rs.is_empty() { @@ -484,7 +537,7 @@ fn build_backend( ))); } for r in rs { - backend.enqueue(r); + enqueue_role(&backend, r); } } FakeCorpus::Single(opt) => { @@ -494,11 +547,11 @@ fn build_backend( role.label() )) })?; - backend.enqueue(r); + enqueue_role(&backend, r); } FakeCorpus::Optional(rs) => { for r in rs { - backend.enqueue(r); + enqueue_role(&backend, r); } } }; @@ -508,7 +561,7 @@ fn build_backend( let factory = ctx.openai.ok_or_else(|| { RunError(format!( "{} backend = \"openai\" but OpenAI factory not initialized — \ - LLM_BASE_URL must be set in env", + OPEN_AI_BASE_URL must be set in env", role.label() )) })?; @@ -518,6 +571,20 @@ fn build_backend( .map_err(|e| RunError(format!("{} openai backend: {e}", role.label())))?, ) } + BackendKind::Gemini => { + let factory = ctx.gemini.ok_or_else(|| { + RunError(format!( + "{} backend = \"gemini\" but Gemini factory not initialized — \ + GEMINI_API_KEY must be set in env", + role.label() + )) + })?; + Arc::new( + factory + .build(id, model.map(str::to_string)) + .map_err(|e| RunError(format!("{} gemini backend: {e}", role.label())))?, + ) + } }; Ok(Arc::new(LoggingBackend::new(inner, Arc::clone(&ctx.log)))) } @@ -547,6 +614,32 @@ fn build_openai_factory_if_needed( .map_err(|e| RunError(format!("OpenAI factory: {e}"))) } +/// Build the shared Gemini factory only when at least one role uses +/// `backend = "gemini"` — pure fake/openai deployments stay +/// GEMINI_*-env-free. +fn build_gemini_factory_if_needed( + cfg: &config::DeploymentConfig, +) -> Result, RunError> { + let needs_gemini = cfg.steward.actor.backend == BackendKind::Gemini + || cfg.steward.evaluator.backend == BackendKind::Gemini + || cfg + .steward + .tester + .as_ref() + .is_some_and(|t| t.backend == BackendKind::Gemini) + || cfg + .steward + .judge + .as_ref() + .is_some_and(|j| j.backend == BackendKind::Gemini); + if !needs_gemini { + return Ok(None); + } + GeminiBackendFactory::from_env() + .map(Some) + .map_err(|e| RunError(format!("Gemini factory: {e}"))) +} + fn build_evaluator_backends_per_frame( cfg: &config::DeploymentConfig, ctx: &BackendCtx<'_>, diff --git a/runtime/tests/e2e.rs b/runtime/tests/binary_integration.rs similarity index 95% rename from runtime/tests/e2e.rs rename to runtime/tests/binary_integration.rs index dbd57e6..e93c404 100644 --- a/runtime/tests/e2e.rs +++ b/runtime/tests/binary_integration.rs @@ -1,4 +1,9 @@ -//! End-to-end tests against the chartered-runtime binary. +//! Binary-level integration tests against the chartered-runtime binary +//! with `FakeCognitionBackend` standing in for the LLM (`backend = +//! "fake"` in the steward.toml fixtures). Integration per `AGENTS.md +//! §Verification`: a vertical cut across the binary boundary using the +//! fake-LLM side of the test pair; the real-LLM side is in +//! `runtime/tests/llm_e2e.rs` and runs only locally. //! //! Each test constructs a complete `.chartered/` deployment in an //! isolated tempdir, runs the binary, and asserts on: @@ -7,9 +12,9 @@ //! //! The deployments are real production deployments — same loader, //! same runtime path, same OS-touching dispatch tools. The only -//! distinction from a real-LLM deployment is the `backend = "fake"` -//! values in steward.toml, which select FakeCognitionBackend per -//! role with inline canned responses. ZERO test-only code paths. +//! distinction from the e2e LLM deployments is `backend = "fake"` in +//! steward.toml, which selects FakeCognitionBackend per role with +//! inline canned responses. ZERO test-only code paths. //! //! Tempdirs are system temp (outside the repo, naturally gitignored). //! Each test gets its own tempdir for isolation. @@ -54,12 +59,6 @@ fn write_artifact_tools(dep: &TestDeployment) { "native_artifact_modify", "modify_artifact", ); - write_tool( - dep, - "record_finding", - "native_artifact_record_finding", - "record_finding", - ); write_tool( dep, "read_artifact", @@ -133,7 +132,7 @@ fn e2e_selection_refine_modifies_artifact_after_allowed_receipt() { let steward = format!( r#" system_prompt = "test" -tool_registry = ["modify_artifact.toml", "record_finding.toml", "read_artifact.toml", "list_artifacts.toml"] +tool_registry = ["modify_artifact.toml", "read_artifact.toml", "list_artifacts.toml"] [actor] backend = "fake" @@ -200,7 +199,7 @@ fn e2e_selection_reject_refine_allow_changes_only_allowed_text() { let steward = format!( r#" system_prompt = "test" -tool_registry = ["modify_artifact.toml", "record_finding.toml", "read_artifact.toml", "list_artifacts.toml"] +tool_registry = ["modify_artifact.toml", "read_artifact.toml", "list_artifacts.toml"] [actor] backend = "fake" @@ -243,27 +242,33 @@ fn e2e_selection_review_records_finding_without_mutating_artifact() { let dep = TestDeployment::new(); dep.write_chartered_toml(); dep.write_charter_ref(1); - dep.write_charter(&frames_allow_all(&["record_finding"]), SCOPES_MD_EMPTY); + dep.write_charter(&frames_allow_all(&["modify_artifact"]), SCOPES_MD_EMPTY); dep.write_role_context_md(); write_artifact_tools(&dep); let original = "shared cache without tenant isolation"; std::fs::write(dep.workspace_file("architecture.md"), original).unwrap(); let proposal = serde_json::json!({ - "tool": "record_finding", + "tool": "modify_artifact", "params": { - "artifact_id": "architecture.md", - "range": { "start": 0, "end": 37, "start_line": 1, "end_line": 1 }, - "concern": "Tenant isolation", - "severity": "high", - "detail": "Shared cache lacks tenant keying" + "kind": "record-store", + "artifact_id": "records", + "edit": { + "append": { + "artifact_id": "architecture.md", + "range": { "start": 0, "end": 37, "start_line": 1, "end_line": 1 }, + "concern": "Tenant isolation", + "severity": "high", + "detail": "Shared cache lacks tenant keying" + } + } } }); let halt = serde_json::json!({"halt": true}); let steward = format!( r#" system_prompt = "test" -tool_registry = ["modify_artifact.toml", "record_finding.toml", "read_artifact.toml", "list_artifacts.toml"] +tool_registry = ["modify_artifact.toml", "read_artifact.toml", "list_artifacts.toml"] [actor] backend = "fake" @@ -294,21 +299,24 @@ always_allow = ["ALLOW: ok"] assert_eq!(receipts[0]["outcome"].as_str(), Some("Allowed")); assert_eq!( receipts[0]["tool_call"]["tool"].as_str(), - Some("record_finding") + Some("modify_artifact") + ); + assert_eq!( + receipts[0]["tool_call"]["params"]["kind"].as_str(), + Some("record-store") ); assert_eq!(receipts[1]["tool_call"]["tool"].as_str(), Some("")); assert_eq!( std::fs::read_to_string(dep.workspace_file("architecture.md")).unwrap(), original ); - let findings = - std::fs::read_to_string(dep.workspace_file(".chartered/findings.jsonl")).unwrap(); - let finding: serde_json::Value = serde_json::from_str(findings.trim()).unwrap(); - assert_eq!(finding["task_id"], receipts[0]["task_id"]); - assert_eq!(finding["author_steward_id"], "sut"); - assert!(finding.get("frame_id").is_none()); - assert_eq!(finding["artifact_id"], "architecture.md"); - assert_eq!(finding["admitting_receipt_id"], receipts[0]["receipt_id"]); + let records = + std::fs::read_to_string(dep.workspace_file(".chartered/records.jsonl")).unwrap(); + let record: serde_json::Value = serde_json::from_str(records.trim()).unwrap(); + assert_eq!(record["task_id"], receipts[0]["task_id"]); + assert_eq!(record["steward_id"], "sut"); + assert_eq!(record["artifact_id"], "architecture.md"); + assert_eq!(record["receipt_id"], receipts[0]["receipt_id"]); } /// Happy path: Actor proposes write_file → Frame ALLOW → file written → @@ -1228,13 +1236,13 @@ always_allow = ["ALLOW: ok"] "actor's first user message should carry the --user-message" ); assert!( - actor_first["response"]["text"] + actor_first["response"]["content"] .as_str() .unwrap() .contains("write_file") ); assert_eq!( - eval_calls[0]["response"]["text"].as_str(), + eval_calls[0]["response"]["content"].as_str(), Some("ALLOW: ok") ); } diff --git a/core/tests/charter_loader.rs b/runtime/tests/charter_loader.rs similarity index 61% rename from core/tests/charter_loader.rs rename to runtime/tests/charter_loader.rs index 49075c0..d6d897d 100644 --- a/core/tests/charter_loader.rs +++ b/runtime/tests/charter_loader.rs @@ -9,11 +9,11 @@ use std::sync::Arc; use async_trait::async_trait; use chartered_core::{ - build_charter, build_role_context, load_charter_def, load_role_context_def, Evaluator, - FrameDef, LlmEvaluator, ScopeKind, Snapshot, Steward, StewardId, ToolExecutor, ToolId, - ToolParams, ToolRegistry, ToolResult, Workspace, WorkspaceId, + build_charter, build_role_context, Evaluator, FakeCognitionBackend, FrameDef, LlmEvaluator, + ScopeKind, Snapshot, Steward, StewardId, ToolExecutor, ToolId, ToolParams, ToolRegistry, + ToolResult, Workspace, WorkspaceId, }; -use chartered_core::FakeCognitionBackend; +use chartered_runtime::charter_loader::{load_charter_def, load_role_context_def, load_skills}; fn examples_dir() -> &'static Path { Path::new(env!("CARGO_MANIFEST_DIR")) @@ -73,7 +73,11 @@ fn load_coding_agent_charter() { } // scopes.md produced 4 named sections. - let scope_names: Vec<_> = def.charter_scopes.iter().map(|(n, _)| n.as_str()).collect(); + let scope_names: Vec<&str> = def + .charter_scopes + .iter() + .map(|(n, _): &(String, String)| n.as_str()) + .collect(); assert!(scope_names.contains(&"file_system_access"), "names: {scope_names:?}"); assert!(scope_names.contains(&"shell_commands")); assert!(scope_names.contains(&"network_access")); @@ -94,7 +98,7 @@ fn load_coding_agent_charter() { // Build Charter and Workspace; validation should pass. let permitted = def.permitted_tools.clone(); let charter = build_charter(def, 1, evaluator_factory()); - let snap = Snapshot::new(charter, chartered_core::RoleContext::empty()); + let snap = Snapshot::new(charter, chartered_core::RoleContext::empty(), Vec::new()); let registry = registry_for(&permitted); let steward = Steward::new(StewardId::new("sut"), snap, Arc::new(registry)); let _ws = Workspace::single(WorkspaceId::new("ws-coding"), steward) @@ -118,7 +122,11 @@ fn load_customer_service_charter_with_role_context() { // Load Role context from the shipped template. let rc_path = dir.join("role_context_template.md"); let rc_def = load_role_context_def(&rc_path).expect("load role context template"); - let rc_names: Vec<_> = rc_def.scopes.iter().map(|(n, _)| n.as_str()).collect(); + let rc_names: Vec<&str> = rc_def + .scopes + .iter() + .map(|(n, _): &(String, String)| n.as_str()) + .collect(); assert!(rc_names.contains(&"product_pricing_fees"), "names: {rc_names:?}"); assert!(rc_names.contains(&"returns_warranty_policy")); assert!(rc_names.contains(&"service_scope_limitations")); @@ -128,7 +136,7 @@ fn load_customer_service_charter_with_role_context() { let permitted = def.permitted_tools.clone(); let charter = build_charter(def, 2, evaluator_factory()); let role_context = build_role_context(rc_def, 5); - let snap = Snapshot::new(charter, role_context); + let snap = Snapshot::new(charter, role_context, Vec::new()); let registry = registry_for(&permitted); let steward = Steward::new(StewardId::new("sut"), snap, Arc::new(registry)); let _ws = Workspace::single(WorkspaceId::new("ws-cs"), steward) @@ -143,7 +151,7 @@ fn workspace_validation_fails_when_role_context_omitted() { let def = load_charter_def(&dir).unwrap(); let permitted = def.permitted_tools.clone(); let charter = build_charter(def, 1, evaluator_factory()); - let snap = Snapshot::new(charter, chartered_core::RoleContext::empty()); + let snap = Snapshot::new(charter, chartered_core::RoleContext::empty(), Vec::new()); let registry = registry_for(&permitted); let steward = Steward::new(StewardId::new("sut"), snap, Arc::new(registry)); let err = match Workspace::single(WorkspaceId::new("ws"), steward) { @@ -157,3 +165,68 @@ fn workspace_validation_fails_when_role_context_omitted() { ); } +#[test] +fn load_skills_returns_empty_when_directory_absent() { + // Skills are optional: a Charter without `skills/` loads as + // empty Vec, not as an error. + let dir = tempfile::tempdir().unwrap(); + let skills = load_skills(dir.path()).expect("load_skills tolerates missing dir"); + assert!(skills.is_empty()); +} + +#[test] +fn load_skills_reads_markdown_files_sorted_by_id() { + // /skills/.md → one Skill each. Loader sorts by id + // so the resulting `skills_content_hash` is filesystem-iteration + // order-independent. + let dir = tempfile::tempdir().unwrap(); + let skills_dir = dir.path().join("skills"); + std::fs::create_dir_all(&skills_dir).unwrap(); + std::fs::write(skills_dir.join("triage.md"), "triage guidance body").unwrap(); + std::fs::write(skills_dir.join("billing.md"), "billing guidance body").unwrap(); + // A non-.md file should be ignored — only `*.md` becomes a Skill. + std::fs::write(skills_dir.join("notes.txt"), "should be ignored").unwrap(); + + let skills = load_skills(dir.path()).expect("load_skills"); + assert_eq!(skills.len(), 2, "two .md files → two Skills"); + assert_eq!(skills[0].id, "billing", "sorted by id"); + assert_eq!(skills[1].id, "triage"); + assert_eq!(skills[0].content.trim(), "billing guidance body"); + assert_ne!(skills[0].content_hash, skills[1].content_hash); +} + +#[test] +fn skills_content_propagates_into_snapshot_id() { + use chartered_core::{skills_content_hash, Charter, RoleContext, Skill}; + let mk_charter = || Charter { + frames: vec![], + permitted_tools: vec![], + charter_scopes: vec![], + behavioral_spec: String::new(), + charter_version: 1, + charter_content_hash: "c".into(), + }; + let mk_rc = || RoleContext { + scopes: vec![], + role_context_version: 1, + role_context_content_hash: "r".into(), + }; + + // Same Charter + Role context, different Skills → different + // Snapshot IDs (Skills are part of the content-addressed identity). + let snap_no_skills = Snapshot::new(mk_charter(), mk_rc(), Vec::new()); + let snap_with_skill = Snapshot::new( + mk_charter(), + mk_rc(), + vec![Skill::new("s", "skill body")], + ); + assert_ne!(snap_no_skills.id, snap_with_skill.id); + + // Aggregate hash of empty Skills differs from aggregate hash of any + // non-empty Skill set (sanity for the composition rule). + assert_ne!( + skills_content_hash(&[]), + skills_content_hash(&[Skill::new("s", "skill body")]) + ); +} + diff --git a/runtime/tests/config.rs b/runtime/tests/config.rs index 62e787b..b7abf0c 100644 --- a/runtime/tests/config.rs +++ b/runtime/tests/config.rs @@ -64,7 +64,6 @@ fn load_full_deployment_config() { // Charter reference assert_eq!(cfg.charter_ref.version, 1); - // Charter content (loaded via core::charter_loader) assert_eq!(cfg.charter_def.frames.len(), 4); assert_eq!(cfg.charter_def.permitted_tools.len(), 3); for f in &cfg.charter_def.frames { @@ -124,8 +123,8 @@ fn loaded_charter_builds_into_valid_workspace() { fd.concern.clone(), )) }; - let (charter, role_context) = cfg.build_charter(factory); - let snap = Snapshot::new(charter, role_context); + let (charter, role_context, skills) = cfg.build_charter(factory); + let snap = Snapshot::new(charter, role_context, skills); let mut registry = ToolRegistry::new(); for t in &permitted { diff --git a/runtime/tests/llm_e2e.rs b/runtime/tests/llm_e2e.rs index e9a1464..27834d1 100644 --- a/runtime/tests/llm_e2e.rs +++ b/runtime/tests/llm_e2e.rs @@ -1,10 +1,17 @@ -//! Real-LLM end-to-end tests against the chartered-runtime binary. +//! E2e tests — actual full agent loop, real LLM, production transport. //! -//! Same code path as the fake-mode E2E in `e2e.rs` — only the -//! `backend` value in `steward.toml` differs. The deployments are -//! real production deployments configured for `backend = "openai"`, -//! reading `LLM_BASE_URL` / `LLM_MODEL` / `LLM_API_KEY` from -//! environment (loaded by the binary via `dotenvy::dotenv()`). +//! Per `AGENTS.md §Verification`, e2e tests are local-only and every +//! test in this file is `#[ignore]`d so default `cargo test` skips +//! them. Opt-in via `cargo test -- --ignored`. No soft-skip — a runner +//! that fires these tests intentionally opted in and must see real +//! failures. +//! +//! Same code path as the fake-mode binary integration in +//! `binary_integration.rs` — only the `backend` value in +//! `steward.toml` differs. The deployments are real production +//! deployments configured for `backend = "openai"`, reading +//! `OPEN_AI_BASE_URL` / `OPEN_AI_MODEL` / `OPEN_AI_API_KEY` from environment +//! (loaded by the binary via `dotenvy::dotenv()`). //! //! Both real OpenAI and local OpenAI-compatible servers (LM Studio, //! llama.cpp server, vLLM, SGLang) work through the same @@ -13,8 +20,8 @@ //! `CHARTERED_LOCAL_LM_BASE_URL` when the local server is reachable //! through a host-specific dynamic address. //! -//! Environment files are explicit test inputs. Missing `LLM_BASE_URL` -//! or `LLM_MODEL` in the selected file is a test failure. +//! Environment files are explicit test inputs. Missing `OPEN_AI_BASE_URL` +//! or `OPEN_AI_MODEL` in the selected file is a test failure. mod common; @@ -139,10 +146,21 @@ declared_scopes = [ ) } -fn steward_openai(actor_model_override: Option<&str>) -> String { +/// Map an `LlmEnv` to the `backend = "..."` string used inside its +/// `steward.toml` fixture. `openai` / `local_lm` both target the +/// OpenAI-compatible wire format, so both yield `"openai"`; `gemini` +/// targets the native generativelanguage API. +fn steward_backend(target: &LlmEnv) -> &'static str { + match target.name { + "gemini" => "gemini", + _ => "openai", + } +} + +fn steward_for_backend(backend: &str, actor_model_override: Option<&str>) -> String { let actor_block = match actor_model_override { - Some(m) => format!("[actor]\nbackend = \"openai\"\nmodel = \"{m}\"\n"), - None => "[actor]\nbackend = \"openai\"\n".to_string(), + Some(m) => format!("[actor]\nbackend = \"{backend}\"\nmodel = \"{m}\"\n"), + None => format!("[actor]\nbackend = \"{backend}\"\n"), }; format!( r#"system_prompt = """ @@ -156,29 +174,30 @@ tool_registry = ["write_file.toml"] {actor_block} [evaluator] -backend = "openai" +backend = "{backend}" "# ) } -fn steward_openai_artifacts() -> String { - r#"system_prompt = """ +fn steward_artifacts_for_backend(backend: &str) -> String { + format!( + r#"system_prompt = """ You are a chartered workspace Steward. Reply only with JSON Action objects. For Refine or Expand, call: -{"tool":"modify_artifact","params":{"kind":"text","artifact_id":"","range":{"start":0,"end":0,"start_line":1,"end_line":1},"replacement":"","summary":""}} +{{"tool":"modify_artifact","params":{{"kind":"text","artifact_id":"","range":{{"start":0,"end":0,"start_line":1,"end_line":1}},"replacement":"","summary":""}}}} For Review, call: -{"tool":"record_finding","params":{"artifact_id":"","range":{"start":0,"end":0,"start_line":1,"end_line":1},"concern":"","severity":"low|medium|high","detail":""}} +{{"tool":"modify_artifact","params":{{"kind":"record-store","artifact_id":"records","edit":{{"append":{{"artifact_id":"","range":{{"start":0,"end":0,"start_line":1,"end_line":1}},"concern":"","severity":"low|medium|high","detail":""}}}}}}}} Use the artifact_id and range from the selection trigger. Halt after one successful allowed action. """ -tool_registry = ["modify_artifact.toml", "record_finding.toml", "read_artifact.toml", "list_artifacts.toml"] +tool_registry = ["modify_artifact.toml", "read_artifact.toml", "list_artifacts.toml"] [actor] -backend = "openai" +backend = "{backend}" [evaluator] -backend = "openai" +backend = "{backend}" "# - .into() + ) } fn write_artifact_tools(dep: &TestDeployment) { @@ -188,12 +207,6 @@ fn write_artifact_tools(dep: &TestDeployment) { "native_artifact_modify", "modify_artifact", ); - write_tool( - dep, - "record_finding", - "native_artifact_record_finding", - "record_finding", - ); write_tool( dep, "read_artifact", @@ -228,7 +241,19 @@ impl LlmEnv { Self { name: "local_lm", file: ".env.dev", - overrides: BTreeMap::from([("LLM_API_KEY".to_string(), String::new())]), + overrides: BTreeMap::from([("OPEN_AI_API_KEY".to_string(), String::new())]), + } + } + + /// Gemini cloud — Google's native generativelanguage API. Reads + /// `GEMINI_API_KEY` and `GEMINI_MODEL_ID` from `.env`. Distinct + /// from `openai` (different backend wire shape, different env-var + /// surface). + fn gemini() -> Self { + Self { + name: "gemini", + file: ".env", + overrides: BTreeMap::new(), } } @@ -245,23 +270,38 @@ impl LlmEnv { for (k, v) in &self.overrides { env.insert(k.clone(), v.clone()); } - if self.name == "local_lm" { - if let Ok(base_url) = std::env::var("CHARTERED_LOCAL_LM_BASE_URL") { - if !base_url.is_empty() { - env.insert("LLM_BASE_URL".into(), base_url); - } - } + if self.name == "local_lm" + && let Ok(base_url) = std::env::var("CHARTERED_LOCAL_LM_BASE_URL") + && !base_url.is_empty() + { + env.insert("OPEN_AI_BASE_URL".into(), base_url); + } + // Vendor-specific env-var assertions. The gemini path requires + // GEMINI_API_KEY + GEMINI_MODEL_ID; the openai/local_lm paths + // require OPEN_AI_BASE_URL + OPEN_AI_MODEL. + if self.name == "gemini" { + assert!( + env.get("GEMINI_API_KEY").is_some_and(|v| !v.is_empty()), + "{} must define GEMINI_API_KEY", + self.file + ); + assert!( + env.get("GEMINI_MODEL_ID").is_some_and(|v| !v.is_empty()), + "{} must define GEMINI_MODEL_ID", + self.file + ); + } else { + assert!( + env.get("OPEN_AI_BASE_URL").is_some_and(|v| !v.is_empty()), + "{} must define OPEN_AI_BASE_URL", + self.file + ); + assert!( + env.get("OPEN_AI_MODEL").is_some_and(|v| !v.is_empty()), + "{} must define OPEN_AI_MODEL", + self.file + ); } - assert!( - env.get("LLM_BASE_URL").is_some_and(|v| !v.is_empty()), - "{} must define LLM_BASE_URL", - self.file - ); - assert!( - env.get("LLM_MODEL").is_some_and(|v| !v.is_empty()), - "{} must define LLM_MODEL", - self.file - ); env } } @@ -336,14 +376,36 @@ fn persist_output(target: &LlmEnv, name: &str, out: &std::process::Output) { &out.stderr, ) .unwrap(); + // Also copy cognition.jsonl out of the per-run tempdir so it + // survives `TempDir::drop` and operators can grep the verbatim LLM + // exchanges after a failure. + if let Ok(v) = serde_json::from_slice::(&out.stdout) + && let Some(cog_path) = v["cognition_log"].as_str() + && let Ok(cog) = std::fs::read(cog_path) + { + let _ = std::fs::write( + temp.join(format!("llm_e2e_{}_{}.cognition.jsonl", target.name, name)), + cog, + ); + } } #[test] +#[ignore = "e2e: requires real LLM at $OPEN_AI_BASE_URL with $OPEN_AI_API_KEY; opt-in via `cargo test -- --ignored`"] fn llm_e2e_openai_writes_requested_file_then_halts() { + writes_requested_file_then_halts_strict(LlmEnv::openai(), "hello.txt"); +} + +#[test] +#[ignore = "e2e: requires Gemini at $GEMINI_API_KEY + $GEMINI_MODEL_ID; opt-in via `cargo test -- --ignored`"] +fn llm_e2e_gemini_writes_requested_file_then_halts() { + writes_requested_file_then_halts_strict(LlmEnv::gemini(), "hello.txt"); +} + +fn writes_requested_file_then_halts_strict(target: LlmEnv, expected_filename: &str) { let _guard = llm_e2e_lock() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let target = LlmEnv::openai(); let dep = TestDeployment::new(); dep.write_chartered_toml(); @@ -351,25 +413,24 @@ fn llm_e2e_openai_writes_requested_file_then_halts() { dep.write_charter(frames_toml_basic(), SCOPES_MD_BASIC); dep.write_role_context_md(); write_tool(&dep, "write_file", "native_fs_write", "write_file"); - dep.write("steward.toml", &steward_openai(None)); + dep.write("steward.toml", &steward_for_backend(steward_backend(&target), None)); - let out = run_with_user_message_env( - &dep, - &target, - "Please create a file named hello.txt containing exactly the text \ - `hi from chartered-runtime` and then halt.", + let user_message = format!( + "Please create a file named {expected_filename} containing exactly the text \ + `hi from chartered-runtime` and then halt." ); + let out = run_with_user_message_env(&dep, &target, &user_message); persist_output(&target, "writes_requested_file_then_halts", &out); - if !out.status.success() { - eprintln!( - "real-LLM run did not succeed (this can happen on cold local \ - models). stdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) - ); - return; - } + // E2e per AGENTS.md §Verification: tests MUST fail when + // preconditions absent. The `#[ignore]` on this test is the only + // gate — a runner that reaches here intentionally opted in and + // must see real failures. + assert!( + out.status.success(), + "real-LLM run did not succeed; stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); let v = parse_stdout_json(&out); let receipts = v["receipts"].as_array().unwrap(); @@ -387,17 +448,14 @@ fn llm_e2e_openai_writes_requested_file_then_halts() { }) .collect(); if allowed_writes.is_empty() { - eprintln!( - "no allowed write_file Receipts; the real LLM may have refused \ - or the Charter denied. trail:\n{}", - serde_json::to_string_pretty(&receipts).unwrap() - ); - // Surface the raw LLM responses so the operator can see why the - // evaluator's verdict was empty-trace or unparseable. + // Surface the raw LLM responses so the operator can see why + // the evaluator's verdict was empty-trace or unparseable, then + // fail — e2e is opt-in, so a runner that gets here expects to + // see real failures, not soft-skips disguised as passing. if let Some(cog_path) = v["cognition_log"].as_str() && let Ok(text) = std::fs::read_to_string(cog_path) { - eprintln!("cognition.jsonl:\n"); + eprintln!("cognition.jsonl:"); for line in text.lines() { if let Ok(e) = serde_json::from_str::(line) { eprintln!( @@ -409,21 +467,23 @@ fn llm_e2e_openai_writes_requested_file_then_halts() { } } } - return; - } - - // hello.txt should exist; its content may differ from our exact - // string (LLMs paraphrase), so we just assert presence + non-empty. - let path = dep.workspace_file("hello.txt"); - if !path.exists() { - eprintln!( - "hello.txt missing; LLM may have written elsewhere. workspace files:\n{:?}", - list_files_under(&dep.workspace_root) + panic!( + "no allowed write_file Receipts; the real LLM may have refused \ + or the Charter denied. trail:\n{}", + serde_json::to_string_pretty(&receipts).unwrap() ); - return; } + + // expected_filename should exist; its content may differ from our + // exact string (LLMs paraphrase), so we just assert presence + non-empty. + let path = dep.workspace_file(expected_filename); + assert!( + path.exists(), + "{expected_filename} missing; LLM may have written elsewhere. workspace files:\n{:?}", + list_files_under(&dep.workspace_root) + ); let content = std::fs::read_to_string(&path).unwrap(); - assert!(!content.is_empty(), "hello.txt is empty"); + assert!(!content.is_empty(), "{expected_filename} is empty"); // cognition.jsonl carries the real prompts and responses. let cog_path = std::path::PathBuf::from(v["cognition_log"].as_str().unwrap()); @@ -455,6 +515,7 @@ fn llm_e2e_openai_writes_requested_file_then_halts() { } #[test] +#[ignore = "e2e: requires local LM at $OPEN_AI_BASE_URL; opt-in via `cargo test -- --ignored`"] fn llm_e2e_local_lm_writes_requested_file_then_halts() { let _guard = llm_e2e_lock() .lock() @@ -467,7 +528,7 @@ fn llm_e2e_local_lm_writes_requested_file_then_halts() { dep.write_charter(frames_toml_basic(), SCOPES_MD_BASIC); dep.write_role_context_md(); write_tool(&dep, "write_file", "native_fs_write", "write_file"); - dep.write("steward.toml", &steward_openai(None)); + dep.write("steward.toml", &steward_for_backend(steward_backend(&target), None)); let out = run_with_user_message_env( &dep, @@ -477,20 +538,21 @@ fn llm_e2e_local_lm_writes_requested_file_then_halts() { ); persist_output(&target, "writes_requested_file_then_halts", &out); - if !out.status.success() { - eprintln!( - "local-LM run did not succeed; stderr:\n{}", - String::from_utf8_lossy(&out.stderr) - ); - return; - } + assert!( + out.status.success(), + "local-LM run did not succeed; stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); let v = parse_stdout_json(&out); let receipts = v["receipts"].as_array().unwrap(); assert!(!receipts.is_empty()); - // Best-effort assertion: the Steward should have produced at least - // one Allowed write_file. We don't pin model behavior beyond that. + // E2e tests fail when preconditions are absent (AGENTS.md + // §Verification). The Steward must produce at least one Allowed + // write_file; an Escalated outcome means the local LM couldn't + // produce a parseable Action within the Actor's inner step + // budget — a real failure, not a soft-skip. let allowed_writes = receipts .iter() .filter(|r| { @@ -498,24 +560,31 @@ fn llm_e2e_local_lm_writes_requested_file_then_halts() { && r["tool_call"]["tool"].as_str() == Some("write_file") }) .count(); - if allowed_writes == 0 { - eprintln!( - "local LM did not produce any Allowed write_file; trail:\n{}", - serde_json::to_string_pretty(&receipts).unwrap() - ); - } + assert!( + allowed_writes > 0, + "local LM did not produce any Allowed write_file; trail:\n{}", + serde_json::to_string_pretty(&receipts).unwrap() + ); } #[test] +#[ignore = "e2e: requires real LLM at $OPEN_AI_BASE_URL with $OPEN_AI_API_KEY; opt-in via `cargo test -- --ignored`"] fn llm_e2e_openai_selection_refine_modifies_artifact() { selection_refine_modifies_artifact(LlmEnv::openai()); } #[test] +#[ignore = "e2e: requires local LM at $OPEN_AI_BASE_URL; opt-in via `cargo test -- --ignored`"] fn llm_e2e_local_lm_selection_refine_modifies_artifact() { selection_refine_modifies_artifact(LlmEnv::local_lm()); } +#[test] +#[ignore = "e2e: requires Gemini at $GEMINI_API_KEY + $GEMINI_MODEL_ID; opt-in via `cargo test -- --ignored`"] +fn llm_e2e_gemini_selection_refine_modifies_artifact() { + selection_refine_modifies_artifact(LlmEnv::gemini()); +} + fn selection_refine_modifies_artifact(target: LlmEnv) { let _guard = llm_e2e_lock() .lock() @@ -531,7 +600,7 @@ fn selection_refine_modifies_artifact(target: LlmEnv) { write_artifact_tools(&dep); let original = "The vendor liability cap is unclear and may not match precedent."; std::fs::write(dep.workspace_file("deal.md"), original).unwrap(); - dep.write("steward.toml", &steward_openai_artifacts()); + dep.write("steward.toml", &steward_artifacts_for_backend(steward_backend(&target))); let out = run_selection_env( &dep, @@ -543,14 +612,12 @@ fn selection_refine_modifies_artifact(target: LlmEnv) { "generative", ); persist_output(&target, "selection_refine_modifies_artifact", &out); - if !out.status.success() { - eprintln!( - "real selection refine failed; stdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) - ); - return; - } + assert!( + out.status.success(), + "real selection refine failed; stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); let v = parse_stdout_json(&out); let receipts = v["receipts"].as_array().unwrap(); @@ -569,15 +636,23 @@ fn selection_refine_modifies_artifact(target: LlmEnv) { } #[test] +#[ignore = "e2e: requires real LLM at $OPEN_AI_BASE_URL with $OPEN_AI_API_KEY; opt-in via `cargo test -- --ignored`"] fn llm_e2e_openai_selection_review_records_finding() { selection_review_records_finding(LlmEnv::openai()); } #[test] +#[ignore = "e2e: requires local LM at $OPEN_AI_BASE_URL; opt-in via `cargo test -- --ignored`"] fn llm_e2e_local_lm_selection_review_records_finding() { selection_review_records_finding(LlmEnv::local_lm()); } +#[test] +#[ignore = "e2e: requires Gemini at $GEMINI_API_KEY + $GEMINI_MODEL_ID; opt-in via `cargo test -- --ignored`"] +fn llm_e2e_gemini_selection_review_records_finding() { + selection_review_records_finding(LlmEnv::gemini()); +} + fn selection_review_records_finding(target: LlmEnv) { let _guard = llm_e2e_lock() .lock() @@ -586,14 +661,14 @@ fn selection_review_records_finding(target: LlmEnv) { dep.write_chartered_toml(); dep.write_charter_ref(1); dep.write_charter( - &frames_toml_artifact(&["record_finding"]), + &frames_toml_artifact(&["modify_artifact"]), SCOPES_MD_ARTIFACT, ); dep.write_role_context_md(); write_artifact_tools(&dep); let original = "Public API gateway uses a shared cache without tenant key isolation."; std::fs::write(dep.workspace_file("architecture.md"), original).unwrap(); - dep.write("steward.toml", &steward_openai_artifacts()); + dep.write("steward.toml", &steward_artifacts_for_backend(steward_backend(&target))); let out = run_selection_env( &dep, @@ -605,47 +680,54 @@ fn selection_review_records_finding(target: LlmEnv) { "evaluative", ); persist_output(&target, "selection_review_records_finding", &out); - if !out.status.success() { - eprintln!( - "real selection review failed; stdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) - ); - return; - } + assert!( + out.status.success(), + "real selection review failed; stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); let v = parse_stdout_json(&out); let receipts = v["receipts"].as_array().unwrap(); let allowed_finding = receipts.iter().any(|r| { r["outcome"].as_str() == Some("Allowed") - && r["tool_call"]["tool"].as_str() == Some("record_finding") + && r["tool_call"]["tool"].as_str() == Some("modify_artifact") + && r["tool_call"]["params"]["kind"].as_str() == Some("record-store") }); assert!( allowed_finding, - "expected allowed record_finding receipt; trail:\n{}", + "expected allowed modify_artifact(kind=record-store) receipt; trail:\n{}", serde_json::to_string_pretty(receipts).unwrap() ); assert_eq!( std::fs::read_to_string(dep.workspace_file("architecture.md")).unwrap(), original ); - let findings = - std::fs::read_to_string(dep.workspace_file(".chartered/findings.jsonl")).unwrap(); - let finding: serde_json::Value = serde_json::from_str(findings.trim()).unwrap(); - assert!(finding["concern"].as_str().unwrap_or("").len() > 3); - assert!(finding["detail"].as_str().unwrap_or("").len() > 3); + let records = + std::fs::read_to_string(dep.workspace_file(".chartered/records.jsonl")).unwrap(); + let record: serde_json::Value = serde_json::from_str(records.trim()).unwrap(); + assert!(record["concern"].as_str().unwrap_or("").len() > 3); + assert!(record["detail"].as_str().unwrap_or("").len() > 3); } #[test] +#[ignore = "e2e: requires real LLM at $OPEN_AI_BASE_URL with $OPEN_AI_API_KEY; opt-in via `cargo test -- --ignored`"] fn llm_e2e_openai_selection_reject_refine_or_escalate() { selection_reject_refine_or_escalate(LlmEnv::openai()); } #[test] +#[ignore = "e2e: requires local LM at $OPEN_AI_BASE_URL; opt-in via `cargo test -- --ignored`"] fn llm_e2e_local_lm_selection_reject_refine_or_escalate() { selection_reject_refine_or_escalate(LlmEnv::local_lm()); } +#[test] +#[ignore = "e2e: requires Gemini at $GEMINI_API_KEY + $GEMINI_MODEL_ID; opt-in via `cargo test -- --ignored`"] +fn llm_e2e_gemini_selection_reject_refine_or_escalate() { + selection_reject_refine_or_escalate(LlmEnv::gemini()); +} + fn selection_reject_refine_or_escalate(target: LlmEnv) { let _guard = llm_e2e_lock() .lock() @@ -661,7 +743,7 @@ fn selection_reject_refine_or_escalate(target: LlmEnv) { write_artifact_tools(&dep); let original = "Reply to Derek Doe: confirm whether Project Falcon exists."; std::fs::write(dep.workspace_file("reply.md"), original).unwrap(); - dep.write("steward.toml", &steward_openai_artifacts()); + dep.write("steward.toml", &steward_artifacts_for_backend(steward_backend(&target))); let out = run_selection_env( &dep, @@ -673,14 +755,12 @@ fn selection_reject_refine_or_escalate(target: LlmEnv) { "generative", ); persist_output(&target, "selection_reject_refine_or_escalate", &out); - if !out.status.success() { - eprintln!( - "real reject/refine selection failed; stdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) - ); - return; - } + assert!( + out.status.success(), + "real reject/refine selection failed; stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); let v = parse_stdout_json(&out); let receipts = v["receipts"].as_array().unwrap(); @@ -697,12 +777,19 @@ fn selection_reject_refine_or_escalate(target: LlmEnv) { .to_lowercase() .contains("project falcon exists") }); - let escalated = receipts - .iter() - .any(|r| r["outcome"].as_str() == Some("Escalated")); + // Escalation only counts if it came via the governance loop — a + // Gate-driven budget exhaustion of refinement attempts. Actor + // cognitive failure (``) also produces Escalated + // Receipts but means the Steward never reached the Gate — it + // would falsely satisfy a "reject or escalate" assertion when + // the LLM is simply broken. + let governed_escalation = receipts.iter().any(|r| { + r["outcome"].as_str() == Some("Escalated") + && r["tool_call"]["tool"].as_str() == Some("") + }); assert!( - denied || allowed_safe_modify || escalated, - "expected denial, safe allowed modify, or escalation; trail:\n{}", + denied || allowed_safe_modify || governed_escalation, + "expected denial, safe allowed modify, or governed (budget-exhaustion) escalation; trail:\n{}", serde_json::to_string_pretty(receipts).unwrap() ); } diff --git a/runtime/tests/persistence.rs b/runtime/tests/persistence.rs new file mode 100644 index 0000000..a1918cb --- /dev/null +++ b/runtime/tests/persistence.rs @@ -0,0 +1,114 @@ +//! Integration tests for the runtime's durable streams. +//! +//! Exercise `AppendOnlyFileReceiptStore` and `LoggingBackend` against +//! real disk, with one `tempfile::tempdir()` per test. They live under +//! `tests/` rather than in `#[cfg(test)] mod tests` because they are +//! not unit tests by `AGENTS.md §Verification` — unit tests are +//! literally stateless. + +use std::sync::Arc; + +use chartered_core::{ + AttemptId, CognitionBackend, CognitionRequest, FakeCognitionBackend, GovernanceMode, Message, + Outcome, Receipt, ReceiptId, ReceiptStore, SnapshotId, StewardId, TaskId, ToolCall, ToolId, + ToolParams, +}; +use chartered_dispatch::JsonlSink; +use chartered_runtime::persistence::{AppendOnlyFileReceiptStore, LoggingBackend}; + +fn mk_receipt(context_id: &str) -> Receipt { + Receipt { + receipt_id: ReceiptId("test-id".into()), + task_id: TaskId::new("task-test"), + attempt_id: Some(AttemptId::new("attempt-test")), + steward_id: StewardId::new("test-steward"), + governance_mode: GovernanceMode::FULL, + tool_call: ToolCall { + tool: ToolId::new("noop"), + params: ToolParams(serde_json::json!({})), + context_id: Arc::from(context_id), + source_id: Arc::from("steward"), + }, + verdicts: vec![], + outcome: Outcome::Allowed, + timestamp: std::time::SystemTime::now(), + intercept_complete: true, + charter_version: 1, + role_context_version: 1, + snapshot_id: SnapshotId("snap-test".into()), + } +} + +#[tokio::test] +async fn receipt_store_writes_jsonl_and_caches_in_memory() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("receipts.jsonl"); + let store = AppendOnlyFileReceiptStore::create(&path).await.unwrap(); + + let receipt = mk_receipt("ctx-1"); + store.append(&receipt).await.unwrap(); + + // In-memory mirror reflects the write immediately. + assert_eq!(store.all().len(), 1); + + // The file on disk is one valid JSON line. + let text = std::fs::read_to_string(&path).unwrap(); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 1); + let v: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); + assert_eq!(v["tool_call"]["context_id"].as_str(), Some("ctx-1")); +} + +#[tokio::test] +async fn cognition_log_records_request_and_response() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cognition.jsonl"); + let log: Arc = Arc::new(JsonlSink::create(&path).await.unwrap()); + + let inner = Arc::new(FakeCognitionBackend::new("eval-test")); + inner.enqueue("ALLOW: ok"); + let wrapped = LoggingBackend::new(inner, log.clone()); + + let req = CognitionRequest { + messages: vec![Message::user("hello")], + max_output_tokens: Some(64), + }; + let resp = wrapped.complete(&req).await.unwrap(); + assert_eq!(resp.content, "ALLOW: ok"); + + let text = std::fs::read_to_string(&path).unwrap(); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 1); + let v: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); + assert_eq!(v["backend_id"].as_str(), Some("eval-test")); + assert_eq!(v["response"]["content"].as_str(), Some("ALLOW: ok")); + assert!( + v["request"]["messages"][0]["content"] + .as_str() + .unwrap() + .contains("hello") + ); +} + +#[tokio::test] +async fn cognition_log_records_backend_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cognition.jsonl"); + let log: Arc = Arc::new(JsonlSink::create(&path).await.unwrap()); + + // Empty queue → error on first call. + let inner = Arc::new(FakeCognitionBackend::new("evaluator")); + let wrapped = LoggingBackend::new(inner, log.clone()); + + let req = CognitionRequest { + messages: vec![Message::user("x")], + max_output_tokens: None, + }; + let result = wrapped.complete(&req).await; + assert!(result.is_err()); + + let text = std::fs::read_to_string(&path).unwrap(); + let v: serde_json::Value = serde_json::from_str(text.lines().next().unwrap()).unwrap(); + assert!(v["response"].is_null()); + assert!(v["error"].as_str().unwrap().contains("queue empty")); +} From ed7fccd188d8fa947e0156c0be912f1df1d2f024 Mon Sep 17 00:00:00 2001 From: Christoforus Yoga Haryanto Date: Sun, 24 May 2026 19:24:21 +1000 Subject: [PATCH 2/2] ci: install libseccomp-dev for chartered-tracer link step chartered-tracer depends on libseccomp, whose libseccomp-sys build script emits an unconditional `cargo:rustc-link-lib=dylib=seccomp`. The ubuntu-latest runner does not ship libseccomp.so, so the linker fails with `unable to find library -lseccomp`. Install libseccomp-dev alongside protobuf-compiler. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cb9038..da09a36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,8 +15,8 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install protobuf compiler - run: sudo apt-get install -y protobuf-compiler + - name: Install system dependencies + run: sudo apt-get install -y protobuf-compiler libseccomp-dev - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable