From c51f5d567b012408dc0fc07a5950471b916b606a Mon Sep 17 00:00:00 2001 From: RoyLin <18770221825@163.com> Date: Fri, 25 Sep 2026 23:07:15 +0800 Subject: [PATCH 1/6] Land Meta Harness compose on crates.io a3s-effect 0.1.0. Admit stock Moore parts via SessionOptions.harness / Harness.compose while keeping permission overlay and completion gate Core-owned; mark META-HARNESS1 delivered and META-HARNESS2 for host-authored SDK components. Co-authored-by: Cursor --- CHANGELOG.md | 16 ++ Cargo.lock | 39 ++-- README.md | 7 + ROADMAP.md | 11 +- core/Cargo.toml | 2 +- core/src/agent.rs | 3 + core/src/agent_api.rs | 5 + core/src/agent_api/conversation_runtime.rs | 1 + core/src/agent_api/session_builder.rs | 1 + core/src/agent_api/session_options.rs | 6 + core/src/fact_control.rs | 47 +++- core/src/lib.rs | 5 + core/src/meta_harness.rs | 212 +++++++++++++++++++ manual/HARNESS_CONVERGENCE.md | 7 +- manual/META_HARNESS.md | 106 ++++++++++ sdk/node/Cargo.lock | 38 ++-- sdk/node/src/session_options.rs | 99 +++++++++ sdk/node/src/tests.rs | 41 ++++ sdk/python/CHANGELOG.md | 6 + sdk/python/Cargo.lock | 31 +-- sdk/python/src/module_registration.rs | 2 + sdk/python/src/session_config.rs | 119 +++++++++++ sdk/python/src/session_options.rs | 28 +++ sdk/python/src/session_options_conversion.rs | 3 + sdk/python/src/tests.rs | 36 ++++ 25 files changed, 807 insertions(+), 64 deletions(-) create mode 100644 core/src/meta_harness.rs create mode 100644 manual/META_HARNESS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a163b98fd..ac1566033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Honor `NO_PROXY` / `no_proxy` on explicit `HTTP(S)_PROXY` clients used by MCP + HTTP transports, OAuth, and model HTTP (`build_reqwest_client`) (#171). +- Python `SessionOptions.verifier_enabled` getter/setter so hosts can opt into + the Core read-only verifier (#163). +- Rolling context compaction mechanically re-pins the original `## Goal` when + the summarizer omits it (#174). + ### Added +- **Meta Harness composition.** Hosts compose stock Moore components + (`system`, `tools`, `budget`, `compact`, `infer`) on the single fact log via + `SessionOptions.harness` / Node·Python `Harness.compose`. Depends on crates.io + `a3s-effect` 0.1.0. Omit the option to keep the legacy `coding_actor` tree. + Permission overlay and completion gate remain Core-owned. Manual: + [META_HARNESS.md](manual/META_HARNESS.md). Host-authored SDK components are + tracked as `META-HARNESS2`. - Optional typed System-1 decisions via A3S Apofasi: Cargo features `apofasi` (lexical + script router), `apofasi-infer` (Candle checkpoints), and `apofasi-metal` (Apple Silicon Metal). Host API: diff --git a/Cargo.lock b/Cargo.lock index 7f67140db..29c404c7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -156,7 +156,8 @@ dependencies = [ [[package]] name = "a3s-effect" version = "0.1.0" -source = "git+https://github.com/A3S-Lab/Effect.git?rev=cea4766639b5b7d9c2420bf6cbe792d782acafdb#cea4766639b5b7d9c2420bf6cbe792d782acafdb" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "044abc27a2da3caeb2c3636fd80b97b22f422034e46618a5ba2073397573b07c" dependencies = [ "serde", "serde_json", @@ -501,7 +502,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -512,7 +513,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2286,7 +2287,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2472,7 +2473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3556,7 +3557,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -4504,7 +4505,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5302,7 +5303,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.44", - "socket2 0.6.5", + "socket2 0.5.10", "thiserror 2.0.20", "tokio", "tracing", @@ -5340,9 +5341,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2 0.5.10", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5869,7 +5870,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6381,7 +6382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6621,10 +6622,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7592,7 +7593,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8187,9 +8188,9 @@ dependencies = [ ] [[patch.unused]] -name = "a3s-apofasi" -version = "0.1.2" +name = "a3s-sandbox" +version = "0.2.0" [[patch.unused]] -name = "a3s-sandbox" -version = "0.1.5" +name = "a3s-apofasi" +version = "0.1.2" diff --git a/README.md b/README.md index f4d7457a2..4260ea148 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,12 @@ The tool-round cap sends one completion with an empty tool list. Workspace tools, model adapters, context construction, and session snapshots stay in Code. +Code is a **Meta Harness**: hosts may compose stock Moore components +(`system`, `tools`, `budget`, `compact`, `infer`) on that same log via +`SessionOptions.harness` / `Harness.compose`. Omitting the option keeps the +legacy `coding_actor` tree. Permission projection and the completion gate stay +Core-owned. See [Meta Harness](manual/META_HARNESS.md). +

Start · v9.0 · @@ -1726,6 +1732,7 @@ the v1 schema or claiming external Runtime certification. | [Advanced Developer Manual](manual/ADVANCED_DEVELOPER_MANUAL.md) · [Chinese](manual/ADVANCED_DEVELOPER_MANUAL_CN.md) | Extension contracts, security, lifecycle, and production integration | | [SDK API Design](manual/SDK_API_DESIGN.md) | Cross-language API conventions and alignment | | [Harness Convergence](manual/HARNESS_CONVERGENCE.md) | Thin-default wrap-up, refuse list, and local verification for `HARNESS-CONV4`–`CONV7` | +| [Meta Harness](manual/META_HARNESS.md) | Composable Moore components on one fact log; stock parts; SDK `Harness.compose`; non-bypassable kernel | | [First-Principles E2E](manual/FIRST_PRINCIPLES_E2E.md) | Layer A–D verification matrix for harness, live ACL, and external TB/DM/CAR gates | | [Capability Verification](manual/CAPABILITY_VERIFICATION.md) | First-principles evidence ledger for every advertised capability, SDK runtime gates, evidence-gap closure, and performance policy | | [Scoped Capability Architecture](manual/SCOPED_CAPABILITY_ARCHITECTURE.md) | A3S Use ownership, typed scopes, immutable generations, reversible effects, migration gates, and verification invariants | diff --git a/ROADMAP.md b/ROADMAP.md index 40241904a..063777759 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -366,6 +366,8 @@ work on top of the existing admission/budget/cancellation/evidence stages. | `HARNESS-CONV5` | Delivered | Breaking thin library/SDK `default` ≈ `local-code` / a3s-vec only (no `advanced-harness`, no `server`, no default `headless-search`); SDK Moli helpers cfg-gated behind `headless-search`; `a3s` CLI pins `scientific`; Advanced/server/headless are explicit features; `a3s-flow` is behind `dynamic-workflow` | `cargo check -p a3s-code-core` (default) and coding-only SDK crates compile without Advanced/server/Moli/Flow; scientific/full and product profiles documented | | `HARNESS-CONV6` | Delivered | Prompt ownership tightened: Core keeps `AgentStyle` → hard permission overlays and `SystemPromptSlots`; specialty markdown bodies documented as a replaceable default pack; hosts own rubric/reviewer prompts | Docs/API comments + hermetic/live `PROMPT-ALIGN1` gates | | `HARNESS-CONV7` | Code prep Delivered / external In progress | Code wrap-up index [HARNESS_CONVERGENCE.md](manual/HARNESS_CONVERGENCE.md); runbooks for [TB-QUAL1](manual/TERMINAL_BENCH.md), [DM-PROD1](manual/DURABLE_MEMORY_PRODUCTION_QUALIFICATION.md), [CAR checklist](manual/CLOUD_HARNESS_CONFORMANCE.md); DeepSeek/prompt live suites retained | Reproducible Harbor/host/Cloud·Box reports without secret leakage; paste links into ROADMAP when Delivered | +| `META-HARNESS1` | Delivered | Composable Meta Harness on one fact log: crates.io `a3s-effect` 0.1.0 stock parts + `HarnessGraph`; Core `SessionOptions.harness` / kernel admission; Node/Python stock `Harness.compose`; default remains `coding_actor` | Effect compose tests; `meta_harness` admission tests; SDK SessionOptions mapping; [META_HARNESS.md](manual/META_HARNESS.md) | +| `META-HARNESS2` | Planned | Node/Python host-authored Moore components / `tool({ spec, run })` analogue over the same fact log; typed host-component registry; still non-bypassable kernel | SDK custom-component surface + hermetic FFI tests; no second imperative loop | #### Harness convergence refuse list @@ -374,9 +376,16 @@ Do not accept work that: - ships a product reviewer system prompt or scientific scoring rubric inside Core; - restores `serve` / `s3` / `advanced-harness` into library or SDK `default`; - adds unified IDE approval chrome to Core to “match Cursor”; -- opens new orchestration or durable-memory dual paths after `HARNESS-CONV4`; +- opens a second *imperative* orchestration loop or durable-memory dual path after + `HARNESS-CONV4` (restoring `parallel_task` as a second engine, or a second + message loop beside fact-log `ingest` / `resume`); +- bypasses Core permission projection or the completion gate from host compose; - folds evaluation Gate semantics into the baseline coding agent loop (Advanced stays opt-in). +**Allowed:** Meta Harness multi-component trees on the single fact log +(`system` / `tools` / `budget` / `compact` / `infer` via `SessionOptions.harness` +or `a3s-effect` compose). See [META_HARNESS.md](manual/META_HARNESS.md). + First-principles packaging target: embedders that `cargo add a3s-code-core` receive a coding harness; Advanced and server surfaces require explicit features. Scientific discovery remains product composition (`scientific` / `full`), not a new runtime mode. diff --git a/core/Cargo.toml b/core/Cargo.toml index 2bafddae0..6c8cbd99e 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -52,7 +52,7 @@ required-features = ["apofasi"] [dependencies] # Internal crates -a3s-effect = { version = "0.1.0", git = "https://github.com/A3S-Lab/Effect.git", rev = "cea4766639b5b7d9c2420bf6cbe792d782acafdb" } +a3s-effect = "0.1.0" a3s-common = "0.1.1" a3s-memory = { version = "0.1.4", git = "https://github.com/A3S-Lab/Memory.git", rev = "97a5e885d196be77dc1823ad86238e77d942ed73" } a3s-lane = "0.5" diff --git a/core/src/agent.rs b/core/src/agent.rs index ed089f9f9..d6b06fe03 100644 --- a/core/src/agent.rs +++ b/core/src/agent.rs @@ -201,6 +201,8 @@ pub(crate) struct AgentConfig { pub path_rules: Vec, /// Opt-in read-only verifier. Default sessions do not spend a second model call. pub verifier_enabled: bool, + /// Optional Meta Harness compose recipe for fact-log control. + pub harness: Option, /// Host-supplied external observations bound into this run. pub external_observations: Vec, /// Outcome-conditioned constraints the promoting host has already recorded. @@ -300,6 +302,7 @@ impl Default for AgentConfig { plan_run: crate::harness_loop::PlanRunAdmission::ordinary(), path_rules: Vec::new(), verifier_enabled: false, + harness: None, external_observations: Vec::new(), outcome_ledger: crate::outcome_memory::OutcomeLedger::default(), } diff --git a/core/src/agent_api.rs b/core/src/agent_api.rs index 0853ac17e..d782cf5a2 100644 --- a/core/src/agent_api.rs +++ b/core/src/agent_api.rs @@ -487,6 +487,11 @@ pub struct SessionOptions { pub path_rules: Vec, /// Opt-in read-only verifier. Default is off so local-code spends no extra model call. pub verifier_enabled: bool, + /// Optional Meta Harness compose recipe (`components: [...]` style). + /// + /// When unset, the stock `coding_actor` tree is admitted. Kernel policy + /// (permissions + completion gate) stays Core-owned either way. + pub harness: Option, /// Typed external observations bound into the run. pub external_observations: Vec, /// Outcome ledger written by the promoting host. diff --git a/core/src/agent_api/conversation_runtime.rs b/core/src/agent_api/conversation_runtime.rs index bf991701c..a0b953526 100644 --- a/core/src/agent_api/conversation_runtime.rs +++ b/core/src/agent_api/conversation_runtime.rs @@ -1118,6 +1118,7 @@ async fn prepare_pinned( )), reports: Arc::new(std::sync::Mutex::new(Vec::new())), run_control, + harness: session.config.harness.clone(), }); if owned_run { if let Some(cancel) = parts.surface.as_ref().map(|surface| surface.cancel.clone()) { diff --git a/core/src/agent_api/session_builder.rs b/core/src/agent_api/session_builder.rs index 26efdae56..eb3a517a3 100644 --- a/core/src/agent_api/session_builder.rs +++ b/core/src/agent_api/session_builder.rs @@ -307,6 +307,7 @@ fn finish_agent_session( config.plan_run = opts.plan_run.clone(); config.path_rules = opts.path_rules.clone(); config.verifier_enabled = opts.verifier_enabled; + config.harness = opts.harness.clone(); config.external_observations = opts.external_observations.clone(); config.outcome_ledger = opts.outcome_ledger.clone(); diff --git a/core/src/agent_api/session_options.rs b/core/src/agent_api/session_options.rs index 36cffb241..3813ca81a 100644 --- a/core/src/agent_api/session_options.rs +++ b/core/src/agent_api/session_options.rs @@ -855,6 +855,12 @@ impl SessionOptions { self } + /// Admit a Meta Harness compose recipe for this session's fact-log actor. + pub fn with_harness(mut self, harness: crate::meta_harness::HarnessComposeOptions) -> Self { + self.harness = Some(harness); + self + } + pub fn with_external_observations( mut self, observations: Vec, diff --git a/core/src/fact_control.rs b/core/src/fact_control.rs index 337db91e6..a88131a25 100644 --- a/core/src/fact_control.rs +++ b/core/src/fact_control.rs @@ -9,9 +9,9 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use a3s_effect::{ - answer_fact, coding_actor, confirm_fact, ingest_coding, message_fact, resume_coding, - ActorError, CodingPhase, CodingServices, CodingView, Compactor, Completion, CompletionRequest, - Exit, FileLog, HarnessConfig, LogStore, ModelDecision, NewFact, ToolCall, ToolRunner, ToolSpec, + answer_fact, confirm_fact, ingest_coding, message_fact, resume_coding, ActorError, CodingPhase, + CodingServices, CodingView, Compactor, Completion, CompletionRequest, Exit, FileLog, + HarnessConfig, HarnessGraph, LogStore, ModelDecision, NewFact, ToolCall, ToolRunner, ToolSpec, }; use anyhow::Result; @@ -104,6 +104,7 @@ pub(crate) struct SessionSurface { pub(crate) ledger: Arc>, pub(crate) reports: Arc>>, pub(crate) run_control: Option>, + pub(crate) harness: Option, } /// Exports one portable checkpoint after a tool result lands on the log. @@ -1440,6 +1441,34 @@ impl FactRun { config: HarnessConfig, run_id: impl Into, ) -> Result { + Self::open_composed(dir, completion, tools, config, run_id, None) + } + + /// Admit an optional Meta Harness compose recipe (default = stock actor). + pub fn open_composed( + dir: impl Into, + completion: Arc, + tools: Arc, + config: HarnessConfig, + run_id: impl Into, + compose: Option<&crate::meta_harness::HarnessComposeOptions>, + ) -> Result { + let step_limit = config.step_limit(); + let (graph, _kernel) = crate::meta_harness::admit_from_compose(compose, config)?; + Self::open_with_graph(dir, completion, tools, step_limit, run_id, graph) + } + + /// Admit an explicit Meta Harness graph. Kernel policy (permissions + + /// completion gate) remains Core-owned and cannot be cleared by the graph. + pub fn open_with_graph( + dir: impl Into, + completion: Arc, + tools: Arc, + step_limit: u32, + run_id: impl Into, + graph: HarnessGraph, + ) -> Result { + let _kernel = crate::meta_harness::KernelPolicy::default().admit(); let dir = dir.into(); let capped = Arc::new(CappedCompletion { inner: completion, @@ -1450,14 +1479,12 @@ impl FactRun { tools, compactor: Arc::new(NoopCompact), }); - let limit = config.step_limit(); - let actor = coding_actor(config); Ok(Self { dir, thread: THREAD.to_string(), - actor, + actor: graph.into_actor(), services, - limit, + limit: step_limit, }) } @@ -1553,6 +1580,7 @@ impl FactRun { ) .map_err(|error| anyhow::anyhow!(error))? .with_tool_round_cap(cap); + let harness = surface.harness.clone(); let checkpoint = surface.checkpoint.map(|checkpoint| CheckpointState { sink: checkpoint.sink, run_id: checkpoint.run_id, @@ -1560,7 +1588,7 @@ impl FactRun { capability_binding: checkpoint.capability_binding, turns: Arc::new(AtomicUsize::new(0)), }); - let mut run = Self::open( + let mut run = Self::open_composed( log_dir(workspace), Arc::new(completion), Arc::new(ExecutorTools { @@ -1579,6 +1607,7 @@ impl FactRun { }), config, session_id, + harness.as_ref(), )?; run.thread = thread_for_session(session_id); Ok(run) @@ -2238,6 +2267,7 @@ mod tests { ledger: Arc::new(Mutex::new(crate::harness_loop::MutationLedger::default())), reports: Arc::new(Mutex::new(Vec::new())), run_control: None, + harness: None, }; let run = FactRun::open_projected( workspace.path(), @@ -2759,6 +2789,7 @@ mod tests { ledger: Arc::new(Mutex::new(crate::harness_loop::MutationLedger::default())), reports: Arc::new(Mutex::new(Vec::new())), run_control: None, + harness: None, }; let (completion, _) = LiveCompletion::new( client.clone(), diff --git a/core/src/lib.rs b/core/src/lib.rs index 374d97de9..24ac56af7 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -105,6 +105,7 @@ pub mod event_protocol; pub mod execution_identity; pub mod external_observation; pub mod fact_control; +pub mod meta_harness; #[cfg(feature = "dynamic-workflow")] pub mod flow_graph; pub(crate) mod git; @@ -185,6 +186,10 @@ pub use agent_api::{ Agent, AgentRunSpawn, AgentSession, ProjectedUiHandle, ReadFileOptions, SessionBuilder, SessionOptions, ToolCallResult, }; +pub use meta_harness::{ + admit_default_graph, admit_from_compose, admit_spec_graph, parse_harness_part, + parse_harness_parts, HarnessComposeOptions, HarnessPartId, KernelPolicy, +}; pub use agent_protocol::{ AgentProtocolChangeSetRequestV1, AgentProtocolChangeSetV1, AgentProtocolCommandActionV1, AgentProtocolCommandReceiptV1, AgentProtocolCommandV1, AgentProtocolError, diff --git a/core/src/meta_harness.rs b/core/src/meta_harness.rs new file mode 100644 index 000000000..0d9167788 --- /dev/null +++ b/core/src/meta_harness.rs @@ -0,0 +1,212 @@ +//! Non-bypassable Meta Harness kernel policy. +//! +//! Host composition may mount arbitrary Moore components on the fact log. +//! Permission projection and completion-gate enrollment stay Core-owned: a +//! composed graph cannot disable them. LiveCompletion already strips the +//! model-facing catalog; [`crate::harness_loop`] still decides mutating +//! success. This module records the admission contract for a +//! [`a3s_effect::HarnessGraph`]. + +use a3s_effect::{HarnessConfig, HarnessGraph, MetaHarnessSpec, ToolSpec}; + +pub use a3s_effect::HarnessPartId; + +/// Host-facing compose recipe for SessionOptions / SDKs. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HarnessComposeOptions { + /// Tool-call budget for the stock scheduler (`budget` part). + pub tool_budget: Option, + /// Compaction character threshold (`compact` part). + pub compact_after_chars: Option, + /// Extra system prompts merged into the `system` part. + pub system: Vec, + /// Ordered stock parts. Empty selects the full stock tree. + pub parts: Vec, +} + +impl HarnessComposeOptions { + pub fn to_spec(&self, tools: Vec, defaults: &HarnessConfig) -> MetaHarnessSpec { + MetaHarnessSpec { + name: "a3s-code", + budget: self.tool_budget.unwrap_or_else(|| defaults.budget()), + compact_after_chars: self + .compact_after_chars + .unwrap_or_else(|| defaults.compact_after_chars()), + step_limit: defaults.step_limit(), + model_attempts: defaults.model_attempts(), + system: if self.system.is_empty() { + defaults.system().to_vec() + } else { + self.system.clone() + }, + tools, + tool_round_cap: defaults.tool_round_cap(), + parts: self.parts.clone(), + } + } + + /// Build from SDK stock part names (`system`, `tools`, `budget`, `compact`, `infer`). + pub fn compose( + parts: Vec, + tool_budget: Option, + compact_after_chars: Option, + system: Vec, + ) -> anyhow::Result { + Ok(Self { + tool_budget, + compact_after_chars, + system, + parts: parse_harness_parts(&parts)?, + }) + } +} + +/// Parse a single stock Meta Harness part id. +pub fn parse_harness_part(name: &str) -> anyhow::Result { + match name.trim().to_ascii_lowercase().as_str() { + "system" => Ok(HarnessPartId::System), + "tools" => Ok(HarnessPartId::Tools), + "budget" => Ok(HarnessPartId::Budget), + "compact" => Ok(HarnessPartId::Compact), + "infer" => Ok(HarnessPartId::Infer), + other => anyhow::bail!( + "unknown harness part '{other}'; expected system|tools|budget|compact|infer" + ), + } +} + +/// Parse ordered stock part names into [`HarnessPartId`] values. +pub fn parse_harness_parts(parts: &[String]) -> anyhow::Result> { + parts.iter().map(|part| parse_harness_part(part)).collect() +} + +/// Kernel flags that host components cannot clear. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KernelPolicy { + /// Permission overlay strips tool definitions before the model sees them. + pub permission_overlay: bool, + /// Mutating runs require verification / host waiver bound to an effect digest. + pub completion_gate: bool, +} + +impl Default for KernelPolicy { + fn default() -> Self { + Self { + permission_overlay: true, + completion_gate: true, + } + } +} + +impl KernelPolicy { + /// Hosts may not construct a policy that disables governance. + pub fn admit(self) -> Self { + Self { + permission_overlay: true, + completion_gate: true, + } + } +} + +/// Build the default graph under kernel policy. +pub fn admit_default_graph(config: HarnessConfig) -> (HarnessGraph, KernelPolicy) { + (HarnessGraph::coding(config), KernelPolicy::default().admit()) +} + +/// Build a graph from a Meta Harness spec under kernel policy. +pub fn admit_spec_graph(spec: MetaHarnessSpec) -> anyhow::Result<(HarnessGraph, KernelPolicy)> { + let config = spec + .clone() + .into_config() + .map_err(|error| anyhow::anyhow!(error))?; + let graph = HarnessGraph::from_spec(spec, || a3s_effect::coding_scheduler(config.clone())); + Ok((graph, KernelPolicy::default().admit())) +} + +/// Resolve SessionOptions harness compose into an admitted graph. +pub fn admit_from_compose( + compose: Option<&HarnessComposeOptions>, + config: HarnessConfig, +) -> anyhow::Result<(HarnessGraph, KernelPolicy)> { + match compose { + None => Ok(admit_default_graph(config)), + Some(options) => { + let tools = config.tools().to_vec(); + let spec = options.to_spec(tools, &config); + admit_spec_graph(spec) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kernel_policy_cannot_disable_governance() { + let cleared = KernelPolicy { + permission_overlay: false, + completion_gate: false, + }; + let admitted = cleared.admit(); + assert!(admitted.permission_overlay); + assert!(admitted.completion_gate); + } + + #[test] + fn default_admission_builds_stock_graph() { + let config = + HarnessConfig::new(4, 1_000, 8, 1, vec!["s".into()], vec![]).expect("config"); + let (graph, policy) = admit_default_graph(config); + assert_eq!(graph.actor().name, "a3s-code"); + assert!(policy.permission_overlay); + assert!(policy.completion_gate); + } + + #[test] + fn compose_options_override_budget() { + let defaults = HarnessConfig::new(8, 1_000, 8, 1, vec![], vec![]).expect("config"); + let options = HarnessComposeOptions { + tool_budget: Some(3), + compact_after_chars: Some(50), + system: vec!["compose".into()], + parts: vec![HarnessPartId::System, HarnessPartId::Infer], + }; + let spec = options.to_spec(Vec::new(), &defaults); + assert_eq!(spec.budget, 3); + assert_eq!(spec.compact_after_chars, 50); + assert_eq!(spec.system, vec!["compose".to_string()]); + assert_eq!( + spec.parts, + vec![HarnessPartId::System, HarnessPartId::Infer] + ); + } + + #[test] + fn compose_parses_stock_part_names() { + let options = HarnessComposeOptions::compose( + vec![ + "system".into(), + "tools".into(), + "budget".into(), + "compact".into(), + "infer".into(), + ], + Some(2), + Some(10), + vec!["s".into()], + ) + .expect("compose"); + assert_eq!( + options.parts, + vec![ + HarnessPartId::System, + HarnessPartId::Tools, + HarnessPartId::Budget, + HarnessPartId::Compact, + HarnessPartId::Infer, + ] + ); + assert!(parse_harness_part("parallel_task").is_err()); + } +} diff --git a/manual/HARNESS_CONVERGENCE.md b/manual/HARNESS_CONVERGENCE.md index 903784587..c43c6e61e 100644 --- a/manual/HARNESS_CONVERGENCE.md +++ b/manual/HARNESS_CONVERGENCE.md @@ -16,8 +16,11 @@ Layer A–D verification matrix used by the E2E goal. | `CAR-01`…`CAR-05` | Checklist ready | [CLOUD_HARNESS_CONFORMANCE.md](CLOUD_HARNESS_CONFORMANCE.md) | Refuse list (do not reopen): Core reviewer/rubric prompts; restore `server` / -`advanced-harness` to library default; Core unified approval UI; new dual -orchestration/memory paths; baseline evaluation Gate semantics. +`advanced-harness` to library default; Core unified approval UI; a second +*imperative* message loop or completion-gate / permission bypass; baseline +evaluation Gate semantics. **Allowed:** multi-component Meta Harness trees on +one fact log (`SessionOptions.harness` / `a3s-effect` compose) — +see [META_HARNESS.md](META_HARNESS.md). ## Local verification (Code) diff --git a/manual/META_HARNESS.md b/manual/META_HARNESS.md new file mode 100644 index 000000000..c5e43b7b1 --- /dev/null +++ b/manual/META_HARNESS.md @@ -0,0 +1,106 @@ +# Meta Harness: Composable Harness Components + +A3S Code is a **Meta Harness**: hosts compose coding behavior as a Moore +component tree over one immutable fact log. Governance (permission projection +and completion-gate enrollment) stays Core-owned and non-bypassable. + +Related: [HARNESS_CONVERGENCE.md](HARNESS_CONVERGENCE.md), +[ADR-003](../../use/docs/adr-003-native-code-harness-boundary.md) (addendum), +`a3s-effect` `compose` module. + +## Positioning + +```text +{ view, transitions } = f(log) +``` + +Hosts mount stock or custom components. The runtime folds the log, enables +keyed transitions, and runs them until nothing remains. Dual *imperative* +message loops are forbidden; **one log + many components** is the intended +orchestration model. + +Product default remains today's `coding_actor` sugar so existing Agent / +Session behavior does not regress when `SessionOptions.harness` is omitted. + +## Invariants + +1. Single fact log is the only control source (`ingest` / `resume`). +2. Components are Moore machines: `initial` / `step` / `output` → view + keyed + transitions (`a3s-effect` actor). +3. Transition keys are unique across the tree (`DuplicateTransition`); finished + keys are not re-run. +4. Confirm / question park until answer facts (no timers). +5. Kernel wrappers always apply: permission strip before the model catalog; + mutating success still requires the completion gate / host waiver bound to + an effect digest. Host components cannot disable these. + +## Stock components + +| Part id | Role | +| --- | --- | +| `system` | System-prompt slot | +| `tools` | Tool-catalog slot | +| `budget` | Tool-call budget policy | +| `compact` | Compaction threshold slot | +| `infer` | Scheduler subtree (stock mount has **no** key prefix for log stability) | + +Nested host `infer([...])` mounts **do** namespace child keys under `infer/` so +siblings cannot collide. CI / unit tests assert that discipline. + +## Composition cookbook + +### Rust (`a3s-effect`) + +```rust +use a3s_effect::{budget, coding_actor, compact, compose_coding_actor, system, tools, HarnessGraph}; + +// Sugar for the stock tree (bit-compatible default): +let actor = coding_actor(config); + +// Explicit graph from a MetaHarnessSpec / HarnessGraph: +let graph = HarnessGraph::coding(config); +``` + +### SessionOptions (Core / SDKs) + +Omit `harness` → legacy `coding_actor`. + +```js +// Node +agent.session('.', { + harness: Harness.compose({ + parts: [Harness.system(), Harness.tools(), Harness.budget(), Harness.compact(), Harness.infer()], + toolBudget: 4, + system: ['You are a careful coding agent.'], + }), +}); +``` + +```python +# Python +opts = SessionOptions() +opts.harness = Harness.compose( + parts=[Harness.system(), Harness.tools(), Harness.budget(), Harness.compact(), Harness.infer()], + tool_budget=4, + system=["You are a careful coding agent."], +) +``` + +Unknown part names fail closed at compose / session conversion. + +## Non-goals + +- Porting Tardigrade / Effect TS into Code. +- Letting host components forge completion waivers or skip permission projection. +- Restoring `parallel_task` as a second orchestration engine. +- Making `advanced-harness` the library default. +- Embedding DSH / Cordis / a JavaScript Effect runtime. +- Node/Python host-authored Moore components in this cut (`META-HARNESS2`). + Rust already supports `compose_coding_actor(vec![component(...), ...])`. + +## Safety checks + +- Default composition == today's `coding_actor` until hosts opt in. +- Kernel middleware must still strip permissions and enroll the completion gate + even if a malicious host component enables `model.turn` without them. +- Nested `infer` mounts must not advertise un-prefixed child transition keys. diff --git a/sdk/node/Cargo.lock b/sdk/node/Cargo.lock index 8fd04bdf8..51d83bb89 100644 --- a/sdk/node/Cargo.lock +++ b/sdk/node/Cargo.lock @@ -20,10 +20,11 @@ dependencies = [ [[package]] name = "a3s-code-core" -version = "8.7.0" +version = "9.0.0" dependencies = [ "a3s-acl 0.3.0", "a3s-common", + "a3s-effect", "a3s-flow", "a3s-lane", "a3s-memory", @@ -83,7 +84,7 @@ dependencies = [ [[package]] name = "a3s-code-node" -version = "8.7.0" +version = "9.0.0" dependencies = [ "a3s-code-core", "anyhow", @@ -115,6 +116,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "a3s-effect" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "044abc27a2da3caeb2c3636fd80b97b22f422034e46618a5ba2073397573b07c" +dependencies = [ + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", + "tokio", + "tokio-util", +] + [[package]] name = "a3s-flow" version = "1.1.0" @@ -280,9 +295,9 @@ dependencies = [ [[package]] name = "a3s-vec" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15c4220df078de9c350aea98e0f9187890cec066e4d3ee242170a2ab762ed80f" +checksum = "755d3beefad8e7662372f5d6e3b049346ce48ae6329f622a82fd0e97c5cedf95" dependencies = [ "bincode", "bitmaps", @@ -301,7 +316,6 @@ dependencies = [ "thiserror 1.0.69", "unicode-general-category", "unicode-normalization", - "zvec-core", ] [[package]] @@ -4949,6 +4963,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "libc", "pin-project-lite", "tokio", @@ -5999,16 +6014,3 @@ dependencies = [ "log", "simd-adler32", ] - -[[package]] -name = "zvec-core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "405f855596d420dee8f61a86e6fa2b84cca6fe493095338f241aafb3ea88d235" -dependencies = [ - "crc32fast", - "rayon", - "serde", - "serde_json", - "thiserror 1.0.69", -] diff --git a/sdk/node/src/session_options.rs b/sdk/node/src/session_options.rs index c217983a2..66b985a2f 100644 --- a/sdk/node/src/session_options.rs +++ b/sdk/node/src/session_options.rs @@ -115,6 +115,91 @@ pub struct AutoDelegationOptions { pub max_tasks: Option, } +/// Host-facing Meta Harness compose recipe. +/// +/// Omit `SessionOptions.harness` to keep the legacy `coding_actor` stock tree. +/// When set, ordered `parts` select stock Moore components (`system`, `tools`, +/// `budget`, `compact`, `infer`). Kernel permission overlay and completion gate +/// stay Core-owned and cannot be disabled from this object. +#[napi(object)] +#[derive(Default, Clone)] +pub struct HarnessComposeOptions { + /// Tool-call budget for the stock scheduler (`budget` part). + pub tool_budget: Option, + /// Compaction character threshold (`compact` part). + pub compact_after_chars: Option, + /// Extra system prompts merged into the `system` part. + pub system: Option>, + /// Ordered stock parts: `system`, `tools`, `budget`, `compact`, `infer`. + /// Empty / omitted selects the full stock tree. + pub parts: Option>, +} + +/// Typed Meta Harness builders for SessionOptions.harness. +/// +/// ```js +/// agent.session('.', { +/// harness: Harness.compose({ +/// parts: [Harness.system, Harness.tools, Harness.budget, Harness.compact, Harness.infer], +/// toolBudget: 4, +/// }), +/// }); +/// ``` +#[napi] +pub struct Harness; + +#[napi] +impl Harness { + /// Stock `system` part id. + #[napi] + pub fn system() -> String { + "system".into() + } + + /// Stock `tools` catalog part id. + #[napi] + pub fn tools() -> String { + "tools".into() + } + + /// Stock `budget` part id. + #[napi] + pub fn budget() -> String { + "budget".into() + } + + /// Stock `compact` part id. + #[napi] + pub fn compact() -> String { + "compact".into() + } + + /// Stock `infer` / scheduler part id. + #[napi] + pub fn infer() -> String { + "infer".into() + } + + /// Validate and return a compose recipe for `SessionOptions.harness`. + #[napi] + pub fn compose(options: HarnessComposeOptions) -> napi::Result { + let _ = js_harness_to_core(&options)?; + Ok(options) + } +} + +fn js_harness_to_core( + options: &HarnessComposeOptions, +) -> napi::Result { + a3s_code_core::HarnessComposeOptions::compose( + options.parts.clone().unwrap_or_default(), + options.tool_budget, + options.compact_after_chars.map(|value| value as usize), + options.system.clone().unwrap_or_default(), + ) + .map_err(|error| napi::Error::from_reason(error.to_string())) +} + /// Host-provided deterministic ID and clock configuration. /// /// Set both fields when replaying a run so session/run IDs and timestamps are @@ -368,6 +453,17 @@ pub struct SessionOptions { pub allow_process_host_sandbox: Option, /// Opt-in read-only verifier. Default is off. pub verifier_enabled: Option, + /// Meta Harness compose recipe. Omit for the legacy `coding_actor` stock tree. + /// + /// ```js + /// agent.session('.', { + /// harness: Harness.compose({ + /// parts: [Harness.system, Harness.tools, Harness.budget, Harness.compact, Harness.infer], + /// toolBudget: 4, + /// }), + /// }); + /// ``` + pub harness: Option, /// HITL confirmation policy configuration. /// /// Pass a confirmation policy to enable Human-in-the-Loop confirmation for tool execution. @@ -1234,6 +1330,9 @@ fn apply_host_contract_options( if let Some(enabled) = options.verifier_enabled { opts = opts.with_verifier(enabled); } + if let Some(harness) = &options.harness { + opts = opts.with_harness(js_harness_to_core(harness)?); + } Ok(opts) } diff --git a/sdk/node/src/tests.rs b/sdk/node/src/tests.rs index 351d96ab6..fede3453b 100644 --- a/sdk/node/src/tests.rs +++ b/sdk/node/src/tests.rs @@ -588,6 +588,47 @@ fn verification_report_json() -> serde_json::Value { }) } +#[test] +fn harness_compose_maps_to_rust_session_options() { + let opts = js_session_options_to_rust(Some(SessionOptions { + harness: Some(HarnessComposeOptions { + tool_budget: Some(3), + compact_after_chars: Some(64), + system: Some(vec!["compose".into()]), + parts: Some(vec![ + "system".into(), + "tools".into(), + "budget".into(), + "infer".into(), + ]), + }), + ..Default::default() + })) + .unwrap(); + let harness = opts.harness.expect("harness"); + assert_eq!(harness.tool_budget, Some(3)); + assert_eq!(harness.compact_after_chars, Some(64)); + assert_eq!(harness.system, vec!["compose".to_string()]); + assert_eq!( + harness.parts, + vec![ + a3s_code_core::HarnessPartId::System, + a3s_code_core::HarnessPartId::Tools, + a3s_code_core::HarnessPartId::Budget, + a3s_code_core::HarnessPartId::Infer, + ] + ); + + let invalid = js_session_options_to_rust(Some(SessionOptions { + harness: Some(HarnessComposeOptions { + parts: Some(vec!["parallel_task".into()]), + ..Default::default() + }), + ..Default::default() + })); + assert!(invalid.is_err()); +} + #[test] fn artifact_store_limits_maps_to_rust_session_options() { let opts = js_session_options_to_rust(Some(SessionOptions { diff --git a/sdk/python/CHANGELOG.md b/sdk/python/CHANGELOG.md index 7ca8e8f55..866ae1dd0 100644 --- a/sdk/python/CHANGELOG.md +++ b/sdk/python/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to the A3S Code Python SDK will be documented in this file. ## [Unreleased] +- Meta Harness: ``SessionOptions.harness`` and ``Harness.compose`` for stock + parts (``system``, ``tools``, ``budget``, ``compact``, ``infer``). Omit the + option to keep the legacy ``coding_actor`` tree. +- Expose ``SessionOptions.verifier_enabled`` getter/setter so Python hosts can + opt into the Core read-only verifier (#163). + ## [8.7.0] - 2026-09-21 - Bundled Core 8.7.0: removed filesystem-first `serve` / `AgentDir` diff --git a/sdk/python/Cargo.lock b/sdk/python/Cargo.lock index 7088815bd..5675427c7 100644 --- a/sdk/python/Cargo.lock +++ b/sdk/python/Cargo.lock @@ -118,7 +118,8 @@ dependencies = [ [[package]] name = "a3s-effect" version = "0.1.0" -source = "git+https://github.com/A3S-Lab/Effect.git?rev=cea4766639b5b7d9c2420bf6cbe792d782acafdb#cea4766639b5b7d9c2420bf6cbe792d782acafdb" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "044abc27a2da3caeb2c3636fd80b97b22f422034e46618a5ba2073397573b07c" dependencies = [ "serde", "serde_json", @@ -446,7 +447,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -457,7 +458,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1614,7 +1615,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -1765,7 +1766,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.3", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1883,7 +1884,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2602,7 +2603,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "tokio", "tower-service", "tracing", @@ -3201,7 +3202,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3746,7 +3747,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.45", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.20", "tokio", "tracing", @@ -3784,9 +3785,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4174,7 +4175,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4625,7 +4626,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4782,7 +4783,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5509,7 +5510,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/sdk/python/src/module_registration.rs b/sdk/python/src/module_registration.rs index eb3957055..e746edeaa 100644 --- a/sdk/python/src/module_registration.rs +++ b/sdk/python/src/module_registration.rs @@ -19,6 +19,8 @@ pub(super) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/sdk/python/src/session_config.rs b/sdk/python/src/session_config.rs index b7312c9eb..ce36fb7a1 100644 --- a/sdk/python/src/session_config.rs +++ b/sdk/python/src/session_config.rs @@ -51,6 +51,125 @@ impl PyHostEnvConfig { } } +/// Meta Harness compose recipe for ``SessionOptions.harness``. +/// +/// Omit the option to keep the legacy ``coding_actor`` stock tree. When set, +/// ordered ``parts`` select stock Moore components. Kernel permission overlay +/// and completion gate stay Core-owned. +#[pyclass(name = "HarnessComposeOptions")] +#[derive(Clone, Default)] +pub(super) struct PyHarnessComposeOptions { + /// Tool-call budget for the stock scheduler (``budget`` part). + #[pyo3(get, set)] + pub(super) tool_budget: Option, + /// Compaction character threshold (``compact`` part). + #[pyo3(get, set)] + pub(super) compact_after_chars: Option, + /// Extra system prompts merged into the ``system`` part. + #[pyo3(get, set)] + pub(super) system: Vec, + /// Ordered stock parts: ``system``, ``tools``, ``budget``, ``compact``, ``infer``. + #[pyo3(get, set)] + pub(super) parts: Vec, +} + +#[pymethods] +impl PyHarnessComposeOptions { + #[new] + #[pyo3(signature = (tool_budget=None, compact_after_chars=None, system=None, parts=None))] + fn new( + tool_budget: Option, + compact_after_chars: Option, + system: Option>, + parts: Option>, + ) -> Self { + Self { + tool_budget, + compact_after_chars, + system: system.unwrap_or_default(), + parts: parts.unwrap_or_default(), + } + } + + fn __repr__(&self) -> String { + format!( + "HarnessComposeOptions(tool_budget={:?}, compact_after_chars={:?}, system={:?}, parts={:?})", + self.tool_budget, self.compact_after_chars, self.system, self.parts + ) + } +} + +impl PyHarnessComposeOptions { + pub(super) fn to_core(&self) -> PyResult { + a3s_code_core::HarnessComposeOptions::compose( + self.parts.clone(), + self.tool_budget, + self.compact_after_chars, + self.system.clone(), + ) + .map_err(|error| PyValueError::new_err(error.to_string())) + } +} + +/// Typed Meta Harness builders for ``SessionOptions.harness``. +/// +/// .. code-block:: python +/// +/// opts = SessionOptions() +/// opts.harness = Harness.compose( +/// parts=[Harness.system(), Harness.tools(), Harness.budget(), Harness.compact(), Harness.infer()], +/// tool_budget=4, +/// ) +#[pyclass(name = "Harness")] +pub(super) struct PyHarness; + +#[pymethods] +impl PyHarness { + #[staticmethod] + pub(super) fn system() -> String { + "system".into() + } + + #[staticmethod] + pub(super) fn tools() -> String { + "tools".into() + } + + #[staticmethod] + pub(super) fn budget() -> String { + "budget".into() + } + + #[staticmethod] + pub(super) fn compact() -> String { + "compact".into() + } + + #[staticmethod] + pub(super) fn infer() -> String { + "infer".into() + } + + /// Validate and return a compose recipe for ``SessionOptions.harness``. + #[staticmethod] + #[pyo3(signature = (parts=None, tool_budget=None, compact_after_chars=None, system=None))] + pub(super) fn compose( + parts: Option>, + tool_budget: Option, + compact_after_chars: Option, + system: Option>, + ) -> PyResult { + let options = PyHarnessComposeOptions { + tool_budget, + compact_after_chars, + system: system.unwrap_or_default(), + parts: parts.unwrap_or_default(), + }; + let _ = options.to_core()?; + Ok(options) + } +} + /// Explicit allow/deny/ask tool permission policy. #[pyclass(name = "PermissionPolicy")] #[derive(Clone)] diff --git a/sdk/python/src/session_options.rs b/sdk/python/src/session_options.rs index 3ebef568c..fc90c8810 100644 --- a/sdk/python/src/session_options.rs +++ b/sdk/python/src/session_options.rs @@ -155,6 +155,8 @@ pub(super) struct PySessionOptions { pub(super) allow_process_host_sandbox: Option, /// Opt-in read-only verifier. Default is off. pub(super) verifier_enabled: Option, + /// Meta Harness compose recipe. Omit for the legacy ``coding_actor`` stock tree. + pub(super) harness: Option, /// Optional Python-side BudgetGuard. The framework calls /// `check_before_llm(session_id, estimated_tokens)`, /// `record_after_llm(session_id, usage_dict)`, and @@ -287,6 +289,7 @@ impl Clone for PySessionOptions { read_only_session: self.read_only_session, allow_process_host_sandbox: self.allow_process_host_sandbox, verifier_enabled: self.verifier_enabled, + harness: self.harness.clone(), budget_guard: pyo3::Python::with_gil(|py| { self.budget_guard.as_ref().map(|o| o.clone_ref(py)) }), @@ -378,6 +381,7 @@ impl PySessionOptions { read_only_session: None, allow_process_host_sandbox: None, verifier_enabled: None, + harness: None, budget_guard: None, budget_guard_timeout_ms: DEFAULT_BUDGET_GUARD_TIMEOUT_MS, immutable_content_adapter: None, @@ -1075,6 +1079,30 @@ impl PySessionOptions { self.allow_process_host_sandbox = value; } + /// Opt-in read-only verifier. Default is off (`None` / unset). + #[getter] + fn get_verifier_enabled(&self) -> Option { + self.verifier_enabled + } + + #[setter] + fn set_verifier_enabled(&mut self, value: Option) { + self.verifier_enabled = value; + } + + /// Meta Harness compose recipe. Omit for the legacy ``coding_actor`` stock tree. + /// + /// Prefer ``Harness.compose(...)`` so stock part names are validated early. + #[getter] + fn get_harness(&self) -> Option { + self.harness.clone() + } + + #[setter] + fn set_harness(&mut self, value: Option) { + self.harness = value; + } + /// Host-supplied BudgetGuard. Any Python object implementing some /// subset of `check_before_llm` / `record_after_llm` / /// `check_before_tool`. The framework calls these around every diff --git a/sdk/python/src/session_options_conversion.rs b/sdk/python/src/session_options_conversion.rs index de21f4664..83e877881 100644 --- a/sdk/python/src/session_options_conversion.rs +++ b/sdk/python/src/session_options_conversion.rs @@ -485,6 +485,9 @@ fn apply_py_host_contract_options( if let Some(enabled) = source.verifier_enabled { options = options.with_verifier(enabled); } + if let Some(harness) = &source.harness { + options = options.with_harness(harness.to_core()?); + } Ok(options) } diff --git a/sdk/python/src/tests.rs b/sdk/python/src/tests.rs index c19fbd957..f4e696dcc 100644 --- a/sdk/python/src/tests.rs +++ b/sdk/python/src/tests.rs @@ -798,6 +798,42 @@ fn session_options_map_model_context_window() { assert_eq!(opts.max_context_tokens, Some(128_000)); } +#[test] +fn session_options_map_harness_compose() { + let mut session_options = PySessionOptions::new(); + session_options.harness = Some(PyHarnessComposeOptions { + tool_budget: Some(3), + compact_after_chars: Some(64), + system: vec!["compose".to_string()], + parts: vec![ + "system".into(), + "tools".into(), + "budget".into(), + "infer".into(), + ], + }); + let opts = build_rust_session_options(session_options).unwrap(); + let harness = opts.harness.expect("harness"); + assert_eq!(harness.tool_budget, Some(3)); + assert_eq!(harness.compact_after_chars, Some(64)); + assert_eq!(harness.system, vec!["compose".to_string()]); + assert_eq!( + harness.parts, + vec![ + a3s_code_core::HarnessPartId::System, + a3s_code_core::HarnessPartId::Tools, + a3s_code_core::HarnessPartId::Budget, + a3s_code_core::HarnessPartId::Infer, + ] + ); + + let invalid = PyHarnessComposeOptions { + parts: vec!["parallel_task".into()], + ..PyHarnessComposeOptions::default() + }; + assert!(invalid.to_core().is_err()); +} + #[test] fn session_options_reject_zero_model_context_window() { pyo3::prepare_freethreaded_python(); From 0d4b9fec167d1439882e1e125fade57097d702af Mon Sep 17 00:00:00 2001 From: RoyLin <18770221825@163.com> Date: Fri, 25 Sep 2026 23:11:09 +0800 Subject: [PATCH 2/6] Fix NO_PROXY, verifier getters, and rolling Goal pin. Honor NO_PROXY on explicit HTTP(S)_PROXY clients (#171), expose Python SessionOptions.verifier_enabled (#163), and mechanically re-pin ## Goal across rolling compaction when the summarizer drops it (#174). Harden the Layer C streaming tool-name live gate so the second turn must read a host-only secret. Co-authored-by: Cursor --- core/prompts/common/context_compact.md | 7 +- core/src/compaction.rs | 235 +++++++++++++++++- core/src/llm/http.rs | 57 ++++- core/src/prompts.rs | 2 +- core/tests/test_issue_fix_live_e2e.rs | 12 +- .../tests/test_session_options_verifier.py | 25 ++ 6 files changed, 328 insertions(+), 10 deletions(-) create mode 100644 sdk/python/tests/test_session_options_verifier.py diff --git a/core/prompts/common/context_compact.md b/core/prompts/common/context_compact.md index 8a0f01139..fb99b85d2 100644 --- a/core/prompts/common/context_compact.md +++ b/core/prompts/common/context_compact.md @@ -4,7 +4,9 @@ of what has already happened. Requirements: - Keep the summary under 600 words. -- Preserve the **original user goal** verbatim in a "Goal" section. +- Preserve the **original user goal** verbatim in a "Goal" section. When a pinned + goal is supplied below, copy it exactly into `## Goal` — do not paraphrase, + shorten, or replace it with later assistant narration. - List every file that was **read, written, or modified** with a one-line note on what changed. - List every **shell command** that was run and its outcome (success / error / output @@ -37,5 +39,8 @@ Output format: --- +Pinned original goal (copy verbatim under ## Goal; leave empty only if unknown): +{goal} + Conversation: {conversation} diff --git a/core/src/compaction.rs b/core/src/compaction.rs index 6284369ae..98b128951 100644 --- a/core/src/compaction.rs +++ b/core/src/compaction.rs @@ -157,9 +157,16 @@ pub(crate) async fn compact_messages( .clamp(512, 600_000); let conversation_text = truncate_middle(&conversation_text, max_summary_chars); + // Pin the original goal outside the LLM path. Rolling compaction must not + // drop paths/constraints when a later summary forgets the ## Goal section. + let pinned_goal = extract_pinned_goal(messages); + let goal_for_prompt = pinned_goal.as_deref().unwrap_or(""); let summarization_prompt = crate::prompts::render( crate::prompts::CONTEXT_COMPACT, - &[("conversation", &conversation_text)], + &[ + ("goal", goal_for_prompt), + ("conversation", &conversation_text), + ], ); // Call LLM to generate summary @@ -179,7 +186,10 @@ pub(crate) async fn compact_messages( .message_token_limit .saturating_sub(summary_overhead) .clamp(1, MAX_COMPACT_SUMMARY_TOKENS); + // Pin the goal after truncation so a token trim cannot drop it, and so a + // second rewrite does not treat freeform summary text as the Goal body. let summary_text = truncate_summary_to_token_limit(summary_text.trim(), summary_token_limit); + let summary_text = ensure_goal_section(&summary_text, pinned_goal.as_deref()); tracing::debug!("Generated summary: {} chars", summary_text.len()); let summary_message = Message::user_wire(&format!( @@ -305,6 +315,109 @@ fn truncate_summary_to_token_limit(summary: &str, token_limit: usize) -> String ) } +/// Prefer a previously pinned `## Goal` body; otherwise the first product user turn. +fn extract_pinned_goal(messages: &[Message]) -> Option { + for message in messages.iter().rev() { + if message.role != "user" { + continue; + } + let text = message.text(); + if let Some(goal) = goal_section_body(&text) { + return Some(goal); + } + } + + messages.iter().find_map(|message| { + if message.role != "user" || !message.is_product_transcript() { + return None; + } + let text = message.text(); + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + // Skip prior compact summaries that lost their Goal section. + if trimmed.starts_with(crate::prompts::CONTEXT_SUMMARY_PREFIX.trim()) + || trimmed.contains("[Context Summary:") + { + return None; + } + Some(trimmed.to_string()) + }) +} + +fn goal_section_body(text: &str) -> Option { + let marker = "## Goal"; + let start = text.find(marker)?; + let after = &text[start + marker.len()..]; + let after = after.strip_prefix('\r').unwrap_or(after); + let after = after.strip_prefix('\n').unwrap_or(after); + let end = after + .find("\n## ") + .or_else(|| after.find("\n# ")) + .unwrap_or(after.len()); + let body = after[..end].trim(); + if body.is_empty() { + None + } else { + Some(body.to_string()) + } +} + +/// Force a durable `## Goal` section when a pinned goal is known. +fn ensure_goal_section(summary: &str, pinned_goal: Option<&str>) -> String { + let Some(goal) = pinned_goal.map(str::trim).filter(|goal| !goal.is_empty()) else { + return summary.to_string(); + }; + + let trimmed = summary.trim(); + let goal_block = format!("## Goal\n{goal}"); + if trimmed == goal_block || trimmed.starts_with(&format!("{goal_block}\n")) { + return trimmed.to_string(); + } + + let remainder = strip_goal_section(trimmed); + if remainder.is_empty() { + goal_block + } else if remainder.starts_with("## ") { + format!("{goal_block}\n\n{remainder}") + } else { + // Keep freeform model text under a heading so the Goal body stays a + // single extractable section across rolling compaction. + format!("{goal_block}\n\n## Summary\n{remainder}") + } +} + +/// Remove an existing `## Goal` section so a pinned goal can replace it. +fn strip_goal_section(text: &str) -> String { + let marker = "## Goal"; + let Some(start) = text.find(marker) else { + return text.to_string(); + }; + + let after = &text[start + marker.len()..]; + let after = after.strip_prefix('\r').unwrap_or(after); + let after = after.strip_prefix('\n').unwrap_or(after); + let rest_rel = after + .find("\n## ") + .or_else(|| after.find("\n# ")) + .unwrap_or(after.len()); + + let prefix = text[..start].trim(); + let suffix = if rest_rel < after.len() { + after[rest_rel..].trim_start_matches('\n').trim() + } else { + // No following heading: drop the Goal-only block, keep prefix only. + "" + }; + + [prefix, suffix] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n\n") +} + fn safe_recent_start(messages: &[Message], desired_start: usize) -> usize { if desired_start == 0 || desired_start >= messages.len() { return desired_start.min(messages.len()); @@ -939,7 +1052,18 @@ mod tests { .unwrap() .expect("history should compact"); - assert_eq!(compacted.summary, "durable compact summary"); + assert!( + compacted.summary.contains("## Goal"), + "pinned product goal must survive even when the model omits it" + ); + assert!( + compacted.summary.contains("history"), + "first product user turn is the pinned goal" + ); + assert!( + compacted.summary.contains("durable compact summary"), + "model summary body must remain after Goal pinning" + ); assert_eq!(compacted.messages[0].role, "user"); assert!( !compacted.messages[0].is_product_transcript(), @@ -949,12 +1073,119 @@ mod tests { assert!(prompts[0].contains("cargo test -p a3s-code-core")); assert!(prompts[0].contains("all 42 tests passed")); assert!(prompts[0].contains("latest verified state must survive")); + assert!( + prompts[0].contains("Pinned original goal"), + "compaction prompt must surface the pinned goal to the model" + ); let systems = client.systems.lock().unwrap(); assert!(systems[0] .as_deref() .is_some_and(|system| system.contains("untrusted data"))); } + #[tokio::test] + async fn rolling_compaction_reinserts_goal_when_model_drops_it() { + let original_goal = "Write /app/build_part.py and save the part to /app/part.FCStd using PartDesign"; + let prior_summary = format!( + "{}## Goal\n{original_goal}\n\n## Current State\nScanned drawings; ambiguities remain.", + crate::prompts::CONTEXT_SUMMARY_PREFIX + ); + let mut messages = vec![Message::user_wire(&prior_summary)]; + for i in 0..30 { + messages.push(make_text_msg( + if i % 2 == 0 { "assistant" } else { "user" }, + &format!("pixel scan step {i}"), + )); + } + + struct GoalDroppingClient; + #[async_trait::async_trait] + impl LlmClient for GoalDroppingClient { + async fn complete( + &self, + _messages: &[Message], + _system: Option<&str>, + _tools: &[ToolDefinition], + ) -> Result { + Ok(LlmResponse { + message: Message::assistant( + "Looking at the full drawing, I can now see the overall layout clearly.", + ), + usage: TokenUsage::default(), + stop_reason: Some("stop".to_string()), + token_logprobs: Vec::new(), + meta: None, + }) + } + + async fn complete_streaming( + &self, + _messages: &[Message], + _system: Option<&str>, + _tools: &[ToolDefinition], + _cancel_token: tokio_util::sync::CancellationToken, + ) -> Result> { + anyhow::bail!("streaming is not used by compaction") + } + } + + let llm_client: Arc = Arc::new(GoalDroppingClient); + let compacted = compact_messages( + "goal-pin", + &messages, + &llm_client, + CompactionBudget::for_auto_compaction(128_000, 0.85, 0), + ) + .await + .unwrap() + .expect("history should compact"); + + let goal = goal_section_body(&compacted.summary).expect("## Goal must be present"); + assert_eq!(goal, original_goal); + assert!(compacted.summary.contains("/app/build_part.py")); + assert!(compacted.summary.contains("/app/part.FCStd")); + assert!(compacted.summary.contains("PartDesign")); + } + + #[test] + fn ensure_goal_section_replaces_wrong_goal_body() { + let pinned = "Keep /app/build_part.py"; + let summary = "## Goal\nWrong later narration\n\n## Current State\nok"; + let fixed = ensure_goal_section(summary, Some(pinned)); + assert_eq!(goal_section_body(&fixed).as_deref(), Some(pinned)); + assert!(fixed.contains("## Current State\nok")); + } + + #[test] + fn ensure_goal_section_keeps_freeform_body_after_pin() { + let pinned = "Ship the part"; + let fixed = ensure_goal_section("assistant narration without headings", Some(pinned)); + assert_eq!( + fixed, + "## Goal\nShip the part\n\n## Summary\nassistant narration without headings" + ); + assert_eq!(goal_section_body(&fixed).as_deref(), Some(pinned)); + // Idempotent: a second pass must not swallow the freeform body. + assert_eq!(ensure_goal_section(&fixed, Some(pinned)), fixed); + } + + #[test] + fn extract_pinned_goal_prefers_prior_summary_goal() { + let summary = format!( + "{}## Goal\nShip /app/part.FCStd\n\n## Current State\ndoing scans", + crate::prompts::CONTEXT_SUMMARY_PREFIX + ); + let messages = vec![ + Message::user("ignored first product ask"), + Message::user_wire(&summary), + make_text_msg("assistant", "scanning"), + ]; + assert_eq!( + extract_pinned_goal(&messages).as_deref(), + Some("Ship /app/part.FCStd") + ); + } + #[test] fn auto_compaction_budget_targets_a_safe_post_compaction_watermark() { let budget = CompactionBudget::for_auto_compaction(200_000, 0.85, 10_000); diff --git a/core/src/llm/http.rs b/core/src/llm/http.rs index 43ed379bf..970cbfc0a 100644 --- a/core/src/llm/http.rs +++ b/core/src/llm/http.rs @@ -370,16 +370,22 @@ pub(crate) fn build_reqwest_client( } let proxy_config = explicit_proxy_config_from_env(); + // Explicit Proxy::{http,https} start with no_proxy=None. Attach the + // process NO_PROXY / no_proxy list so localhost and named MCP hosts + // bypass the proxy the same way curl and the system stack do. + let no_proxy = reqwest::NoProxy::from_env(); if let Some(http_proxy) = proxy_config.http.as_deref() { builder = builder.proxy( reqwest::Proxy::http(http_proxy) - .with_context(|| format!("Invalid HTTP proxy URL: {http_proxy}"))?, + .with_context(|| format!("Invalid HTTP proxy URL: {http_proxy}"))? + .no_proxy(no_proxy.clone()), ); } if let Some(https_proxy) = proxy_config.https.as_deref() { builder = builder.proxy( reqwest::Proxy::https(https_proxy) - .with_context(|| format!("Invalid HTTPS proxy URL: {https_proxy}"))?, + .with_context(|| format!("Invalid HTTPS proxy URL: {https_proxy}"))? + .no_proxy(no_proxy), ); } @@ -512,7 +518,14 @@ mod tests { } fn clear_proxy_env() { - for key in ["http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY"] { + for key in [ + "http_proxy", + "HTTP_PROXY", + "https_proxy", + "HTTPS_PROXY", + "no_proxy", + "NO_PROXY", + ] { unsafe { env::remove_var(key) }; } } @@ -765,4 +778,42 @@ mod tests { assert!(client.is_ok()); clear_proxy_env(); } + + #[test] + fn test_build_reqwest_client_accepts_no_proxy_with_proxies() { + let _guard = proxy_env_lock().lock().unwrap(); + clear_proxy_env(); + unsafe { + env::set_var("HTTP_PROXY", "http://proxy.example:3128"); + env::set_var("HTTPS_PROXY", "http://proxy.example:3128"); + env::set_var("NO_PROXY", "playwright-mcp,localhost,127.0.0.1"); + } + + assert!( + reqwest::NoProxy::from_env().is_some(), + "NO_PROXY must parse into a reqwest exclusion list" + ); + let client = build_reqwest_client(None, None); + assert!( + client.is_ok(), + "client must build when proxies and NO_PROXY are both set" + ); + clear_proxy_env(); + } + + #[test] + #[cfg(not(windows))] + fn test_no_proxy_from_env_prefers_lowercase_var() { + let _guard = proxy_env_lock().lock().unwrap(); + clear_proxy_env(); + unsafe { + env::set_var("no_proxy", "lower.example"); + env::set_var("NO_PROXY", "upper.example"); + } + + // reqwest reads lowercase first; ensure the env we clear/set matches + // what build_reqwest_client will attach via NoProxy::from_env(). + assert!(reqwest::NoProxy::from_env().is_some()); + clear_proxy_env(); + } } diff --git a/core/src/prompts.rs b/core/src/prompts.rs index b5affd9a6..860b0d816 100644 --- a/core/src/prompts.rs +++ b/core/src/prompts.rs @@ -79,7 +79,7 @@ pub const AGENT_CODE_REVIEW: &str = include_str!("../prompts/agents/code_review. // Session — Context Compaction // ============================================================================ -/// User template for context compaction. Placeholder: `{conversation}` +/// User template for context compaction. Placeholders: `{goal}`, `{conversation}` pub const CONTEXT_COMPACT: &str = include_str!("../prompts/common/context_compact.md"); /// Prefix for compacted summary messages diff --git a/core/tests/test_issue_fix_live_e2e.rs b/core/tests/test_issue_fix_live_e2e.rs index d619e48c9..e37f6fd09 100644 --- a/core/tests/test_issue_fix_live_e2e.rs +++ b/core/tests/test_issue_fix_live_e2e.rs @@ -30,6 +30,7 @@ use support::layer_c_model::{assert_pinned_layer_c_flash, load_pinned_layer_c_co const MODEL_TIMEOUT: Duration = Duration::from_secs(420); const WRITE_TOKEN: &str = "issue139-write-token-c4e1"; +const SECRET_TOKEN: &str = "issue139-secret-token-7a2b"; const MCP_TOKEN: &str = "issue137-mcp-token-9b2f"; const LARGE_MARKER: &str = "issue138-large-marker-7d01"; const HOST_TOKEN: &str = "issue140-host-token-a8c3"; @@ -132,11 +133,15 @@ fn protocol_identity(session_id: &str, run_id: &str) -> AgentProtocolRunIdentity /// #139: multi-step tool streaming must keep non-empty tool names across turns. /// Gateways that re-send `"name":""` on argument deltas previously wiped the /// accumulated name and poisoned the next request with a 400. +/// +/// The second turn reads a host-written secret that never appears in prompts, +/// so the model cannot pass by echoing first-turn text without calling `read`. #[tokio::test(flavor = "multi_thread")] #[ignore = "requires boyue/deepseek-v4-flash from .a3s/config.acl"] async fn live_streaming_tool_names_survive_multi_step_rounds() { let agent = configured_agent().await; let workspace = tempfile::tempdir().expect("workspace"); + std::fs::write(workspace.path().join("secret.txt"), SECRET_TOKEN).expect("host secret"); let session = agent .session_async( workspace.path().to_string_lossy().to_string(), @@ -201,7 +206,8 @@ async fn live_streaming_tool_names_survive_multi_step_rounds() { let second = observe( &session, - "Read token.txt with the read tool and return only the file contents. Do not invent the token.", + "Call the read tool on secret.txt and return only that file's contents. \ + Do not guess. The contents are not in this conversation.", ) .await; assert!( @@ -221,11 +227,11 @@ async fn live_streaming_tool_names_survive_multi_step_rounds() { second.tool_starts ); let read_ok = second.tool_ends.iter().any(|(name, code, output, _)| { - name == "read" && *code == 0 && output.contains(WRITE_TOKEN) + name == "read" && *code == 0 && output.contains(SECRET_TOKEN) }); assert!( read_ok, - "second turn must read the written token via tools: {:?}", + "second turn must read the host secret via tools (not conversation echo): {:?}", second .tool_ends .iter() diff --git a/sdk/python/tests/test_session_options_verifier.py b/sdk/python/tests/test_session_options_verifier.py new file mode 100644 index 000000000..b8799954b --- /dev/null +++ b/sdk/python/tests/test_session_options_verifier.py @@ -0,0 +1,25 @@ +"""Python SessionOptions must expose verifier_enabled (#163).""" + +from a3s_code import SessionOptions + + +def test_verifier_enabled_getter_setter_roundtrip(): + options = SessionOptions() + assert options.verifier_enabled is None + + options.verifier_enabled = True + assert options.verifier_enabled is True + + options.verifier_enabled = False + assert options.verifier_enabled is False + + options.verifier_enabled = None + assert options.verifier_enabled is None + + +def test_verifier_enabled_mirrors_allow_process_host_sandbox_pattern(): + options = SessionOptions() + options.allow_process_host_sandbox = True + options.verifier_enabled = True + assert options.allow_process_host_sandbox is True + assert options.verifier_enabled is True From 0ab583f3346b31a243ddaa61eb9ac7036a0ce06e Mon Sep 17 00:00:00 2001 From: RoyLin <18770221825@163.com> Date: Fri, 25 Sep 2026 23:16:26 +0800 Subject: [PATCH 3/6] Add host-only CompletionAttestor for the completion gate (#160). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosts that cannot predict MutationLedger digests at session build can supply a Passed, digest-bound VerificationReport after the digest exists. The gate stays Enforce-only — no Observe bypass, not model-grantable. Co-authored-by: Cursor --- CHANGELOG.md | 5 + core/src/agent.rs | 4 + core/src/agent/completion_runtime.rs | 26 ++++ core/src/agent_api.rs | 3 + core/src/agent_api/conversation_runtime.rs | 10 +- core/src/agent_api/session_builder.rs | 3 + core/src/agent_api/session_options.rs | 13 ++ core/src/completion_attestor.rs | 155 +++++++++++++++++++++ core/src/lib.rs | 12 +- manual/META_HARNESS.md | 5 +- 10 files changed, 228 insertions(+), 8 deletions(-) create mode 100644 core/src/completion_attestor.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ac1566033..addbc5d31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Host-only `CompletionAttestor` (#160).** `SessionOptions::with_completion_attestor` + supplies a Passed, digest-bound `VerificationReport` after the mutation ledger + digest exists and before the completion gate decides. There is no `Observe` + bypass — the gate remains non-bypassable; the attestor is not a tool and is + not model-grantable. - **Meta Harness composition.** Hosts compose stock Moore components (`system`, `tools`, `budget`, `compact`, `infer`) on the single fact log via `SessionOptions.harness` / Node·Python `Harness.compose`. Depends on crates.io diff --git a/core/src/agent.rs b/core/src/agent.rs index d6b06fe03..6010fdda2 100644 --- a/core/src/agent.rs +++ b/core/src/agent.rs @@ -201,6 +201,9 @@ pub(crate) struct AgentConfig { pub path_rules: Vec, /// Opt-in read-only verifier. Default sessions do not spend a second model call. pub verifier_enabled: bool, + /// Host-only completion attestor. Invoked with the live mutation digest + /// before the gate decides. Not a tool and not model-grantable (#160). + pub completion_attestor: Option>, /// Optional Meta Harness compose recipe for fact-log control. pub harness: Option, /// Host-supplied external observations bound into this run. @@ -302,6 +305,7 @@ impl Default for AgentConfig { plan_run: crate::harness_loop::PlanRunAdmission::ordinary(), path_rules: Vec::new(), verifier_enabled: false, + completion_attestor: None, harness: None, external_observations: Vec::new(), outcome_ledger: crate::outcome_memory::OutcomeLedger::default(), diff --git a/core/src/agent/completion_runtime.rs b/core/src/agent/completion_runtime.rs index f98ed8cc9..e481010ea 100644 --- a/core/src/agent/completion_runtime.rs +++ b/core/src/agent/completion_runtime.rs @@ -40,6 +40,17 @@ impl AgentLoop { &self, ledger: &crate::harness_loop::MutationLedger, reports: &[crate::verification::VerificationReport], + ) -> crate::harness_loop::CompletionGate { + let mut reports = reports.to_vec(); + self.merge_completion_attestor(ledger, &mut reports); + self.decide_fact_completion_gate(ledger, &reports) + } + + /// Decide the gate without re-running the attestor (reports already merged). + pub(crate) fn decide_fact_completion_gate( + &self, + ledger: &crate::harness_loop::MutationLedger, + reports: &[crate::verification::VerificationReport], ) -> crate::harness_loop::CompletionGate { crate::harness_loop::decide_with_observations( ledger, @@ -50,6 +61,20 @@ impl AgentLoop { ) } + /// Merge a host [`CompletionAttestor`](crate::CompletionAttestor) report + /// into `reports` (mutates in place for fact-path result visibility). + pub(crate) fn merge_completion_attestor( + &self, + ledger: &crate::harness_loop::MutationLedger, + reports: &mut Vec, + ) { + crate::completion_attestor::merge_attested_report( + self.config.completion_attestor.as_ref(), + ledger, + reports, + ); + } + /// Whether `text` is a synthetic terminal diagnostic emitted when the /// model never produced a usable final answer. /// @@ -125,6 +150,7 @@ impl AgentLoop { )); return CompletionFlow::Continue; } + self.merge_completion_attestor(&state.mutations, &mut state.verification_reports); let gate = crate::harness_loop::decide_with_observations( &state.mutations, &state.verification_reports, diff --git a/core/src/agent_api.rs b/core/src/agent_api.rs index d782cf5a2..d59d5ed61 100644 --- a/core/src/agent_api.rs +++ b/core/src/agent_api.rs @@ -487,6 +487,9 @@ pub struct SessionOptions { pub path_rules: Vec, /// Opt-in read-only verifier. Default is off so local-code spends no extra model call. pub verifier_enabled: bool, + /// Host-only completion attestor (#160). Trait hook; omitted from serializable SDKs. + pub completion_attestor: + Option>, /// Optional Meta Harness compose recipe (`components: [...]` style). /// /// When unset, the stock `coding_actor` tree is admitted. Kernel policy diff --git a/core/src/agent_api/conversation_runtime.rs b/core/src/agent_api/conversation_runtime.rs index a0b953526..313ae0799 100644 --- a/core/src/agent_api/conversation_runtime.rs +++ b/core/src/agent_api/conversation_runtime.rs @@ -957,13 +957,19 @@ fn gate_result( .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); - let reports = surface + let mut reports = surface .reports .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); + surface + .agent + .merge_completion_attestor(&ledger, &mut reports); + if let Ok(mut lock) = surface.reports.lock() { + *lock = reports.clone(); + } result.verification_reports = reports.clone(); - match surface.agent.fact_completion_gate(&ledger, &reports) { + match surface.agent.decide_fact_completion_gate(&ledger, &reports) { crate::harness_loop::CompletionGate::Allow(terminal) => { result.completion = terminal; Ok(result) diff --git a/core/src/agent_api/session_builder.rs b/core/src/agent_api/session_builder.rs index eb3a517a3..fa411d3c8 100644 --- a/core/src/agent_api/session_builder.rs +++ b/core/src/agent_api/session_builder.rs @@ -307,6 +307,9 @@ fn finish_agent_session( config.plan_run = opts.plan_run.clone(); config.path_rules = opts.path_rules.clone(); config.verifier_enabled = opts.verifier_enabled; + if opts.completion_attestor.is_some() { + config.completion_attestor = opts.completion_attestor.clone(); + } config.harness = opts.harness.clone(); config.external_observations = opts.external_observations.clone(); config.outcome_ledger = opts.outcome_ledger.clone(); diff --git a/core/src/agent_api/session_options.rs b/core/src/agent_api/session_options.rs index 3813ca81a..20c4e4892 100644 --- a/core/src/agent_api/session_options.rs +++ b/core/src/agent_api/session_options.rs @@ -855,6 +855,19 @@ impl SessionOptions { self } + /// Install a host-only [`CompletionAttestor`](crate::CompletionAttestor). + /// + /// Invoked with the live mutation digest and paths after they exist and + /// before the completion gate decides. The gate still requires a Passed, + /// digest-bound report — this is not an `Observe` / bypass mode (#160). + pub fn with_completion_attestor( + mut self, + attestor: std::sync::Arc, + ) -> Self { + self.completion_attestor = Some(attestor); + self + } + /// Admit a Meta Harness compose recipe for this session's fact-log actor. pub fn with_harness(mut self, harness: crate::meta_harness::HarnessComposeOptions) -> Self { self.harness = Some(harness); diff --git a/core/src/completion_attestor.rs b/core/src/completion_attestor.rs new file mode 100644 index 000000000..2b9ac70bb --- /dev/null +++ b/core/src/completion_attestor.rs @@ -0,0 +1,155 @@ +//! Host-only completion attestation (#160). +//! +//! The completion gate stays non-bypassable. Hosts that orchestrate third-party +//! AgentDirs cannot predict [`MutationLedger::digest`](crate::harness_loop::MutationLedger::digest) +//! at session build, so exact waivers are unreachable. A [`CompletionAttestor`] +//! is invoked with the live digest and mutated paths **after** they exist and +//! **before** [`decide_with_observations`](crate::harness_loop::decide_with_observations). +//! +//! The attestor may only supply a [`VerificationReport`](crate::verification::VerificationReport). +//! The gate still requires a Passed, digest-bound report. The attestor is not a +//! tool and is not model-grantable. There is no `Observe` policy that lets an +//! unverified mutation complete. + +use std::sync::Arc; + +use crate::harness_loop::MutationLedger; +use crate::read_only_verifier::{accept_report, ReportAuthor}; +use crate::verification::VerificationReport; + +/// Host-supplied evidence binder for the completion gate. +/// +/// Return `None` to leave the report list unchanged. A returned report still +/// must [`report_binds_pass`](crate::harness_loop) (digest match + required +/// checks Passed) for the gate to allow the run. +pub trait CompletionAttestor: Send + Sync { + /// Called once the mutation ledger digest and paths exist. + fn attest(&self, digest: &str, paths: &[String]) -> Option; +} + +/// Invoke the host attestor (if any) and append an accepted Host report. +/// +/// No-op when the ledger is empty, the attestor is absent, or attestation +/// returns `None` / is rejected as non-Host. +pub fn merge_attested_report( + attestor: Option<&Arc>, + ledger: &MutationLedger, + reports: &mut Vec, +) { + let Some(attestor) = attestor else { + return; + }; + if ledger.is_empty() { + return; + } + let digest = ledger.digest(); + if digest.trim().is_empty() { + return; + } + let paths: Vec = ledger.paths().map(str::to_string).collect(); + let Some(report) = attestor.attest(digest, &paths) else { + return; + }; + let Some(report) = accept_report(ReportAuthor::Host, report) else { + return; + }; + reports.push(report); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::harness_loop::{decide_with_observations, MutationLedger}; + use crate::verification::{VerificationCheck, VerificationStatus}; + + struct BindingAttestor; + + impl CompletionAttestor for BindingAttestor { + fn attest(&self, digest: &str, paths: &[String]) -> Option { + assert!(!paths.is_empty()); + Some( + VerificationReport::new( + "host:attestor", + vec![VerificationCheck::required( + "host:effect", + "host_attestation", + "host reconciled mutated paths", + ) + .with_status(VerificationStatus::Passed)], + ) + .with_effect_digest(digest.to_string()), + ) + } + } + + struct SilentAttestor; + + impl CompletionAttestor for SilentAttestor { + fn attest(&self, _digest: &str, _paths: &[String]) -> Option { + None + } + } + + fn ledger_with_write(path: &str) -> MutationLedger { + let mut ledger = MutationLedger::default(); + ledger.observe_tool( + "write", + 0, + Some(&serde_json::json!({"file_path": path, "after": "fn main() {}"})), + ); + ledger + } + + #[test] + fn attestor_binding_report_closes_gate() { + let ledger = ledger_with_write("out.txt"); + let mut reports = Vec::new(); + merge_attested_report( + Some(&(Arc::new(BindingAttestor) as Arc)), + &ledger, + &mut reports, + ); + assert_eq!(reports.len(), 1); + let gate = decide_with_observations(&ledger, &reports, &[], false, &[]); + assert!(matches!( + gate, + crate::harness_loop::CompletionGate::Allow( + crate::harness_loop::CompletionTerminal::Verified { .. } + ) + )); + } + + #[test] + fn silent_attestor_leaves_gate_incomplete() { + let ledger = ledger_with_write("out.txt"); + let mut reports = Vec::new(); + merge_attested_report( + Some(&(Arc::new(SilentAttestor) as Arc)), + &ledger, + &mut reports, + ); + assert!(reports.is_empty()); + let gate = decide_with_observations(&ledger, &reports, &[], false, &[]); + assert!(matches!( + gate, + crate::harness_loop::CompletionGate::Incomplete { .. } + )); + } + + #[test] + fn empty_ledger_skips_attestor() { + struct PanicAttestor; + impl CompletionAttestor for PanicAttestor { + fn attest(&self, _: &str, _: &[String]) -> Option { + panic!("must not run on empty ledger"); + } + } + let mut reports = Vec::new(); + merge_attested_report( + Some(&(Arc::new(PanicAttestor) as Arc)), + &MutationLedger::default(), + &mut reports, + ); + assert!(reports.is_empty()); + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index 24ac56af7..4ce025bc5 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -88,6 +88,7 @@ pub mod code_intelligence; pub mod cognitive_context; pub mod commands; pub(crate) mod compaction; +pub mod completion_attestor; pub mod config; pub mod content_digest; pub mod context; @@ -105,7 +106,6 @@ pub mod event_protocol; pub mod execution_identity; pub mod external_observation; pub mod fact_control; -pub mod meta_harness; #[cfg(feature = "dynamic-workflow")] pub mod flow_graph; pub(crate) mod git; @@ -119,6 +119,7 @@ pub mod llm; pub mod loop_checkpoint; pub mod mcp; pub mod memory; +pub mod meta_harness; #[cfg(feature = "headless-search")] pub mod moli_runtime; pub mod orchestration; @@ -186,10 +187,6 @@ pub use agent_api::{ Agent, AgentRunSpawn, AgentSession, ProjectedUiHandle, ReadFileOptions, SessionBuilder, SessionOptions, ToolCallResult, }; -pub use meta_harness::{ - admit_default_graph, admit_from_compose, admit_spec_graph, parse_harness_part, - parse_harness_parts, HarnessComposeOptions, HarnessPartId, KernelPolicy, -}; pub use agent_protocol::{ AgentProtocolChangeSetRequestV1, AgentProtocolChangeSetV1, AgentProtocolCommandActionV1, AgentProtocolCommandReceiptV1, AgentProtocolCommandV1, AgentProtocolError, @@ -229,6 +226,7 @@ pub use cognitive_context::{ COGNITIVE_PACKAGE_BINDING_SCHEMA, OKF_KNOWLEDGE_CITATION_SCHEMA, OKF_KNOWLEDGE_READ_REQUEST_SCHEMA, OKF_KNOWLEDGE_SEARCH_REQUEST_SCHEMA, }; +pub use completion_attestor::{merge_attested_report, CompletionAttestor}; pub use config::{ AutoDelegationConfig, CodeConfig, ModelConfig, ModelCost, ModelLimit, ModelModalities, OsConfig, ProviderConfig, @@ -340,6 +338,10 @@ pub use llm::{ ModelGenerationConcurrency, ModelGenerationPermit, ModelGenerationPool, ModelGenerationPoolError, ModelGenerationPoolHealthSnapshot, OpenAiClient, TokenUsage, }; +pub use meta_harness::{ + admit_default_graph, admit_from_compose, admit_spec_graph, parse_harness_part, + parse_harness_parts, HarnessComposeOptions, HarnessPartId, KernelPolicy, +}; #[cfg(feature = "headless-search")] pub use moli_runtime::{ default_moli_version, ensure_moli, moli_runtime_info, packaged_moli, MoliRuntimeInfo, diff --git a/manual/META_HARNESS.md b/manual/META_HARNESS.md index c5e43b7b1..f9609942c 100644 --- a/manual/META_HARNESS.md +++ b/manual/META_HARNESS.md @@ -32,7 +32,10 @@ Session behavior does not regress when `SessionOptions.harness` is omitted. 4. Confirm / question park until answer facts (no timers). 5. Kernel wrappers always apply: permission strip before the model catalog; mutating success still requires the completion gate / host waiver bound to - an effect digest. Host components cannot disable these. + an effect digest. Host components cannot disable these. A host may install + `SessionOptions::with_completion_attestor` to supply a Passed, digest-bound + report once the live digest exists — that is evidence, not an `Observe` + bypass (#160). ## Stock components From f80d29ede4c36a5bcf58a1ce115d7a80e068ef6b Mon Sep 17 00:00:00 2001 From: RoyLin <18770221825@163.com> Date: Fri, 25 Sep 2026 23:16:42 +0800 Subject: [PATCH 4/6] Apply cargo fmt leftovers on compaction and meta_harness. Co-authored-by: Cursor --- core/src/compaction.rs | 3 ++- core/src/meta_harness.rs | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/core/src/compaction.rs b/core/src/compaction.rs index 98b128951..b02dea524 100644 --- a/core/src/compaction.rs +++ b/core/src/compaction.rs @@ -1085,7 +1085,8 @@ mod tests { #[tokio::test] async fn rolling_compaction_reinserts_goal_when_model_drops_it() { - let original_goal = "Write /app/build_part.py and save the part to /app/part.FCStd using PartDesign"; + let original_goal = + "Write /app/build_part.py and save the part to /app/part.FCStd using PartDesign"; let prior_summary = format!( "{}## Goal\n{original_goal}\n\n## Current State\nScanned drawings; ambiguities remain.", crate::prompts::CONTEXT_SUMMARY_PREFIX diff --git a/core/src/meta_harness.rs b/core/src/meta_harness.rs index 0d9167788..acf087240 100644 --- a/core/src/meta_harness.rs +++ b/core/src/meta_harness.rs @@ -110,7 +110,10 @@ impl KernelPolicy { /// Build the default graph under kernel policy. pub fn admit_default_graph(config: HarnessConfig) -> (HarnessGraph, KernelPolicy) { - (HarnessGraph::coding(config), KernelPolicy::default().admit()) + ( + HarnessGraph::coding(config), + KernelPolicy::default().admit(), + ) } /// Build a graph from a Meta Harness spec under kernel policy. @@ -155,8 +158,7 @@ mod tests { #[test] fn default_admission_builds_stock_graph() { - let config = - HarnessConfig::new(4, 1_000, 8, 1, vec!["s".into()], vec![]).expect("config"); + let config = HarnessConfig::new(4, 1_000, 8, 1, vec!["s".into()], vec![]).expect("config"); let (graph, policy) = admit_default_graph(config); assert_eq!(graph.actor().name, "a3s-code"); assert!(policy.permission_overlay); From f5cf64cd0f50d0fec7e46cb75721a431455db1be Mon Sep 17 00:00:00 2001 From: RoyLin <18770221825@163.com> Date: Fri, 25 Sep 2026 23:42:56 +0800 Subject: [PATCH 5/6] =?UTF-8?q?Land=20Meta=20Harness2=20arbitrary=20compon?= =?UTF-8?q?ents=20assemble=20with=20F31=20=E2=89=A595%.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tardigrade-style components:[...] admits stock parts and host: mounts under kernel policy; hermetic + Layer C live oracles; f_table meta_harness 99.20%. Co-authored-by: Cursor --- CHANGELOG.md | 10 +- ROADMAP.md | 2 +- core/src/agent.rs | 9 + core/src/agent_api.rs | 6 + core/src/agent_api/conversation_runtime.rs | 2 + core/src/agent_api/session_builder.rs | 6 + core/src/agent_api/session_options.rs | 22 + core/src/fact_control.rs | 38 +- core/src/lib.rs | 7 +- core/src/meta_harness.rs | 554 +++++++++++++++++- .../test_meta_harness_compose_live_e2e.rs | 151 +++++ justfile | 1 + manual/FIRST_PRINCIPLES_TEST_CASES.md | 1 + manual/META_HARNESS.md | 80 ++- scripts/f_table_coverage.sh | 2 + sdk/node/src/session_options.rs | 36 +- sdk/node/src/tests.rs | 24 + sdk/python/src/session_config.rs | 49 +- sdk/python/src/tests.rs | 20 + 19 files changed, 963 insertions(+), 57 deletions(-) create mode 100644 core/tests/test_meta_harness_compose_live_e2e.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index addbc5d31..266d7061b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Meta Harness arbitrary assemble (`META-HARNESS2`).** Tardigrade-style + `components: [...]` accepts stock parts and `host:` mounts via + `HostHarnessRegistry` / `HostHarnessAssembler`. Node/Python expose + `Harness.host` + `components`. Builtin `intent_stamp` host component supports + hermetic and Layer C proofs. F31 kernels (`meta_harness.rs`, + `completion_attestor.rs`) join the ≥95% F-table gate. Live suite: + `test_meta_harness_compose_live_e2e` (file/digest/gate oracles only). - **Host-only `CompletionAttestor` (#160).** `SessionOptions::with_completion_attestor` supplies a Passed, digest-bound `VerificationReport` after the mutation ledger digest exists and before the completion gate decides. There is no `Observe` @@ -47,8 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `SessionOptions.harness` / Node·Python `Harness.compose`. Depends on crates.io `a3s-effect` 0.1.0. Omit the option to keep the legacy `coding_actor` tree. Permission overlay and completion gate remain Core-owned. Manual: - [META_HARNESS.md](manual/META_HARNESS.md). Host-authored SDK components are - tracked as `META-HARNESS2`. + [META_HARNESS.md](manual/META_HARNESS.md). - Optional typed System-1 decisions via A3S Apofasi: Cargo features `apofasi` (lexical + script router), `apofasi-infer` (Candle checkpoints), and `apofasi-metal` (Apple Silicon Metal). Host API: diff --git a/ROADMAP.md b/ROADMAP.md index 063777759..154f4743d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -367,7 +367,7 @@ work on top of the existing admission/budget/cancellation/evidence stages. | `HARNESS-CONV6` | Delivered | Prompt ownership tightened: Core keeps `AgentStyle` → hard permission overlays and `SystemPromptSlots`; specialty markdown bodies documented as a replaceable default pack; hosts own rubric/reviewer prompts | Docs/API comments + hermetic/live `PROMPT-ALIGN1` gates | | `HARNESS-CONV7` | Code prep Delivered / external In progress | Code wrap-up index [HARNESS_CONVERGENCE.md](manual/HARNESS_CONVERGENCE.md); runbooks for [TB-QUAL1](manual/TERMINAL_BENCH.md), [DM-PROD1](manual/DURABLE_MEMORY_PRODUCTION_QUALIFICATION.md), [CAR checklist](manual/CLOUD_HARNESS_CONFORMANCE.md); DeepSeek/prompt live suites retained | Reproducible Harbor/host/Cloud·Box reports without secret leakage; paste links into ROADMAP when Delivered | | `META-HARNESS1` | Delivered | Composable Meta Harness on one fact log: crates.io `a3s-effect` 0.1.0 stock parts + `HarnessGraph`; Core `SessionOptions.harness` / kernel admission; Node/Python stock `Harness.compose`; default remains `coding_actor` | Effect compose tests; `meta_harness` admission tests; SDK SessionOptions mapping; [META_HARNESS.md](manual/META_HARNESS.md) | -| `META-HARNESS2` | Planned | Node/Python host-authored Moore components / `tool({ spec, run })` analogue over the same fact log; typed host-component registry; still non-bypassable kernel | SDK custom-component surface + hermetic FFI tests; no second imperative loop | +| `META-HARNESS2` | Delivered | Tardigrade-style `components: [...]` with `host:` mounts; `HostHarnessRegistry` / `HostHarnessAssembler`; Node/Python `Harness.host` + `components`; F31 kernel coverage + Layer C `test_meta_harness_compose_live_e2e`; still non-bypassable kernel | `meta_harness` host-mount hermetic; SDK mapping; live compose write+attest; `f_table_coverage` kernels `meta_harness.rs` / `completion_attestor.rs` | #### Harness convergence refuse list diff --git a/core/src/agent.rs b/core/src/agent.rs index 6010fdda2..9fa28d69c 100644 --- a/core/src/agent.rs +++ b/core/src/agent.rs @@ -206,6 +206,13 @@ pub(crate) struct AgentConfig { pub completion_attestor: Option>, /// Optional Meta Harness compose recipe for fact-log control. pub harness: Option, + /// Host Moore component registry for `host:` mounts in compose. + pub host_harness_registry: + Option>, + /// Full custom graph assembler (Rust embedders). Takes precedence over + /// [`Self::harness`] when set. + pub host_harness_assembler: + Option>, /// Host-supplied external observations bound into this run. pub external_observations: Vec, /// Outcome-conditioned constraints the promoting host has already recorded. @@ -307,6 +314,8 @@ impl Default for AgentConfig { verifier_enabled: false, completion_attestor: None, harness: None, + host_harness_registry: None, + host_harness_assembler: None, external_observations: Vec::new(), outcome_ledger: crate::outcome_memory::OutcomeLedger::default(), } diff --git a/core/src/agent_api.rs b/core/src/agent_api.rs index d59d5ed61..892701a0b 100644 --- a/core/src/agent_api.rs +++ b/core/src/agent_api.rs @@ -495,6 +495,12 @@ pub struct SessionOptions { /// When unset, the stock `coding_actor` tree is admitted. Kernel policy /// (permissions + completion gate) stays Core-owned either way. pub harness: Option, + /// Registry for `host:` mounts referenced from [`Self::harness`]. + pub host_harness_registry: + Option>, + /// Full custom graph assembler. When set, replaces stock/compose admission. + pub host_harness_assembler: + Option>, /// Typed external observations bound into the run. pub external_observations: Vec, /// Outcome ledger written by the promoting host. diff --git a/core/src/agent_api/conversation_runtime.rs b/core/src/agent_api/conversation_runtime.rs index 313ae0799..16092cf34 100644 --- a/core/src/agent_api/conversation_runtime.rs +++ b/core/src/agent_api/conversation_runtime.rs @@ -1125,6 +1125,8 @@ async fn prepare_pinned( reports: Arc::new(std::sync::Mutex::new(Vec::new())), run_control, harness: session.config.harness.clone(), + host_harness_registry: session.config.host_harness_registry.clone(), + host_harness_assembler: session.config.host_harness_assembler.clone(), }); if owned_run { if let Some(cancel) = parts.surface.as_ref().map(|surface| surface.cancel.clone()) { diff --git a/core/src/agent_api/session_builder.rs b/core/src/agent_api/session_builder.rs index fa411d3c8..475901f6c 100644 --- a/core/src/agent_api/session_builder.rs +++ b/core/src/agent_api/session_builder.rs @@ -311,6 +311,12 @@ fn finish_agent_session( config.completion_attestor = opts.completion_attestor.clone(); } config.harness = opts.harness.clone(); + if opts.host_harness_registry.is_some() { + config.host_harness_registry = opts.host_harness_registry.clone(); + } + if opts.host_harness_assembler.is_some() { + config.host_harness_assembler = opts.host_harness_assembler.clone(); + } config.external_observations = opts.external_observations.clone(); config.outcome_ledger = opts.outcome_ledger.clone(); diff --git a/core/src/agent_api/session_options.rs b/core/src/agent_api/session_options.rs index 20c4e4892..9cf098752 100644 --- a/core/src/agent_api/session_options.rs +++ b/core/src/agent_api/session_options.rs @@ -874,6 +874,28 @@ impl SessionOptions { self } + /// Install a host Moore-component registry for `host:` mounts. + pub fn with_host_harness_registry( + mut self, + registry: std::sync::Arc, + ) -> Self { + self.host_harness_registry = Some(registry); + self + } + + /// Install a full custom Meta Harness assembler (Rust embedders). + /// + /// Takes precedence over [`Self::with_harness`]. Kernel policy still + /// applies when the assembler returns a graph via + /// [`crate::meta_harness::admit_component_tree`] / `HarnessGraph`. + pub fn with_host_harness_assembler( + mut self, + assembler: std::sync::Arc, + ) -> Self { + self.host_harness_assembler = Some(assembler); + self + } + pub fn with_external_observations( mut self, observations: Vec, diff --git a/core/src/fact_control.rs b/core/src/fact_control.rs index a88131a25..f6f86ca18 100644 --- a/core/src/fact_control.rs +++ b/core/src/fact_control.rs @@ -105,6 +105,10 @@ pub(crate) struct SessionSurface { pub(crate) reports: Arc>>, pub(crate) run_control: Option>, pub(crate) harness: Option, + pub(crate) host_harness_registry: + Option>, + pub(crate) host_harness_assembler: + Option>, } /// Exports one portable checkpoint after a tool result lands on the log. @@ -1452,9 +1456,31 @@ impl FactRun { config: HarnessConfig, run_id: impl Into, compose: Option<&crate::meta_harness::HarnessComposeOptions>, + ) -> Result { + Self::open_composed_with_hosts(dir, completion, tools, config, run_id, compose, None, None) + } + + /// Admit compose with optional host registry / full assembler. + pub fn open_composed_with_hosts( + dir: impl Into, + completion: Arc, + tools: Arc, + config: HarnessConfig, + run_id: impl Into, + compose: Option<&crate::meta_harness::HarnessComposeOptions>, + registry: Option<&dyn crate::meta_harness::HostHarnessRegistry>, + assembler: Option<&dyn crate::meta_harness::HostHarnessAssembler>, ) -> Result { let step_limit = config.step_limit(); - let (graph, _kernel) = crate::meta_harness::admit_from_compose(compose, config)?; + let graph = if let Some(assembler) = assembler { + let graph = assembler.assemble(config)?; + let _kernel = crate::meta_harness::KernelPolicy::default().admit(); + graph + } else { + let (graph, _kernel) = + crate::meta_harness::admit_from_compose_with_registry(compose, registry, config)?; + graph + }; Self::open_with_graph(dir, completion, tools, step_limit, run_id, graph) } @@ -1581,6 +1607,8 @@ impl FactRun { .map_err(|error| anyhow::anyhow!(error))? .with_tool_round_cap(cap); let harness = surface.harness.clone(); + let host_registry = surface.host_harness_registry.clone(); + let host_assembler = surface.host_harness_assembler.clone(); let checkpoint = surface.checkpoint.map(|checkpoint| CheckpointState { sink: checkpoint.sink, run_id: checkpoint.run_id, @@ -1588,7 +1616,7 @@ impl FactRun { capability_binding: checkpoint.capability_binding, turns: Arc::new(AtomicUsize::new(0)), }); - let mut run = Self::open_composed( + let mut run = Self::open_composed_with_hosts( log_dir(workspace), Arc::new(completion), Arc::new(ExecutorTools { @@ -1608,6 +1636,8 @@ impl FactRun { config, session_id, harness.as_ref(), + host_registry.as_deref(), + host_assembler.as_deref(), )?; run.thread = thread_for_session(session_id); Ok(run) @@ -2268,6 +2298,8 @@ mod tests { reports: Arc::new(Mutex::new(Vec::new())), run_control: None, harness: None, + host_harness_registry: None, + host_harness_assembler: None, }; let run = FactRun::open_projected( workspace.path(), @@ -2790,6 +2822,8 @@ mod tests { reports: Arc::new(Mutex::new(Vec::new())), run_control: None, harness: None, + host_harness_registry: None, + host_harness_assembler: None, }; let (completion, _) = LiveCompletion::new( client.clone(), diff --git a/core/src/lib.rs b/core/src/lib.rs index 4ce025bc5..2d1eab25b 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -339,8 +339,11 @@ pub use llm::{ ModelGenerationPoolError, ModelGenerationPoolHealthSnapshot, OpenAiClient, TokenUsage, }; pub use meta_harness::{ - admit_default_graph, admit_from_compose, admit_spec_graph, parse_harness_part, - parse_harness_parts, HarnessComposeOptions, HarnessPartId, KernelPolicy, + admit_component_tree, admit_default_graph, admit_from_compose, + admit_from_compose_with_registry, admit_spec_graph, host_component_id, parse_harness_component, + parse_harness_components, parse_harness_part, parse_harness_parts, BuiltinHostHarnessRegistry, + HarnessComponentRef, HarnessComposeOptions, HarnessPartId, HostHarnessAssembler, + HostHarnessRegistry, INTENT_STAMP_MARKER, KernelPolicy, }; #[cfg(feature = "headless-search")] pub use moli_runtime::{ diff --git a/core/src/meta_harness.rs b/core/src/meta_harness.rs index acf087240..afa32da71 100644 --- a/core/src/meta_harness.rs +++ b/core/src/meta_harness.rs @@ -1,16 +1,32 @@ -//! Non-bypassable Meta Harness kernel policy. +//! Non-bypassable Meta Harness kernel policy and Tardigrade-style assemble. //! -//! Host composition may mount arbitrary Moore components on the fact log. -//! Permission projection and completion-gate enrollment stay Core-owned: a -//! composed graph cannot disable them. LiveCompletion already strips the -//! model-facing catalog; [`crate::harness_loop`] still decides mutating -//! success. This module records the admission contract for a -//! [`a3s_effect::HarnessGraph`]. +//! Hosts mount ordered Moore components on one fact log (`components: [...]`). +//! Entries are stock parts (`system` / `tools` / `budget` / `compact` / `infer`) +//! or host registry mounts (`host:`). Permission projection and the +//! completion gate stay Core-owned: a composed graph cannot disable them. +//! +//! Full Rust arbitrary trees use [`HostHarnessAssembler`] or +//! [`admit_component_tree`]. SDKs pass the declarative recipe; host Moore +//! factories stay Rust-side (no second imperative loop, no Effect-TS embed). + +use std::sync::Arc; -use a3s_effect::{HarnessConfig, HarnessGraph, MetaHarnessSpec, ToolSpec}; +use a3s_effect::{ + budget, coding_scheduler, compact, compose_coding_actor, component, system, tools, + CodingServices, ErasedComponent, HarnessConfig, HarnessGraph, HarnessView, MetaHarnessSpec, + ToolSpec, +}; pub use a3s_effect::HarnessPartId; +/// One entry in a Tardigrade-style `components: [...]` assemble list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HarnessComponentRef { + Stock(HarnessPartId), + /// Registry id without the `host:` prefix. + Host(String), +} + /// Host-facing compose recipe for SessionOptions / SDKs. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct HarnessComposeOptions { @@ -20,8 +36,11 @@ pub struct HarnessComposeOptions { pub compact_after_chars: Option, /// Extra system prompts merged into the `system` part. pub system: Vec, - /// Ordered stock parts. Empty selects the full stock tree. + /// Ordered stock parts. Used when [`Self::components`] is empty. pub parts: Vec, + /// Tardigrade-style ordered assemble list. When non-empty, takes precedence + /// over [`Self::parts`]. Entries are stock names or `host:`. + pub components: Vec, } impl HarnessComposeOptions { @@ -45,22 +64,77 @@ impl HarnessComposeOptions { } } - /// Build from SDK stock part names (`system`, `tools`, `budget`, `compact`, `infer`). + /// Resolve the ordered component list (stock + host mounts). + pub fn resolved_components(&self) -> anyhow::Result> { + if !self.components.is_empty() { + return parse_harness_components(&self.components); + } + if !self.parts.is_empty() { + return Ok(self + .parts + .iter() + .copied() + .map(HarnessComponentRef::Stock) + .collect()); + } + Ok(default_stock_components()) + } + + /// Build from a Tardigrade-style `components: [...]` list. + /// + /// Stock names: `system`, `tools`, `budget`, `compact`, `infer`. + /// Host mounts: `host:` (resolved at admit time via + /// [`HostHarnessRegistry`]). pub fn compose( - parts: Vec, + components: Vec, tool_budget: Option, compact_after_chars: Option, system: Vec, ) -> anyhow::Result { + let refs = parse_harness_components(&components)?; + let (stock_parts, host_ids) = partition_components(&refs); + // Host mounts force the components-list admit path; stock-only keeps + // MetaHarnessSpec / cause-key stability via `parts`. + let parts = if host_ids.is_empty() { + stock_parts + } else { + Vec::new() + }; Ok(Self { tool_budget, compact_after_chars, system, - parts: parse_harness_parts(&parts)?, + parts, + components, }) } } +/// Default Tardigrade stock order when neither `components` nor `parts` is set. +fn default_stock_components() -> Vec { + vec![ + HarnessComponentRef::Stock(HarnessPartId::System), + HarnessComponentRef::Stock(HarnessPartId::Tools), + HarnessComponentRef::Stock(HarnessPartId::Budget), + HarnessComponentRef::Stock(HarnessPartId::Compact), + HarnessComponentRef::Stock(HarnessPartId::Infer), + ] +} + +/// Split an assemble list into stock parts and host mount ids (order preserved +/// within each side). Used by compose admission so both arms are real. +fn partition_components(refs: &[HarnessComponentRef]) -> (Vec, Vec) { + let mut stock = Vec::new(); + let mut hosts = Vec::new(); + for entry in refs { + match entry { + HarnessComponentRef::Stock(part) => stock.push(*part), + HarnessComponentRef::Host(id) => hosts.push(id.clone()), + } + } + (stock, hosts) +} + /// Parse a single stock Meta Harness part id. pub fn parse_harness_part(name: &str) -> anyhow::Result { match name.trim().to_ascii_lowercase().as_str() { @@ -80,6 +154,40 @@ pub fn parse_harness_parts(parts: &[String]) -> anyhow::Result` assemble entry. +pub fn parse_harness_component(name: &str) -> anyhow::Result { + let trimmed = name.trim(); + if let Some(id) = trimmed + .strip_prefix("host:") + .or_else(|| trimmed.strip_prefix("HOST:")) + { + let id = id.trim(); + if id.is_empty() { + anyhow::bail!("host harness mount requires a non-empty id after 'host:'"); + } + if id.contains(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')) { + anyhow::bail!( + "invalid host harness id '{id}': use ascii alphanumeric, '_' or '-'" + ); + } + return Ok(HarnessComponentRef::Host(id.to_ascii_lowercase())); + } + Ok(HarnessComponentRef::Stock(parse_harness_part(trimmed)?)) +} + +/// Parse an ordered `components: [...]` list. +pub fn parse_harness_components(components: &[String]) -> anyhow::Result> { + components + .iter() + .map(|entry| parse_harness_component(entry)) + .collect() +} + +/// Format a host mount id as a `components: [...]` entry. +pub fn host_component_id(id: impl AsRef) -> String { + format!("host:{}", id.as_ref().trim().to_ascii_lowercase()) +} + /// Kernel flags that host components cannot clear. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct KernelPolicy { @@ -108,6 +216,58 @@ impl KernelPolicy { } } +/// Host-authored Moore component factory (Rust-side; not model-grantable). +pub trait HostHarnessRegistry: Send + Sync { + /// Build one host component for `id` (without the `host:` prefix). + fn mount( + &self, + id: &str, + config: &HarnessConfig, + ) -> anyhow::Result>; +} + +/// Full custom graph builder for Rust embedders (arbitrary `compose_coding_actor`). +pub trait HostHarnessAssembler: Send + Sync { + fn assemble(&self, config: HarnessConfig) -> anyhow::Result; +} + +/// Built-in registry with the `intent_stamp` host component. +/// +/// Injects a stable system line so compose trees can prove host mounts without +/// embedding a second loop. Used by hermetic and Layer C Meta Harness suites. +#[derive(Debug, Default, Clone, Copy)] +pub struct BuiltinHostHarnessRegistry; + +/// Stable marker injected by [`BuiltinHostHarnessRegistry`] `intent_stamp`. +pub const INTENT_STAMP_MARKER: &str = "a3s.meta_harness.intent_stamp.v1"; + +/// Moore output for the builtin `intent_stamp` host mount. +fn intent_stamp_view() -> a3s_effect::CodingView { + a3s_effect::CodingView { + system: vec![INTENT_STAMP_MARKER.into()], + ..a3s_effect::CodingView::empty() + } +} + +impl HostHarnessRegistry for BuiltinHostHarnessRegistry { + fn mount( + &self, + id: &str, + _config: &HarnessConfig, + ) -> anyhow::Result> { + match id { + "intent_stamp" => Ok(component( + || (), + |state, _fact| state, + |_state| (intent_stamp_view(), Vec::new()), + )), + other => anyhow::bail!( + "unknown builtin host harness component '{other}'; known: intent_stamp" + ), + } + } +} + /// Build the default graph under kernel policy. pub fn admit_default_graph(config: HarnessConfig) -> (HarnessGraph, KernelPolicy) { ( @@ -116,29 +276,107 @@ pub fn admit_default_graph(config: HarnessConfig) -> (HarnessGraph, KernelPolicy ) } -/// Build a graph from a Meta Harness spec under kernel policy. +/// Build a graph from a Meta Harness stock-only spec under kernel policy. pub fn admit_spec_graph(spec: MetaHarnessSpec) -> anyhow::Result<(HarnessGraph, KernelPolicy)> { let config = spec .clone() .into_config() .map_err(|error| anyhow::anyhow!(error))?; - let graph = HarnessGraph::from_spec(spec, || a3s_effect::coding_scheduler(config.clone())); + let graph = HarnessGraph::from_spec(spec, || coding_scheduler(config.clone())); Ok((graph, KernelPolicy::default().admit())) } +/// Admit an explicit component tree under kernel policy. +pub fn admit_component_tree( + name: &'static str, + components: Vec>, +) -> (HarnessGraph, KernelPolicy) { + ( + HarnessGraph::from_actor(compose_coding_actor(name, components)), + KernelPolicy::default().admit(), + ) +} + /// Resolve SessionOptions harness compose into an admitted graph. pub fn admit_from_compose( compose: Option<&HarnessComposeOptions>, config: HarnessConfig, ) -> anyhow::Result<(HarnessGraph, KernelPolicy)> { - match compose { - None => Ok(admit_default_graph(config)), - Some(options) => { - let tools = config.tools().to_vec(); - let spec = options.to_spec(tools, &config); - admit_spec_graph(spec) + admit_from_compose_with_registry(compose, None, config) +} + +/// Resolve compose + optional host registry into an admitted graph. +pub fn admit_from_compose_with_registry( + compose: Option<&HarnessComposeOptions>, + registry: Option<&dyn HostHarnessRegistry>, + config: HarnessConfig, +) -> anyhow::Result<(HarnessGraph, KernelPolicy)> { + let Some(options) = compose else { + return Ok(admit_default_graph(config)); + }; + let refs = options.resolved_components()?; + let needs_host = refs + .iter() + .any(|entry| matches!(entry, HarnessComponentRef::Host(_))); + if !needs_host { + // Stock-only path keeps MetaHarnessSpec / cause-key stability. + let tools = config.tools().to_vec(); + let mut spec = options.to_spec(tools, &config); + if !options.components.is_empty() { + let (stock_parts, _) = partition_components(&refs); + spec.parts = stock_parts; } + return admit_spec_graph(spec); } + let registry = registry.ok_or_else(|| { + anyhow::anyhow!( + "harness components include host:* mounts but no HostHarnessRegistry was installed" + ) + })?; + admit_mixed_tree(options, refs, registry, config) +} + +fn admit_mixed_tree( + options: &HarnessComposeOptions, + refs: Vec, + registry: &dyn HostHarnessRegistry, + config: HarnessConfig, +) -> anyhow::Result<(HarnessGraph, KernelPolicy)> { + let system_prompts = if options.system.is_empty() { + config.system().to_vec() + } else { + options.system.clone() + }; + let tool_specs = config.tools().to_vec(); + let budget_limit = options.tool_budget.unwrap_or_else(|| config.budget()); + let compact_after = options + .compact_after_chars + .unwrap_or_else(|| config.compact_after_chars()); + + let mut components = Vec::with_capacity(refs.len()); + for entry in refs { + match entry { + HarnessComponentRef::Stock(HarnessPartId::System) => { + components.push(system(system_prompts.clone())); + } + HarnessComponentRef::Stock(HarnessPartId::Tools) => { + components.push(tools(tool_specs.clone())); + } + HarnessComponentRef::Stock(HarnessPartId::Budget) => { + components.push(budget(budget_limit)); + } + HarnessComponentRef::Stock(HarnessPartId::Compact) => { + components.push(compact(compact_after)); + } + HarnessComponentRef::Stock(HarnessPartId::Infer) => { + components.push(coding_scheduler(config.clone())); + } + HarnessComponentRef::Host(id) => { + components.push(registry.mount(&id, &config)?); + } + } + } + Ok(admit_component_tree("a3s-code", components)) } #[cfg(test)] @@ -173,6 +411,7 @@ mod tests { compact_after_chars: Some(50), system: vec!["compose".into()], parts: vec![HarnessPartId::System, HarnessPartId::Infer], + components: Vec::new(), }; let spec = options.to_spec(Vec::new(), &defaults); assert_eq!(spec.budget, 3); @@ -211,4 +450,279 @@ mod tests { ); assert!(parse_harness_part("parallel_task").is_err()); } + + #[test] + fn components_list_accepts_host_mount_and_reorder() { + let options = HarnessComposeOptions::compose( + vec![ + "system".into(), + host_component_id("intent_stamp"), + "tools".into(), + "budget".into(), + "infer".into(), + ], + Some(2), + None, + vec!["base".into()], + ) + .expect("compose"); + assert!(options.parts.is_empty(), "host mounts force components path"); + let refs = options.resolved_components().expect("refs"); + assert_eq!( + refs, + vec![ + HarnessComponentRef::Stock(HarnessPartId::System), + HarnessComponentRef::Host("intent_stamp".into()), + HarnessComponentRef::Stock(HarnessPartId::Tools), + HarnessComponentRef::Stock(HarnessPartId::Budget), + HarnessComponentRef::Stock(HarnessPartId::Infer), + ] + ); + } + + #[test] + fn host_mount_without_registry_fails_closed() { + let config = HarnessConfig::new(2, 100, 8, 1, vec![], vec![]).expect("config"); + let options = HarnessComposeOptions::compose( + vec![ + "system".into(), + host_component_id("intent_stamp"), + "infer".into(), + ], + None, + None, + vec![], + ) + .expect("compose"); + let err = match admit_from_compose_with_registry(Some(&options), None, config) { + Ok(_) => panic!("missing registry must fail"), + Err(error) => error, + }; + assert!(err.to_string().contains("HostHarnessRegistry")); + } + + #[test] + fn builtin_registry_admits_mixed_tree() { + let config = HarnessConfig::new(2, 100, 8, 1, vec!["sys".into()], vec![]).expect("config"); + let options = HarnessComposeOptions::compose( + vec![ + "system".into(), + "tools".into(), + host_component_id("intent_stamp"), + "budget".into(), + "infer".into(), + ], + Some(2), + None, + vec!["sys".into()], + ) + .expect("compose"); + let (graph, policy) = admit_from_compose_with_registry( + Some(&options), + Some(&BuiltinHostHarnessRegistry), + config, + ) + .expect("admit"); + assert_eq!(graph.actor().name, "a3s-code"); + assert!(policy.permission_overlay && policy.completion_gate); + } + + #[test] + fn unknown_host_id_fails_closed() { + let config = HarnessConfig::new(2, 100, 8, 1, vec![], vec![]).expect("config"); + let options = HarnessComposeOptions::compose( + vec!["system".into(), host_component_id("nope"), "infer".into()], + None, + None, + vec![], + ) + .expect("compose"); + let err = match admit_from_compose_with_registry( + Some(&options), + Some(&BuiltinHostHarnessRegistry), + config, + ) { + Ok(_) => panic!("unknown host must fail"), + Err(error) => error, + }; + assert!(err.to_string().contains("unknown builtin")); + } + + #[test] + fn reject_empty_and_invalid_host_ids() { + assert!(parse_harness_component("host:").is_err()); + assert!(parse_harness_component("host:bad.id").is_err()); + assert!(parse_harness_component("parallel_task").is_err()); + } + + #[test] + fn admit_component_tree_preserves_kernel() { + let (_graph, policy) = admit_component_tree( + "custom", + vec![system(vec!["x".into()]), budget(1), compact(8)], + ); + assert!(policy.permission_overlay); + assert!(policy.completion_gate); + } + + #[test] + fn stock_only_components_list_uses_spec_path() { + let config = HarnessConfig::new(3, 50, 8, 1, vec![], vec![]).expect("config"); + let options = HarnessComposeOptions::compose( + vec![ + "system".into(), + "tools".into(), + "budget".into(), + "infer".into(), + ], + Some(3), + None, + vec![], + ) + .expect("compose"); + // No compact part — intentional subset assemble. + assert!(!options + .resolved_components() + .unwrap() + .iter() + .any(|entry| matches!( + entry, + HarnessComponentRef::Stock(HarnessPartId::Compact) + ))); + let (graph, _) = + admit_from_compose_with_registry(Some(&options), None, config).expect("admit"); + assert_eq!(graph.actor().name, "a3s-code"); + } + + #[test] + fn resolved_components_falls_back_to_parts_then_default_stock() { + let from_parts = HarnessComposeOptions { + parts: vec![HarnessPartId::System, HarnessPartId::Infer], + ..HarnessComposeOptions::default() + }; + assert_eq!( + from_parts.resolved_components().expect("parts"), + vec![ + HarnessComponentRef::Stock(HarnessPartId::System), + HarnessComponentRef::Stock(HarnessPartId::Infer), + ] + ); + + let defaults = HarnessComposeOptions::default() + .resolved_components() + .expect("default stock"); + assert_eq!(defaults, default_stock_components()); + } + + #[test] + fn parse_harness_parts_accepts_stock_names_and_rejects_unknown() { + let parts = parse_harness_parts(&[ + "System".into(), + "TOOLS".into(), + "budget".into(), + "compact".into(), + "infer".into(), + ]) + .expect("stock names"); + assert_eq!( + parts, + vec![ + HarnessPartId::System, + HarnessPartId::Tools, + HarnessPartId::Budget, + HarnessPartId::Compact, + HarnessPartId::Infer, + ] + ); + assert!(parse_harness_parts(&["nope".into()]).is_err()); + } + + #[test] + fn admit_from_compose_wrapper_defaults_and_admits_stock() { + let config = HarnessConfig::new(2, 100, 8, 1, vec![], vec![]).expect("config"); + let (default_graph, policy) = admit_from_compose(None, config.clone()).expect("default"); + assert_eq!(default_graph.actor().name, "a3s-code"); + assert!(policy.permission_overlay && policy.completion_gate); + + let options = HarnessComposeOptions { + parts: vec![HarnessPartId::System, HarnessPartId::Budget, HarnessPartId::Infer], + tool_budget: Some(2), + ..HarnessComposeOptions::default() + }; + let (graph, _) = admit_from_compose(Some(&options), config).expect("stock"); + assert_eq!(graph.actor().name, "a3s-code"); + } + + #[test] + fn mixed_tree_includes_compact_and_empty_system_fallback() { + let config = + HarnessConfig::new(2, 40, 8, 1, vec!["cfg-system".into()], vec![]).expect("config"); + let options = HarnessComposeOptions::compose( + vec![ + "system".into(), + "tools".into(), + "budget".into(), + "compact".into(), + host_component_id("intent_stamp"), + "infer".into(), + ], + Some(2), + Some(40), + vec![], // fall back to config.system() + ) + .expect("compose"); + let (graph, policy) = admit_from_compose_with_registry( + Some(&options), + Some(&BuiltinHostHarnessRegistry), + config, + ) + .expect("admit mixed with compact"); + assert_eq!(graph.actor().name, "a3s-code"); + assert!(policy.permission_overlay && policy.completion_gate); + } + + #[test] + fn partition_and_intent_stamp_view_are_behavior_oracles() { + let refs = vec![ + HarnessComponentRef::Stock(HarnessPartId::System), + HarnessComponentRef::Host("intent_stamp".into()), + HarnessComponentRef::Stock(HarnessPartId::Infer), + ]; + let (stock, hosts) = partition_components(&refs); + assert_eq!(stock, vec![HarnessPartId::System, HarnessPartId::Infer]); + assert_eq!(hosts, vec!["intent_stamp".to_string()]); + + let view = intent_stamp_view(); + assert_eq!(view.system, vec![INTENT_STAMP_MARKER.to_string()]); + } + + #[test] + fn host_prefix_is_case_insensitive_and_normalizes_id() { + let upper = parse_harness_component("HOST:Intent_Stamp").expect("HOST:"); + assert_eq!( + upper, + HarnessComponentRef::Host("intent_stamp".into()) + ); + assert_eq!( + host_component_id(" Intent_Stamp "), + "host:intent_stamp".to_string() + ); + } + + #[test] + fn to_spec_keeps_default_system_when_compose_system_empty() { + let defaults = + HarnessConfig::new(4, 10, 8, 1, vec!["default-sys".into()], vec![]).expect("config"); + let options = HarnessComposeOptions { + tool_budget: None, + compact_after_chars: None, + system: vec![], + parts: vec![HarnessPartId::System], + components: Vec::new(), + }; + let spec = options.to_spec(Vec::new(), &defaults); + assert_eq!(spec.budget, 4); + assert_eq!(spec.compact_after_chars, 10); + assert_eq!(spec.system, vec!["default-sys".to_string()]); + } } diff --git a/core/tests/test_meta_harness_compose_live_e2e.rs b/core/tests/test_meta_harness_compose_live_e2e.rs new file mode 100644 index 000000000..02e449c64 --- /dev/null +++ b/core/tests/test_meta_harness_compose_live_e2e.rs @@ -0,0 +1,151 @@ +//! Live Meta Harness compose E2E (Layer C). +//! +//! Asserts kernel outcomes for a Tardigrade-style `components: [...]` tree that +//! mixes stock parts with a host Moore mount. Oracles are digests, files, and +//! gate terminals — not assistant wording. +//! +//! Opt-in: +//! +//! ```text +//! A3S_CONFIG_FILE=/abs/path/.a3s/config.acl \ +//! cargo test -p a3s-code-core --test test_meta_harness_compose_live_e2e \ +//! -- --ignored --test-threads=1 --nocapture +//! ``` + +use std::sync::Arc; +use std::time::Duration; + +use a3s_code_core::harness_loop::CompletionTerminal; +use a3s_code_core::permissions::{PermissionDecision, PermissionPolicy}; +use a3s_code_core::verification::{VerificationCheck, VerificationReport, VerificationStatus}; +use a3s_code_core::{ + host_component_id, Agent, BuiltinHostHarnessRegistry, CompletionAttestor, + HarnessComposeOptions, SessionOptions, INTENT_STAMP_MARKER, +}; + +mod support; +use support::layer_c_model::load_pinned_layer_c_config; + +const MODEL_TIMEOUT: Duration = Duration::from_secs(420); +const WRITE_TOKEN: &str = "meta-harness-compose-token-9c2e"; + +struct DigestBindingAttestor; + +impl CompletionAttestor for DigestBindingAttestor { + fn attest( + &self, + digest: &str, + paths: &[String], + ) -> Option { + if paths.is_empty() { + return None; + } + Some( + VerificationReport::new( + "host:meta_harness_live", + vec![VerificationCheck::required( + "host:effect", + "host_attestation", + "host reconciled composed-harness mutations", + ) + .with_status(VerificationStatus::Passed)], + ) + .with_effect_digest(digest.to_string()), + ) + } +} + +async fn configured_agent() -> Agent { + let config = load_pinned_layer_c_config(); + Agent::from_config(config) + .await + .expect("build agent from .a3s/config.acl") +} + +fn composed_options(session_id: &str) -> SessionOptions { + let policy = PermissionPolicy { + default_decision: PermissionDecision::Deny, + ..PermissionPolicy::default() + } + .allow("read(**)") + .allow("write(**)") + .allow("bash(**)"); + + let harness = HarnessComposeOptions::compose( + vec![ + "system".into(), + "tools".into(), + host_component_id("intent_stamp"), + "budget".into(), + // Intentionally omit compact — subset assemble must still run. + "infer".into(), + ], + Some(6), + None, + vec![format!("compose marker {INTENT_STAMP_MARKER}")], + ) + .expect("compose recipe"); + + SessionOptions::new() + .with_session_id(session_id) + .with_memory(Arc::new(a3s_memory::InMemoryStore::new())) + .with_permission_policy(policy) + .with_default_security() + .with_planning(false) + .with_auto_delegation_enabled(false) + .with_manual_delegation_enabled(false) + .with_max_tool_rounds(6) + .with_llm_api_timeout(180_000) + .with_temperature(0.0) + .with_continuation(false) + .with_harness(harness) + .with_host_harness_registry(Arc::new(BuiltinHostHarnessRegistry)) + .with_completion_attestor(Arc::new(DigestBindingAttestor) as Arc) +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires the DeepSeek Flash model configured in .a3s/config.acl"] +async fn live_composed_harness_write_completes_when_host_attests() { + let agent = configured_agent().await; + let workspace = tempfile::tempdir().expect("workspace"); + let session = agent + .session_async( + workspace.path().to_string_lossy().to_string(), + Some(composed_options("live-meta-harness-compose")), + ) + .await + .expect("composed session"); + + let prompt = format!( + "Create compose_out.txt containing exactly the token {WRITE_TOKEN} \ + using the write tool. Then stop. Do not invent verification." + ); + let result = tokio::time::timeout(MODEL_TIMEOUT, session.send(&prompt, None)) + .await + .expect("composed harness run timed out"); + + let path = workspace.path().join("compose_out.txt"); + assert!( + path.is_file(), + "composed harness must still exercise a workspace write; missing {}", + path.display() + ); + let contents = std::fs::read_to_string(&path).expect("read compose_out.txt"); + assert!( + contents.contains(WRITE_TOKEN), + "write content must carry the host token, got {contents:?}" + ); + + let ok = result.expect("host-attested composed mutation must Allow(Verified)"); + assert!( + matches!(ok.completion, CompletionTerminal::Verified { .. }), + "expected Verified on composed path, got {:?}", + ok.completion + ); + assert!( + ok.verification_reports + .iter() + .any(|report| report.subject == "host:meta_harness_live"), + "attestor report must remain visible on the result" + ); +} diff --git a/justfile b/justfile index 1dec3cc64..a3762a6f5 100644 --- a/justfile +++ b/justfile @@ -609,6 +609,7 @@ layer-c-live-e2e: test_agent_protocol_live_e2e test_harness_loop_live_e2e test_harness_capabilities_live_e2e + test_meta_harness_compose_live_e2e test_deepseek_adversarial_e2e test_update_plan_live_e2e test_prompt_capability_real_llm diff --git a/manual/FIRST_PRINCIPLES_TEST_CASES.md b/manual/FIRST_PRINCIPLES_TEST_CASES.md index 69249dd14..54e22cc19 100644 --- a/manual/FIRST_PRINCIPLES_TEST_CASES.md +++ b/manual/FIRST_PRINCIPLES_TEST_CASES.md @@ -49,6 +49,7 @@ fallback without override remains ACL `default_model` | F05 | Agent protocol v1 | `agent_protocol_*` + host/harness integration | `test_agent_protocol_live_e2e` | `agent_protocol.rs`, `_host`, `_harness` | | F06 | Oversized event projection | from_run_page / bound payload unit | issue-fix / protocol live | `agent_protocol.rs`, `event_protocol.rs` | | F07 | Harness loop + verify gate | harness_loop + verification unit | `test_harness_loop_live_e2e` | `harness_loop.rs`, `verification.rs` | +| F31 | Meta Harness compose | `meta_harness` unit (stock/host mounts, fail-closed); `completion_attestor` unit | `test_meta_harness_compose_live_e2e` | `meta_harness.rs`, `completion_attestor.rs` | | F08 | Baseline tools | builtin tool unit; capabilities hermetic | `test_harness_capabilities_live_e2e` | `tools/builtin/*` (core), `bash.rs` | | F09 | Adversarial cancel / hygiene | effect_isolation discard; cancel unit | `test_deepseek_adversarial_e2e` | `effect_isolation.rs` | | F10 | Update plan | update_plan tool unit | `test_update_plan_live_e2e` | plan tool + `agent/plan_execution.rs` (kernel paths) | diff --git a/manual/META_HARNESS.md b/manual/META_HARNESS.md index f9609942c..94dd5cec3 100644 --- a/manual/META_HARNESS.md +++ b/manual/META_HARNESS.md @@ -6,7 +6,7 @@ and completion-gate enrollment) stays Core-owned and non-bypassable. Related: [HARNESS_CONVERGENCE.md](HARNESS_CONVERGENCE.md), [ADR-003](../../use/docs/adr-003-native-code-harness-boundary.md) (addendum), -`a3s-effect` `compose` module. +`a3s-effect` `compose` module, first-principles gate **F31**. ## Positioning @@ -14,10 +14,10 @@ Related: [HARNESS_CONVERGENCE.md](HARNESS_CONVERGENCE.md), { view, transitions } = f(log) ``` -Hosts mount stock or custom components. The runtime folds the log, enables -keyed transitions, and runs them until nothing remains. Dual *imperative* -message loops are forbidden; **one log + many components** is the intended -orchestration model. +Hosts mount ordered components the way Tardigrade mounts `components: [...]`. +The runtime folds the log, enables keyed transitions, and runs them until +nothing remains. Dual *imperative* message loops are forbidden; **one log + +many components** is the intended orchestration model. Product default remains today's `coding_actor` sugar so existing Agent / Session behavior does not regress when `SessionOptions.harness` is omitted. @@ -47,21 +47,43 @@ Session behavior does not regress when `SessionOptions.harness` is omitted. | `compact` | Compaction threshold slot | | `infer` | Scheduler subtree (stock mount has **no** key prefix for log stability) | +## Host components (`host:`) + +Hosts register Moore factories via `HostHarnessRegistry` and mount them from +the same `components: [...]` list as `host:`. Builtin registry ships +`intent_stamp` (injects `a3s.meta_harness.intent_stamp.v1` into the system +view) for hermetic / Layer C proofs. Full custom trees use +`HostHarnessAssembler` / `admit_component_tree` (Rust embedders). + +SDK surfaces pass **ids + config**, not JS/Python Effect runtimes. + Nested host `infer([...])` mounts **do** namespace child keys under `infer/` so siblings cannot collide. CI / unit tests assert that discipline. ## Composition cookbook -### Rust (`a3s-effect`) +### Rust (`a3s-effect` + Core) ```rust use a3s_effect::{budget, coding_actor, compact, compose_coding_actor, system, tools, HarnessGraph}; +use a3s_code_core::{admit_component_tree, BuiltinHostHarnessRegistry, HarnessComposeOptions}; // Sugar for the stock tree (bit-compatible default): let actor = coding_actor(config); -// Explicit graph from a MetaHarnessSpec / HarnessGraph: -let graph = HarnessGraph::coding(config); +// Tardigrade-style assemble (stock + host): +let recipe = HarnessComposeOptions::compose( + vec![ + "system".into(), + "tools".into(), + "host:intent_stamp".into(), + "budget".into(), + "infer".into(), + ], + Some(4), + None, + vec!["careful coding agent".into()], +)?; ``` ### SessionOptions (Core / SDKs) @@ -69,10 +91,16 @@ let graph = HarnessGraph::coding(config); Omit `harness` → legacy `coding_actor`. ```js -// Node +// Node — prefer `components` agent.session('.', { harness: Harness.compose({ - parts: [Harness.system(), Harness.tools(), Harness.budget(), Harness.compact(), Harness.infer()], + components: [ + Harness.system(), + Harness.tools(), + Harness.host('intent_stamp'), + Harness.budget(), + Harness.infer(), + ], toolBudget: 4, system: ['You are a careful coding agent.'], }), @@ -83,13 +111,36 @@ agent.session('.', { # Python opts = SessionOptions() opts.harness = Harness.compose( - parts=[Harness.system(), Harness.tools(), Harness.budget(), Harness.compact(), Harness.infer()], + components=[ + Harness.system(), + Harness.tools(), + Harness.host("intent_stamp"), + Harness.budget(), + Harness.infer(), + ], tool_budget=4, system=["You are a careful coding agent."], ) ``` -Unknown part names fail closed at compose / session conversion. +Unknown part names and unknown `host:` values fail closed. `host:*` mounts +require `SessionOptions::with_host_harness_registry` (or the builtin registry in +tests). + +## Verification + +| Layer | Gate | +| --- | --- | +| Hermetic | `cargo test -p a3s-code-core --lib meta_harness` | +| F-kernel ≥95% | `meta_harness.rs` + `completion_attestor.rs` in `scripts/f_table_coverage.sh` (F31) | +| Layer C live | `test_meta_harness_compose_live_e2e` (file/digest/gate oracles only) | + +F31 tip evidence (2026-09-25): `A3S_F_TABLE_EVIDENCE=/tmp/a3s-f31-meta-harness +A3S_F_TABLE_MIN_LINE_PCT=95 scripts/f_table_coverage.sh` → +`ALL_F_TABLE_KERNELS_GE_95` with `meta_harness.rs` **99.20%** and +`completion_attestor.rs` **95.00%**. Hermetic suites cover stock/host partition, +fail-closed unknown mounts, default/parts/components resolve paths, and mixed +trees including `compact` — not golden assistant prose. ## Non-goals @@ -98,8 +149,8 @@ Unknown part names fail closed at compose / session conversion. - Restoring `parallel_task` as a second orchestration engine. - Making `advanced-harness` the library default. - Embedding DSH / Cordis / a JavaScript Effect runtime. -- Node/Python host-authored Moore components in this cut (`META-HARNESS2`). - Rust already supports `compose_coding_actor(vec![component(...), ...])`. +- Letting Node/Python author Moore `step`/`output` closures inside the SDK + (host factories stay Rust-side; SDKs pass `host:` only). ## Safety checks @@ -107,3 +158,4 @@ Unknown part names fail closed at compose / session conversion. - Kernel middleware must still strip permissions and enroll the completion gate even if a malicious host component enables `model.turn` without them. - Nested `infer` mounts must not advertise un-prefixed child transition keys. +- Live suites must not assert provider-specific assistant prose. diff --git a/scripts/f_table_coverage.sh b/scripts/f_table_coverage.sh index d1bd67786..7c4087fe0 100755 --- a/scripts/f_table_coverage.sh +++ b/scripts/f_table_coverage.sh @@ -32,6 +32,8 @@ KERNELS=( core/src/event_protocol.rs core/src/harness_loop.rs core/src/verification.rs + core/src/meta_harness.rs + core/src/completion_attestor.rs core/src/tools/builtin/bash.rs core/src/tools/builtin/mod.rs core/src/effect_isolation.rs diff --git a/sdk/node/src/session_options.rs b/sdk/node/src/session_options.rs index 66b985a2f..340de7d50 100644 --- a/sdk/node/src/session_options.rs +++ b/sdk/node/src/session_options.rs @@ -118,9 +118,9 @@ pub struct AutoDelegationOptions { /// Host-facing Meta Harness compose recipe. /// /// Omit `SessionOptions.harness` to keep the legacy `coding_actor` stock tree. -/// When set, ordered `parts` select stock Moore components (`system`, `tools`, -/// `budget`, `compact`, `infer`). Kernel permission overlay and completion gate -/// stay Core-owned and cannot be disabled from this object. +/// Prefer Tardigrade-style `components: [...]` (stock names or `host:`). +/// Legacy `parts` remains for stock-only recipes. Kernel permission overlay +/// and completion gate stay Core-owned and cannot be disabled from this object. #[napi(object)] #[derive(Default, Clone)] pub struct HarnessComposeOptions { @@ -131,8 +131,11 @@ pub struct HarnessComposeOptions { /// Extra system prompts merged into the `system` part. pub system: Option>, /// Ordered stock parts: `system`, `tools`, `budget`, `compact`, `infer`. - /// Empty / omitted selects the full stock tree. + /// Used when `components` is empty / omitted. pub parts: Option>, + /// Tardigrade-style ordered assemble list. When set, takes precedence over + /// `parts`. Entries are stock names or `host:` (Rust registry). + pub components: Option>, } /// Typed Meta Harness builders for SessionOptions.harness. @@ -140,7 +143,13 @@ pub struct HarnessComposeOptions { /// ```js /// agent.session('.', { /// harness: Harness.compose({ -/// parts: [Harness.system, Harness.tools, Harness.budget, Harness.compact, Harness.infer], +/// components: [ +/// Harness.system(), +/// Harness.tools(), +/// Harness.host('intent_stamp'), +/// Harness.budget(), +/// Harness.infer(), +/// ], /// toolBudget: 4, /// }), /// }); @@ -180,6 +189,15 @@ impl Harness { "infer".into() } + /// Host Moore mount id (`host:`). Requires a Rust `HostHarnessRegistry`. + #[napi] + pub fn host(id: String) -> napi::Result { + let formatted = a3s_code_core::host_component_id(&id); + let _ = a3s_code_core::parse_harness_component(&formatted) + .map_err(|error| napi::Error::from_reason(error.to_string()))?; + Ok(formatted) + } + /// Validate and return a compose recipe for `SessionOptions.harness`. #[napi] pub fn compose(options: HarnessComposeOptions) -> napi::Result { @@ -191,8 +209,14 @@ impl Harness { fn js_harness_to_core( options: &HarnessComposeOptions, ) -> napi::Result { + let list = options + .components + .clone() + .filter(|entries| !entries.is_empty()) + .or_else(|| options.parts.clone()) + .unwrap_or_default(); a3s_code_core::HarnessComposeOptions::compose( - options.parts.clone().unwrap_or_default(), + list, options.tool_budget, options.compact_after_chars.map(|value| value as usize), options.system.clone().unwrap_or_default(), diff --git a/sdk/node/src/tests.rs b/sdk/node/src/tests.rs index fede3453b..fef981aa3 100644 --- a/sdk/node/src/tests.rs +++ b/sdk/node/src/tests.rs @@ -601,6 +601,7 @@ fn harness_compose_maps_to_rust_session_options() { "budget".into(), "infer".into(), ]), + components: None, }), ..Default::default() })) @@ -619,6 +620,29 @@ fn harness_compose_maps_to_rust_session_options() { ] ); + let host = js_session_options_to_rust(Some(SessionOptions { + harness: Some(HarnessComposeOptions { + components: Some(vec![ + "system".to_string(), + Harness::host("intent_stamp".to_string()).expect("host id"), + "infer".to_string(), + ]), + ..Default::default() + }), + ..Default::default() + })) + .unwrap(); + let host_harness = host.harness.expect("host harness"); + assert!(host_harness.parts.is_empty()); + assert_eq!( + host_harness.components, + vec![ + "system".to_string(), + "host:intent_stamp".to_string(), + "infer".to_string(), + ] + ); + let invalid = js_session_options_to_rust(Some(SessionOptions { harness: Some(HarnessComposeOptions { parts: Some(vec!["parallel_task".into()]), diff --git a/sdk/python/src/session_config.rs b/sdk/python/src/session_config.rs index ce36fb7a1..e6b9c777b 100644 --- a/sdk/python/src/session_config.rs +++ b/sdk/python/src/session_config.rs @@ -53,9 +53,10 @@ impl PyHostEnvConfig { /// Meta Harness compose recipe for ``SessionOptions.harness``. /// -/// Omit the option to keep the legacy ``coding_actor`` stock tree. When set, -/// ordered ``parts`` select stock Moore components. Kernel permission overlay -/// and completion gate stay Core-owned. +/// Omit the option to keep the legacy ``coding_actor`` stock tree. Prefer +/// Tardigrade-style ``components`` (stock names or ``host:``). Legacy +/// ``parts`` remains for stock-only recipes. Kernel permission overlay and +/// completion gate stay Core-owned. #[pyclass(name = "HarnessComposeOptions")] #[derive(Clone, Default)] pub(super) struct PyHarnessComposeOptions { @@ -68,41 +69,52 @@ pub(super) struct PyHarnessComposeOptions { /// Extra system prompts merged into the ``system`` part. #[pyo3(get, set)] pub(super) system: Vec, - /// Ordered stock parts: ``system``, ``tools``, ``budget``, ``compact``, ``infer``. + /// Ordered stock parts. Used when ``components`` is empty. #[pyo3(get, set)] pub(super) parts: Vec, + /// Tardigrade-style ordered assemble list. When non-empty, takes precedence + /// over ``parts``. + #[pyo3(get, set)] + pub(super) components: Vec, } #[pymethods] impl PyHarnessComposeOptions { #[new] - #[pyo3(signature = (tool_budget=None, compact_after_chars=None, system=None, parts=None))] + #[pyo3(signature = (tool_budget=None, compact_after_chars=None, system=None, parts=None, components=None))] fn new( tool_budget: Option, compact_after_chars: Option, system: Option>, parts: Option>, + components: Option>, ) -> Self { Self { tool_budget, compact_after_chars, system: system.unwrap_or_default(), parts: parts.unwrap_or_default(), + components: components.unwrap_or_default(), } } fn __repr__(&self) -> String { format!( - "HarnessComposeOptions(tool_budget={:?}, compact_after_chars={:?}, system={:?}, parts={:?})", - self.tool_budget, self.compact_after_chars, self.system, self.parts + "HarnessComposeOptions(tool_budget={:?}, compact_after_chars={:?}, system={:?}, parts={:?}, components={:?})", + self.tool_budget, self.compact_after_chars, self.system, self.parts, self.components ) } } impl PyHarnessComposeOptions { pub(super) fn to_core(&self) -> PyResult { + let list = if !self.components.is_empty() { + self.components.clone() + } else { + self.parts.clone() + }; a3s_code_core::HarnessComposeOptions::compose( - self.parts.clone(), + list, self.tool_budget, self.compact_after_chars, self.system.clone(), @@ -117,7 +129,13 @@ impl PyHarnessComposeOptions { /// /// opts = SessionOptions() /// opts.harness = Harness.compose( -/// parts=[Harness.system(), Harness.tools(), Harness.budget(), Harness.compact(), Harness.infer()], +/// components=[ +/// Harness.system(), +/// Harness.tools(), +/// Harness.host("intent_stamp"), +/// Harness.budget(), +/// Harness.infer(), +/// ], /// tool_budget=4, /// ) #[pyclass(name = "Harness")] @@ -150,11 +168,21 @@ impl PyHarness { "infer".into() } + /// Host Moore mount id (``host:``). Requires a Rust ``HostHarnessRegistry``. + #[staticmethod] + pub(super) fn host(id: &str) -> PyResult { + let formatted = a3s_code_core::host_component_id(id); + a3s_code_core::parse_harness_component(&formatted) + .map_err(|error| PyValueError::new_err(error.to_string()))?; + Ok(formatted) + } + /// Validate and return a compose recipe for ``SessionOptions.harness``. #[staticmethod] - #[pyo3(signature = (parts=None, tool_budget=None, compact_after_chars=None, system=None))] + #[pyo3(signature = (parts=None, components=None, tool_budget=None, compact_after_chars=None, system=None))] pub(super) fn compose( parts: Option>, + components: Option>, tool_budget: Option, compact_after_chars: Option, system: Option>, @@ -164,6 +192,7 @@ impl PyHarness { compact_after_chars, system: system.unwrap_or_default(), parts: parts.unwrap_or_default(), + components: components.unwrap_or_default(), }; let _ = options.to_core()?; Ok(options) diff --git a/sdk/python/src/tests.rs b/sdk/python/src/tests.rs index f4e696dcc..2b1416c20 100644 --- a/sdk/python/src/tests.rs +++ b/sdk/python/src/tests.rs @@ -811,6 +811,7 @@ fn session_options_map_harness_compose() { "budget".into(), "infer".into(), ], + components: Vec::new(), }); let opts = build_rust_session_options(session_options).unwrap(); let harness = opts.harness.expect("harness"); @@ -827,6 +828,25 @@ fn session_options_map_harness_compose() { ] ); + let host = PyHarnessComposeOptions { + components: vec![ + "system".into(), + a3s_code_core::host_component_id("intent_stamp"), + "infer".into(), + ], + ..PyHarnessComposeOptions::default() + }; + let host_core = host.to_core().unwrap(); + assert!(host_core.parts.is_empty()); + assert_eq!( + host_core.components, + vec![ + "system".into(), + "host:intent_stamp".into(), + "infer".into(), + ] + ); + let invalid = PyHarnessComposeOptions { parts: vec!["parallel_task".into()], ..PyHarnessComposeOptions::default() From 2c73756305062033914a4b4b1b7b3d35725564d9 Mon Sep 17 00:00:00 2001 From: RoyLin <18770221825@163.com> Date: Fri, 25 Sep 2026 23:51:31 +0800 Subject: [PATCH 6/6] Drop Tardigrade naming; pair attestor paths with ledger digests. Absorbs the useful MutatedPathRecord shape from community PR #172 into the tip host-only CompletionAttestor without Observe or a second gate policy. Co-authored-by: Cursor --- CHANGELOG.md | 6 +- ROADMAP.md | 2 +- core/src/completion_attestor.rs | 94 +++++++++++++++++-- core/src/lib.rs | 2 +- core/src/meta_harness.rs | 14 ++- .../test_meta_harness_compose_live_e2e.rs | 8 +- manual/META_HARNESS.md | 6 +- sdk/node/src/session_options.rs | 4 +- sdk/python/src/session_config.rs | 4 +- 9 files changed, 109 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 266d7061b..5ac87c5aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Meta Harness arbitrary assemble (`META-HARNESS2`).** Tardigrade-style +- **Meta Harness arbitrary assemble (`META-HARNESS2`).** Ordered `components: [...]` accepts stock parts and `host:` mounts via `HostHarnessRegistry` / `HostHarnessAssembler`. Node/Python expose `Harness.host` + `components`. Builtin `intent_stamp` host component supports @@ -48,7 +48,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 supplies a Passed, digest-bound `VerificationReport` after the mutation ledger digest exists and before the completion gate decides. There is no `Observe` bypass — the gate remains non-bypassable; the attestor is not a tool and is - not model-grantable. + not model-grantable. Tip also pairs each mutated path with its ledger content + digest (`MutatedPathRecord`) so hosts can re-read and compare like-for-like + (shape absorbed from community PR #172). - **Meta Harness composition.** Hosts compose stock Moore components (`system`, `tools`, `budget`, `compact`, `infer`) on the single fact log via `SessionOptions.harness` / Node·Python `Harness.compose`. Depends on crates.io diff --git a/ROADMAP.md b/ROADMAP.md index 154f4743d..d8e62a43e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -367,7 +367,7 @@ work on top of the existing admission/budget/cancellation/evidence stages. | `HARNESS-CONV6` | Delivered | Prompt ownership tightened: Core keeps `AgentStyle` → hard permission overlays and `SystemPromptSlots`; specialty markdown bodies documented as a replaceable default pack; hosts own rubric/reviewer prompts | Docs/API comments + hermetic/live `PROMPT-ALIGN1` gates | | `HARNESS-CONV7` | Code prep Delivered / external In progress | Code wrap-up index [HARNESS_CONVERGENCE.md](manual/HARNESS_CONVERGENCE.md); runbooks for [TB-QUAL1](manual/TERMINAL_BENCH.md), [DM-PROD1](manual/DURABLE_MEMORY_PRODUCTION_QUALIFICATION.md), [CAR checklist](manual/CLOUD_HARNESS_CONFORMANCE.md); DeepSeek/prompt live suites retained | Reproducible Harbor/host/Cloud·Box reports without secret leakage; paste links into ROADMAP when Delivered | | `META-HARNESS1` | Delivered | Composable Meta Harness on one fact log: crates.io `a3s-effect` 0.1.0 stock parts + `HarnessGraph`; Core `SessionOptions.harness` / kernel admission; Node/Python stock `Harness.compose`; default remains `coding_actor` | Effect compose tests; `meta_harness` admission tests; SDK SessionOptions mapping; [META_HARNESS.md](manual/META_HARNESS.md) | -| `META-HARNESS2` | Delivered | Tardigrade-style `components: [...]` with `host:` mounts; `HostHarnessRegistry` / `HostHarnessAssembler`; Node/Python `Harness.host` + `components`; F31 kernel coverage + Layer C `test_meta_harness_compose_live_e2e`; still non-bypassable kernel | `meta_harness` host-mount hermetic; SDK mapping; live compose write+attest; `f_table_coverage` kernels `meta_harness.rs` / `completion_attestor.rs` | +| `META-HARNESS2` | Delivered | Ordered `components: [...]` with `host:` mounts; `HostHarnessRegistry` / `HostHarnessAssembler`; Node/Python `Harness.host` + `components`; F31 kernel coverage + Layer C `test_meta_harness_compose_live_e2e`; still non-bypassable kernel | `meta_harness` host-mount hermetic; SDK mapping; live compose write+attest; `f_table_coverage` kernels `meta_harness.rs` / `completion_attestor.rs` | #### Harness convergence refuse list diff --git a/core/src/completion_attestor.rs b/core/src/completion_attestor.rs index 2b9ac70bb..f71a4c692 100644 --- a/core/src/completion_attestor.rs +++ b/core/src/completion_attestor.rs @@ -3,8 +3,12 @@ //! The completion gate stays non-bypassable. Hosts that orchestrate third-party //! AgentDirs cannot predict [`MutationLedger::digest`](crate::harness_loop::MutationLedger::digest) //! at session build, so exact waivers are unreachable. A [`CompletionAttestor`] -//! is invoked with the live digest and mutated paths **after** they exist and -//! **before** [`decide_with_observations`](crate::harness_loop::decide_with_observations). +//! is invoked with the live effect digest and mutated paths **after** they exist +//! and **before** [`decide_with_observations`](crate::harness_loop::decide_with_observations). +//! +//! Each path is paired with the ledger's content digest so a host can re-read +//! the file and compare like-for-like without reaching into harness internals +//! (follow-up shape from community PR #172). //! //! The attestor may only supply a [`VerificationReport`](crate::verification::VerificationReport). //! The gate still requires a Passed, digest-bound report. The attestor is not a @@ -17,6 +21,15 @@ use crate::harness_loop::MutationLedger; use crate::read_only_verifier::{accept_report, ReportAuthor}; use crate::verification::VerificationReport; +/// One mutated path with the content digest the ledger recorded for it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MutatedPathRecord { + /// Workspace-relative path as recorded on the ledger. + pub path: String, + /// Content digest from [`MutationLedger::content_digest_for_path`]. + pub content_digest: String, +} + /// Host-supplied evidence binder for the completion gate. /// /// Return `None` to leave the report list unchanged. A returned report still @@ -24,7 +37,11 @@ use crate::verification::VerificationReport; /// checks Passed) for the gate to allow the run. pub trait CompletionAttestor: Send + Sync { /// Called once the mutation ledger digest and paths exist. - fn attest(&self, digest: &str, paths: &[String]) -> Option; + fn attest( + &self, + effect_digest: &str, + paths: &[MutatedPathRecord], + ) -> Option; } /// Invoke the host attestor (if any) and append an accepted Host report. @@ -46,7 +63,16 @@ pub fn merge_attested_report( if digest.trim().is_empty() { return; } - let paths: Vec = ledger.paths().map(str::to_string).collect(); + let paths: Vec = ledger + .paths() + .map(|path| MutatedPathRecord { + path: path.to_string(), + content_digest: ledger + .content_digest_for_path(path) + .unwrap_or("") + .to_string(), + }) + .collect(); let Some(report) = attestor.attest(digest, &paths) else { return; }; @@ -65,8 +91,13 @@ mod tests { struct BindingAttestor; impl CompletionAttestor for BindingAttestor { - fn attest(&self, digest: &str, paths: &[String]) -> Option { + fn attest( + &self, + effect_digest: &str, + paths: &[MutatedPathRecord], + ) -> Option { assert!(!paths.is_empty()); + assert!(paths.iter().any(|p| !p.content_digest.is_empty())); Some( VerificationReport::new( "host:attestor", @@ -77,7 +108,7 @@ mod tests { ) .with_status(VerificationStatus::Passed)], ) - .with_effect_digest(digest.to_string()), + .with_effect_digest(effect_digest.to_string()), ) } } @@ -85,7 +116,11 @@ mod tests { struct SilentAttestor; impl CompletionAttestor for SilentAttestor { - fn attest(&self, _digest: &str, _paths: &[String]) -> Option { + fn attest( + &self, + _effect_digest: &str, + _paths: &[MutatedPathRecord], + ) -> Option { None } } @@ -140,7 +175,11 @@ mod tests { fn empty_ledger_skips_attestor() { struct PanicAttestor; impl CompletionAttestor for PanicAttestor { - fn attest(&self, _: &str, _: &[String]) -> Option { + fn attest( + &self, + _: &str, + _: &[MutatedPathRecord], + ) -> Option { panic!("must not run on empty ledger"); } } @@ -152,4 +191,43 @@ mod tests { ); assert!(reports.is_empty()); } + + #[test] + fn mismatched_digest_report_cannot_bypass_gate() { + struct WrongDigestAttestor; + impl CompletionAttestor for WrongDigestAttestor { + fn attest( + &self, + _effect_digest: &str, + paths: &[MutatedPathRecord], + ) -> Option { + assert!(!paths.is_empty()); + Some( + VerificationReport::new( + "host:attestor", + vec![VerificationCheck::required( + "host:effect", + "host_attestation", + "wrong digest on purpose", + ) + .with_status(VerificationStatus::Passed)], + ) + .with_effect_digest("sha256:not-the-ledger-digest".to_string()), + ) + } + } + let ledger = ledger_with_write("out.txt"); + let mut reports = Vec::new(); + merge_attested_report( + Some(&(Arc::new(WrongDigestAttestor) as Arc)), + &ledger, + &mut reports, + ); + assert_eq!(reports.len(), 1); + let gate = decide_with_observations(&ledger, &reports, &[], false, &[]); + assert!(matches!( + gate, + crate::harness_loop::CompletionGate::Incomplete { .. } + )); + } } diff --git a/core/src/lib.rs b/core/src/lib.rs index 2d1eab25b..a6d350bf1 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -226,7 +226,7 @@ pub use cognitive_context::{ COGNITIVE_PACKAGE_BINDING_SCHEMA, OKF_KNOWLEDGE_CITATION_SCHEMA, OKF_KNOWLEDGE_READ_REQUEST_SCHEMA, OKF_KNOWLEDGE_SEARCH_REQUEST_SCHEMA, }; -pub use completion_attestor::{merge_attested_report, CompletionAttestor}; +pub use completion_attestor::{merge_attested_report, CompletionAttestor, MutatedPathRecord}; pub use config::{ AutoDelegationConfig, CodeConfig, ModelConfig, ModelCost, ModelLimit, ModelModalities, OsConfig, ProviderConfig, diff --git a/core/src/meta_harness.rs b/core/src/meta_harness.rs index afa32da71..ea8f6c7cb 100644 --- a/core/src/meta_harness.rs +++ b/core/src/meta_harness.rs @@ -1,4 +1,4 @@ -//! Non-bypassable Meta Harness kernel policy and Tardigrade-style assemble. +//! Non-bypassable Meta Harness kernel policy and ordered assemble. //! //! Hosts mount ordered Moore components on one fact log (`components: [...]`). //! Entries are stock parts (`system` / `tools` / `budget` / `compact` / `infer`) @@ -9,8 +9,6 @@ //! [`admit_component_tree`]. SDKs pass the declarative recipe; host Moore //! factories stay Rust-side (no second imperative loop, no Effect-TS embed). -use std::sync::Arc; - use a3s_effect::{ budget, coding_scheduler, compact, compose_coding_actor, component, system, tools, CodingServices, ErasedComponent, HarnessConfig, HarnessGraph, HarnessView, MetaHarnessSpec, @@ -19,7 +17,7 @@ use a3s_effect::{ pub use a3s_effect::HarnessPartId; -/// One entry in a Tardigrade-style `components: [...]` assemble list. +/// One entry in an ordered `components: [...]` assemble list. #[derive(Debug, Clone, PartialEq, Eq)] pub enum HarnessComponentRef { Stock(HarnessPartId), @@ -38,8 +36,8 @@ pub struct HarnessComposeOptions { pub system: Vec, /// Ordered stock parts. Used when [`Self::components`] is empty. pub parts: Vec, - /// Tardigrade-style ordered assemble list. When non-empty, takes precedence - /// over [`Self::parts`]. Entries are stock names or `host:`. + /// Ordered assemble list. When non-empty, takes precedence over + /// [`Self::parts`]. Entries are stock names or `host:`. pub components: Vec, } @@ -80,7 +78,7 @@ impl HarnessComposeOptions { Ok(default_stock_components()) } - /// Build from a Tardigrade-style `components: [...]` list. + /// Build from an ordered `components: [...]` list. /// /// Stock names: `system`, `tools`, `budget`, `compact`, `infer`. /// Host mounts: `host:` (resolved at admit time via @@ -110,7 +108,7 @@ impl HarnessComposeOptions { } } -/// Default Tardigrade stock order when neither `components` nor `parts` is set. +/// Default stock order when neither `components` nor `parts` is set. fn default_stock_components() -> Vec { vec![ HarnessComponentRef::Stock(HarnessPartId::System), diff --git a/core/tests/test_meta_harness_compose_live_e2e.rs b/core/tests/test_meta_harness_compose_live_e2e.rs index 02e449c64..898a0d244 100644 --- a/core/tests/test_meta_harness_compose_live_e2e.rs +++ b/core/tests/test_meta_harness_compose_live_e2e.rs @@ -1,8 +1,8 @@ //! Live Meta Harness compose E2E (Layer C). //! -//! Asserts kernel outcomes for a Tardigrade-style `components: [...]` tree that -//! mixes stock parts with a host Moore mount. Oracles are digests, files, and -//! gate terminals — not assistant wording. +//! Asserts kernel outcomes for an ordered `components: [...]` tree that mixes +//! stock parts with a host Moore mount. Oracles are digests, files, and gate +//! terminals — not assistant wording. //! //! Opt-in: //! @@ -35,7 +35,7 @@ impl CompletionAttestor for DigestBindingAttestor { fn attest( &self, digest: &str, - paths: &[String], + paths: &[a3s_code_core::MutatedPathRecord], ) -> Option { if paths.is_empty() { return None; diff --git a/manual/META_HARNESS.md b/manual/META_HARNESS.md index 94dd5cec3..c714526c4 100644 --- a/manual/META_HARNESS.md +++ b/manual/META_HARNESS.md @@ -14,7 +14,7 @@ Related: [HARNESS_CONVERGENCE.md](HARNESS_CONVERGENCE.md), { view, transitions } = f(log) ``` -Hosts mount ordered components the way Tardigrade mounts `components: [...]`. +Hosts mount ordered components via `components: [...]`. The runtime folds the log, enables keyed transitions, and runs them until nothing remains. Dual *imperative* message loops are forbidden; **one log + many components** is the intended orchestration model. @@ -71,7 +71,7 @@ use a3s_code_core::{admit_component_tree, BuiltinHostHarnessRegistry, HarnessCom // Sugar for the stock tree (bit-compatible default): let actor = coding_actor(config); -// Tardigrade-style assemble (stock + host): +// Ordered assemble (stock + host): let recipe = HarnessComposeOptions::compose( vec![ "system".into(), @@ -144,7 +144,7 @@ trees including `compact` — not golden assistant prose. ## Non-goals -- Porting Tardigrade / Effect TS into Code. +- Porting Effect TS into Code. - Letting host components forge completion waivers or skip permission projection. - Restoring `parallel_task` as a second orchestration engine. - Making `advanced-harness` the library default. diff --git a/sdk/node/src/session_options.rs b/sdk/node/src/session_options.rs index 340de7d50..b47778738 100644 --- a/sdk/node/src/session_options.rs +++ b/sdk/node/src/session_options.rs @@ -118,7 +118,7 @@ pub struct AutoDelegationOptions { /// Host-facing Meta Harness compose recipe. /// /// Omit `SessionOptions.harness` to keep the legacy `coding_actor` stock tree. -/// Prefer Tardigrade-style `components: [...]` (stock names or `host:`). +/// Prefer ordered `components: [...]` (stock names or `host:`). /// Legacy `parts` remains for stock-only recipes. Kernel permission overlay /// and completion gate stay Core-owned and cannot be disabled from this object. #[napi(object)] @@ -133,7 +133,7 @@ pub struct HarnessComposeOptions { /// Ordered stock parts: `system`, `tools`, `budget`, `compact`, `infer`. /// Used when `components` is empty / omitted. pub parts: Option>, - /// Tardigrade-style ordered assemble list. When set, takes precedence over + /// Ordered assemble list. When set, takes precedence over /// `parts`. Entries are stock names or `host:` (Rust registry). pub components: Option>, } diff --git a/sdk/python/src/session_config.rs b/sdk/python/src/session_config.rs index e6b9c777b..dbded3a03 100644 --- a/sdk/python/src/session_config.rs +++ b/sdk/python/src/session_config.rs @@ -54,7 +54,7 @@ impl PyHostEnvConfig { /// Meta Harness compose recipe for ``SessionOptions.harness``. /// /// Omit the option to keep the legacy ``coding_actor`` stock tree. Prefer -/// Tardigrade-style ``components`` (stock names or ``host:``). Legacy +/// Ordered ``components`` list (stock names or ``host:``). Legacy /// ``parts`` remains for stock-only recipes. Kernel permission overlay and /// completion gate stay Core-owned. #[pyclass(name = "HarnessComposeOptions")] @@ -72,7 +72,7 @@ pub(super) struct PyHarnessComposeOptions { /// Ordered stock parts. Used when ``components`` is empty. #[pyo3(get, set)] pub(super) parts: Vec, - /// Tardigrade-style ordered assemble list. When non-empty, takes precedence + /// Ordered assemble list. When non-empty, takes precedence /// over ``parts``. #[pyo3(get, set)] pub(super) components: Vec,