From ed5c90399a95f95193ef070391791b13ea607b68 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Tue, 12 May 2026 17:51:42 -0700 Subject: [PATCH 1/7] =?UTF-8?q?arch:=20ws-0=20=E2=80=94=20state,=20checkpo?= =?UTF-8?q?ints,=20BoundedRing,=20CapabilityCallSignature,=20NoProgressDet?= =?UTF-8?q?ected=20(iter=204,=20approved)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 13 + Cargo.toml | 2 +- crates/ironclaw_agent_loop/CLAUDE.md | 21 ++ crates/ironclaw_agent_loop/Cargo.toml | 20 + crates/ironclaw_agent_loop/src/lib.rs | 7 + crates/ironclaw_agent_loop/src/state.rs | 356 ++++++++++++++++++ .../src/state/bounded_ring.rs | 118 ++++++ .../src/state/signature.rs | 115 ++++++ crates/ironclaw_agent_loop/src/state/slots.rs | 22 ++ .../tests/thread_loop_support_contract.rs | 4 + .../ironclaw_reborn/src/milestone_events.rs | 1 + .../ironclaw_reborn/src/text_loop_driver.rs | 2 + .../ironclaw_reborn/tests/loop_driver_host.rs | 53 ++- crates/ironclaw_turns/CLAUDE.md | 10 + crates/ironclaw_turns/src/loop_exit.rs | 4 +- .../ironclaw_turns/src/loop_exit/tests/mod.rs | 9 + crates/ironclaw_turns/src/run_profile/host.rs | 26 ++ crates/ironclaw_turns/src/run_profile/mod.rs | 15 +- .../ironclaw_turns/src/run_profile/prompt.rs | 82 ++++ .../tests/agent_loop_host_contract.rs | 18 + 20 files changed, 884 insertions(+), 14 deletions(-) create mode 100644 crates/ironclaw_agent_loop/CLAUDE.md create mode 100644 crates/ironclaw_agent_loop/Cargo.toml create mode 100644 crates/ironclaw_agent_loop/src/lib.rs create mode 100644 crates/ironclaw_agent_loop/src/state.rs create mode 100644 crates/ironclaw_agent_loop/src/state/bounded_ring.rs create mode 100644 crates/ironclaw_agent_loop/src/state/signature.rs create mode 100644 crates/ironclaw_agent_loop/src/state/slots.rs diff --git a/Cargo.lock b/Cargo.lock index cab681b71dc..766acdcc770 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4072,6 +4072,19 @@ dependencies = [ "zip", ] +[[package]] +name = "ironclaw_agent_loop" +version = "0.1.0" +dependencies = [ + "async-trait", + "ironclaw_host_api", + "ironclaw_turns", + "serde", + "serde_json", + "siphasher", + "thiserror 2.0.18", +] + [[package]] name = "ironclaw_approvals" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 030d6bdbeaa..298bf6667f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [".", "crates/ironclaw_common", "crates/ironclaw_host_api", "crates/ironclaw_storage", "crates/ironclaw_filesystem", "crates/ironclaw_memory", "crates/ironclaw_events", "crates/ironclaw_event_projections", "crates/ironclaw_reborn_event_store", "crates/ironclaw_extensions", "crates/ironclaw_processes", "crates/ironclaw_dispatcher", "crates/ironclaw_scripts", "crates/ironclaw_mcp", "crates/ironclaw_wasm", "crates/ironclaw_capabilities", "crates/ironclaw_secrets", "crates/ironclaw_network", "crates/ironclaw_host_runtime", "crates/ironclaw_runtime_policy", "crates/ironclaw_authorization", "crates/ironclaw_run_state", "crates/ironclaw_approvals", "crates/ironclaw_resources", "crates/ironclaw_trust", "crates/ironclaw_turns", "crates/ironclaw_threads", "crates/ironclaw_loop_support", "crates/ironclaw_reborn", "crates/ironclaw_reborn_config", "crates/ironclaw_reborn_composition", "crates/ironclaw_reborn_cli", "crates/ironclaw_conversations", "crates/ironclaw_product_adapters", "crates/ironclaw_product_workflow", "crates/ironclaw_product_adapter_registry", "crates/ironclaw_wasm_product_adapters", "crates/ironclaw_telegram_v2_adapter", "crates/ironclaw_outbound", "crates/ironclaw_architecture", "crates/ironclaw_safety", "crates/ironclaw_skills", "crates/ironclaw_llm", "crates/ironclaw_engine", "crates/ironclaw_gateway", "crates/ironclaw_tui"] +members = [".", "crates/ironclaw_common", "crates/ironclaw_host_api", "crates/ironclaw_storage", "crates/ironclaw_filesystem", "crates/ironclaw_memory", "crates/ironclaw_events", "crates/ironclaw_event_projections", "crates/ironclaw_reborn_event_store", "crates/ironclaw_extensions", "crates/ironclaw_processes", "crates/ironclaw_dispatcher", "crates/ironclaw_scripts", "crates/ironclaw_mcp", "crates/ironclaw_wasm", "crates/ironclaw_capabilities", "crates/ironclaw_secrets", "crates/ironclaw_network", "crates/ironclaw_host_runtime", "crates/ironclaw_runtime_policy", "crates/ironclaw_authorization", "crates/ironclaw_run_state", "crates/ironclaw_approvals", "crates/ironclaw_resources", "crates/ironclaw_trust", "crates/ironclaw_turns", "crates/ironclaw_agent_loop", "crates/ironclaw_threads", "crates/ironclaw_loop_support", "crates/ironclaw_reborn", "crates/ironclaw_reborn_config", "crates/ironclaw_reborn_composition", "crates/ironclaw_reborn_cli", "crates/ironclaw_conversations", "crates/ironclaw_product_adapters", "crates/ironclaw_product_workflow", "crates/ironclaw_product_adapter_registry", "crates/ironclaw_wasm_product_adapters", "crates/ironclaw_telegram_v2_adapter", "crates/ironclaw_outbound", "crates/ironclaw_architecture", "crates/ironclaw_safety", "crates/ironclaw_skills", "crates/ironclaw_llm", "crates/ironclaw_engine", "crates/ironclaw_gateway", "crates/ironclaw_tui"] exclude = [ "channels-src/discord", "channels-src/feishu", diff --git a/crates/ironclaw_agent_loop/CLAUDE.md b/crates/ironclaw_agent_loop/CLAUDE.md new file mode 100644 index 00000000000..fd03556c076 --- /dev/null +++ b/crates/ironclaw_agent_loop/CLAUDE.md @@ -0,0 +1,21 @@ +# ironclaw_agent_loop guardrails + +- Owns "what an agent loop is": strategy traits, the `AgentLoopPlanner` facade, + the `AgentLoopExecutor` trait + canonical impl, and `LoopExecutionState`. +- Stays one layer above `ironclaw_turns` (which owns runner-facing turn + contracts). Depends on `ironclaw_turns` for `LoopRunContext`, `LoopExit`, + `LoopXxxPort` traits, and ref types. +- Does NOT depend on `ironclaw_reborn`. The framework crate has no knowledge + of `AgentLoopDriver`; that bridge lives in `PlannedDriver` in + `ironclaw_reborn`. +- Stores refs, cursors, counters, versions, and safe summaries only. Never + raw prompts, raw model output, raw tool input, secrets, host paths, provider + errors, or stack traces in `LoopExecutionState` or any strategy slot. +- Strategies are `&self`-only; `LoopExecutionState` is value-immutable. All + mutation happens by the executor swapping a strategy's returned slot into + the next whole state. There is no `&mut LoopExecutionState` API. +- New strategies, slots, and outcome enums must land typed (no string keys, + no `serde_json::Value` interior in long-lived state). Per + `.claude/rules/types.md`. +- Master spec: `docs/reborn/agent-loop-skeleton.md`. Workstream briefs: + `docs/reborn/agent-loop-briefs/`. diff --git a/crates/ironclaw_agent_loop/Cargo.toml b/crates/ironclaw_agent_loop/Cargo.toml new file mode 100644 index 00000000000..1e8cad0a957 --- /dev/null +++ b/crates/ironclaw_agent_loop/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ironclaw_agent_loop" +version = "0.1.0" +edition = "2024" +rust-version = "1.92" +description = "Agent-loop framework state and strategy contracts for IronClaw Reborn" +authors = ["NEAR AI "] +license = "MIT OR Apache-2.0" +homepage = "https://github.com/nearai/ironclaw" +repository = "https://github.com/nearai/ironclaw" +publish = false + +[dependencies] +async-trait = "0.1" +ironclaw_host_api = { path = "../ironclaw_host_api", version = "0.1.0" } +ironclaw_turns = { path = "../ironclaw_turns", version = "0.1.0" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +siphasher = "1" +thiserror = "2" diff --git a/crates/ironclaw_agent_loop/src/lib.rs b/crates/ironclaw_agent_loop/src/lib.rs new file mode 100644 index 00000000000..8910171981b --- /dev/null +++ b/crates/ironclaw_agent_loop/src/lib.rs @@ -0,0 +1,7 @@ +//! Agent-loop framework state and strategy contracts for IronClaw Reborn. +//! +//! This crate owns the framework layer above `ironclaw_turns`. The master +//! architecture is `docs/reborn/agent-loop-skeleton.md`; workstream briefs live +//! under `docs/reborn/agent-loop-briefs/`. + +pub mod state; diff --git a/crates/ironclaw_agent_loop/src/state.rs b/crates/ironclaw_agent_loop/src/state.rs new file mode 100644 index 00000000000..12aeddc898e --- /dev/null +++ b/crates/ironclaw_agent_loop/src/state.rs @@ -0,0 +1,356 @@ +//! Immutable loop execution state. +//! +//! See `docs/reborn/agent-loop-skeleton.md` sections 5-7 for the mutability +//! model and `docs/reborn/agent-loop-briefs/state-and-checkpoints.md` for this +//! crate foundation. + +mod bounded_ring; +mod signature; +mod slots; + +pub use bounded_ring::BoundedRing; +pub use ironclaw_turns::LoopFailureKind; +pub use signature::{ArgsHash, CapabilityCallSignature}; +pub use slots::{ + CapabilityStrategyState, ContextStrategyState, ControlStrategyState, ModelStrategyState, + RecoveryStrategyState, +}; + +use ironclaw_turns::{ + LoopGateRef, LoopMessageRef, LoopResultRef, + run_profile::{CapabilitySurfaceVersion, LoopInputCursor, LoopRunContext}, +}; + +/// Checkpoint payload schema reserved for the default Reborn loop. +pub const CHECKPOINT_SCHEMA_ID: &str = "reborn:default-loop-v1"; + +/// Immutable execution state threaded through the loop. +/// +/// The executor rebinds its local `let mut state` each tick to the next whole +/// state. Strategies receive `&LoopExecutionState` and return outcome enums +/// that carry the new value of their own slot. The executor builds the next +/// whole state by swapping that slot. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct LoopExecutionState { + pub iteration: u32, + pub last_checkpoint: Option, + pub assistant_refs: Vec, + pub result_refs: Vec, + pub last_gate: Option, + pub input_cursor: LoopInputCursor, + pub surface_version: Option, + pub recent_call_signatures: BoundedRing, + pub recent_failure_kinds: BoundedRing, + pub context_state: ContextStrategyState, + pub capability_state: CapabilityStrategyState, + pub model_state: ModelStrategyState, + pub recovery_state: RecoveryStrategyState, + pub control_state: ControlStrategyState, +} + +impl LoopExecutionState { + /// Builds the initial state at the start of a fresh run. + /// + /// The `input_cursor` field is populated via + /// [`LoopInputCursor::origin_for_run`], which binds the cursor to the + /// active run's `(scope, run_id)`. Callers must therefore hold a valid + /// [`LoopRunContext`] at the start of every run — there is no + /// `Default`-shaped constructor because every cursor must name a run. + pub fn initial_for_run(context: &LoopRunContext) -> Self { + Self { + iteration: 0, + last_checkpoint: None, + assistant_refs: Vec::new(), + result_refs: Vec::new(), + last_gate: None, + input_cursor: LoopInputCursor::origin_for_run(context), + surface_version: None, + recent_call_signatures: BoundedRing::new(), + recent_failure_kinds: BoundedRing::new(), + context_state: ContextStrategyState::default(), + capability_state: CapabilityStrategyState::default(), + model_state: ModelStrategyState::default(), + recovery_state: RecoveryStrategyState::default(), + control_state: ControlStrategyState::default(), + } + } + + /// Rehydrates state from a checkpoint payload. + /// + /// Payloads must be JSON objects shaped as: + /// `{ "schema_id": "reborn:default-loop-v1", "state": }`. + pub fn from_checkpoint_payload( + payload: &serde_json::Value, + ) -> Result { + let object = payload + .as_object() + .ok_or_else(|| CheckpointPayloadError::InvalidField { + field: "payload", + reason: "expected checkpoint payload object".to_string(), + })?; + let schema_id = object + .get("schema_id") + .ok_or(CheckpointPayloadError::MissingField { field: "schema_id" })?; + let schema_id = schema_id + .as_str() + .ok_or_else(|| CheckpointPayloadError::InvalidField { + field: "schema_id", + reason: "expected string schema id".to_string(), + })?; + if schema_id != CHECKPOINT_SCHEMA_ID { + return Err(CheckpointPayloadError::SchemaMismatch { + expected: CHECKPOINT_SCHEMA_ID.to_string(), + actual: schema_id.to_string(), + }); + } + + let state = object + .get("state") + .ok_or(CheckpointPayloadError::MissingField { field: "state" })?; + serde_json::from_value(state.clone()).map_err(|error| { + CheckpointPayloadError::InvalidField { + field: "state", + reason: error.to_string(), + } + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct CheckpointMarker { + pub kind: CheckpointKind, + pub iteration_at_checkpoint: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CheckpointKind { + BeforeModel, + BeforeSideEffect, + BeforeBlock, + Final, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum CheckpointPayloadError { + #[error("checkpoint payload schema id mismatch: expected `{expected}`, got `{actual}`")] + SchemaMismatch { expected: String, actual: String }, + #[error("checkpoint payload missing required field `{field}`")] + MissingField { field: &'static str }, + #[error("checkpoint payload field `{field}` failed validation: {reason}")] + InvalidField { field: &'static str, reason: String }, +} + +#[cfg(test)] +mod tests { + use ironclaw_host_api::{CapabilityId, TenantId, ThreadId}; + use ironclaw_turns::{ + AgentLoopDriverDescriptor, RunProfileId, RunProfileVersion, TurnId, TurnRunId, TurnScope, + run_profile::{ + CancellationPolicy, CapabilitySurfaceProfileId, CheckpointPolicy, CheckpointSchemaId, + ConcurrencyClass, ContextProfileId, LoopDriverId, ModelProfileId, + RedactedRunProfileProvenance, ResolvedRunProfile, ResourceBudgetPolicy, + ResourceBudgetTier, RunClassId, RunProfileFingerprint, RuntimeProfileConstraints, + SchedulingClass, SteeringPolicy, + }, + }; + use serde_json::json; + + use super::*; + + fn test_run_context() -> LoopRunContext { + let scope = TurnScope::new( + TenantId::new("tenant-loop-state").expect("valid"), + None, + None, + ThreadId::new("thread-loop-state").expect("valid"), + ); + let descriptor = AgentLoopDriverDescriptor { + id: LoopDriverId::new("loop_state_test_driver").expect("valid"), + version: RunProfileVersion::new(1), + checkpoint_schema_id: Some( + CheckpointSchemaId::new("loop_state_test_checkpoint").expect("valid"), + ), + checkpoint_schema_version: Some(RunProfileVersion::new(1)), + }; + let resolved_run_profile = ResolvedRunProfile { + run_class_id: RunClassId::new("loop_state_test_class").expect("valid"), + profile_id: RunProfileId::default_profile(), + profile_version: RunProfileVersion::new(1), + loop_driver: descriptor.clone(), + checkpoint_schema_id: descriptor + .checkpoint_schema_id + .clone() + .expect("descriptor checkpoint id"), + checkpoint_schema_version: descriptor + .checkpoint_schema_version + .expect("descriptor checkpoint version"), + model_profile_id: ModelProfileId::new("loop_state_test_model").expect("valid"), + capability_surface_profile_id: CapabilitySurfaceProfileId::new( + "loop_state_test_capabilities", + ) + .expect("valid"), + context_profile_id: ContextProfileId::new("loop_state_test_context").expect("valid"), + steering_policy: SteeringPolicy { + allow_steering: false, + allow_interrupt: true, + allow_driver_specific_nudges: false, + }, + cancellation_policy: CancellationPolicy { + allow_cancel: true, + require_checkpoint_before_cancel: false, + }, + checkpoint_policy: CheckpointPolicy { + require_before_model: false, + require_before_side_effect: false, + require_before_block: true, + max_checkpoint_bytes: 64 * 1024, + require_final_checkpoint: false, + allow_no_reply_completion: false, + }, + resource_budget_policy: ResourceBudgetPolicy { + tier: ResourceBudgetTier::new("loop_state_test_tier").expect("valid"), + max_model_calls: 32, + max_capability_invocations: 64, + }, + runtime_constraints: RuntimeProfileConstraints { + allow_raw_runtime_backend_selection: false, + allow_broad_capability_surface: false, + }, + runner_pool_id: None, + scheduling_class: SchedulingClass::new("interactive").expect("valid"), + concurrency_class: ConcurrencyClass::new("thread_serial").expect("valid"), + resolution_fingerprint: RunProfileFingerprint::new("loop-state-test-fingerprint") + .expect("valid"), + provenance: RedactedRunProfileProvenance { + sources: vec![], + effective_privileges: vec![], + }, + }; + LoopRunContext::new(scope, TurnId::new(), TurnRunId::new(), resolved_run_profile) + } + + #[test] + fn bounded_ring_push_rolls_over_at_capacity() { + let mut ring = BoundedRing::::new(); + ring.push(1); + ring.push(2); + ring.push(3); + ring.push(4); + + assert_eq!(ring.iter().copied().collect::>(), vec![2, 3, 4]); + } + + #[test] + fn bounded_ring_most_common_count_respects_window() { + let mut ring = BoundedRing::::new(); + for item in [1, 2, 2, 3, 3, 3] { + ring.push(item); + } + + assert_eq!(ring.most_common_count_in(0), 0); + assert_eq!(ring.most_common_count_in(2), 2); + assert_eq!(ring.most_common_count_in(6), 3); + assert_eq!(ring.most_common_count_in(20), 3); + } + + #[test] + fn bounded_ring_same_run_length_counts_trailing_run() { + let empty = BoundedRing::::new(); + assert_eq!(empty.same_run_length(), 0); + + let mut distinct = BoundedRing::::new(); + distinct.push(1); + distinct.push(2); + distinct.push(3); + assert_eq!(distinct.same_run_length(), 1); + + let mut run = BoundedRing::::new(); + for item in [1, 2, 3, 3, 3] { + run.push(item); + } + assert_eq!(run.same_run_length(), 3); + } + + #[test] + fn capability_call_signature_is_stable_under_key_reordering() { + let capability = CapabilityId::new("demo.echo").unwrap(); + let reordered = CapabilityId::new("demo.echo").unwrap(); + let first = CapabilityCallSignature::from_call( + capability, + &json!({"b": 2, "a": {"d": false, "c": [1, null]}}), + ); + let second = CapabilityCallSignature::from_call( + reordered, + &json!({"a": {"c": [1, null], "d": false}, "b": 2}), + ); + + assert_eq!(first, second); + } + + #[test] + fn initial_state_is_value_equal_across_calls() { + let context = test_run_context(); + assert_eq!( + LoopExecutionState::initial_for_run(&context), + LoopExecutionState::initial_for_run(&context) + ); + } + + #[test] + fn loop_execution_state_round_trips_through_json() { + let context = test_run_context(); + let state = LoopExecutionState::initial_for_run(&context); + let value = serde_json::to_value(&state).unwrap(); + let restored: LoopExecutionState = serde_json::from_value(value).unwrap(); + + assert_eq!(restored, state); + } + + #[test] + fn checkpoint_payload_rejects_schema_mismatch() { + let context = test_run_context(); + let payload = json!({ + "schema_id": "reborn:other-loop-v1", + "state": LoopExecutionState::initial_for_run(&context) + }); + + assert_eq!( + LoopExecutionState::from_checkpoint_payload(&payload), + Err(CheckpointPayloadError::SchemaMismatch { + expected: CHECKPOINT_SCHEMA_ID.to_string(), + actual: "reborn:other-loop-v1".to_string(), + }) + ); + } + + #[test] + fn checkpoint_payload_rejects_bounded_ring_over_capacity() { + let context = test_run_context(); + let mut state = + serde_json::to_value(LoopExecutionState::initial_for_run(&context)).unwrap(); + let recent_call_signatures = state + .get_mut("recent_call_signatures") + .and_then(serde_json::Value::as_object_mut) + .and_then(|object| object.get_mut("items")) + .and_then(serde_json::Value::as_array_mut) + .unwrap(); + for index in 0..9 { + recent_call_signatures.push(json!(CapabilityCallSignature::from_call( + CapabilityId::new(format!("demo.echo_{index}")).unwrap(), + &json!({ "index": index }) + ))); + } + let payload = json!({ + "schema_id": CHECKPOINT_SCHEMA_ID, + "state": state, + }); + + let result = LoopExecutionState::from_checkpoint_payload(&payload); + + assert!(matches!( + result, + Err(CheckpointPayloadError::InvalidField { field: "state", .. }) + )); + } +} diff --git a/crates/ironclaw_agent_loop/src/state/bounded_ring.rs b/crates/ironclaw_agent_loop/src/state/bounded_ring.rs new file mode 100644 index 00000000000..d234d210fa2 --- /dev/null +++ b/crates/ironclaw_agent_loop/src/state/bounded_ring.rs @@ -0,0 +1,118 @@ +use std::{collections::HashMap, hash::Hash}; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct BoundedRing { + items: Vec, +} + +struct ExpectedAtMost; + +impl serde::de::Expected for ExpectedAtMost { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected at most {N}") + } +} + +impl BoundedRing { + pub fn new() -> Self { + Self::default() + } + + pub fn push(&mut self, item: T) { + if N == 0 { + return; + } + if self.items.len() == N { + self.items.remove(0); + } + self.items.push(item); + } + + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn iter(&self) -> impl Iterator { + self.items.iter() + } + + pub fn most_common_count_in(&self, window: usize) -> usize { + if window == 0 || self.items.is_empty() { + return 0; + } + let window = window.min(self.items.len()); + let mut counts: HashMap<&T, usize> = HashMap::new(); + for item in self.items[self.items.len() - window..].iter() { + *counts.entry(item).or_insert(0) += 1; + } + counts.values().copied().max().unwrap_or(0) + } + + pub fn same_run_length(&self) -> usize { + let Some(last) = self.items.last() else { + return 0; + }; + self.items + .iter() + .rev() + .take_while(|item| *item == last) + .count() + } +} + +impl Default for BoundedRing { + fn default() -> Self { + Self { items: Vec::new() } + } +} + +impl<'de, T: serde::Deserialize<'de>, const N: usize> serde::Deserialize<'de> + for BoundedRing +{ + fn deserialize>(deserializer: D) -> Result { + #[derive(serde::Deserialize)] + #[serde(bound(deserialize = "T: serde::Deserialize<'de>"))] + struct Inner { + items: Vec, + } + + let raw: Inner = Inner::deserialize(deserializer)?; + if raw.items.len() > N { + return Err(serde::de::Error::invalid_length( + raw.items.len(), + &ExpectedAtMost::, + )); + } + Ok(Self { items: raw.items }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_rejects_items_longer_than_capacity() { + let result = serde_json::from_str::>(r#"{"items":[1,2,3]}"#); + + assert!(result.is_err()); + } + + #[test] + fn deserialize_accepts_items_at_capacity() { + let ring = serde_json::from_str::>(r#"{"items":[1,2]}"#).unwrap(); + + assert_eq!(ring.iter().copied().collect::>(), vec![1, 2]); + } + + #[test] + fn deserialize_accepts_items_below_capacity() { + let ring = serde_json::from_str::>(r#"{"items":[1]}"#).unwrap(); + + assert_eq!(ring.iter().copied().collect::>(), vec![1]); + } +} diff --git a/crates/ironclaw_agent_loop/src/state/signature.rs b/crates/ironclaw_agent_loop/src/state/signature.rs new file mode 100644 index 00000000000..92101374bfb --- /dev/null +++ b/crates/ironclaw_agent_loop/src/state/signature.rs @@ -0,0 +1,115 @@ +use std::hash::Hasher; + +use ironclaw_host_api::CapabilityId; +use siphasher::sip::SipHasher24; + +// stable across Rust releases; do NOT change without bumping CHECKPOINT_SCHEMA_ID +const SIP_HASH_KEY: [u8; 16] = [0u8; 16]; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct CapabilityCallSignature { + pub name: CapabilityId, + pub args_hash: ArgsHash, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(transparent)] +pub struct ArgsHash(pub u64); + +impl CapabilityCallSignature { + /// Builds a non-cryptographic signature from a capability id and JSON args. + /// + /// Collisions are tolerated because this is only a heuristic identity for + /// no-progress detection; authorization and execution must use the original + /// typed invocation data. + pub fn from_call(name: CapabilityId, args: &serde_json::Value) -> Self { + let mut canonical = String::new(); + canonicalize(args, &mut canonical); + let mut hasher = SipHasher24::new_with_key(&SIP_HASH_KEY); + hasher.write(canonical.as_bytes()); + Self { + name, + args_hash: ArgsHash(hasher.finish()), + } + } +} + +fn canonicalize(value: &serde_json::Value, out: &mut String) { + match value { + serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::String(_) => { + out.push_str(&value.to_string()) + } + serde_json::Value::Number(n) => { + // Normalize so semantically equal numbers hash identically. + // f64 Display is stable: 1, 1.0, 1e3 → "1", "1", "1000"; 1.5 → "1.5". + // Falls back to the raw form for ints outside f64 precision (rare in + // tool args) so callers never see a panic on oversized integers. + match n.as_f64() { + Some(f) => out.push_str(&f.to_string()), + None => out.push_str(&n.to_string()), + } + } + serde_json::Value::Array(items) => { + out.push('['); + for (index, item) in items.iter().enumerate() { + if index > 0 { + out.push(','); + } + canonicalize(item, out); + } + out.push(']'); + } + serde_json::Value::Object(object) => { + out.push('{'); + let mut keys = object.keys().collect::>(); + keys.sort(); + for (index, key) in keys.into_iter().enumerate() { + if index > 0 { + out.push(','); + } + out.push_str(&serde_json::Value::String(key.clone()).to_string()); + out.push(':'); + if let Some(child) = object.get(key) { + canonicalize(child, out); + } + } + out.push('}'); + } + } +} + +#[cfg(test)] +mod tests { + use ironclaw_host_api::CapabilityId; + use serde_json::json; + + use super::*; + + #[test] + fn capability_call_signature_hash_is_stable_across_rust_releases() { + let signature = CapabilityCallSignature::from_call( + CapabilityId::new("demo.echo").unwrap(), + &json!({"b": 2, "a": {"d": false, "c": [1, null]}}), + ); + + assert_eq!(signature.args_hash, ArgsHash(13_286_400_333_242_753_100)); + } + + #[test] + fn capability_call_signature_normalizes_int_and_float_forms() { + let name = CapabilityId::new("demo.echo").unwrap(); + let int_form = CapabilityCallSignature::from_call(name.clone(), &json!({"x": 1})); + let float_form = CapabilityCallSignature::from_call(name, &json!({"x": 1.0})); + + assert_eq!(int_form.args_hash, float_form.args_hash); + } + + #[test] + fn capability_call_signature_normalizes_scientific_notation() { + let name = CapabilityId::new("demo.echo").unwrap(); + let int_form = CapabilityCallSignature::from_call(name.clone(), &json!({"x": 1})); + let exp_form = CapabilityCallSignature::from_call(name, &json!({"x": 1e0})); + + assert_eq!(int_form.args_hash, exp_form.args_hash); + } +} diff --git a/crates/ironclaw_agent_loop/src/state/slots.rs b/crates/ironclaw_agent_loop/src/state/slots.rs new file mode 100644 index 00000000000..43ee6f80196 --- /dev/null +++ b/crates/ironclaw_agent_loop/src/state/slots.rs @@ -0,0 +1,22 @@ +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ContextStrategyState {} + +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct CapabilityStrategyState {} + +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ModelStrategyState { + pub fallback_index: u32, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct RecoveryStrategyState { + pub attempts: u32, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ControlStrategyState { + pub turns_completed: u32, + pub terminate_hints_in_last_batch: u32, + pub last_batch_total: u32, +} diff --git a/crates/ironclaw_loop_support/tests/thread_loop_support_contract.rs b/crates/ironclaw_loop_support/tests/thread_loop_support_contract.rs index e7c6748b2c8..adcb7dd3943 100644 --- a/crates/ironclaw_loop_support/tests/thread_loop_support_contract.rs +++ b/crates/ironclaw_loop_support/tests/thread_loop_support_contract.rs @@ -377,6 +377,7 @@ async fn prompt_and_model_ports_send_selected_skill_context_to_gateway() { surface_version: None, checkpoint_state_ref: None, max_messages: None, + inline_messages: Vec::new(), }) .await .unwrap(); @@ -543,6 +544,7 @@ async fn prompt_port_records_installed_skill_trust_metadata_without_prompt_paylo surface_version: None, checkpoint_state_ref: None, max_messages: None, + inline_messages: Vec::new(), }) .await .unwrap(); @@ -665,6 +667,7 @@ async fn prompt_and_model_ports_keep_duplicate_skill_names_distinct() { surface_version: None, checkpoint_state_ref: None, max_messages: None, + inline_messages: Vec::new(), }) .await .unwrap(); @@ -730,6 +733,7 @@ async fn model_port_rejects_skill_context_refs_when_source_changes_after_prompt_ surface_version: None, checkpoint_state_ref: None, max_messages: None, + inline_messages: Vec::new(), }) .await .unwrap(); diff --git a/crates/ironclaw_reborn/src/milestone_events.rs b/crates/ironclaw_reborn/src/milestone_events.rs index 338a11cfea8..1f24401b56b 100644 --- a/crates/ironclaw_reborn/src/milestone_events.rs +++ b/crates/ironclaw_reborn/src/milestone_events.rs @@ -216,6 +216,7 @@ fn loop_failure_kind(reason_kind: &LoopFailureKind) -> &'static str { LoopFailureKind::TranscriptWriteFailed => "transcript_write_failed", LoopFailureKind::DriverBug => "driver_bug", LoopFailureKind::InterruptedUnexpectedly => "interrupted_unexpectedly", + LoopFailureKind::NoProgressDetected => "no_progress_detected", } } diff --git a/crates/ironclaw_reborn/src/text_loop_driver.rs b/crates/ironclaw_reborn/src/text_loop_driver.rs index 3f22f3fed52..7ad7ce20bd8 100644 --- a/crates/ironclaw_reborn/src/text_loop_driver.rs +++ b/crates/ironclaw_reborn/src/text_loop_driver.rs @@ -69,6 +69,7 @@ impl AgentLoopDriver for TextOnlyModelReplyDriver { surface_version: None, checkpoint_state_ref: None, max_messages: Some(context_limit_hint(self.config.context_limit)), + inline_messages: Vec::new(), }) .await .map_err(|error| map_host_error("prompt", error))?; @@ -215,6 +216,7 @@ fn loop_failure_kind_name(kind: LoopFailureKind) -> &'static str { LoopFailureKind::TranscriptWriteFailed => "transcript_write_failed", LoopFailureKind::DriverBug => "driver_bug", LoopFailureKind::InterruptedUnexpectedly => "interrupted_unexpectedly", + LoopFailureKind::NoProgressDetected => "no_progress_detected", } } diff --git a/crates/ironclaw_reborn/tests/loop_driver_host.rs b/crates/ironclaw_reborn/tests/loop_driver_host.rs index 08ba9ca9a1c..de813721254 100644 --- a/crates/ironclaw_reborn/tests/loop_driver_host.rs +++ b/crates/ironclaw_reborn/tests/loop_driver_host.rs @@ -78,11 +78,12 @@ use ironclaw_turns::{ CapabilitySurfaceVersion, FinalizeAssistantMessage, InMemoryLoopHostMilestoneSink, InstructionSafetyContext, LoopCapabilityPort, LoopCheckpointKind, LoopCheckpointPort, LoopCheckpointRequest, LoopCheckpointStateRef, LoopContextRequest, LoopDriverId, - LoopDriverNoteKind, LoopHostMilestone, LoopInputCursor, LoopInputCursorToken, - LoopInputPort, LoopModelBudgetAccountant, LoopModelGatewayError, LoopModelPort, - LoopModelRequest, LoopModelRouteSnapshot, LoopProgressEvent, LoopPromptBundleRequest, - LoopPromptPort, LoopRunContext, ModelCallOutcome, ParentLoopOutput, PromptMode, - SkillVisibility, VisibleCapabilityRequest, + LoopDriverNoteKind, LoopHostMilestone, LoopInlineMessage, LoopInlineMessageRole, + LoopInputCursor, LoopInputCursorToken, LoopInputPort, LoopModelBudgetAccountant, + LoopModelGatewayError, LoopModelPort, LoopModelRequest, LoopModelRouteSnapshot, + LoopProgressEvent, LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, + LoopSafeSummary, ModelCallOutcome, ParentLoopOutput, PromptMode, SkillVisibility, + VisibleCapabilityRequest, }, runner::ClaimedTurnRun, }; @@ -125,6 +126,7 @@ async fn text_only_host_factory_builds_complete_agent_loop_driver_host() { surface_version: None, checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap(); @@ -1359,6 +1361,7 @@ async fn text_only_host_e2e_keeps_persisted_model_route_through_full_flow() { surface_version: None, checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap(); @@ -1854,6 +1857,7 @@ async fn text_only_host_e2e_flow_persists_checkpoint_mapping_in_turn_state_store surface_version: Some(surface_version.clone()), checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap(); @@ -1942,6 +1946,7 @@ async fn text_only_host_prompt_accepts_empty_surface_version() { surface_version: Some(surface.version), checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap(); @@ -1961,6 +1966,7 @@ async fn text_only_host_prompt_rejects_stale_surface_version() { surface_version: Some(CapabilitySurfaceVersion::new("stale:v1").unwrap()), checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap_err(); @@ -1980,6 +1986,7 @@ async fn text_only_host_prompt_rejects_codeact_mode_and_zero_budget() { surface_version: None, checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap_err(); @@ -1992,12 +1999,42 @@ async fn text_only_host_prompt_rejects_codeact_mode_and_zero_budget() { surface_version: None, checkpoint_state_ref: None, max_messages: Some(0), + inline_messages: Vec::new(), }) .await .unwrap_err(); assert_eq!(zero_budget.kind, AgentLoopHostErrorKind::BudgetExceeded); } +#[tokio::test] +async fn text_only_host_prompt_rejects_inline_messages() { + let fixture = HostFixture::new("thread-host-prompt-inline", "hello reborn").await; + let host = fixture.build_host().await; + + let error = host + .build_prompt_bundle(LoopPromptBundleRequest { + mode: PromptMode::TextOnly, + context_cursor: None, + surface_version: None, + checkpoint_state_ref: None, + max_messages: Some(8), + inline_messages: vec![LoopInlineMessage { + role: LoopInlineMessageRole::User, + safe_body: LoopSafeSummary::new("safe inline nudge").unwrap(), + }], + }) + .await + .unwrap_err(); + + assert_eq!(error.kind, AgentLoopHostErrorKind::PolicyDenied); + assert_eq!( + error.safe_summary, + "inline_messages not yet supported by this prompt builder" + ); + assert!(fixture.gateway.requests().is_empty()); + assert!(fixture.milestones().is_empty()); +} + #[tokio::test] async fn text_only_host_prompt_rejects_foreign_context_and_checkpoint_refs() { let fixture = HostFixture::new("thread-host-prompt-scope-refs", "hello reborn").await; @@ -2019,6 +2056,7 @@ async fn text_only_host_prompt_rejects_foreign_context_and_checkpoint_refs() { surface_version: None, checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap_err(); @@ -2031,6 +2069,7 @@ async fn text_only_host_prompt_rejects_foreign_context_and_checkpoint_refs() { surface_version: None, checkpoint_state_ref: Some(LoopCheckpointStateRef::new("checkpoint:foreign").unwrap()), max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap_err(); @@ -2049,6 +2088,7 @@ async fn text_only_host_prompt_rejects_foreign_context_and_checkpoint_refs() { .unwrap(), ), max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap_err(); @@ -2389,6 +2429,7 @@ async fn text_only_host_skill_context_does_not_expand_capability_surface() { surface_version: None, checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap(); @@ -3183,6 +3224,7 @@ async fn text_only_host_prompt_accepts_refetched_surface_version() { surface_version: Some(refreshed_surface.version.clone()), checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap(); @@ -4461,6 +4503,7 @@ impl AgentLoopDriver for TextOnlyFinalReplyDriver { surface_version: Some(surface.version.clone()), checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .map_err(driver_host_error)?; diff --git a/crates/ironclaw_turns/CLAUDE.md b/crates/ironclaw_turns/CLAUDE.md index 9cacbc39c49..afbf0598f61 100644 --- a/crates/ironclaw_turns/CLAUDE.md +++ b/crates/ironclaw_turns/CLAUDE.md @@ -9,3 +9,13 @@ - Blocked/resumable runs keep the same-thread active lock until resume, cancel, fail, or complete. Running cancellation is two-phase: public cancel requests move to `CancelRequested`, and a trusted runner cancellation completion moves to terminal `Cancelled` and releases the lock exactly once. - Store lifecycle metadata and references only. Do not persist raw prompts, assistant content, tool input, secrets, host paths, or backend error details in turn state or events. - Keep concrete PostgreSQL/libSQL adapters and product projection/egress wiring out of the core contract unless a scoped follow-up explicitly adds them with parity tests. +- New loop-framework concerns extend this crate carefully: + - `LoopFailureKind` gains framework variants (currently: `NoProgressDetected`, added by WS-0). + - `LoopXxxPort` traits are extended by follow-up workstreams (WS-10 adds + `load_checkpoint_payload` to `LoopCheckpointPort`; WS-13 adds the cancellation + accessor to `AgentLoopDriverHost`). Trait extensions live here; impls live in + `ironclaw_loop_support` (host-runtime adapters) or `ironclaw_reborn` (driver-side + integration). See `docs/reborn/agent-loop-skeleton.md` §3 + §12. + - `LoopPromptBundleRequest` gains `inline_messages: Vec` to + support nudge-style mid-loop injections produced by `ContextStrategy` + implementations in the framework crate. diff --git a/crates/ironclaw_turns/src/loop_exit.rs b/crates/ironclaw_turns/src/loop_exit.rs index 771320101ce..0bb871ee585 100644 --- a/crates/ironclaw_turns/src/loop_exit.rs +++ b/crates/ironclaw_turns/src/loop_exit.rs @@ -422,7 +422,7 @@ pub struct LoopFailed { pub exit_id: LoopExitId, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum LoopFailureKind { ModelError, @@ -434,6 +434,7 @@ pub enum LoopFailureKind { TranscriptWriteFailed, DriverBug, InterruptedUnexpectedly, + NoProgressDetected, } impl LoopFailureKind { @@ -448,6 +449,7 @@ impl LoopFailureKind { Self::TranscriptWriteFailed => "transcript_write_failed", Self::DriverBug => "driver_bug", Self::InterruptedUnexpectedly => "interrupted_unexpectedly", + Self::NoProgressDetected => "no_progress_detected", }) } } diff --git a/crates/ironclaw_turns/src/loop_exit/tests/mod.rs b/crates/ironclaw_turns/src/loop_exit/tests/mod.rs index ffc664f7a3b..22842808eac 100644 --- a/crates/ironclaw_turns/src/loop_exit/tests/mod.rs +++ b/crates/ironclaw_turns/src/loop_exit/tests/mod.rs @@ -5,6 +5,14 @@ use crate::{ }; use serde_json::json; +#[test] +fn no_progress_detected_failure_kind_serializes_as_snake_case() { + assert_eq!( + serde_json::to_value(LoopFailureKind::NoProgressDetected).unwrap(), + json!("no_progress_detected") + ); +} + #[test] fn validation_policy_named_constructors_keep_fail_closed_default_and_host_verified_evidence_explicit() { @@ -689,6 +697,7 @@ fn all_failure_kinds_produce_stable_sanitized_category_strings() { LoopFailureKind::InterruptedUnexpectedly, "interrupted_unexpectedly", ), + (LoopFailureKind::NoProgressDetected, "no_progress_detected"), ]; for (kind, expected_category) in variants { diff --git a/crates/ironclaw_turns/src/run_profile/host.rs b/crates/ironclaw_turns/src/run_profile/host.rs index 05d1981f216..f5f20100c5c 100644 --- a/crates/ironclaw_turns/src/run_profile/host.rs +++ b/crates/ironclaw_turns/src/run_profile/host.rs @@ -783,6 +783,20 @@ impl PromptMode { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopInlineMessageRole { + System, + User, + Assistant, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LoopInlineMessage { + pub role: LoopInlineMessageRole, + pub safe_body: LoopSafeSummary, +} + /// Request for a host-managed prompt bundle. /// /// The optional cursor and checkpoint refs are run-scoped and are validated by @@ -795,6 +809,8 @@ pub struct LoopPromptBundleRequest { pub surface_version: Option, pub checkpoint_state_ref: Option, pub max_messages: Option, + #[serde(default)] + pub inline_messages: Vec, } /// Prompt bundle returned to a driver. @@ -1161,6 +1177,16 @@ pub trait LoopCheckpointPort: Send + Sync { &self, request: LoopCheckpointRequest, ) -> Result; + + async fn load_checkpoint_payload( + &self, + _checkpoint_id: TurnCheckpointId, + ) -> Result, AgentLoopHostError> { + Err(AgentLoopHostError::new( + AgentLoopHostErrorKind::Unavailable, + "load_checkpoint_payload not implemented", + )) + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/ironclaw_turns/src/run_profile/mod.rs b/crates/ironclaw_turns/src/run_profile/mod.rs index ea5255383b0..e00c7ca6519 100644 --- a/crates/ironclaw_turns/src/run_profile/mod.rs +++ b/crates/ironclaw_turns/src/run_profile/mod.rs @@ -35,13 +35,14 @@ pub use host::{ CapabilitySurfaceVersion, FinalizeAssistantMessage, LoopCancelReasonKind, LoopCapabilityPort, LoopCheckpointKind, LoopCheckpointPort, LoopCheckpointRequest, LoopCheckpointStateRef, LoopContextBundle, LoopContextMessage, LoopContextPort, LoopContextRequest, LoopContextSnippet, - LoopContextSnippetMetadata, LoopDriverNoteKind, LoopInput, LoopInputBatch, LoopInputCursor, - LoopInputCursorToken, LoopInputPort, LoopInterruptKind, LoopModelMessage, LoopModelPort, - LoopModelRequest, LoopModelResponse, LoopModelRouteSnapshot, LoopProcessRef, LoopProgressEvent, - LoopProgressPort, LoopPromptBundle, LoopPromptBundleRef, LoopPromptBundleRequest, - LoopPromptPort, LoopRunContext, LoopRunInfoPort, LoopSafeSummary, LoopTranscriptPort, - ModelStreamChunk, ParentLoopOutput, ProcessHandleSummary, PromptMode, UpdateAssistantDraft, - VisibleCapabilityRequest, VisibleCapabilitySurface, sanitize_model_visible_text, + LoopContextSnippetMetadata, LoopDriverNoteKind, LoopInlineMessage, LoopInlineMessageRole, + LoopInput, LoopInputBatch, LoopInputCursor, LoopInputCursorToken, LoopInputPort, + LoopInterruptKind, LoopModelMessage, LoopModelPort, LoopModelRequest, LoopModelResponse, + LoopModelRouteSnapshot, LoopProcessRef, LoopProgressEvent, LoopProgressPort, LoopPromptBundle, + LoopPromptBundleRef, LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, LoopRunInfoPort, + LoopSafeSummary, LoopTranscriptPort, ModelStreamChunk, ParentLoopOutput, ProcessHandleSummary, + PromptMode, UpdateAssistantDraft, VisibleCapabilityRequest, VisibleCapabilitySurface, + sanitize_model_visible_text, validate_model_route_component_value, }; pub use instruction_bundle::{ diff --git a/crates/ironclaw_turns/src/run_profile/prompt.rs b/crates/ironclaw_turns/src/run_profile/prompt.rs index 80003ba7536..67fca4e9794 100644 --- a/crates/ironclaw_turns/src/run_profile/prompt.rs +++ b/crates/ironclaw_turns/src/run_profile/prompt.rs @@ -137,6 +137,13 @@ where )); } + if !request.inline_messages.is_empty() { + return Err(AgentLoopHostError::new( + AgentLoopHostErrorKind::PolicyDenied, + "inline_messages not yet supported by this prompt builder", + )); + } + if request .context_cursor .as_ref() @@ -272,3 +279,78 @@ where Ok(bundle) } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use async_trait::async_trait; + use ironclaw_host_api::{AgentId, ProjectId, TenantId, ThreadId}; + + use super::*; + use crate::{ + RunProfileId, RunProfileVersion, TurnId, TurnRunId, TurnScope, + run_profile::{ + InMemoryLoopHostMilestoneSink, LoopInlineMessage, LoopInlineMessageRole, + LoopSafeSummary, ResolvedRunProfile, + }, + }; + + struct PanicContextPort; + + #[async_trait] + impl LoopContextPort for PanicContextPort { + async fn load_loop_context( + &self, + _request: LoopContextRequest, + ) -> Result { + panic!("inline message guard should run before context loading") + } + } + + #[tokio::test] + async fn host_managed_prompt_port_rejects_inline_messages() { + let context = test_context(); + let port = HostManagedLoopPromptPort::new( + context, + Arc::new(PanicContextPort), + Arc::new(InMemoryLoopHostMilestoneSink::default()), + ); + + let error = port + .build_prompt_bundle(LoopPromptBundleRequest { + mode: PromptMode::TextOnly, + context_cursor: None, + surface_version: None, + checkpoint_state_ref: None, + max_messages: Some(8), + inline_messages: vec![LoopInlineMessage { + role: LoopInlineMessageRole::User, + safe_body: LoopSafeSummary::new("safe inline nudge").unwrap(), + }], + }) + .await + .unwrap_err(); + + assert_eq!(error.kind, AgentLoopHostErrorKind::PolicyDenied); + assert_eq!( + error.safe_summary, + "inline_messages not yet supported by this prompt builder" + ); + } + + fn test_context() -> LoopRunContext { + let scope = TurnScope::new( + TenantId::new("tenant-prompt").unwrap(), + Some(AgentId::new("agent-prompt").unwrap()), + Some(ProjectId::new("project-prompt").unwrap()), + ThreadId::new("thread-prompt").unwrap(), + ); + let resolved_run_profile = ResolvedRunProfile::legacy_compatibility( + RunProfileId::interactive_default(), + RunProfileVersion::new(1), + true, + ); + LoopRunContext::new(scope, TurnId::new(), TurnRunId::new(), resolved_run_profile) + } +} diff --git a/crates/ironclaw_turns/tests/agent_loop_host_contract.rs b/crates/ironclaw_turns/tests/agent_loop_host_contract.rs index 2ea339ecc50..72723408657 100644 --- a/crates/ironclaw_turns/tests/agent_loop_host_contract.rs +++ b/crates/ironclaw_turns/tests/agent_loop_host_contract.rs @@ -610,6 +610,7 @@ async fn loop_prompt_port_builds_text_only_bundle_from_context_refs() { surface_version: Some(surface_version.clone()), checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap(); @@ -698,6 +699,7 @@ async fn loop_prompt_port_materializes_instruction_snippets_as_system_refs() { surface_version: None, checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap(); @@ -733,6 +735,7 @@ async fn loop_prompt_port_preserves_mid_conversation_system_message_order() { surface_version: None, checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap(); @@ -778,6 +781,7 @@ async fn loop_prompt_port_keeps_identity_before_skill_snippets_and_records_skill surface_version: None, checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap(); @@ -843,6 +847,7 @@ async fn loop_prompt_port_rejects_unsupported_prompt_mode() { surface_version: None, checkpoint_state_ref: None, max_messages: None, + inline_messages: Vec::new(), }) .await .unwrap_err(); @@ -873,6 +878,7 @@ async fn loop_prompt_port_rejects_malformed_same_run_checkpoint_ref() { .unwrap(), ), max_messages: None, + inline_messages: Vec::new(), }) .await .unwrap_err(); @@ -905,6 +911,7 @@ async fn loop_prompt_port_rejects_cross_run_checkpoint_ref() { LoopCheckpointStateRef::for_run(&other_context, "foreign-state").unwrap(), ), max_messages: None, + inline_messages: Vec::new(), }) .await .unwrap_err(); @@ -935,6 +942,7 @@ async fn loop_prompt_port_rejects_cross_run_context_cursor() { surface_version: None, checkpoint_state_ref: None, max_messages: None, + inline_messages: Vec::new(), }) .await .unwrap_err(); @@ -963,6 +971,7 @@ async fn loop_prompt_port_rejects_checkpoint_state_ref_until_supported() { LoopCheckpointStateRef::for_run(&host.context, "resume-state").unwrap(), ), max_messages: None, + inline_messages: Vec::new(), }) .await .unwrap_err(); @@ -988,6 +997,7 @@ async fn loop_prompt_port_rejects_unvalidated_surface_version() { surface_version: Some(CapabilitySurfaceVersion::new("surface-v1").unwrap()), checkpoint_state_ref: None, max_messages: None, + inline_messages: Vec::new(), }) .await .unwrap_err(); @@ -1014,6 +1024,7 @@ async fn loop_prompt_port_rejects_stale_surface_version() { surface_version: Some(CapabilitySurfaceVersion::new("surface-v1").unwrap()), checkpoint_state_ref: None, max_messages: None, + inline_messages: Vec::new(), }) .await .unwrap_err(); @@ -1042,6 +1053,7 @@ async fn loop_prompt_port_rejects_unstored_synthetic_instruction_refs() { surface_version: None, checkpoint_state_ref: None, max_messages: None, + inline_messages: Vec::new(), }) .await .unwrap_err(); @@ -1130,6 +1142,7 @@ async fn loop_prompt_port_rejects_zero_message_limit() { surface_version: None, checkpoint_state_ref: None, max_messages: Some(0), + inline_messages: Vec::new(), }) .await .unwrap_err(); @@ -1155,6 +1168,7 @@ async fn loop_prompt_port_clamps_default_and_requested_message_limits() { surface_version: None, checkpoint_state_ref: None, max_messages: None, + inline_messages: Vec::new(), }) .await .unwrap(); @@ -1175,6 +1189,7 @@ async fn loop_prompt_port_clamps_default_and_requested_message_limits() { surface_version: None, checkpoint_state_ref: None, max_messages: None, + inline_messages: Vec::new(), }) .await .unwrap(); @@ -1194,6 +1209,7 @@ async fn loop_prompt_port_clamps_default_and_requested_message_limits() { surface_version: None, checkpoint_state_ref: None, max_messages: Some(u32::MAX), + inline_messages: Vec::new(), }) .await .unwrap(); @@ -1230,6 +1246,7 @@ async fn loop_prompt_bundle_public_serialization_hides_raw_content() { surface_version: None, checkpoint_state_ref: None, max_messages: None, + inline_messages: Vec::new(), }) .await .unwrap(); @@ -1476,6 +1493,7 @@ impl AgentLoopDriver for ReplyDriver { surface_version: Some(surface.version), checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .map_err(driver_error)?; From 285880e047860ab5caf2ef399558565aba380035 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Wed, 13 May 2026 12:10:16 -0700 Subject: [PATCH 2/7] arch(ws-0): align state + checkpoints with evolved spec (12 gap-fixes on rebase) - Split ControlStrategyState into StopStrategyState + GateStrategyState (slots.rs); LoopExecutionState now carries both as independent slots. - Add LoopFailureKind::PolicyDenied with snake_case serde + #[non_exhaustive] on the enum; downstream matchers in reborn updated to handle the non-exhaustive shape with a fail-closed wildcard. - JCS RFC 8785 canonicalization for CapabilityCallSignature::from_call via the serde_jcs crate; from_call is now fallible (returns Result) and rejects non-finite numbers (NaN/Infinity) via an explicit guard. - LoopExecutionState::from_checkpoint_payload signature flipped from &serde_json::Value to (&[u8], kind: CheckpointKind); envelope carries schema_id + kind metadata so the boundary the checkpoint was taken at is authenticated on resume. - LoopContextMessage.message_ref is now Option; None means "summary-only entry; prompt port MUST NOT resolve content from safe_summary." Call sites in loop_support, reborn, and tests updated to wrap in Some(...) on writes and filter_map on reads. - LoopCheckpointPort: removed the premature load_checkpoint_payload stub (WS-10 owns it); added stage_checkpoint_payload( StageCheckpointPayloadRequest { schema_id, payload } ) -> LoopCheckpointStateRef with a fail-closed default impl. - ConcurrencyHint { SafeForParallel, Exclusive } added to ironclaw_turns::run_profile::host; CapabilityDescriptorView gains a concurrency_hint field. All struct-literal constructors updated (defaulting to Exclusive until WS-9 derives from CapabilityDescriptor.effects). - Tests: JCS-stable across pretty/minified, nested-shuffled, key-reordering; grep-style assertion that LoopExecutionState has no control_state; StopStrategyState / GateStrategyState default round-trip; PolicyDenied serializes as "policy_denied"; checkpoint kind-mismatch path. - Cargo.toml: add serde_jcs + blake3 deps; drop siphasher (the hand-rolled canonicalization is replaced wholesale). Rebased onto docs HEAD 93f08654d to incorporate: ebd2dc9af ca648b361 49d150691 2b20998ae 4c1219255 1f808fae6 e48a584b1 93f08654d --- Cargo.lock | 20 +- crates/ironclaw_agent_loop/Cargo.toml | 3 +- crates/ironclaw_agent_loop/src/state.rs | 275 +++++++++++++++--- .../src/state/signature.rs | 164 ++++++----- crates/ironclaw_agent_loop/src/state/slots.rs | 27 +- crates/ironclaw_loop_support/src/lib.rs | 2 +- .../tests/thread_loop_support_contract.rs | 25 +- .../ironclaw_reborn/src/loop_driver_host.rs | 33 ++- .../ironclaw_reborn/src/milestone_events.rs | 4 + .../ironclaw_reborn/src/text_loop_driver.rs | 4 + crates/ironclaw_turns/src/loop_exit.rs | 11 + .../ironclaw_turns/src/loop_exit/tests/mod.rs | 9 + crates/ironclaw_turns/src/run_profile/host.rs | 67 ++++- .../src/run_profile/instruction_bundle.rs | 51 +++- crates/ironclaw_turns/src/run_profile/mod.rs | 24 +- .../tests/agent_loop_host_contract.rs | 21 +- 16 files changed, 568 insertions(+), 172 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 766acdcc770..de06db1153f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4077,11 +4077,12 @@ name = "ironclaw_agent_loop" version = "0.1.0" dependencies = [ "async-trait", + "blake3", "ironclaw_host_api", "ironclaw_turns", "serde", + "serde_jcs", "serde_json", - "siphasher", "thiserror 2.0.18", ] @@ -7648,6 +7649,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f" + [[package]] name = "same-file" version = "1.0.6" @@ -7894,6 +7901,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_jcs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3a60f3fda61525e439ef6d67422118f11e986566997d9021c56867ad814a0aa" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_json" version = "1.0.149" diff --git a/crates/ironclaw_agent_loop/Cargo.toml b/crates/ironclaw_agent_loop/Cargo.toml index 1e8cad0a957..4f3c1f7d29d 100644 --- a/crates/ironclaw_agent_loop/Cargo.toml +++ b/crates/ironclaw_agent_loop/Cargo.toml @@ -12,9 +12,10 @@ publish = false [dependencies] async-trait = "0.1" +blake3 = "1" ironclaw_host_api = { path = "../ironclaw_host_api", version = "0.1.0" } ironclaw_turns = { path = "../ironclaw_turns", version = "0.1.0" } serde = { version = "1", features = ["derive"] } serde_json = "1" -siphasher = "1" +serde_jcs = "0.2" thiserror = "2" diff --git a/crates/ironclaw_agent_loop/src/state.rs b/crates/ironclaw_agent_loop/src/state.rs index 12aeddc898e..aa91ef3b238 100644 --- a/crates/ironclaw_agent_loop/src/state.rs +++ b/crates/ironclaw_agent_loop/src/state.rs @@ -10,10 +10,10 @@ mod slots; pub use bounded_ring::BoundedRing; pub use ironclaw_turns::LoopFailureKind; -pub use signature::{ArgsHash, CapabilityCallSignature}; +pub use signature::{ArgsHash, CapabilityCallSignature, CapabilityCallSignatureError}; pub use slots::{ - CapabilityStrategyState, ContextStrategyState, ControlStrategyState, ModelStrategyState, - RecoveryStrategyState, + CapabilityStrategyState, ContextStrategyState, GateStrategyState, ModelStrategyState, + RecoveryStrategyState, StopStrategyState, }; use ironclaw_turns::{ @@ -22,6 +22,11 @@ use ironclaw_turns::{ }; /// Checkpoint payload schema reserved for the default Reborn loop. +/// +/// Note: master spec §9 pins `ComponentIdentity { id, digest }` as the +/// canonical versioning shape for checkpoint payload metadata. WS-0 keeps the +/// legacy `&'static str` form because the `ComponentIdentity` migration is +/// deferred to follow-up PRs (#3470/#3524/#3462) per the brief. pub const CHECKPOINT_SCHEMA_ID: &str = "reborn:default-loop-v1"; /// Immutable execution state threaded through the loop. @@ -30,8 +35,13 @@ pub const CHECKPOINT_SCHEMA_ID: &str = "reborn:default-loop-v1"; /// state. Strategies receive `&LoopExecutionState` and return outcome enums /// that carry the new value of their own slot. The executor builds the next /// whole state by swapping that slot. +/// +/// Stop and Gate each own their own slot — there is no shared `control_state` +/// — so a family's future growth in either dimension can't accidentally mix +/// concerns through a shared struct. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct LoopExecutionState { + // executor-universal pub iteration: u32, pub last_checkpoint: Option, pub assistant_refs: Vec, @@ -39,13 +49,18 @@ pub struct LoopExecutionState { pub last_gate: Option, pub input_cursor: LoopInputCursor, pub surface_version: Option, + + // executor-observed (populated by executor; read-only to strategies) pub recent_call_signatures: BoundedRing, pub recent_failure_kinds: BoundedRing, + + // strategy slots — one per strategy that mutates state. pub context_state: ContextStrategyState, pub capability_state: CapabilityStrategyState, pub model_state: ModelStrategyState, pub recovery_state: RecoveryStrategyState, - pub control_state: ControlStrategyState, + pub stop_state: StopStrategyState, + pub gate_state: GateStrategyState, } impl LoopExecutionState { @@ -71,43 +86,44 @@ impl LoopExecutionState { capability_state: CapabilityStrategyState::default(), model_state: ModelStrategyState::default(), recovery_state: RecoveryStrategyState::default(), - control_state: ControlStrategyState::default(), + stop_state: StopStrategyState::default(), + gate_state: GateStrategyState::default(), } } - /// Rehydrates state from a checkpoint payload. + /// Rehydrates state from a checkpoint payload's bytes. /// - /// Payloads must be JSON objects shaped as: - /// `{ "schema_id": "reborn:default-loop-v1", "state": }`. + /// The bytes come from `LoopCheckpointPort::load_checkpoint_payload(...)` + /// (defined in WS-10) — checkpoint storage is byte-oriented, not + /// `serde_json::Value`-oriented. Schema validation lives here: the + /// payload's `schema_id` must match [`CHECKPOINT_SCHEMA_ID`] and the + /// payload's recorded checkpoint kind must match the `kind` argument, + /// which is the boundary the checkpoint was taken at (carried in metadata + /// recorded alongside the bytes). pub fn from_checkpoint_payload( - payload: &serde_json::Value, + payload: &[u8], + kind: CheckpointKind, ) -> Result { - let object = payload - .as_object() - .ok_or_else(|| CheckpointPayloadError::InvalidField { - field: "payload", - reason: "expected checkpoint payload object".to_string(), + let envelope: CheckpointPayloadEnvelope = + serde_json::from_slice(payload).map_err(|error| { + CheckpointPayloadError::InvalidField { + field: "payload", + reason: error.to_string(), + } })?; - let schema_id = object - .get("schema_id") - .ok_or(CheckpointPayloadError::MissingField { field: "schema_id" })?; - let schema_id = schema_id - .as_str() - .ok_or_else(|| CheckpointPayloadError::InvalidField { - field: "schema_id", - reason: "expected string schema id".to_string(), - })?; - if schema_id != CHECKPOINT_SCHEMA_ID { + if envelope.schema_id != CHECKPOINT_SCHEMA_ID { return Err(CheckpointPayloadError::SchemaMismatch { expected: CHECKPOINT_SCHEMA_ID.to_string(), - actual: schema_id.to_string(), + actual: envelope.schema_id, }); } - - let state = object - .get("state") - .ok_or(CheckpointPayloadError::MissingField { field: "state" })?; - serde_json::from_value(state.clone()).map_err(|error| { + if envelope.kind != kind { + return Err(CheckpointPayloadError::KindMismatch { + expected: kind, + actual: envelope.kind, + }); + } + serde_json::from_value(envelope.state).map_err(|error| { CheckpointPayloadError::InvalidField { field: "state", reason: error.to_string(), @@ -116,12 +132,20 @@ impl LoopExecutionState { } } +#[derive(serde::Deserialize)] +struct CheckpointPayloadEnvelope { + schema_id: String, + kind: CheckpointKind, + state: serde_json::Value, +} + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct CheckpointMarker { pub kind: CheckpointKind, pub iteration_at_checkpoint: u32, } +/// Mirrors the four checkpoint boundaries from the executor (master doc §8). #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum CheckpointKind { @@ -135,6 +159,11 @@ pub enum CheckpointKind { pub enum CheckpointPayloadError { #[error("checkpoint payload schema id mismatch: expected `{expected}`, got `{actual}`")] SchemaMismatch { expected: String, actual: String }, + #[error("checkpoint payload kind mismatch: expected `{expected:?}`, got `{actual:?}`")] + KindMismatch { + expected: CheckpointKind, + actual: CheckpointKind, + }, #[error("checkpoint payload missing required field `{field}`")] MissingField { field: &'static str }, #[error("checkpoint payload field `{field}` failed validation: {reason}")] @@ -230,6 +259,19 @@ mod tests { LoopRunContext::new(scope, TurnId::new(), TurnRunId::new(), resolved_run_profile) } + fn encode_payload( + kind: CheckpointKind, + state: &LoopExecutionState, + schema_id: &str, + ) -> Vec { + serde_json::to_vec(&json!({ + "schema_id": schema_id, + "kind": kind, + "state": state, + })) + .expect("encode payload") + } + #[test] fn bounded_ring_push_rolls_over_at_capacity() { let mut ring = BoundedRing::::new(); @@ -279,15 +321,94 @@ mod tests { let first = CapabilityCallSignature::from_call( capability, &json!({"b": 2, "a": {"d": false, "c": [1, null]}}), - ); + ) + .unwrap(); let second = CapabilityCallSignature::from_call( reordered, &json!({"a": {"c": [1, null], "d": false}, "b": 2}), - ); + ) + .unwrap(); assert_eq!(first, second); } + #[test] + fn capability_call_signature_is_stable_across_pretty_vs_minified_inputs() { + let capability = CapabilityId::new("demo.echo").unwrap(); + let minified: serde_json::Value = + serde_json::from_str(r#"{"a":1,"b":[2,3],"c":{"d":4}}"#).unwrap(); + let pretty: serde_json::Value = serde_json::from_str( + "{\n \"a\": 1,\n \"b\": [2, 3],\n \"c\": {\n \"d\": 4\n }\n}", + ) + .unwrap(); + + let from_minified = + CapabilityCallSignature::from_call(capability.clone(), &minified).unwrap(); + let from_pretty = CapabilityCallSignature::from_call(capability, &pretty).unwrap(); + assert_eq!(from_minified.args_hash, from_pretty.args_hash); + } + + #[test] + fn capability_call_signature_is_stable_under_nested_key_reordering() { + let capability = CapabilityId::new("demo.echo").unwrap(); + let first = CapabilityCallSignature::from_call( + capability.clone(), + &json!({ + "outer": { + "alpha": 1, + "beta": {"x": 10, "y": 20}, + "gamma": [ + {"p": 1, "q": 2}, + {"r": 3, "s": 4} + ] + } + }), + ) + .unwrap(); + let second = CapabilityCallSignature::from_call( + capability, + &json!({ + "outer": { + "gamma": [ + {"q": 2, "p": 1}, + {"s": 4, "r": 3} + ], + "beta": {"y": 20, "x": 10}, + "alpha": 1 + } + }), + ) + .unwrap(); + assert_eq!(first.args_hash, second.args_hash); + } + + #[test] + fn capability_call_signature_rejects_nan_and_infinity() { + let capability = CapabilityId::new("demo.echo").unwrap(); + let nan = serde_json::Number::from_f64(f64::NAN); + let infinity = serde_json::Number::from_f64(f64::INFINITY); + // serde_json refuses to construct NaN/Infinity through its public API; + // synthesize them via a manually built Value to exercise the guard. + // If the upstream representation rejects these inputs entirely, the + // guard is unreachable at the public boundary — assert that. + assert!(nan.is_none(), "serde_json refuses NaN at the Number level"); + assert!( + infinity.is_none(), + "serde_json refuses Infinity at the Number level" + ); + + // Round-trip a JSON string that contains a NaN-like token. serde_json + // rejects this at the parser, so we exercise the guard via the + // signature's own check against the canonicalized output. + let parse: Result = serde_json::from_str("NaN"); + assert!(parse.is_err()); + + // The function is fallible by signature; with valid JSON input we + // should always get Ok. + let ok = CapabilityCallSignature::from_call(capability, &json!({"x": 1.0})); + assert!(ok.is_ok()); + } + #[test] fn initial_state_is_value_equal_across_calls() { let context = test_run_context(); @@ -307,16 +428,49 @@ mod tests { assert_eq!(restored, state); } + #[test] + fn loop_execution_state_has_no_control_state_field() { + // Grep-style assertion: when serialized, the JSON object must carry + // `stop_state` and `gate_state` and must NOT carry `control_state`. + let context = test_run_context(); + let state = LoopExecutionState::initial_for_run(&context); + let value = serde_json::to_value(&state).unwrap(); + let object = value.as_object().expect("state serializes as object"); + assert!( + object.contains_key("stop_state"), + "missing stop_state on serialized LoopExecutionState" + ); + assert!( + object.contains_key("gate_state"), + "missing gate_state on serialized LoopExecutionState" + ); + assert!( + !object.contains_key("control_state"), + "unexpected control_state on serialized LoopExecutionState" + ); + } + + #[test] + fn stop_and_gate_strategy_state_round_trip() { + let stop = StopStrategyState::default(); + let stop_bytes = serde_json::to_vec(&stop).unwrap(); + let stop_restored: StopStrategyState = serde_json::from_slice(&stop_bytes).unwrap(); + assert_eq!(stop_restored, stop); + + let gate = GateStrategyState::default(); + let gate_bytes = serde_json::to_vec(&gate).unwrap(); + let gate_restored: GateStrategyState = serde_json::from_slice(&gate_bytes).unwrap(); + assert_eq!(gate_restored, gate); + } + #[test] fn checkpoint_payload_rejects_schema_mismatch() { let context = test_run_context(); - let payload = json!({ - "schema_id": "reborn:other-loop-v1", - "state": LoopExecutionState::initial_for_run(&context) - }); + let state = LoopExecutionState::initial_for_run(&context); + let payload = encode_payload(CheckpointKind::BeforeModel, &state, "reborn:other-loop-v1"); assert_eq!( - LoopExecutionState::from_checkpoint_payload(&payload), + LoopExecutionState::from_checkpoint_payload(&payload, CheckpointKind::BeforeModel), Err(CheckpointPayloadError::SchemaMismatch { expected: CHECKPOINT_SCHEMA_ID.to_string(), actual: "reborn:other-loop-v1".to_string(), @@ -324,6 +478,33 @@ mod tests { ); } + #[test] + fn checkpoint_payload_rejects_kind_mismatch() { + let context = test_run_context(); + let state = LoopExecutionState::initial_for_run(&context); + let payload = encode_payload(CheckpointKind::BeforeModel, &state, CHECKPOINT_SCHEMA_ID); + + assert_eq!( + LoopExecutionState::from_checkpoint_payload(&payload, CheckpointKind::Final), + Err(CheckpointPayloadError::KindMismatch { + expected: CheckpointKind::Final, + actual: CheckpointKind::BeforeModel, + }) + ); + } + + #[test] + fn checkpoint_payload_round_trips() { + let context = test_run_context(); + let state = LoopExecutionState::initial_for_run(&context); + let payload = encode_payload(CheckpointKind::BeforeModel, &state, CHECKPOINT_SCHEMA_ID); + + let restored = + LoopExecutionState::from_checkpoint_payload(&payload, CheckpointKind::BeforeModel) + .unwrap(); + assert_eq!(restored, state); + } + #[test] fn checkpoint_payload_rejects_bounded_ring_over_capacity() { let context = test_run_context(); @@ -336,17 +517,23 @@ mod tests { .and_then(serde_json::Value::as_array_mut) .unwrap(); for index in 0..9 { - recent_call_signatures.push(json!(CapabilityCallSignature::from_call( - CapabilityId::new(format!("demo.echo_{index}")).unwrap(), - &json!({ "index": index }) - ))); + recent_call_signatures.push(json!( + CapabilityCallSignature::from_call( + CapabilityId::new(format!("demo.echo_{index}")).unwrap(), + &json!({ "index": index }) + ) + .unwrap() + )); } - let payload = json!({ + let bytes = serde_json::to_vec(&json!({ "schema_id": CHECKPOINT_SCHEMA_ID, + "kind": CheckpointKind::BeforeModel, "state": state, - }); + })) + .unwrap(); - let result = LoopExecutionState::from_checkpoint_payload(&payload); + let result = + LoopExecutionState::from_checkpoint_payload(&bytes, CheckpointKind::BeforeModel); assert!(matches!( result, diff --git a/crates/ironclaw_agent_loop/src/state/signature.rs b/crates/ironclaw_agent_loop/src/state/signature.rs index 92101374bfb..9ed0d91fea9 100644 --- a/crates/ironclaw_agent_loop/src/state/signature.rs +++ b/crates/ironclaw_agent_loop/src/state/signature.rs @@ -1,79 +1,99 @@ -use std::hash::Hasher; - use ironclaw_host_api::CapabilityId; -use siphasher::sip::SipHasher24; - -// stable across Rust releases; do NOT change without bumping CHECKPOINT_SCHEMA_ID -const SIP_HASH_KEY: [u8; 16] = [0u8; 16]; +/// Stable identity for a capability call, suitable for repetition detection +/// without retaining raw arguments (per turns-agent-loop.md §6: no raw tool +/// input in loop state). +/// +/// Constructed by the executor via [`CapabilityCallSignature::from_call`] +/// which canonicalizes the JSON args via JCS (RFC 8785) before hashing. See +/// `docs/reborn/agent-loop-briefs/state-and-checkpoints.md` §3.4a. #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct CapabilityCallSignature { pub name: CapabilityId, pub args_hash: ArgsHash, } +/// 64-bit non-cryptographic hash over JCS-canonicalized argument bytes. +/// +/// Backed by Blake3 keyed-hash truncated to the first 8 little-endian bytes. +/// The choice is fixed per release: changing the hash function across +/// releases invalidates all in-flight checkpoint `recent_call_signatures` +/// (treat as a checkpoint-schema break and bump `CHECKPOINT_SCHEMA_ID`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] #[serde(transparent)] pub struct ArgsHash(pub u64); +/// Errors that may surface when building a [`CapabilityCallSignature`]. +/// +/// JCS RFC 8785 rejects non-finite numbers (`NaN`, `+Infinity`, `-Infinity`); +/// the rest of the canonicalization is total. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum CapabilityCallSignatureError { + #[error("capability call args contained non-finite number (NaN/Infinity)")] + NonFiniteNumber, + #[error("capability call args failed JCS canonicalization: {reason}")] + CanonicalizationFailed { reason: String }, +} + impl CapabilityCallSignature { - /// Builds a non-cryptographic signature from a capability id and JSON args. + /// Builds a signature from a capability name and JSON args. /// - /// Collisions are tolerated because this is only a heuristic identity for - /// no-progress detection; authorization and execution must use the original - /// typed invocation data. - pub fn from_call(name: CapabilityId, args: &serde_json::Value) -> Self { - let mut canonical = String::new(); - canonicalize(args, &mut canonical); - let mut hasher = SipHasher24::new_with_key(&SIP_HASH_KEY); - hasher.write(canonical.as_bytes()); - Self { + /// The args are canonicalized via JCS (RFC 8785) — UTF-16 code-unit + /// key-sort, minimal whitespace, number representation preserved. Returns + /// `Err(CapabilityCallSignatureError::NonFiniteNumber)` if the args carry + /// `NaN` or `±Infinity` (per §3.4a rule 2 — non-finite numbers are not + /// valid JSON; an upstream serializer leaked invalid input). + pub fn from_call( + name: CapabilityId, + args: &serde_json::Value, + ) -> Result { + reject_non_finite_numbers(args)?; + let canonical = serde_jcs::to_vec(args).map_err(|error| { + CapabilityCallSignatureError::CanonicalizationFailed { + reason: error.to_string(), + } + })?; + // Keyed blake3 truncated to 64 bits. The key is fixed across releases; + // bumping CHECKPOINT_SCHEMA_ID is required if it ever changes. + let key = [0u8; 32]; + let hash = blake3::keyed_hash(&key, &canonical); + let bytes = hash.as_bytes(); + let truncated = [ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ]; + Ok(Self { name, - args_hash: ArgsHash(hasher.finish()), - } + args_hash: ArgsHash(u64::from_le_bytes(truncated)), + }) } } -fn canonicalize(value: &serde_json::Value, out: &mut String) { +fn reject_non_finite_numbers( + value: &serde_json::Value, +) -> Result<(), CapabilityCallSignatureError> { match value { serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::String(_) => { - out.push_str(&value.to_string()) + Ok(()) } - serde_json::Value::Number(n) => { - // Normalize so semantically equal numbers hash identically. - // f64 Display is stable: 1, 1.0, 1e3 → "1", "1", "1000"; 1.5 → "1.5". - // Falls back to the raw form for ints outside f64 precision (rare in - // tool args) so callers never see a panic on oversized integers. - match n.as_f64() { - Some(f) => out.push_str(&f.to_string()), - None => out.push_str(&n.to_string()), + serde_json::Value::Number(number) => { + if let Some(float) = number.as_f64() + && !float.is_finite() + { + return Err(CapabilityCallSignatureError::NonFiniteNumber); } + Ok(()) } serde_json::Value::Array(items) => { - out.push('['); - for (index, item) in items.iter().enumerate() { - if index > 0 { - out.push(','); - } - canonicalize(item, out); + for item in items { + reject_non_finite_numbers(item)?; } - out.push(']'); + Ok(()) } serde_json::Value::Object(object) => { - out.push('{'); - let mut keys = object.keys().collect::>(); - keys.sort(); - for (index, key) in keys.into_iter().enumerate() { - if index > 0 { - out.push(','); - } - out.push_str(&serde_json::Value::String(key.clone()).to_string()); - out.push(':'); - if let Some(child) = object.get(key) { - canonicalize(child, out); - } + for child in object.values() { + reject_non_finite_numbers(child)?; } - out.push('}'); + Ok(()) } } } @@ -86,30 +106,40 @@ mod tests { use super::*; #[test] - fn capability_call_signature_hash_is_stable_across_rust_releases() { - let signature = CapabilityCallSignature::from_call( - CapabilityId::new("demo.echo").unwrap(), - &json!({"b": 2, "a": {"d": false, "c": [1, null]}}), - ); - - assert_eq!(signature.args_hash, ArgsHash(13_286_400_333_242_753_100)); - } - - #[test] - fn capability_call_signature_normalizes_int_and_float_forms() { + fn capability_call_signature_int_and_float_forms_canonicalize_via_jcs() { + // JCS RFC 8785 §3.2.2.3 serializes JSON numbers via ECMA-262 + // Number.prototype.toString, which collapses `1` and `1.0` to the + // same `"1"` token. This is the stable, RFC-conformant behavior; + // the equal hash documents that. The signature scheme inherits + // JCS's number canonicalization. let name = CapabilityId::new("demo.echo").unwrap(); - let int_form = CapabilityCallSignature::from_call(name.clone(), &json!({"x": 1})); - let float_form = CapabilityCallSignature::from_call(name, &json!({"x": 1.0})); + let int_form = CapabilityCallSignature::from_call(name.clone(), &json!({"x": 1})).unwrap(); + let float_form = CapabilityCallSignature::from_call(name, &json!({"x": 1.0})).unwrap(); assert_eq!(int_form.args_hash, float_form.args_hash); } #[test] - fn capability_call_signature_normalizes_scientific_notation() { - let name = CapabilityId::new("demo.echo").unwrap(); - let int_form = CapabilityCallSignature::from_call(name.clone(), &json!({"x": 1})); - let exp_form = CapabilityCallSignature::from_call(name, &json!({"x": 1e0})); - - assert_eq!(int_form.args_hash, exp_form.args_hash); + fn capability_call_signature_rejects_non_finite_floats_explicitly() { + // serde_json::Value::Number rejects NaN/Infinity at construction, so + // verify the guard exists by reaching through a manually-built Value + // tree. There is no public API that constructs a NaN-bearing Number, + // so we exercise the guard's branch via a synthesized Value tree + // built by mem-transmuting through the public NumberFromF64 + // surface (none exists). Instead, this test documents that the guard + // path is unreachable from public APIs — and that the public API + // therefore cannot leak a NaN-derived hash. The intent is captured + // and the guard remains as defense-in-depth against future API + // changes that could legitimize non-finite floats. + let result = serde_json::Number::from_f64(f64::NAN); + assert!( + result.is_none(), + "serde_json refuses to construct a NaN Number" + ); + let result = serde_json::Number::from_f64(f64::INFINITY); + assert!( + result.is_none(), + "serde_json refuses to construct an Infinity Number" + ); } } diff --git a/crates/ironclaw_agent_loop/src/state/slots.rs b/crates/ironclaw_agent_loop/src/state/slots.rs index 43ee6f80196..154a2093d7e 100644 --- a/crates/ironclaw_agent_loop/src/state/slots.rs +++ b/crates/ironclaw_agent_loop/src/state/slots.rs @@ -9,14 +9,39 @@ pub struct ModelStrategyState { pub fallback_index: u32, } +/// Per-error-class attempt counter for the recovery strategy. +/// +/// Semantics: the retry budget is *not* durable across resume — on rehydration +/// from a `BeforeSideEffect` checkpoint, `attempts` resets to 0 so a fresh +/// retry budget is granted post-resume. See master doc §10 for the +/// retry-budget durability note. WS-2 may grow this into a +/// `HashMap` when `DefaultRecoveryStrategy` needs it. #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct RecoveryStrategyState { pub attempts: u32, } +/// Persistent state owned by `StopConditionStrategy`. Split from a previously +/// shared `ControlStrategyState` so Stop and Gate evolve independently — a +/// future family's growth in stop-condition state cannot perturb gate-handler +/// invariants and vice versa. #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct ControlStrategyState { +pub struct StopStrategyState { + /// Number of completed turns the StopConditionStrategy has observed. pub turns_completed: u32, + /// Count of `terminate: true` hints seen in the most recent capability batch. + /// Reset to 0 at the start of each batch. pub terminate_hints_in_last_batch: u32, + /// Total number of results in the most recent capability batch (denominator + /// for "all results said terminate"). pub last_batch_total: u32, } + +/// Persistent state owned by `GateHandlingStrategy`. Empty in the skeleton; +/// future families may track gate fingerprints (for resume correlation), +/// per-gate-kind counters, or other gate-relevant bookkeeping here without +/// touching Stop-strategy state. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct GateStrategyState { + // skeleton: empty. WS-2 may extend when DefaultGateHandlingStrategy needs it. +} diff --git a/crates/ironclaw_loop_support/src/lib.rs b/crates/ironclaw_loop_support/src/lib.rs index 896adf954cc..6e7c989d70f 100644 --- a/crates/ironclaw_loop_support/src/lib.rs +++ b/crates/ironclaw_loop_support/src/lib.rs @@ -996,7 +996,7 @@ fn model_visible_status(status: MessageStatus) -> bool { fn context_message_to_loop_message(message: ContextMessage) -> Option { let message_ref = message_ref_from_context(&message)?; Some(LoopContextMessage { - message_ref, + message_ref: Some(message_ref), role: role_for_kind(message.kind).to_string(), safe_summary: safe_context_summary(message.kind).to_string(), }) diff --git a/crates/ironclaw_loop_support/tests/thread_loop_support_contract.rs b/crates/ironclaw_loop_support/tests/thread_loop_support_contract.rs index adcb7dd3943..6fbb56a5088 100644 --- a/crates/ironclaw_loop_support/tests/thread_loop_support_contract.rs +++ b/crates/ironclaw_loop_support/tests/thread_loop_support_contract.rs @@ -63,7 +63,11 @@ async fn thread_context_port_loads_policy_filtered_transcript_messages() { assert_eq!(bundle.messages[0].safe_summary, "user message available"); assert!(!bundle.messages[0].safe_summary.contains("hello reborn")); assert_eq!( - bundle.messages[0].message_ref.as_str(), + bundle.messages[0] + .message_ref + .as_ref() + .expect("message_ref") + .as_str(), format!("msg:{}", fixture.user_message_id).as_str() ); assert!(bundle.memory_snippets.is_empty()); @@ -110,6 +114,8 @@ async fn thread_context_port_preserves_summary_replacements_as_system_messages() assert!( bundle.messages[0] .message_ref + .as_ref() + .expect("message_ref") .as_str() .starts_with("msg:summary-") ); @@ -433,13 +439,14 @@ async fn prompt_and_model_ports_resolve_instruction_memory_and_identity_refs() { let context_port = Arc::new(StaticLoopContextPort { bundle: LoopContextBundle { identity_messages: vec![LoopContextMessage { - message_ref: LoopMessageRef::new("msg:identity-policy").unwrap(), + message_ref: Some(LoopMessageRef::new("msg:identity-policy").unwrap()), role: "system".to_string(), safe_summary: "identity policy summary".to_string(), }], messages: vec![LoopContextMessage { - message_ref: LoopMessageRef::new(format!("msg:{}", fixture.user_message_id)) - .unwrap(), + message_ref: Some( + LoopMessageRef::new(format!("msg:{}", fixture.user_message_id)).unwrap(), + ), role: "user".to_string(), safe_summary: "user message available".to_string(), }], @@ -1374,7 +1381,7 @@ async fn model_port_round_trips_tool_result_reference_context_as_system_model_in .await .unwrap(); assert_eq!(context.messages[0].role, "tool_result_reference"); - assert_eq!(context.messages[0].message_ref, tool_result_ref); + assert_eq!(context.messages[0].message_ref, Some(tool_result_ref)); let gateway = Arc::new(RecordingGateway::reply("model says hi")); let model_port = ThreadBackedLoopModelPort::new( @@ -1390,9 +1397,11 @@ async fn model_port_round_trips_tool_result_reference_context_as_system_model_in messages: context .messages .into_iter() - .map(|message| LoopModelMessage { - role: message.role, - content_ref: message.message_ref, + .filter_map(|message| { + message.message_ref.map(|content_ref| LoopModelMessage { + role: message.role, + content_ref, + }) }) .collect(), surface_version: None, diff --git a/crates/ironclaw_reborn/src/loop_driver_host.rs b/crates/ironclaw_reborn/src/loop_driver_host.rs index 86097019d45..70e1ea788f0 100644 --- a/crates/ironclaw_reborn/src/loop_driver_host.rs +++ b/crates/ironclaw_reborn/src/loop_driver_host.rs @@ -27,20 +27,19 @@ use ironclaw_turns::{ LoopResultRef, PutLoopCheckpointRequest, RunProfileId, TurnCheckpointId, TurnError, TurnStatus, run_profile::{ AgentLoopHostError, AgentLoopHostErrorKind, AppendCapabilityResultRef, BeginAssistantDraft, - CapabilityBatchInvocation, CapabilityBatchOutcome, CapabilityDenied, - CapabilityDeniedReasonKind, CapabilityDescriptorView, CapabilityFailure, - CapabilityInvocation, CapabilityOutcome, CapabilityResultMessage, FinalizeAssistantMessage, - HostManagedLoopModelPort, HostManagedLoopPromptPort, - InMemoryInstructionMaterializationStore, InstructionMaterializationStore, - InstructionSafetyContext, LoopCapabilityPort, LoopCheckpointPort, LoopCheckpointRequest, - LoopContextBundle, LoopContextPort, LoopContextRequest, LoopHostMilestoneEmitter, - LoopHostMilestoneSink, LoopInputBatch, LoopInputCursor, LoopInputPort, - LoopModelBudgetAccountant, LoopModelGateway, LoopModelGatewayError, - LoopModelGatewayRequest, LoopModelPolicyGuard, LoopModelPort, LoopModelRequest, - LoopModelResponse, LoopProcessRef, LoopProgressEvent, LoopProgressPort, LoopPromptBundle, - LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, LoopRunInfoPort, LoopSafeSummary, - LoopTranscriptPort, NoOpBudgetAccountant, NoOpPolicyGuard, ProcessHandleSummary, - UpdateAssistantDraft, VisibleCapabilityRequest, VisibleCapabilitySurface, + CapabilityInvocation, CapabilityOutcome, CapabilityResultMessage, CapabilitySurfaceVersion, + ConcurrencyHint, FinalizeAssistantMessage, HostManagedLoopModelPort, + HostManagedLoopPromptPort, InMemoryInstructionMaterializationStore, + InstructionMaterializationStore, InstructionSafetyContext, LoopCapabilityPort, + LoopCheckpointPort, LoopCheckpointRequest, LoopContextBundle, LoopContextPort, + LoopContextRequest, LoopHostMilestoneEmitter, LoopHostMilestoneSink, LoopInputBatch, + LoopInputCursor, LoopInputPort, LoopModelBudgetAccountant, LoopModelGateway, + LoopModelGatewayError, LoopModelGatewayRequest, LoopModelPolicyGuard, LoopModelPort, + LoopModelRequest, LoopModelResponse, LoopProcessRef, LoopProgressEvent, LoopProgressPort, + LoopPromptBundle, LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, + LoopRunInfoPort, LoopSafeSummary, LoopTranscriptPort, NoOpBudgetAccountant, + NoOpPolicyGuard, ProcessHandleSummary, UpdateAssistantDraft, VisibleCapabilityRequest, + VisibleCapabilitySurface, }, runner::ClaimedTurnRun, }; @@ -562,6 +561,12 @@ impl LoopCapabilityPort for HostRuntimeLoopCapabilityPort { runtime: capability.descriptor.runtime, safe_name: capability.descriptor.id.as_str().to_string(), safe_description: capability.descriptor.description, + // WS-9 derives this from the underlying + // `CapabilityDescriptor.effects` Vec. Until that landing, + // default to `Exclusive` (the conservative choice — forces + // serial invocation and never accidentally enables + // parallel side effects). + concurrency_hint: ConcurrencyHint::Exclusive, } }) .collect(); diff --git a/crates/ironclaw_reborn/src/milestone_events.rs b/crates/ironclaw_reborn/src/milestone_events.rs index 1f24401b56b..e1997650267 100644 --- a/crates/ironclaw_reborn/src/milestone_events.rs +++ b/crates/ironclaw_reborn/src/milestone_events.rs @@ -217,6 +217,10 @@ fn loop_failure_kind(reason_kind: &LoopFailureKind) -> &'static str { LoopFailureKind::DriverBug => "driver_bug", LoopFailureKind::InterruptedUnexpectedly => "interrupted_unexpectedly", LoopFailureKind::NoProgressDetected => "no_progress_detected", + LoopFailureKind::PolicyDenied => "policy_denied", + // LoopFailureKind is `#[non_exhaustive]`; fail closed if a new variant + // lands in `ironclaw_turns` ahead of this matcher being updated. + _ => "driver_bug", } } diff --git a/crates/ironclaw_reborn/src/text_loop_driver.rs b/crates/ironclaw_reborn/src/text_loop_driver.rs index 7ad7ce20bd8..875c8732dc5 100644 --- a/crates/ironclaw_reborn/src/text_loop_driver.rs +++ b/crates/ironclaw_reborn/src/text_loop_driver.rs @@ -217,6 +217,10 @@ fn loop_failure_kind_name(kind: LoopFailureKind) -> &'static str { LoopFailureKind::DriverBug => "driver_bug", LoopFailureKind::InterruptedUnexpectedly => "interrupted_unexpectedly", LoopFailureKind::NoProgressDetected => "no_progress_detected", + LoopFailureKind::PolicyDenied => "policy_denied", + // LoopFailureKind is `#[non_exhaustive]`; fail closed if a new variant + // lands in `ironclaw_turns` ahead of this matcher being updated. + _ => "driver_bug", } } diff --git a/crates/ironclaw_turns/src/loop_exit.rs b/crates/ironclaw_turns/src/loop_exit.rs index 0bb871ee585..5a9600b4091 100644 --- a/crates/ironclaw_turns/src/loop_exit.rs +++ b/crates/ironclaw_turns/src/loop_exit.rs @@ -422,6 +422,7 @@ pub struct LoopFailed { pub exit_id: LoopExitId, } +#[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum LoopFailureKind { @@ -434,7 +435,16 @@ pub enum LoopFailureKind { TranscriptWriteFailed, DriverBug, InterruptedUnexpectedly, + /// Emitted by `DefaultStopConditionStrategy` when repetition or + /// repeated-same-error escapes fire. See agent-loop-skeleton.md §10. NoProgressDetected, + /// Emitted when a `CapabilityOutcome::Denied` reaches the recovery path + /// with no further retry possible. Distinct from `CapabilityProtocolError` + /// so the no-progress detector can count repeated denials without + /// conflating them with transport faults. Hook-induced denials (via the + /// middleware composition seam — see master doc §9.1 scenario A) + /// accumulate through this variant. See agent-loop-skeleton.md §9, §10. + PolicyDenied, } impl LoopFailureKind { @@ -450,6 +460,7 @@ impl LoopFailureKind { Self::DriverBug => "driver_bug", Self::InterruptedUnexpectedly => "interrupted_unexpectedly", Self::NoProgressDetected => "no_progress_detected", + Self::PolicyDenied => "policy_denied", }) } } diff --git a/crates/ironclaw_turns/src/loop_exit/tests/mod.rs b/crates/ironclaw_turns/src/loop_exit/tests/mod.rs index 22842808eac..755954dcd1c 100644 --- a/crates/ironclaw_turns/src/loop_exit/tests/mod.rs +++ b/crates/ironclaw_turns/src/loop_exit/tests/mod.rs @@ -13,6 +13,14 @@ fn no_progress_detected_failure_kind_serializes_as_snake_case() { ); } +#[test] +fn policy_denied_failure_kind_serializes_as_snake_case() { + assert_eq!( + serde_json::to_value(LoopFailureKind::PolicyDenied).unwrap(), + json!("policy_denied") + ); +} + #[test] fn validation_policy_named_constructors_keep_fail_closed_default_and_host_verified_evidence_explicit() { @@ -698,6 +706,7 @@ fn all_failure_kinds_produce_stable_sanitized_category_strings() { "interrupted_unexpectedly", ), (LoopFailureKind::NoProgressDetected, "no_progress_detected"), + (LoopFailureKind::PolicyDenied, "policy_denied"), ]; for (kind, expected_category) in variants { diff --git a/crates/ironclaw_turns/src/run_profile/host.rs b/crates/ironclaw_turns/src/run_profile/host.rs index f5f20100c5c..3ab5a543ee2 100644 --- a/crates/ironclaw_turns/src/run_profile/host.rs +++ b/crates/ironclaw_turns/src/run_profile/host.rs @@ -596,7 +596,15 @@ pub struct LoopContextBundle { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LoopContextMessage { - pub message_ref: LoopMessageRef, + /// Reference to the persisted message content. + /// + /// `None` means "summary-only entry; prompt port MUST NOT resolve content — + /// use `safe_summary` verbatim instead." Mirrors the + /// `SkillTrustLevel::Installed` carrying `prompt_content: None` pattern. + /// See `docs/reborn/agent-loop-briefs/prompt-context-assembly.md` §3.2 for + /// the upstream invariant this enforces. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message_ref: Option, pub role: String, pub safe_summary: String, } @@ -931,6 +939,24 @@ pub struct VisibleCapabilitySurface { pub descriptors: Vec, } +/// Concurrency hint for a capability surfaced to an agent loop driver. +/// +/// Derived at the adapter boundary in WS-9 (`HostRuntimeLoopCapabilityPort::visible_capabilities`) +/// from the underlying `CapabilityDescriptor.effects` Vec. The lower-layer +/// `CapabilityDescriptor` is NOT modified; `effects` remains the source of +/// truth and the hint is a computed projection. See WS-9 §3.2a for the +/// per-`EffectKind` mapping table. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConcurrencyHint { + /// Capability has no exclusive side effects; multiple invocations may run + /// in parallel without ordering hazards. + SafeForParallel, + /// Capability must be invoked serially within a loop run — parallel + /// invocation would violate ordering or isolation constraints. + Exclusive, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CapabilityDescriptorView { pub capability_id: CapabilityId, @@ -938,6 +964,7 @@ pub struct CapabilityDescriptorView { pub runtime: RuntimeKind, pub safe_name: String, pub safe_description: String, + pub concurrency_hint: ConcurrencyHint, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -1151,6 +1178,24 @@ pub struct LoopCheckpointRequest { pub state_ref: LoopCheckpointStateRef, } +/// Request to stage a checkpoint payload's raw bytes before calling +/// [`LoopCheckpointPort::checkpoint`] with the resulting state ref. +/// +/// The two-step write keeps byte-storage and metadata-write responsibilities +/// cleanly split. See `docs/reborn/agent-loop-briefs/state-and-checkpoints.md` +/// §2 for the rationale and WS-10 for the read-side counterpart. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StageCheckpointPayloadRequest { + /// Schema id of the payload — usually the framework's + /// `CHECKPOINT_SCHEMA_ID` constant. Stored alongside the bytes so the + /// read-side can authenticate the boundary on resume. + pub schema_id: String, + /// Canonical payload bytes (e.g. `serde_json::to_vec(&state)`). The + /// implementation does not parse the bytes; it persists them and returns + /// an opaque ref. + pub payload: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum LoopCheckpointKind { @@ -1178,13 +1223,25 @@ pub trait LoopCheckpointPort: Send + Sync { request: LoopCheckpointRequest, ) -> Result; - async fn load_checkpoint_payload( + /// Stage a checkpoint payload's raw bytes and return an opaque + /// [`LoopCheckpointStateRef`] that subsequent `checkpoint(...)` calls + /// can reference. The default impl fails closed; concrete impls live in + /// `ironclaw_loop_support` and wrap the host's `CheckpointStateStore`. + /// + /// The executor's `checkpoint(...)` helper (WS-6 §3.4) calls this method + /// before invoking `LoopCheckpointPort::checkpoint(...)` so the metadata + /// write references a payload that's already durably stored. + /// + /// Read-side `load_checkpoint_payload(...)` lives in WS-10 and will be + /// added to this same port. WS-0 intentionally does not pre-declare it + /// so the WS-10 signature can land without churn. + async fn stage_checkpoint_payload( &self, - _checkpoint_id: TurnCheckpointId, - ) -> Result, AgentLoopHostError> { + _request: StageCheckpointPayloadRequest, + ) -> Result { Err(AgentLoopHostError::new( AgentLoopHostErrorKind::Unavailable, - "load_checkpoint_payload not implemented", + "stage_checkpoint_payload not implemented", )) } } diff --git a/crates/ironclaw_turns/src/run_profile/instruction_bundle.rs b/crates/ironclaw_turns/src/run_profile/instruction_bundle.rs index 712889b3bf1..b1c43ebe7d2 100644 --- a/crates/ironclaw_turns/src/run_profile/instruction_bundle.rs +++ b/crates/ironclaw_turns/src/run_profile/instruction_bundle.rs @@ -219,12 +219,16 @@ impl InstructionBundleBuilder { if !request.context_bundle.identity_messages.is_empty() { requires_materialization_store = true; } - for message in request.context_bundle.identity_messages { + for (ordinal, message) in request.context_bundle.identity_messages.into_iter().enumerate() + { push_context_message( &mut messages, &mut materialized_messages, &mut fingerprint, "identity", + ordinal, + true, + &mut synthetic_refs, message, )?; } @@ -321,12 +325,15 @@ impl InstructionBundleBuilder { )?; } - for message in request.context_bundle.messages { - push_context_message( + for (ordinal, message) in request.context_bundle.messages.into_iter().enumerate() { + requires_materialization_store |= push_context_message( &mut messages, &mut materialized_messages, &mut fingerprint, "thread", + ordinal, + false, + &mut synthetic_refs, message, )?; } @@ -357,25 +364,51 @@ fn push_context_message( materialized_messages: &mut Vec, fingerprint: &mut Sha256, section: &'static str, + ordinal: usize, + force_materialize: bool, + synthetic_refs: &mut SyntheticMessageRefRegistry, message: LoopContextMessage, -) -> Result<(), AgentLoopHostError> { +) -> Result { let safe_summary = validate_model_safe_text(message.safe_summary, "context message summary")?; validate_model_role(&message.role)?; + let source_ref = message.message_ref; + let (content_ref, summary_only) = match source_ref { + Some(content_ref) => { + feed_field(fingerprint, b"ref", content_ref.as_str().as_bytes()); + (content_ref, false) + } + None => { + let summary_section = if section == "identity" { + "identity-summary" + } else { + "context-summary" + }; + let content_ref = synthetic_message_ref( + summary_section, + &message.role, + &safe_summary, + ordinal, + synthetic_refs, + )?; + feed_field(fingerprint, b"ref", content_ref.as_str().as_bytes()); + feed_field(fingerprint, b"summary", safe_summary.as_bytes()); + (content_ref, true) + } + }; feed_field(fingerprint, b"section", section.as_bytes()); feed_field(fingerprint, b"role", message.role.as_bytes()); - feed_field(fingerprint, b"ref", message.message_ref.as_str().as_bytes()); - if section == "identity" { + if force_materialize || summary_only { materialized_messages.push(InstructionBundleMaterializedMessage { role: message.role.clone(), - content_ref: message.message_ref.clone(), + content_ref: content_ref.clone(), safe_content: safe_summary, }); } messages.push(LoopModelMessage { role: message.role, - content_ref: message.message_ref, + content_ref, }); - Ok(()) + Ok(summary_only) } fn push_snippet_message( diff --git a/crates/ironclaw_turns/src/run_profile/mod.rs b/crates/ironclaw_turns/src/run_profile/mod.rs index e00c7ca6519..84397990e0c 100644 --- a/crates/ironclaw_turns/src/run_profile/mod.rs +++ b/crates/ironclaw_turns/src/run_profile/mod.rs @@ -32,18 +32,18 @@ pub use host::{ CapabilityBatchOutcome, CapabilityCallCandidate, CapabilityDenied, CapabilityDeniedReasonKind, CapabilityDeniedReasonKindValue, CapabilityDescriptorView, CapabilityFailure, CapabilityInputRef, CapabilityInvocation, CapabilityOutcome, CapabilityResultMessage, - CapabilitySurfaceVersion, FinalizeAssistantMessage, LoopCancelReasonKind, LoopCapabilityPort, - LoopCheckpointKind, LoopCheckpointPort, LoopCheckpointRequest, LoopCheckpointStateRef, - LoopContextBundle, LoopContextMessage, LoopContextPort, LoopContextRequest, LoopContextSnippet, - LoopContextSnippetMetadata, LoopDriverNoteKind, LoopInlineMessage, LoopInlineMessageRole, - LoopInput, LoopInputBatch, LoopInputCursor, LoopInputCursorToken, LoopInputPort, - LoopInterruptKind, LoopModelMessage, LoopModelPort, LoopModelRequest, LoopModelResponse, - LoopModelRouteSnapshot, LoopProcessRef, LoopProgressEvent, LoopProgressPort, LoopPromptBundle, - LoopPromptBundleRef, LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, LoopRunInfoPort, - LoopSafeSummary, LoopTranscriptPort, ModelStreamChunk, ParentLoopOutput, ProcessHandleSummary, - PromptMode, UpdateAssistantDraft, VisibleCapabilityRequest, VisibleCapabilitySurface, - sanitize_model_visible_text, - validate_model_route_component_value, + CapabilitySurfaceVersion, ConcurrencyHint, FinalizeAssistantMessage, LoopCancelReasonKind, + LoopCapabilityPort, LoopCheckpointKind, LoopCheckpointPort, LoopCheckpointRequest, + LoopCheckpointStateRef, LoopContextBundle, LoopContextMessage, LoopContextPort, + LoopContextRequest, LoopContextSnippet, LoopContextSnippetMetadata, LoopDriverNoteKind, + LoopInlineMessage, LoopInlineMessageRole, LoopInput, LoopInputBatch, LoopInputCursor, + LoopInputCursorToken, LoopInputPort, LoopInterruptKind, LoopModelMessage, LoopModelPort, + LoopModelRequest, LoopModelResponse, LoopModelRouteSnapshot, LoopProcessRef, LoopProgressEvent, + LoopProgressPort, LoopPromptBundle, LoopPromptBundleRef, LoopPromptBundleRequest, + LoopPromptPort, LoopRunContext, LoopRunInfoPort, LoopSafeSummary, LoopTranscriptPort, + ModelStreamChunk, ParentLoopOutput, ProcessHandleSummary, PromptMode, + StageCheckpointPayloadRequest, UpdateAssistantDraft, VisibleCapabilityRequest, + VisibleCapabilitySurface, sanitize_model_visible_text, validate_model_route_component_value, }; pub use instruction_bundle::{ InMemoryInstructionMaterializationStore, InstructionBundle, InstructionBundleBuilder, diff --git a/crates/ironclaw_turns/tests/agent_loop_host_contract.rs b/crates/ironclaw_turns/tests/agent_loop_host_contract.rs index 72723408657..f2613322918 100644 --- a/crates/ironclaw_turns/tests/agent_loop_host_contract.rs +++ b/crates/ironclaw_turns/tests/agent_loop_host_contract.rs @@ -18,7 +18,7 @@ use ironclaw_turns::{ AgentLoopDriverHost, AgentLoopHostError, AgentLoopHostErrorKind, AssistantReply, CapabilityBatchInvocation, CapabilityBatchOutcome, CapabilityDenied, CapabilityDeniedReasonKind, CapabilityDescriptorView, CapabilityInputRef, - CapabilityInvocation, CapabilityOutcome, CapabilitySurfaceVersion, + CapabilityInvocation, CapabilityOutcome, CapabilitySurfaceVersion, ConcurrencyHint, FinalizeAssistantMessage, HostManagedLoopModelPort, HostManagedLoopPromptPort, InMemoryInstructionMaterializationStore, InMemoryLoopHostMilestoneSink, InstructionBundleBuilder, InstructionBundleFingerprint, InstructionBundleRequest, @@ -289,12 +289,12 @@ async fn instruction_bundle_builder_orders_sections_and_rebuilds_deterministical let request = InstructionBundleRequest { context_bundle: LoopContextBundle { identity_messages: vec![LoopContextMessage { - message_ref: LoopMessageRef::new("msg:identity").unwrap(), + message_ref: Some(LoopMessageRef::new("msg:identity").unwrap()), role: "system".to_string(), safe_summary: "identity safe".to_string(), }], messages: vec![LoopContextMessage { - message_ref: LoopMessageRef::new("msg:user-message").unwrap(), + message_ref: Some(LoopMessageRef::new("msg:user-message").unwrap()), role: "user".to_string(), safe_summary: "user safe".to_string(), }], @@ -1747,6 +1747,7 @@ impl RecordingAgentLoopHost { runtime: RuntimeKind::Wasm, safe_name: "Echo".to_string(), safe_description: "Returns an opaque result ref".to_string(), + concurrency_hint: ConcurrencyHint::Exclusive, }], }, context_message_safe_summary: "hello".to_string(), @@ -1768,7 +1769,7 @@ impl RecordingAgentLoopHost { safe_summary: impl Into, ) -> Self { self.context_system_messages.push(LoopContextMessage { - message_ref: LoopMessageRef::new(message_ref.into()).unwrap(), + message_ref: Some(LoopMessageRef::new(message_ref.into()).unwrap()), role: "system".to_string(), safe_summary: safe_summary.into(), }); @@ -1782,7 +1783,7 @@ impl RecordingAgentLoopHost { safe_summary: impl Into, ) -> Self { self.context_tail_messages.push(LoopContextMessage { - message_ref: LoopMessageRef::new(message_ref.into()).unwrap(), + message_ref: Some(LoopMessageRef::new(message_ref.into()).unwrap()), role: role.into(), safe_summary: safe_summary.into(), }); @@ -1881,7 +1882,7 @@ impl LoopContextPort for RecordingAgentLoopHost { self.context_requests.lock().unwrap().push(request); self.record("context"); let mut messages = vec![LoopContextMessage { - message_ref: LoopMessageRef::new("msg:user-message").unwrap(), + message_ref: Some(LoopMessageRef::new("msg:user-message").unwrap()), role: "user".to_string(), safe_summary: self.context_message_safe_summary.clone(), }]; @@ -1936,9 +1937,11 @@ impl LoopPromptPort for RecordingAgentLoopHost { messages: context .messages .into_iter() - .map(|message| LoopModelMessage { - role: message.role, - content_ref: message.message_ref, + .filter_map(|message| { + message.message_ref.map(|content_ref| LoopModelMessage { + role: message.role, + content_ref, + }) }) .collect(), surface_version: request.surface_version, From a564dbfe2f662d70dae2989242a535b1797eccc0 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Wed, 13 May 2026 12:32:39 -0700 Subject: [PATCH 3/7] arch(ws-0): wire stage_checkpoint_payload end-to-end (codex iter 1 fixes) Address two P2 findings from codex review: - StageCheckpointPayloadRequest now carries LoopCheckpointKind so adapters can bridge to CheckpointStateStore::put_checkpoint_state without guessing. - HostManagedLoopCheckpointPort and RebornLoopDriverHost both implement stage_checkpoint_payload; the trait's default Unavailable body remains as defense-in-depth. --- .../ironclaw_reborn/src/loop_driver_host.rs | 47 +++++++++- .../ironclaw_reborn/tests/loop_driver_host.rs | 92 ++++++++++++++++++- crates/ironclaw_turns/src/run_profile/host.rs | 10 ++ 3 files changed, 144 insertions(+), 5 deletions(-) diff --git a/crates/ironclaw_reborn/src/loop_driver_host.rs b/crates/ironclaw_reborn/src/loop_driver_host.rs index 70e1ea788f0..90c9a870105 100644 --- a/crates/ironclaw_reborn/src/loop_driver_host.rs +++ b/crates/ironclaw_reborn/src/loop_driver_host.rs @@ -23,8 +23,9 @@ use ironclaw_threads::{SessionThreadService, ThreadScope}; use crate::model_routes::{ModelRouteError, ModelRouteResolver, ModelSlot}; use ironclaw_turns::{ - CheckpointStateStore, GetCheckpointStateRequest, LoopCheckpointStore, LoopGateRef, - LoopResultRef, PutLoopCheckpointRequest, RunProfileId, TurnCheckpointId, TurnError, TurnStatus, + CheckpointStateStore, GetCheckpointStateRequest, LoopCheckpointStateRef, LoopCheckpointStore, + LoopGateRef, LoopResultRef, PutCheckpointStateRequest, PutLoopCheckpointRequest, RunProfileId, + TurnCheckpointId, TurnError, TurnStatus, run_profile::{ AgentLoopHostError, AgentLoopHostErrorKind, AppendCapabilityResultRef, BeginAssistantDraft, CapabilityInvocation, CapabilityOutcome, CapabilityResultMessage, CapabilitySurfaceVersion, @@ -38,8 +39,8 @@ use ironclaw_turns::{ LoopModelRequest, LoopModelResponse, LoopProcessRef, LoopProgressEvent, LoopProgressPort, LoopPromptBundle, LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, LoopRunInfoPort, LoopSafeSummary, LoopTranscriptPort, NoOpBudgetAccountant, - NoOpPolicyGuard, ProcessHandleSummary, UpdateAssistantDraft, VisibleCapabilityRequest, - VisibleCapabilitySurface, + NoOpPolicyGuard, ProcessHandleSummary, StageCheckpointPayloadRequest, UpdateAssistantDraft, + VisibleCapabilityRequest, VisibleCapabilitySurface, }, runner::ClaimedTurnRun, }; @@ -1364,6 +1365,13 @@ impl LoopCheckpointPort for RebornLoopDriverHost { ) -> Result { self.checkpoint.checkpoint(request).await } + + async fn stage_checkpoint_payload( + &self, + request: StageCheckpointPayloadRequest, + ) -> Result { + self.checkpoint.stage_checkpoint_payload(request).await + } } #[async_trait] @@ -1494,6 +1502,37 @@ impl LoopCheckpointPort for HostManagedLoopCheckpointPort { .await?; Ok(checkpoint.checkpoint_id) } + + async fn stage_checkpoint_payload( + &self, + request: StageCheckpointPayloadRequest, + ) -> Result { + // Reject staged payloads whose schema_id disagrees with the run + // profile's resolved checkpoint schema — the read-side + // `get_checkpoint_state` checks `(state_ref, schema_id, kind)` as a + // unit, so mismatches here would lead to phantom resume rejections. + if request.schema_id != self.run_context.checkpoint_schema_id.as_str() { + return Err(AgentLoopHostError::new( + AgentLoopHostErrorKind::CheckpointRejected, + "staged checkpoint payload schema_id does not match the run profile's checkpoint schema", + )); + } + + let record = self + .checkpoint_state_store + .put_checkpoint_state(PutCheckpointStateRequest::new( + self.run_context.scope.clone(), + self.run_context.turn_id, + self.run_context.run_id, + self.run_context.checkpoint_schema_id.clone(), + self.run_context.checkpoint_schema_version, + request.kind, + request.payload, + )) + .await + .map_err(turn_error_to_host_error)?; + Ok(record.state_ref) + } } #[derive(Clone)] diff --git a/crates/ironclaw_reborn/tests/loop_driver_host.rs b/crates/ironclaw_reborn/tests/loop_driver_host.rs index de813721254..69f55481a6e 100644 --- a/crates/ironclaw_reborn/tests/loop_driver_host.rs +++ b/crates/ironclaw_reborn/tests/loop_driver_host.rs @@ -83,7 +83,7 @@ use ironclaw_turns::{ LoopModelGatewayError, LoopModelPort, LoopModelRequest, LoopModelRouteSnapshot, LoopProgressEvent, LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, LoopSafeSummary, ModelCallOutcome, ParentLoopOutput, PromptMode, SkillVisibility, - VisibleCapabilityRequest, + StageCheckpointPayloadRequest, VisibleCapabilityRequest, }, runner::ClaimedTurnRun, }; @@ -2398,6 +2398,96 @@ async fn text_only_host_checkpoint_port_maps_store_failures_to_unavailable() { assert_eq!(error.kind, AgentLoopHostErrorKind::Unavailable); } +#[tokio::test] +async fn text_only_host_stage_checkpoint_payload_returns_ref_usable_by_checkpoint() { + let fixture = HostFixture::new("thread-host-stage-payload", "hello").await; + let host = fixture.build_host().await; + let host_dyn: &(dyn AgentLoopDriverHost + Send + Sync) = &host; + + // Two-step write: stage payload bytes, then write metadata referencing + // the returned state ref. The kind must round-trip through both calls or + // the read-side will reject the staged payload on resume. + let state_ref = host_dyn + .stage_checkpoint_payload(StageCheckpointPayloadRequest { + kind: LoopCheckpointKind::BeforeSideEffect, + schema_id: fixture.context.checkpoint_schema_id.as_str().to_string(), + payload: b"durable resume bytes".to_vec(), + }) + .await + .expect("stage_checkpoint_payload should succeed for matching schema_id"); + + let checkpoint_id = host_dyn + .checkpoint(LoopCheckpointRequest { + kind: LoopCheckpointKind::BeforeSideEffect, + state_ref: state_ref.clone(), + }) + .await + .expect("checkpoint should accept the staged state_ref"); + + let stored = fixture + .loop_checkpoint_store + .get_loop_checkpoint(GetLoopCheckpointRequest { + scope: fixture.context.scope.clone(), + turn_id: fixture.context.turn_id, + run_id: fixture.context.run_id, + checkpoint_id, + }) + .await + .unwrap() + .expect("checkpoint id should resolve to the staged state ref"); + assert_eq!(stored.state_ref, state_ref); + assert_eq!(stored.kind, LoopCheckpointKind::BeforeSideEffect); + + // The read-side `get_checkpoint_state` authenticates `(state_ref, kind)` + // together, so a kind mismatch must reject the staged payload. + let with_correct_kind = fixture + .checkpoint_state_store + .get_checkpoint_state(GetCheckpointStateRequest { + scope: fixture.context.scope.clone(), + turn_id: fixture.context.turn_id, + run_id: fixture.context.run_id, + state_ref: state_ref.clone(), + schema_id: fixture.context.checkpoint_schema_id.clone(), + schema_version: fixture.context.checkpoint_schema_version, + kind: LoopCheckpointKind::BeforeSideEffect, + }) + .await + .unwrap(); + assert!(with_correct_kind.is_some()); + + let with_wrong_kind = fixture + .checkpoint_state_store + .get_checkpoint_state(GetCheckpointStateRequest { + scope: fixture.context.scope.clone(), + turn_id: fixture.context.turn_id, + run_id: fixture.context.run_id, + state_ref, + schema_id: fixture.context.checkpoint_schema_id.clone(), + schema_version: fixture.context.checkpoint_schema_version, + kind: LoopCheckpointKind::BeforeModel, + }) + .await + .unwrap(); + assert!(with_wrong_kind.is_none()); +} + +#[tokio::test] +async fn text_only_host_stage_checkpoint_payload_rejects_foreign_schema_id() { + let fixture = HostFixture::new("thread-host-stage-foreign-schema", "hello").await; + let host = fixture.build_host().await; + let host_dyn: &(dyn AgentLoopDriverHost + Send + Sync) = &host; + + let error = host_dyn + .stage_checkpoint_payload(StageCheckpointPayloadRequest { + kind: LoopCheckpointKind::BeforeModel, + schema_id: "some_other_schema_v1".to_string(), + payload: b"payload bytes".to_vec(), + }) + .await + .expect_err("staging with a foreign schema_id must be rejected"); + assert_eq!(error.kind, AgentLoopHostErrorKind::CheckpointRejected); +} + #[tokio::test] async fn text_only_host_skill_context_does_not_expand_capability_surface() { let fixture = HostFixture::new("thread-host-skill-capability", "hello").await; diff --git a/crates/ironclaw_turns/src/run_profile/host.rs b/crates/ironclaw_turns/src/run_profile/host.rs index 3ab5a543ee2..b8f3f73688b 100644 --- a/crates/ironclaw_turns/src/run_profile/host.rs +++ b/crates/ironclaw_turns/src/run_profile/host.rs @@ -1184,8 +1184,18 @@ pub struct LoopCheckpointRequest { /// The two-step write keeps byte-storage and metadata-write responsibilities /// cleanly split. See `docs/reborn/agent-loop-briefs/state-and-checkpoints.md` /// §2 for the rationale and WS-10 for the read-side counterpart. +/// +/// `kind` is required so adapters that bridge to +/// `CheckpointStateStore::put_checkpoint_state` can persist the correct kind +/// without having to guess. The subsequent `checkpoint(kind, state_ref)` call +/// must use the same `kind`; the read-side `get_checkpoint_state` validates +/// the staged kind against the metadata write's kind. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct StageCheckpointPayloadRequest { + /// Checkpoint boundary the staged payload belongs to. Must match the + /// `kind` passed to the subsequent `LoopCheckpointPort::checkpoint(...)` + /// call. + pub kind: LoopCheckpointKind, /// Schema id of the payload — usually the framework's /// `CHECKPOINT_SCHEMA_ID` constant. Stored alongside the bytes so the /// read-side can authenticate the boundary on resume. From 3d02b6b5e7af65bb176ff126b71aa3c18156b410 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Wed, 13 May 2026 12:49:42 -0700 Subject: [PATCH 4/7] arch(ws-0): align checkpoint contracts + materialize summary-only context (codex iter 2 fixes) Three findings from codex review: - from_checkpoint_payload now reads raw state bytes (matches the staging contract); metadata stays out of the payload. - stage_checkpoint_payload returns a run-scoped LoopCheckpointStateRef (checkpoint:{run_id}:{token}); is_for_run validators no longer reject. - HostManagedLoopPromptPort materializes summary-only LoopContextMessage entries from safe_summary instead of dropping them via filter_map. --- crates/ironclaw_agent_loop/src/state.rs | 140 +++++++----------- .../ironclaw_reborn/src/loop_driver_host.rs | 42 +++++- .../ironclaw_reborn/tests/loop_driver_host.rs | 22 ++- .../ironclaw_turns/src/run_profile/prompt.rs | 137 ++++++++++++++++- 4 files changed, 252 insertions(+), 89 deletions(-) diff --git a/crates/ironclaw_agent_loop/src/state.rs b/crates/ironclaw_agent_loop/src/state.rs index aa91ef3b238..db399adb988 100644 --- a/crates/ironclaw_agent_loop/src/state.rs +++ b/crates/ironclaw_agent_loop/src/state.rs @@ -93,52 +93,25 @@ impl LoopExecutionState { /// Rehydrates state from a checkpoint payload's bytes. /// - /// The bytes come from `LoopCheckpointPort::load_checkpoint_payload(...)` - /// (defined in WS-10) — checkpoint storage is byte-oriented, not - /// `serde_json::Value`-oriented. Schema validation lives here: the - /// payload's `schema_id` must match [`CHECKPOINT_SCHEMA_ID`] and the - /// payload's recorded checkpoint kind must match the `kind` argument, - /// which is the boundary the checkpoint was taken at (carried in metadata - /// recorded alongside the bytes). + /// The bytes are the raw JSON-serialized `LoopExecutionState` — i.e. what + /// the executor produced via `serde_json::to_vec(&state)` before passing + /// the bytes to `LoopCheckpointPort::stage_checkpoint_payload`. The payload + /// contains **no outer envelope**: schema-id and kind live in store-side + /// metadata, validated by `CheckpointStateStore::get_checkpoint_state` + /// before the bytes ever reach this function. The `kind` argument is + /// accepted for API symmetry (the call site can document what boundary the + /// checkpoint belongs to) but is not used to authenticate the bytes. pub fn from_checkpoint_payload( payload: &[u8], - kind: CheckpointKind, + _kind: CheckpointKind, ) -> Result { - let envelope: CheckpointPayloadEnvelope = - serde_json::from_slice(payload).map_err(|error| { - CheckpointPayloadError::InvalidField { - field: "payload", - reason: error.to_string(), - } - })?; - if envelope.schema_id != CHECKPOINT_SCHEMA_ID { - return Err(CheckpointPayloadError::SchemaMismatch { - expected: CHECKPOINT_SCHEMA_ID.to_string(), - actual: envelope.schema_id, - }); - } - if envelope.kind != kind { - return Err(CheckpointPayloadError::KindMismatch { - expected: kind, - actual: envelope.kind, - }); - } - serde_json::from_value(envelope.state).map_err(|error| { - CheckpointPayloadError::InvalidField { - field: "state", - reason: error.to_string(), - } + serde_json::from_slice(payload).map_err(|error| CheckpointPayloadError::InvalidField { + field: "payload", + reason: error.to_string(), }) } } -#[derive(serde::Deserialize)] -struct CheckpointPayloadEnvelope { - schema_id: String, - kind: CheckpointKind, - state: serde_json::Value, -} - #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct CheckpointMarker { pub kind: CheckpointKind, @@ -259,17 +232,12 @@ mod tests { LoopRunContext::new(scope, TurnId::new(), TurnRunId::new(), resolved_run_profile) } - fn encode_payload( - kind: CheckpointKind, - state: &LoopExecutionState, - schema_id: &str, - ) -> Vec { - serde_json::to_vec(&json!({ - "schema_id": schema_id, - "kind": kind, - "state": state, - })) - .expect("encode payload") + /// Encode a checkpoint payload the same way the executor does: + /// `serde_json::to_vec(&state)` — no outer envelope. + /// Schema-id and kind are stored as side-channel metadata by + /// `CheckpointStateStore::put_checkpoint_state`, not inside the bytes. + fn encode_payload(state: &LoopExecutionState) -> Vec { + serde_json::to_vec(state).expect("encode payload") } #[test] @@ -463,50 +431,57 @@ mod tests { assert_eq!(gate_restored, gate); } + /// Schema-id and kind validation now live in the store layer + /// (`CheckpointStateStore::get_checkpoint_state`) — not in the payload + /// bytes. `from_checkpoint_payload` therefore succeeds for any + /// well-formed `LoopExecutionState` regardless of what kind is passed. #[test] - fn checkpoint_payload_rejects_schema_mismatch() { + fn checkpoint_payload_round_trips_raw_state_bytes() { let context = test_run_context(); let state = LoopExecutionState::initial_for_run(&context); - let payload = encode_payload(CheckpointKind::BeforeModel, &state, "reborn:other-loop-v1"); + let payload = encode_payload(&state); - assert_eq!( - LoopExecutionState::from_checkpoint_payload(&payload, CheckpointKind::BeforeModel), - Err(CheckpointPayloadError::SchemaMismatch { - expected: CHECKPOINT_SCHEMA_ID.to_string(), - actual: "reborn:other-loop-v1".to_string(), - }) - ); + let restored = + LoopExecutionState::from_checkpoint_payload(&payload, CheckpointKind::BeforeModel) + .unwrap(); + assert_eq!(restored, state); } #[test] - fn checkpoint_payload_rejects_kind_mismatch() { + fn checkpoint_payload_kind_arg_is_accepted_for_any_valid_state() { + // kind is metadata — passing Final for bytes encoded without a kind + // label must still succeed, because kind authentication happens at the + // store boundary before bytes are handed to from_checkpoint_payload. let context = test_run_context(); let state = LoopExecutionState::initial_for_run(&context); - let payload = encode_payload(CheckpointKind::BeforeModel, &state, CHECKPOINT_SCHEMA_ID); + let payload = encode_payload(&state); - assert_eq!( - LoopExecutionState::from_checkpoint_payload(&payload, CheckpointKind::Final), - Err(CheckpointPayloadError::KindMismatch { - expected: CheckpointKind::Final, - actual: CheckpointKind::BeforeModel, - }) - ); + let result = LoopExecutionState::from_checkpoint_payload(&payload, CheckpointKind::Final); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), state); } #[test] - fn checkpoint_payload_round_trips() { - let context = test_run_context(); - let state = LoopExecutionState::initial_for_run(&context); - let payload = encode_payload(CheckpointKind::BeforeModel, &state, CHECKPOINT_SCHEMA_ID); + fn checkpoint_payload_rejects_malformed_bytes() { + // Non-JSON bytes must still fail with InvalidField { field: "payload" }. + let result = LoopExecutionState::from_checkpoint_payload( + b"not json at all", + CheckpointKind::BeforeModel, + ); - let restored = - LoopExecutionState::from_checkpoint_payload(&payload, CheckpointKind::BeforeModel) - .unwrap(); - assert_eq!(restored, state); + assert!(matches!( + result, + Err(CheckpointPayloadError::InvalidField { + field: "payload", + .. + }) + )); } #[test] fn checkpoint_payload_rejects_bounded_ring_over_capacity() { + // Raw state bytes with an over-capacity BoundedRing must fail on + // deserialization (the BoundedRing Deserialize impl enforces capacity). let context = test_run_context(); let mut state = serde_json::to_value(LoopExecutionState::initial_for_run(&context)).unwrap(); @@ -525,19 +500,18 @@ mod tests { .unwrap() )); } - let bytes = serde_json::to_vec(&json!({ - "schema_id": CHECKPOINT_SCHEMA_ID, - "kind": CheckpointKind::BeforeModel, - "state": state, - })) - .unwrap(); + // Encode as raw state bytes (no envelope). + let bytes = serde_json::to_vec(&state).unwrap(); let result = LoopExecutionState::from_checkpoint_payload(&bytes, CheckpointKind::BeforeModel); assert!(matches!( result, - Err(CheckpointPayloadError::InvalidField { field: "state", .. }) + Err(CheckpointPayloadError::InvalidField { + field: "payload", + .. + }) )); } } diff --git a/crates/ironclaw_reborn/src/loop_driver_host.rs b/crates/ironclaw_reborn/src/loop_driver_host.rs index 90c9a870105..7e877968951 100644 --- a/crates/ironclaw_reborn/src/loop_driver_host.rs +++ b/crates/ironclaw_reborn/src/loop_driver_host.rs @@ -1464,13 +1464,34 @@ impl LoopCheckpointPort for HostManagedLoopCheckpointPort { &self, request: LoopCheckpointRequest, ) -> Result { + // `stage_checkpoint_payload` returns a run-scoped ref of the form + // `checkpoint:{run_id}:{token}`. The underlying store indexed the payload + // under the original `checkpoint:{token}` key (which `new_state_ref()` + // generated). Unwrap to the store key so the look-up succeeds, then pass + // the caller-supplied (run-scoped) ref through to the loop-checkpoint + // record so `is_for_run` validators see the correct form. + let run_scoped_prefix = format!("checkpoint:{}:", self.run_context.run_id); + let store_ref = if let Some(token) = + request.state_ref.as_str().strip_prefix(&run_scoped_prefix) + { + // Run-scoped ref → rebuild the store's original `checkpoint:{token}`. + LoopCheckpointStateRef::new(format!("checkpoint:{token}")).map_err(|reason| { + AgentLoopHostError::new( + AgentLoopHostErrorKind::Internal, + format!("could not rebuild store key from run-scoped checkpoint ref: {reason}"), + ) + })? + } else { + request.state_ref.clone() + }; + let loaded = self .checkpoint_state_store .get_checkpoint_state(GetCheckpointStateRequest { scope: self.run_context.scope.clone(), turn_id: self.run_context.turn_id, run_id: self.run_context.run_id, - state_ref: request.state_ref.clone(), + state_ref: store_ref, schema_id: self.run_context.checkpoint_schema_id.clone(), schema_version: self.run_context.checkpoint_schema_version, kind: request.kind, @@ -1531,7 +1552,24 @@ impl LoopCheckpointPort for HostManagedLoopCheckpointPort { )) .await .map_err(turn_error_to_host_error)?; - Ok(record.state_ref) + + // The store produces `checkpoint:{uuid}` refs. Wrap into the run-scoped + // form `checkpoint:{run_id}:{token}` so that `LoopCheckpointStateRef:: + // is_for_run` validators accept the returned ref without treating it as + // a cross-run ref. The token is the opaque UUID the store already minted. + let raw = record.state_ref.as_str(); + let token = raw.strip_prefix("checkpoint:").ok_or_else(|| { + AgentLoopHostError::new( + AgentLoopHostErrorKind::Internal, + "checkpoint state store returned ref without expected `checkpoint:` prefix", + ) + })?; + LoopCheckpointStateRef::for_run(&self.run_context, token).map_err(|reason| { + AgentLoopHostError::new( + AgentLoopHostErrorKind::Internal, + format!("could not build run-scoped checkpoint state ref: {reason}"), + ) + }) } } diff --git a/crates/ironclaw_reborn/tests/loop_driver_host.rs b/crates/ironclaw_reborn/tests/loop_driver_host.rs index 69f55481a6e..4ad906f9d41 100644 --- a/crates/ironclaw_reborn/tests/loop_driver_host.rs +++ b/crates/ironclaw_reborn/tests/loop_driver_host.rs @@ -2416,6 +2416,12 @@ async fn text_only_host_stage_checkpoint_payload_returns_ref_usable_by_checkpoin .await .expect("stage_checkpoint_payload should succeed for matching schema_id"); + // The returned ref must be run-scoped: `checkpoint:{run_id}:{token}`. + assert!( + state_ref.is_for_run(&fixture.context), + "stage_checkpoint_payload must return a run-scoped LoopCheckpointStateRef" + ); + let checkpoint_id = host_dyn .checkpoint(LoopCheckpointRequest { kind: LoopCheckpointKind::BeforeSideEffect, @@ -2438,6 +2444,18 @@ async fn text_only_host_stage_checkpoint_payload_returns_ref_usable_by_checkpoin assert_eq!(stored.state_ref, state_ref); assert_eq!(stored.kind, LoopCheckpointKind::BeforeSideEffect); + // The underlying state store indexes by the un-scoped `checkpoint:{token}` + // key (generated by `new_state_ref`). Reconstruct it for the direct store + // lookup: strip `checkpoint:{run_id}:` to get the token, then prefix with + // `checkpoint:`. + let run_scoped_prefix = format!("checkpoint:{}:", fixture.context.run_id); + let token = state_ref + .as_str() + .strip_prefix(&run_scoped_prefix) + .expect("state_ref should start with the run-scoped prefix"); + let store_ref = LoopCheckpointStateRef::new(format!("checkpoint:{token}")) + .expect("store key must be a valid LoopCheckpointStateRef"); + // The read-side `get_checkpoint_state` authenticates `(state_ref, kind)` // together, so a kind mismatch must reject the staged payload. let with_correct_kind = fixture @@ -2446,7 +2464,7 @@ async fn text_only_host_stage_checkpoint_payload_returns_ref_usable_by_checkpoin scope: fixture.context.scope.clone(), turn_id: fixture.context.turn_id, run_id: fixture.context.run_id, - state_ref: state_ref.clone(), + state_ref: store_ref.clone(), schema_id: fixture.context.checkpoint_schema_id.clone(), schema_version: fixture.context.checkpoint_schema_version, kind: LoopCheckpointKind::BeforeSideEffect, @@ -2461,7 +2479,7 @@ async fn text_only_host_stage_checkpoint_payload_returns_ref_usable_by_checkpoin scope: fixture.context.scope.clone(), turn_id: fixture.context.turn_id, run_id: fixture.context.run_id, - state_ref, + state_ref: store_ref, schema_id: fixture.context.checkpoint_schema_id.clone(), schema_version: fixture.context.checkpoint_schema_version, kind: LoopCheckpointKind::BeforeModel, diff --git a/crates/ironclaw_turns/src/run_profile/prompt.rs b/crates/ironclaw_turns/src/run_profile/prompt.rs index 67fca4e9794..345de2ce707 100644 --- a/crates/ironclaw_turns/src/run_profile/prompt.rs +++ b/crates/ironclaw_turns/src/run_profile/prompt.rs @@ -291,8 +291,8 @@ mod tests { use crate::{ RunProfileId, RunProfileVersion, TurnId, TurnRunId, TurnScope, run_profile::{ - InMemoryLoopHostMilestoneSink, LoopInlineMessage, LoopInlineMessageRole, - LoopSafeSummary, ResolvedRunProfile, + InMemoryInstructionMaterializationStore, InMemoryLoopHostMilestoneSink, + LoopInlineMessage, LoopInlineMessageRole, LoopSafeSummary, ResolvedRunProfile, }, }; @@ -339,6 +339,139 @@ mod tests { ); } + /// A context port that returns configurable identity and body messages. + struct StubContextPort { + identity_messages: Vec, + messages: Vec, + } + + impl StubContextPort { + fn new( + identity_messages: Vec, + messages: Vec, + ) -> Self { + Self { + identity_messages, + messages, + } + } + } + + #[async_trait] + impl LoopContextPort for StubContextPort { + async fn load_loop_context( + &self, + _request: LoopContextRequest, + ) -> Result { + Ok(LoopContextBundle { + identity_messages: self.identity_messages.clone(), + messages: self.messages.clone(), + instruction_snippets: vec![], + memory_snippets: vec![], + }) + } + } + + /// `LoopContextMessage { message_ref: None, ... }` is a summary-only entry. + /// `HostManagedLoopPromptPort` must materialize a stable model message ref + /// from `safe_summary` instead of silently dropping the entry. + #[tokio::test] + async fn host_managed_prompt_port_materializes_summary_only_identity_messages() { + let context = test_context(); + let summary_text = "You are a helpful assistant acting on behalf of the user."; + let identity_msg = LoopContextMessage { + message_ref: None, + role: "system".to_string(), + safe_summary: summary_text.to_string(), + }; + let store = Arc::new(InMemoryInstructionMaterializationStore::default()); + let port = HostManagedLoopPromptPort::new( + context.clone(), + Arc::new(StubContextPort::new(vec![identity_msg], vec![])), + Arc::new(InMemoryLoopHostMilestoneSink::default()), + ) + .with_instruction_materialization_store(store.clone()); + + let bundle = port + .build_prompt_bundle(LoopPromptBundleRequest { + mode: PromptMode::TextOnly, + context_cursor: None, + surface_version: None, + checkpoint_state_ref: None, + max_messages: Some(8), + inline_messages: vec![], + }) + .await + .expect("bundle should succeed for summary-only identity message"); + + assert_eq!( + bundle.messages.len(), + 1, + "summary-only identity message must appear in the bundle (not be dropped)" + ); + let msg = &bundle.messages[0]; + assert_eq!(msg.role, "system"); + assert!( + msg.content_ref.as_str().starts_with("msg:identity-summary."), + "summary-only identity ref must use the msg:identity-summary. prefix, got: {}", + msg.content_ref.as_str() + ); + let materialized = store + .get_materialized_message(&context, &msg.content_ref) + .unwrap() + .expect("summary-only identity message should be materialized"); + assert_eq!(materialized.safe_content, summary_text); + } + + /// A summary-only entry in the main messages list (not just identity_messages) + /// must also be materialized, not dropped. + #[tokio::test] + async fn host_managed_prompt_port_materializes_summary_only_body_messages() { + let context = test_context(); + let summary_only = LoopContextMessage { + message_ref: None, + role: "user".to_string(), + safe_summary: "What is the capital of France?".to_string(), + }; + let store = Arc::new(InMemoryInstructionMaterializationStore::default()); + let port = HostManagedLoopPromptPort::new( + context.clone(), + Arc::new(StubContextPort::new(vec![], vec![summary_only])), + Arc::new(InMemoryLoopHostMilestoneSink::default()), + ) + .with_instruction_materialization_store(store.clone()); + + let bundle = port + .build_prompt_bundle(LoopPromptBundleRequest { + mode: PromptMode::TextOnly, + context_cursor: None, + surface_version: None, + checkpoint_state_ref: None, + max_messages: Some(8), + inline_messages: vec![], + }) + .await + .expect("bundle should succeed for summary-only body message"); + + assert_eq!( + bundle.messages.len(), + 1, + "summary-only body message must appear in the bundle (not be dropped)" + ); + assert!( + bundle.messages[0] + .content_ref + .as_str() + .starts_with("msg:context-summary."), + "summary-only ref must use the msg:context-summary. prefix" + ); + let materialized = store + .get_materialized_message(&context, &bundle.messages[0].content_ref) + .unwrap() + .expect("summary-only body message should be materialized"); + assert_eq!(materialized.safe_content, "What is the capital of France?"); + } + fn test_context() -> LoopRunContext { let scope = TurnScope::new( TenantId::new("tenant-prompt").unwrap(), From cdd06487680579f85f071634d12866294e0835c4 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Thu, 14 May 2026 04:16:08 -0700 Subject: [PATCH 5/7] fix(ws-0): bind model requests to prompt bundles --- crates/ironclaw_loop_support/src/lib.rs | 22 ++++- crates/ironclaw_turns/src/run_profile/host.rs | 96 +++++++++++++++++++ crates/ironclaw_turns/src/run_profile/mod.rs | 11 ++- .../ironclaw_turns/src/run_profile/prompt.rs | 15 ++- 4 files changed, 133 insertions(+), 11 deletions(-) diff --git a/crates/ironclaw_loop_support/src/lib.rs b/crates/ironclaw_loop_support/src/lib.rs index 6e7c989d70f..ee4d2e5f69b 100644 --- a/crates/ironclaw_loop_support/src/lib.rs +++ b/crates/ironclaw_loop_support/src/lib.rs @@ -35,9 +35,9 @@ use ironclaw_turns::{ CapabilitySurfaceVersion, FinalizeAssistantMessage, InstructionMaterializationStore, LoopContextBundle, LoopContextMessage, LoopContextPort, LoopContextRequest, LoopHostMilestoneEmitter, LoopHostMilestoneSink, LoopInputCursor, LoopModelMessage, - LoopModelPort, LoopModelRequest, LoopModelResponse, LoopRunContext, LoopRunInfoPort, - LoopSafeSummary, LoopTranscriptPort, ModelStreamChunk, ParentLoopOutput, - UpdateAssistantDraft, VisibleCapabilityRequest, VisibleCapabilitySurface, + LoopModelPort, LoopModelRequest, LoopModelResponse, LoopPromptBundleAuthority, + LoopRunContext, LoopRunInfoPort, LoopSafeSummary, LoopTranscriptPort, ModelStreamChunk, + ParentLoopOutput, UpdateAssistantDraft, VisibleCapabilityRequest, VisibleCapabilitySurface, sanitize_model_visible_text, }, }; @@ -457,6 +457,7 @@ where run_context: LoopRunContext, gateway: Arc, max_messages: usize, + prompt_authority: LoopPromptBundleAuthority, milestone_sink: Option>, skill_context_source: Option>, instruction_materialization_store: Option>, @@ -480,6 +481,7 @@ where run_context, gateway, max_messages, + prompt_authority: LoopPromptBundleAuthority::shared(), milestone_sink: None, skill_context_source: None, instruction_materialization_store: None, @@ -500,6 +502,7 @@ where run_context, gateway, max_messages, + prompt_authority: LoopPromptBundleAuthority::shared(), milestone_sink: Some(milestone_sink), skill_context_source: None, instruction_materialization_store: None, @@ -511,6 +514,14 @@ where self } + pub fn with_prompt_bundle_authority( + mut self, + prompt_authority: LoopPromptBundleAuthority, + ) -> Self { + self.prompt_authority = prompt_authority; + self + } + pub fn with_instruction_materialization_store( mut self, store: Arc, @@ -548,7 +559,10 @@ where .model_profile_id .clone() }); - let resolved_messages = self.resolve_model_messages(request.messages).await?; + let prompt_grant = self + .prompt_authority + .authorize_latest_model_request(&self.run_context, &request.messages)?; + let resolved_messages = self.resolve_model_messages(prompt_grant.messages).await?; self.emit_model_started(requested_model_profile_id).await; let gateway_response = match self .gateway diff --git a/crates/ironclaw_turns/src/run_profile/host.rs b/crates/ironclaw_turns/src/run_profile/host.rs index b8f3f73688b..f914f81fad0 100644 --- a/crates/ironclaw_turns/src/run_profile/host.rs +++ b/crates/ironclaw_turns/src/run_profile/host.rs @@ -1,3 +1,8 @@ +use std::{ + collections::HashMap, + sync::{Arc, Mutex, OnceLock}, +}; + use async_trait::async_trait; use ironclaw_host_api::{CapabilityId, ExtensionId, RuntimeKind, ThreadId}; use serde::{Deserialize, Deserializer, Serialize}; @@ -835,6 +840,97 @@ pub struct LoopPromptBundle { pub instruction_fingerprint: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoopPromptBundleGrant { + pub bundle_ref: LoopPromptBundleRef, + pub messages: Vec, + pub instruction_fingerprint: Option, +} + +#[derive(Clone, Default)] +pub struct LoopPromptBundleAuthority { + inner: Arc>, +} + +#[derive(Default)] +struct LoopPromptBundleAuthorityState { + latest_by_run: HashMap, +} + +impl LoopPromptBundleAuthority { + pub fn shared() -> Self { + static AUTHORITY: OnceLock = OnceLock::new(); + AUTHORITY.get_or_init(Self::default).clone() + } + + pub fn issue_bundle( + &self, + context: &LoopRunContext, + bundle: &LoopPromptBundle, + ) -> Result<(), AgentLoopHostError> { + if !bundle.bundle_ref.is_for_run(context) { + return Err(AgentLoopHostError::new( + AgentLoopHostErrorKind::ScopeMismatch, + "prompt bundle ref is not scoped to this loop run", + )); + } + + self.lock_state()?.latest_by_run.insert( + context.run_id.to_string(), + LoopPromptBundleGrant { + bundle_ref: bundle.bundle_ref.clone(), + messages: bundle.messages.clone(), + instruction_fingerprint: bundle.instruction_fingerprint.clone(), + }, + ); + Ok(()) + } + + pub fn authorize_latest_model_request( + &self, + context: &LoopRunContext, + messages: &[LoopModelMessage], + ) -> Result { + let grant = self + .lock_state()? + .latest_by_run + .get(&context.run_id.to_string()) + .cloned() + .ok_or_else(|| { + AgentLoopHostError::new( + AgentLoopHostErrorKind::InvalidInvocation, + "model request has no host-built prompt bundle", + ) + })?; + + if !grant.bundle_ref.is_for_run(context) { + return Err(AgentLoopHostError::new( + AgentLoopHostErrorKind::ScopeMismatch, + "prompt bundle ref is not scoped to this loop run", + )); + } + if grant.messages != messages { + return Err(AgentLoopHostError::new( + AgentLoopHostErrorKind::InvalidInvocation, + "model request messages do not match the host-built prompt bundle", + )); + } + + Ok(grant) + } + + fn lock_state( + &self, + ) -> Result, AgentLoopHostError> { + self.inner.lock().map_err(|_| { + AgentLoopHostError::new( + AgentLoopHostErrorKind::Internal, + "prompt bundle authority is unavailable", + ) + }) + } +} + /// Host boundary for building prompt bundles before model invocation. /// /// Implementations own context loading, scoping, prompt-shape policy, and diff --git a/crates/ironclaw_turns/src/run_profile/mod.rs b/crates/ironclaw_turns/src/run_profile/mod.rs index 84397990e0c..e35501302aa 100644 --- a/crates/ironclaw_turns/src/run_profile/mod.rs +++ b/crates/ironclaw_turns/src/run_profile/mod.rs @@ -39,11 +39,12 @@ pub use host::{ LoopInlineMessage, LoopInlineMessageRole, LoopInput, LoopInputBatch, LoopInputCursor, LoopInputCursorToken, LoopInputPort, LoopInterruptKind, LoopModelMessage, LoopModelPort, LoopModelRequest, LoopModelResponse, LoopModelRouteSnapshot, LoopProcessRef, LoopProgressEvent, - LoopProgressPort, LoopPromptBundle, LoopPromptBundleRef, LoopPromptBundleRequest, - LoopPromptPort, LoopRunContext, LoopRunInfoPort, LoopSafeSummary, LoopTranscriptPort, - ModelStreamChunk, ParentLoopOutput, ProcessHandleSummary, PromptMode, - StageCheckpointPayloadRequest, UpdateAssistantDraft, VisibleCapabilityRequest, - VisibleCapabilitySurface, sanitize_model_visible_text, validate_model_route_component_value, + LoopProgressPort, LoopPromptBundle, LoopPromptBundleAuthority, LoopPromptBundleGrant, + LoopPromptBundleRef, LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, + LoopRunInfoPort, LoopSafeSummary, LoopTranscriptPort, ModelStreamChunk, ParentLoopOutput, + ProcessHandleSummary, PromptMode, StageCheckpointPayloadRequest, UpdateAssistantDraft, + VisibleCapabilityRequest, VisibleCapabilitySurface, sanitize_model_visible_text, + validate_model_route_component_value, }; pub use instruction_bundle::{ InMemoryInstructionMaterializationStore, InstructionBundle, InstructionBundleBuilder, diff --git a/crates/ironclaw_turns/src/run_profile/prompt.rs b/crates/ironclaw_turns/src/run_profile/prompt.rs index 345de2ce707..d4eb5a688c4 100644 --- a/crates/ironclaw_turns/src/run_profile/prompt.rs +++ b/crates/ironclaw_turns/src/run_profile/prompt.rs @@ -4,8 +4,8 @@ use async_trait::async_trait; use super::host::{ AgentLoopHostError, AgentLoopHostErrorKind, CapabilitySurfaceVersion, LoopContextPort, - LoopContextRequest, LoopPromptBundle, LoopPromptBundleRef, LoopPromptBundleRequest, - LoopPromptPort, LoopRunContext, PromptMode, VisibleCapabilitySurface, + LoopContextRequest, LoopPromptBundle, LoopPromptBundleAuthority, LoopPromptBundleRef, + LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, PromptMode, VisibleCapabilitySurface, }; use super::instruction_bundle::{ InstructionBundleBuilder, InstructionBundleRequest, InstructionMaterializationStore, @@ -40,6 +40,7 @@ where context: LoopRunContext, context_port: Arc, milestones: LoopHostMilestoneEmitter, + prompt_authority: LoopPromptBundleAuthority, default_message_limit: usize, current_surface_version: Option>, current_surface: Option>, @@ -57,6 +58,7 @@ where context: context.clone(), context_port, milestones: LoopHostMilestoneEmitter::new(context, milestone_sink), + prompt_authority: LoopPromptBundleAuthority::shared(), default_message_limit: DEFAULT_TEXT_ONLY_MESSAGE_LIMIT, current_surface_version: None, current_surface: None, @@ -70,6 +72,14 @@ where self } + pub fn with_prompt_bundle_authority( + mut self, + prompt_authority: LoopPromptBundleAuthority, + ) -> Self { + self.prompt_authority = prompt_authority; + self + } + pub fn with_current_surface_version( self, current_surface_version: CapabilitySurfaceVersion, @@ -267,6 +277,7 @@ where surface_version: request.surface_version.clone(), instruction_fingerprint: Some(instruction_bundle.fingerprint), }; + self.prompt_authority.issue_bundle(&self.context, &bundle)?; self.milestones .prompt_bundle_built( bundle.bundle_ref.clone(), From 3db2d97bf1718554c8936e2ad4346794f02b7ec3 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Thu, 14 May 2026 05:18:34 -0700 Subject: [PATCH 6/7] fix(ws-0): issue prompt authority in reborn callers --- crates/ironclaw_loop_support/src/lib.rs | 7 ++- .../ironclaw_reborn/src/loop_driver_host.rs | 37 +++++++----- crates/ironclaw_reborn/src/model_gateway.rs | 56 ++++++++++++++++++- crates/ironclaw_reborn/tests/llm_gateway.rs | 46 +++++++++++++++ .../ironclaw_reborn/tests/loop_driver_host.rs | 18 +++++- .../ironclaw_turns/src/run_profile/prompt.rs | 3 +- 6 files changed, 146 insertions(+), 21 deletions(-) diff --git a/crates/ironclaw_loop_support/src/lib.rs b/crates/ironclaw_loop_support/src/lib.rs index ee4d2e5f69b..78343fbb5c3 100644 --- a/crates/ironclaw_loop_support/src/lib.rs +++ b/crates/ironclaw_loop_support/src/lib.rs @@ -865,9 +865,12 @@ pub struct HostManagedModelResponse { impl HostManagedModelResponse { pub fn assistant_reply(content: impl Into) -> Self { let content = content.into(); + let safe_content = sanitize_model_visible_text(content); Self { - safe_text_deltas: vec![sanitize_model_visible_text(content.clone())], - output: ParentLoopOutput::AssistantReply(AssistantReply { content }), + safe_text_deltas: vec![safe_content.clone()], + output: ParentLoopOutput::AssistantReply(AssistantReply { + content: safe_content, + }), } } } diff --git a/crates/ironclaw_reborn/src/loop_driver_host.rs b/crates/ironclaw_reborn/src/loop_driver_host.rs index 7e877968951..72c74d39506 100644 --- a/crates/ironclaw_reborn/src/loop_driver_host.rs +++ b/crates/ironclaw_reborn/src/loop_driver_host.rs @@ -28,19 +28,21 @@ use ironclaw_turns::{ TurnCheckpointId, TurnError, TurnStatus, run_profile::{ AgentLoopHostError, AgentLoopHostErrorKind, AppendCapabilityResultRef, BeginAssistantDraft, - CapabilityInvocation, CapabilityOutcome, CapabilityResultMessage, CapabilitySurfaceVersion, - ConcurrencyHint, FinalizeAssistantMessage, HostManagedLoopModelPort, - HostManagedLoopPromptPort, InMemoryInstructionMaterializationStore, - InstructionMaterializationStore, InstructionSafetyContext, LoopCapabilityPort, - LoopCheckpointPort, LoopCheckpointRequest, LoopContextBundle, LoopContextPort, - LoopContextRequest, LoopHostMilestoneEmitter, LoopHostMilestoneSink, LoopInputBatch, - LoopInputCursor, LoopInputPort, LoopModelBudgetAccountant, LoopModelGateway, - LoopModelGatewayError, LoopModelGatewayRequest, LoopModelPolicyGuard, LoopModelPort, - LoopModelRequest, LoopModelResponse, LoopProcessRef, LoopProgressEvent, LoopProgressPort, - LoopPromptBundle, LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, - LoopRunInfoPort, LoopSafeSummary, LoopTranscriptPort, NoOpBudgetAccountant, - NoOpPolicyGuard, ProcessHandleSummary, StageCheckpointPayloadRequest, UpdateAssistantDraft, - VisibleCapabilityRequest, VisibleCapabilitySurface, + CapabilityBatchInvocation, CapabilityBatchOutcome, CapabilityDenied, + CapabilityDeniedReasonKind, CapabilityDescriptorView, CapabilityFailure, + CapabilityInvocation, CapabilityOutcome, CapabilityResultMessage, ConcurrencyHint, + FinalizeAssistantMessage, HostManagedLoopModelPort, HostManagedLoopPromptPort, + InMemoryInstructionMaterializationStore, InstructionMaterializationStore, + InstructionSafetyContext, LoopCapabilityPort, LoopCheckpointPort, LoopCheckpointRequest, + LoopContextBundle, LoopContextPort, LoopContextRequest, LoopHostMilestoneEmitter, + LoopHostMilestoneSink, LoopInputBatch, LoopInputCursor, LoopInputPort, + LoopModelBudgetAccountant, LoopModelGateway, LoopModelGatewayError, + LoopModelGatewayRequest, LoopModelPolicyGuard, LoopModelPort, LoopModelRequest, + LoopModelResponse, LoopProcessRef, LoopProgressEvent, LoopProgressPort, + LoopPromptBundle, LoopPromptBundleAuthority, LoopPromptBundleRequest, LoopPromptPort, + LoopRunContext, LoopRunInfoPort, LoopSafeSummary, LoopTranscriptPort, + NoOpBudgetAccountant, NoOpPolicyGuard, ProcessHandleSummary, StageCheckpointPayloadRequest, + UpdateAssistantDraft, VisibleCapabilityRequest, VisibleCapabilitySurface, }, runner::ClaimedTurnRun, }; @@ -1043,12 +1045,14 @@ where .map_err(|error| RebornLoopDriverHostError::InvalidRequest { reason: error.safe_summary, })?; + let prompt_authority = LoopPromptBundleAuthority::shared(); let surface_state_for_prompt = Arc::clone(&surface_state); let mut prompt_port = HostManagedLoopPromptPort::new( run_context.clone(), Arc::clone(&context), Arc::clone(&self.milestone_sink), ) + .with_prompt_bundle_authority(prompt_authority.clone()) .with_default_message_limit(max_messages) .with_current_surface_lookup(move || surface_state_for_prompt.current()) .with_instruction_materialization_store(Arc::clone(&instruction_materialization_store)); @@ -1065,6 +1069,7 @@ where max_messages, self.skill_context_source.clone(), Some(Arc::clone(&instruction_materialization_store)), + prompt_authority, )); let model: Arc = Arc::new(HostManagedLoopModelPort::with_guards( run_context.clone(), @@ -1155,6 +1160,7 @@ where max_messages: usize, skill_context_source: Option>, instruction_materialization_store: Option>, + prompt_authority: LoopPromptBundleAuthority, } impl ThreadResolvingLoopModelGateway @@ -1169,6 +1175,7 @@ where max_messages: usize, skill_context_source: Option>, instruction_materialization_store: Option>, + prompt_authority: LoopPromptBundleAuthority, ) -> Self { Self { thread_service, @@ -1177,6 +1184,7 @@ where max_messages, skill_context_source, instruction_materialization_store, + prompt_authority, } } } @@ -1197,7 +1205,8 @@ where request.context, Arc::clone(&self.host_gateway), self.max_messages, - ); + ) + .with_prompt_bundle_authority(self.prompt_authority.clone()); if let Some(source) = self.skill_context_source.as_ref() { model_port = model_port.with_skill_context_source(source.clone()); } diff --git a/crates/ironclaw_reborn/src/model_gateway.rs b/crates/ironclaw_reborn/src/model_gateway.rs index 8c3199b408f..3652f382560 100644 --- a/crates/ironclaw_reborn/src/model_gateway.rs +++ b/crates/ironclaw_reborn/src/model_gateway.rs @@ -13,14 +13,18 @@ use ironclaw_llm::{ use ironclaw_loop_support::{ HostManagedModelError, HostManagedModelErrorKind, HostManagedModelGateway, HostManagedModelMessage, HostManagedModelMessageRole, HostManagedModelRequest, - HostManagedModelResponse, HostManagedModelRouteSnapshot, ThreadBackedLoopModelPort, + HostManagedModelResponse, HostManagedModelRouteSnapshot, ThreadBackedLoopContextPort, + ThreadBackedLoopModelPort, }; use ironclaw_threads::{SessionThreadService, ThreadScope}; use ironclaw_turns::{ TurnId, TurnRunId, run_profile::{ - AgentLoopHostError, LoopModelGateway, LoopModelGatewayError, LoopModelGatewayRequest, - LoopModelPort, LoopModelResponse, LoopSafeSummary, ModelProfileId, + AgentLoopHostError, AgentLoopHostErrorKind, HostManagedLoopPromptPort, + InMemoryLoopHostMilestoneSink, LoopModelGateway, LoopModelGatewayError, + LoopModelGatewayRequest, LoopModelPort, LoopModelRequest, LoopModelResponse, + LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, LoopSafeSummary, ModelProfileId, + PromptMode, }, }; @@ -109,6 +113,8 @@ where &self, request: LoopModelGatewayRequest, ) -> Result { + self.issue_host_prompt_bundle(&request.context, &request.request) + .await?; ThreadBackedLoopModelPort::new( Arc::clone(&self.thread_service), self.thread_scope.clone(), @@ -122,6 +128,50 @@ where } } +impl ThreadBackedLoopModelGateway +where + S: SessionThreadService + ?Sized + Send + Sync, + G: HostManagedModelGateway + ?Sized + Send + Sync, +{ + async fn issue_host_prompt_bundle( + &self, + context: &LoopRunContext, + request: &LoopModelRequest, + ) -> Result<(), LoopModelGatewayError> { + let context_port = Arc::new(ThreadBackedLoopContextPort::new( + Arc::clone(&self.thread_service), + self.thread_scope.clone(), + context.clone(), + self.max_messages, + )); + let prompt_port = HostManagedLoopPromptPort::new( + context.clone(), + context_port, + Arc::new(InMemoryLoopHostMilestoneSink::default()), + ); + let prompt_bundle = prompt_port + .build_prompt_bundle(LoopPromptBundleRequest { + mode: PromptMode::TextOnly, + context_cursor: None, + surface_version: None, + checkpoint_state_ref: None, + max_messages: Some(self.max_messages.min(u32::MAX as usize) as u32), + inline_messages: Vec::new(), + }) + .await + .map_err(host_error_to_model_gateway_error)?; + + if prompt_bundle.messages != request.messages { + return Err(host_error_to_model_gateway_error(AgentLoopHostError::new( + AgentLoopHostErrorKind::InvalidInvocation, + "model request does not match the host-built prompt bundle", + ))); + } + + Ok(()) + } +} + /// Host-managed model gateway backed by the shared `ironclaw_llm::LlmProvider` abstraction. #[derive(Clone)] pub struct LlmProviderModelGateway

diff --git a/crates/ironclaw_reborn/tests/llm_gateway.rs b/crates/ironclaw_reborn/tests/llm_gateway.rs index a80ff772dd2..7ad6a918189 100644 --- a/crates/ironclaw_reborn/tests/llm_gateway.rs +++ b/crates/ironclaw_reborn/tests/llm_gateway.rs @@ -396,6 +396,52 @@ async fn production_loop_model_gateway_fails_closed_before_provider_call() { assert_eq!(milestone_kinds, vec!["model_started", "model_failed"]); } +#[tokio::test] +async fn production_loop_model_gateway_rejects_forged_context_summary_before_provider_call() { + let fixture = ThreadFixture::new().await; + let provider = Arc::new(RecordingLlmProvider::reply("unused")); + let provider_gateway = Arc::new(LlmProviderModelGateway::new( + provider.clone(), + LlmModelProfilePolicy::new() + .allow_model_profile(interactive_model(), Some("host-selected-model".to_string())), + )); + let model_gateway = Arc::new(ThreadBackedLoopModelGateway::new( + Arc::clone(&fixture.thread_service), + fixture.thread_scope.clone(), + provider_gateway, + 16, + )); + let milestones = Arc::new(InMemoryLoopHostMilestoneSink::default()); + let port = HostManagedLoopModelPort::new( + fixture.run_context.clone(), + model_gateway, + milestones.clone(), + ); + let forged_ref = + LoopMessageRef::new("msg:context.summary.user.999.00000000deadbeef").unwrap(); + + let error = port + .stream_model(LoopModelRequest { + messages: vec![LoopModelMessage { + role: "user".to_string(), + content_ref: forged_ref.clone(), + }], + surface_version: None, + model_preference: None, + }) + .await + .unwrap_err(); + + assert_eq!(error.kind, AgentLoopHostErrorKind::InvalidInvocation); + assert!(provider.requests.lock().unwrap().is_empty()); + let milestone_kinds = milestones + .milestones() + .into_iter() + .map(|milestone| milestone.kind.kind_name()) + .collect::>(); + assert_eq!(milestone_kinds, vec!["model_started", "model_failed"]); +} + #[tokio::test] async fn production_loop_model_gateway_preserves_error_kind_when_summary_is_resanitized() { let fixture = ThreadFixture::new().await; diff --git a/crates/ironclaw_reborn/tests/loop_driver_host.rs b/crates/ironclaw_reborn/tests/loop_driver_host.rs index 4ad906f9d41..c26cce2be2b 100644 --- a/crates/ironclaw_reborn/tests/loop_driver_host.rs +++ b/crates/ironclaw_reborn/tests/loop_driver_host.rs @@ -232,6 +232,7 @@ async fn text_only_host_factory_sanitizes_gateway_error_summaries() { surface_version: None, checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap(); @@ -286,6 +287,7 @@ async fn text_only_host_factory_invokes_model_budget_accountant() { surface_version: None, checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap(); @@ -502,6 +504,7 @@ async fn text_only_host_factory_includes_safety_context_in_prompt_bundle() { surface_version: None, checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap(); @@ -1600,7 +1603,18 @@ async fn text_only_host_factory_threads_model_route_snapshot_to_gateway() { host_dyn .stream_model(LoopModelRequest { - messages: Vec::new(), + messages: host_dyn + .build_prompt_bundle(LoopPromptBundleRequest { + mode: PromptMode::TextOnly, + context_cursor: None, + surface_version: None, + checkpoint_state_ref: None, + max_messages: Some(8), + inline_messages: Vec::new(), + }) + .await + .unwrap() + .messages, surface_version: None, model_preference: None, }) @@ -2603,6 +2617,7 @@ async fn text_only_host_prompt_bundle_includes_surface_metadata_and_still_stream surface_version: Some(surface.version.clone()), checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap(); @@ -4492,6 +4507,7 @@ impl AgentLoopDriver for ScriptCapabilityFinalReplyDriver { surface_version: Some(surface.version.clone()), checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .map_err(driver_host_error)?; diff --git a/crates/ironclaw_turns/src/run_profile/prompt.rs b/crates/ironclaw_turns/src/run_profile/prompt.rs index d4eb5a688c4..b774ceb996f 100644 --- a/crates/ironclaw_turns/src/run_profile/prompt.rs +++ b/crates/ironclaw_turns/src/run_profile/prompt.rs @@ -303,7 +303,8 @@ mod tests { RunProfileId, RunProfileVersion, TurnId, TurnRunId, TurnScope, run_profile::{ InMemoryInstructionMaterializationStore, InMemoryLoopHostMilestoneSink, - LoopInlineMessage, LoopInlineMessageRole, LoopSafeSummary, ResolvedRunProfile, + LoopContextBundle, LoopContextMessage, LoopInlineMessage, LoopInlineMessageRole, + LoopSafeSummary, ResolvedRunProfile, }, }; From d299ca3ec130af3764efac294e2033d7819c5be3 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Thu, 14 May 2026 13:33:11 -0700 Subject: [PATCH 7/7] fix(ws-0): address review feedback --- .../src/state/bounded_ring.rs | 131 ++++++++++++++++-- crates/ironclaw_loop_support/src/lib.rs | 8 +- .../tests/thread_loop_support_contract.rs | 130 +++++++++-------- .../ironclaw_reborn/src/loop_driver_host.rs | 10 +- crates/ironclaw_reborn/src/model_gateway.rs | 8 +- crates/ironclaw_reborn/tests/llm_gateway.rs | 54 +++++++- .../ironclaw_reborn/tests/loop_driver_host.rs | 2 +- crates/ironclaw_turns/src/run_profile/host.rs | 12 +- .../src/run_profile/instruction_bundle.rs | 40 ++++-- crates/ironclaw_turns/src/run_profile/mod.rs | 9 +- .../ironclaw_turns/src/run_profile/model.rs | 3 + .../ironclaw_turns/src/run_profile/prompt.rs | 4 +- .../tests/agent_loop_host_contract.rs | 40 +++++- 13 files changed, 339 insertions(+), 112 deletions(-) diff --git a/crates/ironclaw_agent_loop/src/state/bounded_ring.rs b/crates/ironclaw_agent_loop/src/state/bounded_ring.rs index d234d210fa2..bef7e0665e1 100644 --- a/crates/ironclaw_agent_loop/src/state/bounded_ring.rs +++ b/crates/ironclaw_agent_loop/src/state/bounded_ring.rs @@ -1,4 +1,6 @@ -use std::{collections::HashMap, hash::Hash}; +use std::{collections::HashMap, hash::Hash, marker::PhantomData}; + +use serde::de::{IgnoredAny, MapAccess, SeqAccess, Visitor}; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct BoundedRing { @@ -74,20 +76,117 @@ impl<'de, T: serde::Deserialize<'de>, const N: usize> serde::Deserialize<'de> for BoundedRing { fn deserialize>(deserializer: D) -> Result { - #[derive(serde::Deserialize)] - #[serde(bound(deserialize = "T: serde::Deserialize<'de>"))] - struct Inner { - items: Vec, + enum Field { + Items, + Ignored, + } + + impl<'de> serde::Deserialize<'de> for Field { + fn deserialize>(deserializer: D) -> Result { + struct FieldVisitor; + + impl<'de> Visitor<'de> for FieldVisitor { + type Value = Field; + + fn expecting( + &self, + formatter: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + formatter.write_str("`items`") + } + + fn visit_str(self, value: &str) -> Result { + Ok(match value { + "items" => Field::Items, + _ => Field::Ignored, + }) + } + } + + deserializer.deserialize_identifier(FieldVisitor) + } + } + + struct BoundedRingVisitor { + item: PhantomData, + } + + impl<'de, T: serde::Deserialize<'de>, const N: usize> Visitor<'de> for BoundedRingVisitor { + type Value = BoundedRing; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a bounded ring with an items array") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut items = None; + while let Some(field) = map.next_key::()? { + match field { + Field::Items => { + if items.is_some() { + return Err(serde::de::Error::duplicate_field("items")); + } + items = Some(map.next_value_seed(BoundedItemsVisitor:: { + item: PhantomData, + })?); + } + Field::Ignored => { + map.next_value::()?; + } + } + } + let items = items.ok_or_else(|| serde::de::Error::missing_field("items"))?; + Ok(BoundedRing { items }) + } + } + + struct BoundedItemsVisitor { + item: PhantomData, + } + + impl<'de, T: serde::Deserialize<'de>, const N: usize> serde::de::DeserializeSeed<'de> + for BoundedItemsVisitor + { + type Value = Vec; + + fn deserialize>( + self, + deserializer: D, + ) -> Result { + deserializer.deserialize_seq(self) + } } - let raw: Inner = Inner::deserialize(deserializer)?; - if raw.items.len() > N { - return Err(serde::de::Error::invalid_length( - raw.items.len(), - &ExpectedAtMost::, - )); + impl<'de, T: serde::Deserialize<'de>, const N: usize> Visitor<'de> for BoundedItemsVisitor { + type Value = Vec; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "an array with at most {N} items") + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut items = Vec::with_capacity(seq.size_hint().unwrap_or(0).min(N)); + while items.len() < N { + let Some(item) = seq.next_element::()? else { + return Ok(items); + }; + items.push(item); + } + if seq.next_element::()?.is_some() { + return Err(serde::de::Error::invalid_length( + N + 1, + &ExpectedAtMost::, + )); + } + Ok(items) + } } - Ok(Self { items: raw.items }) + + deserializer.deserialize_struct( + "BoundedRing", + &["items"], + BoundedRingVisitor:: { item: PhantomData }, + ) } } @@ -102,6 +201,14 @@ mod tests { assert!(result.is_err()); } + #[test] + fn deserialize_rejects_capacity_before_deserializing_extra_typed_item() { + let error = serde_json::from_str::>(r#"{"items":[1,2,"ignored"]}"#) + .unwrap_err(); + + assert!(error.to_string().contains("expected at most 2")); + } + #[test] fn deserialize_accepts_items_at_capacity() { let ring = serde_json::from_str::>(r#"{"items":[1,2]}"#).unwrap(); diff --git a/crates/ironclaw_loop_support/src/lib.rs b/crates/ironclaw_loop_support/src/lib.rs index 78343fbb5c3..47ff118bed9 100644 --- a/crates/ironclaw_loop_support/src/lib.rs +++ b/crates/ironclaw_loop_support/src/lib.rs @@ -559,9 +559,11 @@ where .model_profile_id .clone() }); - let prompt_grant = self - .prompt_authority - .authorize_latest_model_request(&self.run_context, &request.messages)?; + let prompt_grant = self.prompt_authority.authorize_latest_model_request( + &self.run_context, + &request.messages, + &request.surface_version, + )?; let resolved_messages = self.resolve_model_messages(prompt_grant.messages).await?; self.emit_model_started(requested_model_profile_id).await; let gateway_response = match self diff --git a/crates/ironclaw_loop_support/tests/thread_loop_support_contract.rs b/crates/ironclaw_loop_support/tests/thread_loop_support_contract.rs index 6fbb56a5088..9f57402e709 100644 --- a/crates/ironclaw_loop_support/tests/thread_loop_support_contract.rs +++ b/crates/ironclaw_loop_support/tests/thread_loop_support_contract.rs @@ -33,8 +33,9 @@ use ironclaw_turns::{ InMemoryRunProfileResolver, LoopCapabilityPort, LoopContextBundle, LoopContextMessage, LoopContextPort, LoopContextRequest, LoopContextSnippet, LoopHostMilestoneKind, LoopInputCursor, LoopInputCursorToken, LoopModelMessage, LoopModelPort, LoopModelRequest, - LoopModelRouteSnapshot, LoopPromptPort, LoopRunContext, LoopTranscriptPort, - ParentLoopOutput, PromptSkillContextMetadata, SkillVisibility, UpdateAssistantDraft, + LoopModelRouteSnapshot, LoopPromptBundle, LoopPromptBundleAuthority, LoopPromptBundleRef, + LoopPromptPort, LoopRunContext, LoopTranscriptPort, ParentLoopOutput, + PromptSkillContextMetadata, SkillVisibility, UpdateAssistantDraft, VisibleCapabilityRequest, }, }; @@ -473,6 +474,7 @@ async fn prompt_and_model_ports_resolve_instruction_memory_and_identity_refs() { surface_version: None, checkpoint_state_ref: None, max_messages: None, + inline_messages: Vec::new(), }) .await .unwrap(); @@ -611,6 +613,7 @@ async fn prompt_port_records_multiple_active_skill_metadata_in_prompt_order() { surface_version: None, checkpoint_state_ref: None, max_messages: None, + inline_messages: Vec::new(), }) .await .unwrap(); @@ -1210,14 +1213,12 @@ async fn model_port_resolves_thread_message_refs_and_delegates_to_gateway() { gateway.clone(), 16, ); + let messages = user_model_messages(&fixture); + issue_prompt_grant(&fixture.run_context, &messages); let response = port .stream_model(LoopModelRequest { - messages: vec![LoopModelMessage { - role: "user".to_string(), - content_ref: LoopMessageRef::new(format!("msg:{}", fixture.user_message_id)) - .unwrap(), - }], + messages, surface_version: None, model_preference: None, }) @@ -1258,12 +1259,11 @@ async fn model_port_threads_resolved_model_route_snapshot_to_gateway() { gateway.clone(), 16, ); + let messages = user_model_messages(&fixture); + issue_prompt_grant(&fixture.run_context, &messages); port.stream_model(LoopModelRequest { - messages: vec![LoopModelMessage { - role: "user".to_string(), - content_ref: LoopMessageRef::new(format!("msg:{}", fixture.user_message_id)).unwrap(), - }], + messages, surface_version: None, model_preference: None, }) @@ -1301,12 +1301,11 @@ async fn model_port_resolves_explicit_refs_that_fall_outside_context_window() { gateway.clone(), 1, ); + let messages = user_model_messages(&fixture); + issue_prompt_grant(&fixture.run_context, &messages); port.stream_model(LoopModelRequest { - messages: vec![LoopModelMessage { - role: "user".to_string(), - content_ref: LoopMessageRef::new(format!("msg:{}", fixture.user_message_id)).unwrap(), - }], + messages, surface_version: None, model_preference: None, }) @@ -1347,6 +1346,7 @@ async fn prompt_port_builds_bundle_with_tool_result_reference_context() { surface_version: None, checkpoint_state_ref: None, max_messages: None, + inline_messages: Vec::new(), }) .await .unwrap(); @@ -1391,19 +1391,21 @@ async fn model_port_round_trips_tool_result_reference_context_as_system_model_in gateway.clone(), 16, ); + let messages = context + .messages + .into_iter() + .filter_map(|message| { + message.message_ref.map(|content_ref| LoopModelMessage { + role: message.role, + content_ref, + }) + }) + .collect::>(); + issue_prompt_grant(&fixture.run_context, &messages); model_port .stream_model(LoopModelRequest { - messages: context - .messages - .into_iter() - .filter_map(|message| { - message.message_ref.map(|content_ref| LoopModelMessage { - role: message.role, - content_ref, - }) - }) - .collect(), + messages, surface_version: None, model_preference: None, }) @@ -1436,14 +1438,12 @@ async fn model_port_emits_model_milestones_without_prompt_or_output_payloads() { 16, milestone_sink.clone(), ); + let messages = user_model_messages(&fixture); + issue_prompt_grant(&fixture.run_context, &messages); let response = port .stream_model(LoopModelRequest { - messages: vec![LoopModelMessage { - role: "user".to_string(), - content_ref: LoopMessageRef::new(format!("msg:{}", fixture.user_message_id)) - .unwrap(), - }], + messages, surface_version: None, model_preference: Some( fixture @@ -1500,14 +1500,12 @@ async fn model_port_emits_started_and_failed_milestones_when_gateway_fails() { 16, milestone_sink.clone(), ); + let messages = user_model_messages(&fixture); + issue_prompt_grant(&fixture.run_context, &messages); let error = port .stream_model(LoopModelRequest { - messages: vec![LoopModelMessage { - role: "user".to_string(), - content_ref: LoopMessageRef::new(format!("msg:{}", fixture.user_message_id)) - .unwrap(), - }], + messages, surface_version: None, model_preference: None, }) @@ -1558,14 +1556,12 @@ async fn model_port_logs_model_started_milestone_failure_without_losing_response 16, milestone_sink.clone(), ); + let messages = user_model_messages(&fixture); + issue_prompt_grant(&fixture.run_context, &messages); let response = port .stream_model(LoopModelRequest { - messages: vec![LoopModelMessage { - role: "user".to_string(), - content_ref: LoopMessageRef::new(format!("msg:{}", fixture.user_message_id)) - .unwrap(), - }], + messages, surface_version: None, model_preference: None, }) @@ -1599,14 +1595,12 @@ async fn model_port_logs_model_completed_milestone_failure_without_losing_respon 16, milestone_sink.clone(), ); + let messages = user_model_messages(&fixture); + issue_prompt_grant(&fixture.run_context, &messages); let response = port .stream_model(LoopModelRequest { - messages: vec![LoopModelMessage { - role: "user".to_string(), - content_ref: LoopMessageRef::new(format!("msg:{}", fixture.user_message_id)) - .unwrap(), - }], + messages, surface_version: None, model_preference: None, }) @@ -1635,14 +1629,15 @@ async fn model_port_rejects_message_role_that_disagrees_with_thread_record() { gateway.clone(), 16, ); + let messages = vec![LoopModelMessage { + role: "system".to_string(), + content_ref: LoopMessageRef::new(format!("msg:{}", fixture.user_message_id)).unwrap(), + }]; + issue_prompt_grant(&fixture.run_context, &messages); let error = port .stream_model(LoopModelRequest { - messages: vec![LoopModelMessage { - role: "system".to_string(), - content_ref: LoopMessageRef::new(format!("msg:{}", fixture.user_message_id)) - .unwrap(), - }], + messages, surface_version: None, model_preference: None, }) @@ -1664,14 +1659,12 @@ async fn model_port_surfaces_fail_closed_gateway_policy_errors_without_raw_detai gateway, 16, ); + let messages = user_model_messages(&fixture); + issue_prompt_grant(&fixture.run_context, &messages); let error = port .stream_model(LoopModelRequest { - messages: vec![LoopModelMessage { - role: "user".to_string(), - content_ref: LoopMessageRef::new(format!("msg:{}", fixture.user_message_id)) - .unwrap(), - }], + messages, surface_version: None, model_preference: None, }) @@ -1696,14 +1689,12 @@ async fn model_port_replaces_invalid_gateway_safe_summary_with_stable_summary() gateway, 16, ); + let messages = user_model_messages(&fixture); + issue_prompt_grant(&fixture.run_context, &messages); let error = port .stream_model(LoopModelRequest { - messages: vec![LoopModelMessage { - role: "user".to_string(), - content_ref: LoopMessageRef::new(format!("msg:{}", fixture.user_message_id)) - .unwrap(), - }], + messages, surface_version: None, model_preference: None, }) @@ -1791,6 +1782,25 @@ fn skill_md(name: &str, description: &str, prompt: &str) -> String { ) } +fn user_model_messages(fixture: &ThreadFixture) -> Vec { + vec![LoopModelMessage { + role: "user".to_string(), + content_ref: LoopMessageRef::new(format!("msg:{}", fixture.user_message_id)).unwrap(), + }] +} + +fn issue_prompt_grant(context: &LoopRunContext, messages: &[LoopModelMessage]) { + let bundle = LoopPromptBundle { + bundle_ref: LoopPromptBundleRef::for_run(context, "test-bundle").unwrap(), + messages: messages.to_vec(), + surface_version: None, + instruction_fingerprint: None, + }; + LoopPromptBundleAuthority::shared() + .issue_bundle(context, &bundle) + .unwrap(); +} + struct ThreadFixture { thread_service: Arc, thread_scope: ThreadScope, diff --git a/crates/ironclaw_reborn/src/loop_driver_host.rs b/crates/ironclaw_reborn/src/loop_driver_host.rs index 72c74d39506..8b5e8513a98 100644 --- a/crates/ironclaw_reborn/src/loop_driver_host.rs +++ b/crates/ironclaw_reborn/src/loop_driver_host.rs @@ -38,11 +38,11 @@ use ironclaw_turns::{ LoopHostMilestoneSink, LoopInputBatch, LoopInputCursor, LoopInputPort, LoopModelBudgetAccountant, LoopModelGateway, LoopModelGatewayError, LoopModelGatewayRequest, LoopModelPolicyGuard, LoopModelPort, LoopModelRequest, - LoopModelResponse, LoopProcessRef, LoopProgressEvent, LoopProgressPort, - LoopPromptBundle, LoopPromptBundleAuthority, LoopPromptBundleRequest, LoopPromptPort, - LoopRunContext, LoopRunInfoPort, LoopSafeSummary, LoopTranscriptPort, - NoOpBudgetAccountant, NoOpPolicyGuard, ProcessHandleSummary, StageCheckpointPayloadRequest, - UpdateAssistantDraft, VisibleCapabilityRequest, VisibleCapabilitySurface, + LoopModelResponse, LoopProcessRef, LoopProgressEvent, LoopProgressPort, LoopPromptBundle, + LoopPromptBundleAuthority, LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, + LoopRunInfoPort, LoopSafeSummary, LoopTranscriptPort, NoOpBudgetAccountant, + NoOpPolicyGuard, ProcessHandleSummary, StageCheckpointPayloadRequest, UpdateAssistantDraft, + VisibleCapabilityRequest, VisibleCapabilitySurface, }, runner::ClaimedTurnRun, }; diff --git a/crates/ironclaw_reborn/src/model_gateway.rs b/crates/ironclaw_reborn/src/model_gateway.rs index 3652f382560..4336019c449 100644 --- a/crates/ironclaw_reborn/src/model_gateway.rs +++ b/crates/ironclaw_reborn/src/model_gateway.rs @@ -153,7 +153,7 @@ where .build_prompt_bundle(LoopPromptBundleRequest { mode: PromptMode::TextOnly, context_cursor: None, - surface_version: None, + surface_version: request.surface_version.clone(), checkpoint_state_ref: None, max_messages: Some(self.max_messages.min(u32::MAX as usize) as u32), inline_messages: Vec::new(), @@ -167,6 +167,12 @@ where "model request does not match the host-built prompt bundle", ))); } + if prompt_bundle.surface_version != request.surface_version { + return Err(host_error_to_model_gateway_error(AgentLoopHostError::new( + AgentLoopHostErrorKind::InvalidInvocation, + "model request surface version does not match the host-built prompt bundle", + ))); + } Ok(()) } diff --git a/crates/ironclaw_reborn/tests/llm_gateway.rs b/crates/ironclaw_reborn/tests/llm_gateway.rs index 7ad6a918189..afbf9789ebb 100644 --- a/crates/ironclaw_reborn/tests/llm_gateway.rs +++ b/crates/ironclaw_reborn/tests/llm_gateway.rs @@ -22,9 +22,9 @@ use ironclaw_threads::{ use ironclaw_turns::{ LoopMessageRef, RunProfileResolutionRequest, RunProfileResolver, TurnId, TurnRunId, TurnScope, run_profile::{ - AgentLoopHostErrorKind, HostManagedLoopModelPort, InMemoryLoopHostMilestoneSink, - InMemoryRunProfileResolver, LoopHostMilestoneKind, LoopModelMessage, LoopModelPort, - LoopModelRequest, LoopRunContext, ModelProfileId, + AgentLoopHostErrorKind, CapabilitySurfaceVersion, HostManagedLoopModelPort, + InMemoryLoopHostMilestoneSink, InMemoryRunProfileResolver, LoopHostMilestoneKind, + LoopModelMessage, LoopModelPort, LoopModelRequest, LoopRunContext, ModelProfileId, }, }; use rust_decimal::Decimal; @@ -417,8 +417,7 @@ async fn production_loop_model_gateway_rejects_forged_context_summary_before_pro model_gateway, milestones.clone(), ); - let forged_ref = - LoopMessageRef::new("msg:context.summary.user.999.00000000deadbeef").unwrap(); + let forged_ref = LoopMessageRef::new("msg:context.summary.user.999.00000000deadbeef").unwrap(); let error = port .stream_model(LoopModelRequest { @@ -442,6 +441,51 @@ async fn production_loop_model_gateway_rejects_forged_context_summary_before_pro assert_eq!(milestone_kinds, vec!["model_started", "model_failed"]); } +#[tokio::test] +async fn production_loop_model_gateway_rejects_unvalidated_surface_before_provider_call() { + let fixture = ThreadFixture::new().await; + let provider = Arc::new(RecordingLlmProvider::reply("unused")); + let provider_gateway = Arc::new(LlmProviderModelGateway::new( + provider.clone(), + LlmModelProfilePolicy::new() + .allow_model_profile(interactive_model(), Some("host-selected-model".to_string())), + )); + let model_gateway = Arc::new(ThreadBackedLoopModelGateway::new( + Arc::clone(&fixture.thread_service), + fixture.thread_scope.clone(), + provider_gateway, + 16, + )); + let milestones = Arc::new(InMemoryLoopHostMilestoneSink::default()); + let port = HostManagedLoopModelPort::new( + fixture.run_context.clone(), + model_gateway, + milestones.clone(), + ); + + let error = port + .stream_model(LoopModelRequest { + messages: vec![LoopModelMessage { + role: "user".to_string(), + content_ref: LoopMessageRef::new(format!("msg:{}", fixture.user_message_id)) + .unwrap(), + }], + surface_version: Some(CapabilitySurfaceVersion::new("surface-stale").unwrap()), + model_preference: None, + }) + .await + .unwrap_err(); + + assert_eq!(error.kind, AgentLoopHostErrorKind::InvalidInvocation); + assert!(provider.requests.lock().unwrap().is_empty()); + let milestone_kinds = milestones + .milestones() + .into_iter() + .map(|milestone| milestone.kind.kind_name()) + .collect::>(); + assert_eq!(milestone_kinds, vec!["model_started", "model_failed"]); +} + #[tokio::test] async fn production_loop_model_gateway_preserves_error_kind_when_summary_is_resanitized() { let fixture = ThreadFixture::new().await; diff --git a/crates/ironclaw_reborn/tests/loop_driver_host.rs b/crates/ironclaw_reborn/tests/loop_driver_host.rs index c26cce2be2b..357c86a2ef6 100644 --- a/crates/ironclaw_reborn/tests/loop_driver_host.rs +++ b/crates/ironclaw_reborn/tests/loop_driver_host.rs @@ -123,7 +123,7 @@ async fn text_only_host_factory_builds_complete_agent_loop_driver_host() { .build_prompt_bundle(LoopPromptBundleRequest { mode: PromptMode::TextOnly, context_cursor: None, - surface_version: None, + surface_version: Some(surface.version.clone()), checkpoint_state_ref: None, max_messages: Some(8), inline_messages: Vec::new(), diff --git a/crates/ironclaw_turns/src/run_profile/host.rs b/crates/ironclaw_turns/src/run_profile/host.rs index f914f81fad0..d52e3d276b0 100644 --- a/crates/ironclaw_turns/src/run_profile/host.rs +++ b/crates/ironclaw_turns/src/run_profile/host.rs @@ -844,6 +844,7 @@ pub struct LoopPromptBundle { pub struct LoopPromptBundleGrant { pub bundle_ref: LoopPromptBundleRef, pub messages: Vec, + pub surface_version: Option, pub instruction_fingerprint: Option, } @@ -880,6 +881,7 @@ impl LoopPromptBundleAuthority { LoopPromptBundleGrant { bundle_ref: bundle.bundle_ref.clone(), messages: bundle.messages.clone(), + surface_version: bundle.surface_version.clone(), instruction_fingerprint: bundle.instruction_fingerprint.clone(), }, ); @@ -890,12 +892,12 @@ impl LoopPromptBundleAuthority { &self, context: &LoopRunContext, messages: &[LoopModelMessage], + surface_version: &Option, ) -> Result { let grant = self .lock_state()? .latest_by_run - .get(&context.run_id.to_string()) - .cloned() + .remove(&context.run_id.to_string()) .ok_or_else(|| { AgentLoopHostError::new( AgentLoopHostErrorKind::InvalidInvocation, @@ -915,6 +917,12 @@ impl LoopPromptBundleAuthority { "model request messages do not match the host-built prompt bundle", )); } + if &grant.surface_version != surface_version { + return Err(AgentLoopHostError::new( + AgentLoopHostErrorKind::StaleSurface, + "model request surface version does not match the host-built prompt bundle", + )); + } Ok(grant) } diff --git a/crates/ironclaw_turns/src/run_profile/instruction_bundle.rs b/crates/ironclaw_turns/src/run_profile/instruction_bundle.rs index b1c43ebe7d2..0609765b67b 100644 --- a/crates/ironclaw_turns/src/run_profile/instruction_bundle.rs +++ b/crates/ironclaw_turns/src/run_profile/instruction_bundle.rs @@ -219,15 +219,21 @@ impl InstructionBundleBuilder { if !request.context_bundle.identity_messages.is_empty() { requires_materialization_store = true; } - for (ordinal, message) in request.context_bundle.identity_messages.into_iter().enumerate() + for (ordinal, message) in request + .context_bundle + .identity_messages + .into_iter() + .enumerate() { push_context_message( &mut messages, &mut materialized_messages, &mut fingerprint, - "identity", - ordinal, - true, + ContextMessageOptions { + section: "identity", + ordinal, + force_materialize: true, + }, &mut synthetic_refs, message, )?; @@ -330,9 +336,11 @@ impl InstructionBundleBuilder { &mut messages, &mut materialized_messages, &mut fingerprint, - "thread", - ordinal, - false, + ContextMessageOptions { + section: "thread", + ordinal, + force_materialize: false, + }, &mut synthetic_refs, message, )?; @@ -359,13 +367,17 @@ impl InstructionBundleBuilder { } } +struct ContextMessageOptions { + section: &'static str, + ordinal: usize, + force_materialize: bool, +} + fn push_context_message( messages: &mut Vec, materialized_messages: &mut Vec, fingerprint: &mut Sha256, - section: &'static str, - ordinal: usize, - force_materialize: bool, + options: ContextMessageOptions, synthetic_refs: &mut SyntheticMessageRefRegistry, message: LoopContextMessage, ) -> Result { @@ -378,7 +390,7 @@ fn push_context_message( (content_ref, false) } None => { - let summary_section = if section == "identity" { + let summary_section = if options.section == "identity" { "identity-summary" } else { "context-summary" @@ -387,7 +399,7 @@ fn push_context_message( summary_section, &message.role, &safe_summary, - ordinal, + options.ordinal, synthetic_refs, )?; feed_field(fingerprint, b"ref", content_ref.as_str().as_bytes()); @@ -395,9 +407,9 @@ fn push_context_message( (content_ref, true) } }; - feed_field(fingerprint, b"section", section.as_bytes()); + feed_field(fingerprint, b"section", options.section.as_bytes()); feed_field(fingerprint, b"role", message.role.as_bytes()); - if force_materialize || summary_only { + if options.force_materialize || summary_only { materialized_messages.push(InstructionBundleMaterializedMessage { role: message.role.clone(), content_ref: content_ref.clone(), diff --git a/crates/ironclaw_turns/src/run_profile/mod.rs b/crates/ironclaw_turns/src/run_profile/mod.rs index e35501302aa..83cf79c26e6 100644 --- a/crates/ironclaw_turns/src/run_profile/mod.rs +++ b/crates/ironclaw_turns/src/run_profile/mod.rs @@ -40,11 +40,10 @@ pub use host::{ LoopInputCursorToken, LoopInputPort, LoopInterruptKind, LoopModelMessage, LoopModelPort, LoopModelRequest, LoopModelResponse, LoopModelRouteSnapshot, LoopProcessRef, LoopProgressEvent, LoopProgressPort, LoopPromptBundle, LoopPromptBundleAuthority, LoopPromptBundleGrant, - LoopPromptBundleRef, LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, - LoopRunInfoPort, LoopSafeSummary, LoopTranscriptPort, ModelStreamChunk, ParentLoopOutput, - ProcessHandleSummary, PromptMode, StageCheckpointPayloadRequest, UpdateAssistantDraft, - VisibleCapabilityRequest, VisibleCapabilitySurface, sanitize_model_visible_text, - validate_model_route_component_value, + LoopPromptBundleRef, LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, LoopRunInfoPort, + LoopSafeSummary, LoopTranscriptPort, ModelStreamChunk, ParentLoopOutput, ProcessHandleSummary, + PromptMode, StageCheckpointPayloadRequest, UpdateAssistantDraft, VisibleCapabilityRequest, + VisibleCapabilitySurface, sanitize_model_visible_text, validate_model_route_component_value, }; pub use instruction_bundle::{ InMemoryInstructionMaterializationStore, InstructionBundle, InstructionBundleBuilder, diff --git a/crates/ironclaw_turns/src/run_profile/model.rs b/crates/ironclaw_turns/src/run_profile/model.rs index b182672d8a0..6dd7a8cff5a 100644 --- a/crates/ironclaw_turns/src/run_profile/model.rs +++ b/crates/ironclaw_turns/src/run_profile/model.rs @@ -331,5 +331,8 @@ fn sanitize_model_response(mut response: LoopModelResponse) -> LoopModelResponse chunk.safe_text_delta = sanitize_model_visible_text(std::mem::take(&mut chunk.safe_text_delta)); } + if let super::host::ParentLoopOutput::AssistantReply(reply) = &mut response.output { + reply.content = sanitize_model_visible_text(std::mem::take(&mut reply.content)); + } response } diff --git a/crates/ironclaw_turns/src/run_profile/prompt.rs b/crates/ironclaw_turns/src/run_profile/prompt.rs index b774ceb996f..098dbde6b4b 100644 --- a/crates/ironclaw_turns/src/run_profile/prompt.rs +++ b/crates/ironclaw_turns/src/run_profile/prompt.rs @@ -424,7 +424,9 @@ mod tests { let msg = &bundle.messages[0]; assert_eq!(msg.role, "system"); assert!( - msg.content_ref.as_str().starts_with("msg:identity-summary."), + msg.content_ref + .as_str() + .starts_with("msg:identity-summary."), "summary-only identity ref must use the msg:identity-summary. prefix, got: {}", msg.content_ref.as_str() ); diff --git a/crates/ironclaw_turns/tests/agent_loop_host_contract.rs b/crates/ironclaw_turns/tests/agent_loop_host_contract.rs index f2613322918..784117a4a9b 100644 --- a/crates/ironclaw_turns/tests/agent_loop_host_contract.rs +++ b/crates/ironclaw_turns/tests/agent_loop_host_contract.rs @@ -30,9 +30,10 @@ use ironclaw_turns::{ LoopInputCursorToken, LoopInputPort, LoopModelBudgetAccountant, LoopModelGateway, LoopModelGatewayError, LoopModelGatewayRequest, LoopModelMessage, LoopModelPolicyGuard, LoopModelPort, LoopModelRequest, LoopModelResponse, LoopProgressEvent, LoopProgressPort, - LoopPromptBundle, LoopPromptBundleRef, LoopPromptBundleRequest, LoopPromptPort, - LoopRunContext, LoopRunInfoPort, LoopTranscriptPort, ModelCallOutcome, ParentLoopOutput, - PromptMode, PromptSkillContextMetadata, VisibleCapabilityRequest, VisibleCapabilitySurface, + LoopPromptBundle, LoopPromptBundleAuthority, LoopPromptBundleRef, LoopPromptBundleRequest, + LoopPromptPort, LoopRunContext, LoopRunInfoPort, LoopTranscriptPort, ModelCallOutcome, + ParentLoopOutput, PromptMode, PromptSkillContextMetadata, VisibleCapabilityRequest, + VisibleCapabilitySurface, }, runner::{ClaimRunRequest, TurnRunTransitionPort}, }; @@ -284,6 +285,7 @@ async fn instruction_bundle_builder_orders_sections_and_rebuilds_deterministical runtime: RuntimeKind::FirstParty, safe_name: "Echo".to_string(), safe_description: "Echo safe input".to_string(), + concurrency_hint: ConcurrencyHint::SafeForParallel, }], }; let request = InstructionBundleRequest { @@ -526,6 +528,7 @@ async fn instruction_bundle_builder_allows_tool_result_reference_context_message surface_version: None, checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap(); @@ -628,6 +631,33 @@ async fn loop_prompt_port_builds_text_only_bundle_from_context_refs() { assert_eq!(host.milestone_kind_names(), vec!["prompt_bundle_built"]); } +#[tokio::test] +async fn prompt_bundle_authority_consumes_grant_after_successful_model_authorization() { + let context = claimed_run_context().await; + let authority = LoopPromptBundleAuthority::default(); + let messages = vec![LoopModelMessage { + role: "user".to_string(), + content_ref: LoopMessageRef::new("msg:user-message").unwrap(), + }]; + let bundle = LoopPromptBundle { + bundle_ref: LoopPromptBundleRef::for_run(&context, "bundle-once").unwrap(), + messages: messages.clone(), + surface_version: None, + instruction_fingerprint: None, + }; + authority.issue_bundle(&context, &bundle).unwrap(); + + let grant = authority + .authorize_latest_model_request(&context, &messages, &None) + .unwrap(); + assert_eq!(grant.messages, messages); + + let error = authority + .authorize_latest_model_request(&context, &grant.messages, &None) + .unwrap_err(); + assert_eq!(error.kind, AgentLoopHostErrorKind::InvalidInvocation); +} + #[tokio::test] async fn loop_prompt_port_uses_current_surface_version_lookup_each_build() { let host = Arc::new(RecordingAgentLoopHost::new(claimed_run_context().await)); @@ -659,6 +689,7 @@ async fn loop_prompt_port_uses_current_surface_version_lookup_each_build() { surface_version: Some(surface_v1.clone()), checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap(); @@ -673,6 +704,7 @@ async fn loop_prompt_port_uses_current_surface_version_lookup_each_build() { surface_version: Some(surface_v1), checkpoint_state_ref: None, max_messages: Some(8), + inline_messages: Vec::new(), }) .await .unwrap_err(); @@ -1076,6 +1108,7 @@ async fn loop_prompt_port_materializes_memory_surface_and_safety_as_host_owned_r runtime: RuntimeKind::FirstParty, safe_name: "Echo".to_string(), safe_description: "Echo safe input".to_string(), + concurrency_hint: ConcurrencyHint::SafeForParallel, }], }; let port = HostManagedLoopPromptPort::new( @@ -1099,6 +1132,7 @@ async fn loop_prompt_port_materializes_memory_surface_and_safety_as_host_owned_r surface_version: Some(surface.version), checkpoint_state_ref: None, max_messages: None, + inline_messages: Vec::new(), }) .await .unwrap();