diff --git a/crates/astra-cli/src/cli/chat_stream/sse_loop/agentic_loop_turn.rs b/crates/astra-cli/src/cli/chat_stream/sse_loop/agentic_loop_turn.rs index 0827695b37..98c3351216 100644 --- a/crates/astra-cli/src/cli/chat_stream/sse_loop/agentic_loop_turn.rs +++ b/crates/astra-cli/src/cli/chat_stream/sse_loop/agentic_loop_turn.rs @@ -147,9 +147,7 @@ fn message_has_tool_calls(m: &Value) -> bool { fn retained_history_messages(messages: &[Value]) -> &[Value] { match messages.split_last() { - Some((last, history)) if last.get("role").and_then(Value::as_str) == Some("user") => { - history - } + Some((last, history)) if astra_turn_types::is_human_user_message(last) => history, _ => messages, } } @@ -193,19 +191,38 @@ fn project_cross_session_memory_hits( fn build_retained_history_turns( messages: &[Value], ) -> Vec { - let mut turns = Vec::new(); + let mut turns: Vec = Vec::new(); for message in messages { + let content = msg_content(message); let role = message .get("role") .and_then(Value::as_str) .unwrap_or("unknown") .to_string(); - let tokens = prompts::estimate_str_tokens(&msg_content(message)) as u32; + let tokens = prompts::estimate_str_tokens(&content) as u32; let has_tool_calls = message_has_tool_calls(message); - let preview = retained_history_preview(&role, &msg_content(message)); + if astra_turn_types::is_runtime_owned_message(message) { + // Provider occupancy still includes append-only authority, but + // user-facing history previews and semantic role summaries must + // never expose or attribute runtime control payloads to a human. + if let Some(turn) = turns.last_mut() { + turn.tokens = turn.tokens.saturating_add(tokens); + turn.has_tool_calls |= has_tool_calls; + } else { + turns.push(astra_turn_core::context_assembly_trace::TurnRetention { + turn_index: 0, + role: "runtime".to_string(), + tokens, + has_tool_calls, + content_preview: String::new(), + }); + } + continue; + } + let preview = retained_history_preview(&role, &content); - if turns.is_empty() || role == "user" { + if turns.is_empty() || astra_turn_types::is_human_user_message(message) { turns.push(astra_turn_core::context_assembly_trace::TurnRetention { turn_index: turns.len() as u32, role, @@ -217,7 +234,7 @@ fn build_retained_history_turns( } if let Some(turn) = turns.last_mut() { - turn.tokens += tokens; + turn.tokens = turn.tokens.saturating_add(tokens); turn.has_tool_calls |= has_tool_calls; if retained_turn_role_priority(&role) > retained_turn_role_priority(&turn.role) { turn.role = role; @@ -2351,6 +2368,7 @@ mod tests { "advisories": [{"kind": "test_signal"}] }), round_index: 3, + attempt_leased: false, }; let required: Vec = Vec::new(); let volatile_texts: Vec = Vec::new(); @@ -2655,6 +2673,44 @@ mod tests { assert!(turns[0].has_tool_calls); } + #[test] + fn retained_history_accounts_runtime_tokens_without_exposing_control_preview() { + use astra_runtime::prompts; + + let mut authority = json!({ + "role": "user", + "content": "\ninternal Work settlement\n" + }); + astra_turn_types::mark_append_only_required_context( + &mut authority, + "final_work_synthesis", + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, + ); + let messages = vec![ + json!({"role": "user", "content": "real request"}), + authority, + json!({"role": "assistant", "content": "visible answer"}), + ]; + let expected_tokens = messages + .iter() + .map(|message| prompts::estimate_str_tokens(&msg_content(message)) as u32) + .fold(0_u32, u32::saturating_add); + + let turns = build_retained_history_turns(&messages); + + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].tokens, expected_tokens); + assert_eq!(turns[0].role, "assistant"); + assert!(turns[0].content_preview.contains("real request")); + assert!(turns[0].content_preview.contains("visible answer")); + assert!(!turns[0].content_preview.contains("runtime-authority-frame")); + assert!( + !turns[0] + .content_preview + .contains("internal Work settlement") + ); + } + #[test] fn retained_history_keeps_system_role_for_system_only_history() { let messages = vec![json!({"role": "system", "content": "system note"})]; diff --git a/crates/astra-cli/src/cli/chat_stream/sse_loop/mod.rs b/crates/astra-cli/src/cli/chat_stream/sse_loop/mod.rs index 5a776474a3..540cf7c74a 100644 --- a/crates/astra-cli/src/cli/chat_stream/sse_loop/mod.rs +++ b/crates/astra-cli/src/cli/chat_stream/sse_loop/mod.rs @@ -519,8 +519,7 @@ pub(crate) async fn stream_chat_sse( // preceding prompt history is inherited context, not a new conversation // item for this run. let root_initial_transcript_item = messages.last().and_then(|message| { - (message.get("role").and_then(serde_json::Value::as_str) == Some("user")) - .then(|| message.clone()) + astra_turn_types::is_human_user_message(message).then(|| message.clone()) }); // ─── Context pre-fetch (disabled) ───────────────────────────────────── @@ -828,6 +827,7 @@ pub(crate) async fn stream_chat_sse( current_session_id, current_run_id: Some(parent_turn_run_id.clone()), current_run_owner_generation: None, + provider_canonical_wal_head_transition_id: None, inference_purpose: astra_turn_types::InferencePurpose::PrimaryAgent, context_manifest_pool: None, context_manifest_user_id: persist_session_artifacts.then_some(current_user_id), @@ -1016,6 +1016,7 @@ pub(crate) async fn stream_chat_sse( budget_wrapup_injected: false, context_compression_triggered: false, canonical_rewrite_state: Default::default(), + provider_canonical_wal_base: None, budget_wrapup_ignored_rounds: 0, compact_tier_applied: astra_turn_core::compaction_types::CompactionTier::Normal, skill_produced_output: false, @@ -1031,8 +1032,8 @@ pub(crate) async fn stream_chat_sse( session_facts: Default::default(), // Canonical Server execution is the sole per-turn memory producer. memory_extraction_service: None, - compact_strategy: astra_turn_core::microcompact::CompactStrategy::from_provider_and_model( - p.provider, p.model, + compact_strategy: astra_turn_core::microcompact::CompactStrategy::from_explicit_or_provider( + None, p.provider, ), approval_overrides: initial_approval_overrides, confidence_trend: Default::default(), diff --git a/crates/astra-cli/src/cli/chat_stream/sse_loop/server_admission_host.rs b/crates/astra-cli/src/cli/chat_stream/sse_loop/server_admission_host.rs index 55d892dd25..3cf5e44d7a 100644 --- a/crates/astra-cli/src/cli/chat_stream/sse_loop/server_admission_host.rs +++ b/crates/astra-cli/src/cli/chat_stream/sse_loop/server_admission_host.rs @@ -723,7 +723,7 @@ impl AgenticLoopHost for CliServerAdmissionHost<'_> { let effective_model_owned = self.model.map(str::to_owned); let effective_model = effective_model_owned.as_deref(); let effective_offering_id = self.offering_id.as_deref(); - let runtime_volatile_injections = state.take_volatile_pending(); + let runtime_volatile_injections = state.lease_volatile_pending()?; let runtime_volatile_texts = self .input_runtime_volatile_texts .iter() diff --git a/crates/astra-cli/src/cli/delegate_subrun.rs b/crates/astra-cli/src/cli/delegate_subrun.rs index 76d83d8145..3376e663bc 100644 --- a/crates/astra-cli/src/cli/delegate_subrun.rs +++ b/crates/astra-cli/src/cli/delegate_subrun.rs @@ -305,9 +305,9 @@ impl SubRunExecutor for CliDelegateSubRunExecutor { .as_deref() .map(|model| astra_turn_core::thinking_config::resolve_model_thinking(model).1) .unwrap_or_default(); - let compact_strategy = astra_turn_core::microcompact::CompactStrategy::from_provider_hint( - effective_model.as_deref().unwrap_or(""), - ); + // The model alias does not establish a cache protocol. The admitted + // server execution owns provider-specific request shaping. + let compact_strategy = astra_turn_core::microcompact::CompactStrategy::default(); // Resolve per-model workflow-guard policy up front; `effective_model` // is moved into the SubRunHost below. let resolved_tool_policy = astra_config::runtime_config::RuntimeConfig::load() @@ -496,6 +496,7 @@ impl SubRunExecutor for CliDelegateSubRunExecutor { current_session_id: Some(config.session_id.clone()), current_run_id: Some(config.run_id.clone()), current_run_owner_generation: None, + provider_canonical_wal_head_transition_id: None, inference_purpose: astra_turn_types::InferencePurpose::SubAgent, context_manifest_pool: None, context_manifest_user_id: Some(user_id), @@ -624,6 +625,7 @@ impl SubRunExecutor for CliDelegateSubRunExecutor { budget_wrapup_injected: false, context_compression_triggered: false, canonical_rewrite_state: Default::default(), + provider_canonical_wal_base: None, budget_wrapup_ignored_rounds: 0, compact_tier_applied: astra_turn_core::compaction_types::CompactionTier::Normal, skill_produced_output: false, diff --git a/crates/astra-cli/src/cli/self_command.rs b/crates/astra-cli/src/cli/self_command.rs index 8577b6f171..d22aa0e80a 100644 --- a/crates/astra-cli/src/cli/self_command.rs +++ b/crates/astra-cli/src/cli/self_command.rs @@ -991,7 +991,7 @@ fn restored_recent_turn_previews( for message in restored.resume_messages() { let role = message.get("role").and_then(serde_json::Value::as_str); match role { - Some("user") => { + Some("user") if astra_turn_types::is_human_user_message(message) => { pending_user = extract_text_content(message); } Some("assistant") => { @@ -1446,7 +1446,7 @@ mod tests { EventPreview, analysis_view_recent_event_previews, build_reflect_response, cli_provider_visible_tool_names, event_preview_has_adverse_signal, event_preview_summary, execute_self_command, persist_config_override, replace_json_path, resolve_session_id, - session_agent_delivery_summary, verify_runtime_config, + restored_recent_turn_previews, session_agent_delivery_summary, verify_runtime_config, }; use crate::cli::cli_config::cli_args::{SelfCmd, SelfReflectArgs, SelfSessionArgs}; use crate::cli::cli_config::cli_utils::{ @@ -1489,6 +1489,43 @@ mod tests { } } + #[test] + fn restored_preview_does_not_show_runtime_authority_as_user_input() { + let mut runtime = serde_json::json!({"role": "user", "content": "runtime control"}); + astra_turn_types::mark_append_only_required_context( + &mut runtime, + "final_answer_settlement", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ); + let messages = vec![ + serde_json::json!({"role": "user", "content": "real request"}), + runtime, + serde_json::json!({"role": "assistant", "content": "answer"}), + ]; + let artifacts = astra_services::self_surface::LoadedSelfSurfaceArtifacts { + session_id: "sid".to_string(), + workspace: None, + restored: Some(astra_services::session_restore::RestoredSession { + session_id: "sid".to_string(), + resume_bundle: Some(typed_resume_bundle("sid", 1, messages)), + ..Default::default() + }), + journal_events: Vec::new(), + latest_full_context_trace: None, + }; + + let previews = restored_recent_turn_previews(&artifacts, 4); + assert_eq!(previews.len(), 1); + assert_eq!( + previews[0].user_input_preview.as_deref(), + Some("real request") + ); + assert_eq!( + previews[0].assistant_output_preview.as_deref(), + Some("answer") + ); + } + #[test] fn reflect_interruption_preserves_resume_and_stall_causality() { let event = EventPreview { diff --git a/crates/astra-cli/src/cli/skill_subrun.rs b/crates/astra-cli/src/cli/skill_subrun.rs index 61824e7b66..95d5918d47 100644 --- a/crates/astra-cli/src/cli/skill_subrun.rs +++ b/crates/astra-cli/src/cli/skill_subrun.rs @@ -435,7 +435,7 @@ impl AgenticLoopHost for SubRunHost { // Drain runtime volatile as typed edge metadata. Do not splice it into // messages[]: that loses producer kind, pollutes prompt-facing history, // and makes soft runtime evidence look like user content. - let runtime_volatile_injections = state.take_volatile_pending(); + let runtime_volatile_injections = state.lease_volatile_pending()?; let effective_model = self.model.as_deref(); let effective_offering_id = self.offering_id.clone(); @@ -1066,9 +1066,9 @@ impl SkillSubRunExecutor for CliSkillSubRunExecutor { .as_deref() .map(|model| astra_turn_core::thinking_config::resolve_model_thinking(model).1) .unwrap_or_default(); - let compact_strategy = astra_turn_core::microcompact::CompactStrategy::from_provider_hint( - effective_model.as_deref().unwrap_or(""), - ); + // The model alias does not establish a cache protocol. The admitted + // server execution owns provider-specific request shaping. + let compact_strategy = astra_turn_core::microcompact::CompactStrategy::default(); // Resolve per-model workflow-guard policy up front; `effective_model` // is moved into the SubRunHost below. let resolved_tool_policy = astra_config::runtime_config::RuntimeConfig::load() @@ -1195,6 +1195,7 @@ impl SkillSubRunExecutor for CliSkillSubRunExecutor { current_session_id: Some(parent_session_id.to_string()), current_run_id: Some(parent_run_id.to_string()), current_run_owner_generation: None, + provider_canonical_wal_head_transition_id: None, inference_purpose: astra_turn_types::InferencePurpose::SubAgent, context_manifest_pool: None, context_manifest_user_id: Some(user_id), @@ -1303,6 +1304,7 @@ impl SkillSubRunExecutor for CliSkillSubRunExecutor { budget_wrapup_injected: false, context_compression_triggered: false, canonical_rewrite_state: Default::default(), + provider_canonical_wal_base: None, budget_wrapup_ignored_rounds: 0, compact_tier_applied: astra_turn_core::compaction_types::CompactionTier::Normal, skill_produced_output: false, @@ -1794,6 +1796,7 @@ mod tests { kind: astra_runtime::turn::agentic_loop::host::VolatileKind::PolicyAdvisory, payload: json!({"signal": "soft subrun evidence"}), round_index: 2, + attempt_leased: false, }]; attach_runtime_volatile_injections(&mut payload, &injections); diff --git a/crates/astra-cli/src/cli/slash/slash_cache.rs b/crates/astra-cli/src/cli/slash/slash_cache.rs index 26b5e71542..b9462ae252 100644 --- a/crates/astra-cli/src/cli/slash/slash_cache.rs +++ b/crates/astra-cli/src/cli/slash/slash_cache.rs @@ -305,6 +305,7 @@ mod tests { round, provider: "openai".into(), model: "test-model".into(), + cache_capability: None, cache_read_tokens, cache_creation_tokens: 0, tool_count: 0, @@ -432,6 +433,7 @@ mod tests { Some(PromptCacheCapabilityData { protocol: astra_services::PromptCacheProtocolData::OpenAiAutoPrefix, volatile_placement: astra_services::PromptCacheVolatilePlacementData::TailSuffix, + volatile_delivery: astra_services::PromptCacheVolatileDeliveryData::All, reuse_scope: Some(PromptCacheReuseScopeData::ConversationTurns), }), &turns, @@ -452,6 +454,7 @@ mod tests { Some(PromptCacheCapabilityData { protocol: astra_services::PromptCacheProtocolData::OpenAiAutoPrefix, volatile_placement: astra_services::PromptCacheVolatilePlacementData::TailSuffix, + volatile_delivery: astra_services::PromptCacheVolatileDeliveryData::All, reuse_scope: Some(PromptCacheReuseScopeData::ConversationTurns), }), &[], diff --git a/crates/astra-cli/src/cli/slash/slash_debug.rs b/crates/astra-cli/src/cli/slash/slash_debug.rs index 26d5ab58c7..3471fc0caf 100644 --- a/crates/astra-cli/src/cli/slash/slash_debug.rs +++ b/crates/astra-cli/src/cli/slash/slash_debug.rs @@ -100,8 +100,7 @@ pub(crate) fn handle_debug_command(arg: &str, state: &SessionState) { .delta .iter() .chain(view.full.iter()) - .find(|m| m.get("role").and_then(|v| v.as_str()) == Some("user")) - .and_then(|m| m.get("content").and_then(|v| v.as_str())) + .find_map(human_user_text) .unwrap_or("(unknown)") .to_string(), tokens_in: 0, @@ -174,6 +173,12 @@ pub(crate) fn handle_debug_command(arg: &str, state: &SessionState) { } } +fn human_user_text(message: &serde_json::Value) -> Option<&str> { + astra_turn_types::is_human_user_message(message) + .then(|| message.get("content").and_then(serde_json::Value::as_str)) + .flatten() +} + // ── Overview ────────────────────────────────────────────────────────────────── fn print_overview(session_id: &str, turns: &[TurnSummary], checkpoints: &[PathBuf]) { @@ -1126,7 +1131,7 @@ fn truncate(s: &str, max: usize) -> String { #[cfg(test)] mod tests { use super::{ - build_turn_messages_view, list_heavy_checkpoints, load_journal_turns, + build_turn_messages_view, human_user_text, list_heavy_checkpoints, load_journal_turns, load_messages_from_heavy_path, message_delta, resolve_session_id, truncate, }; use serde_json::json; @@ -1394,6 +1399,20 @@ mod tests { assert_eq!(v.warning, None); } + #[test] + fn checkpoint_fallback_preview_excludes_runtime_user_frame() { + let human = json!({"role": "user", "content": "real request"}); + let mut runtime = json!({"role": "user", "content": "runtime control"}); + astra_turn_types::mark_append_only_required_context( + &mut runtime, + "final_answer_settlement", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ); + + assert_eq!(human_user_text(&human), Some("real request")); + assert_eq!(human_user_text(&runtime), None); + } + // ── format_tool_display_from_preview ────────────────────────────── #[test] diff --git a/crates/astra-cli/src/cli/spawn_subrun.rs b/crates/astra-cli/src/cli/spawn_subrun.rs index 0e1ba7200e..96566d5db2 100644 --- a/crates/astra-cli/src/cli/spawn_subrun.rs +++ b/crates/astra-cli/src/cli/spawn_subrun.rs @@ -698,11 +698,10 @@ impl SpawnAgentExecutor for CliSpawnAgentExecutor { // Use the working directory from config (may be a worktree) let effective_root = config.working_dir.clone(); - let compact_strategy = config - .model - .as_deref() - .map(astra_turn_core::microcompact::CompactStrategy::from_provider_hint) - .unwrap_or_default(); + // This local sub-run input carries a model alias but no authoritative + // deployment capability. Use the neutral deterministic strategy; the + // server applies the admitted provider capability at inference time. + let compact_strategy = astra_turn_core::microcompact::CompactStrategy::default(); // Resolve the freshest token at spawn time. Without this, // sub-agents fail with 401 in long-running sessions after the @@ -945,6 +944,7 @@ impl SpawnAgentExecutor for CliSpawnAgentExecutor { current_session_id: server_session_id, current_run_id: Some(config.run_id.clone()), current_run_owner_generation: None, + provider_canonical_wal_head_transition_id: None, inference_purpose: astra_turn_types::InferencePurpose::SubAgent, context_manifest_pool: None, context_manifest_user_id: Some(user_id), @@ -1052,6 +1052,7 @@ impl SpawnAgentExecutor for CliSpawnAgentExecutor { budget_wrapup_injected: false, context_compression_triggered: false, canonical_rewrite_state: Default::default(), + provider_canonical_wal_base: None, budget_wrapup_ignored_rounds: 0, compact_tier_applied: astra_turn_core::compaction_types::CompactionTier::Normal, skill_produced_output: false, diff --git a/crates/astra-cli/src/cli/stream/streaming_types.rs b/crates/astra-cli/src/cli/stream/streaming_types.rs index 865965a21b..c3d1a2e454 100644 --- a/crates/astra-cli/src/cli/stream/streaming_types.rs +++ b/crates/astra-cli/src/cli/stream/streaming_types.rs @@ -440,8 +440,7 @@ fn user_inputs_from_current_turn( let user_contents = messages .iter() .filter_map(|message| { - let role = message.get("role")?.as_str()?; - if role != "user" { + if !astra_turn_types::is_human_user_message(message) { return None; } let content = message.get("content")?.as_str()?.trim(); @@ -520,6 +519,26 @@ mod user_input_tests { assert_eq!(latest_user_input_from_messages("1", &messages), "2"); } + #[test] + fn effective_user_input_excludes_user_role_runtime_authority() { + let mut authority = json!({"role": "user", "content": "runtime settlement"}); + astra_turn_types::mark_append_only_required_context( + &mut authority, + "final_answer_settlement", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ); + let messages = vec![json!({"role": "user", "content": "real task"}), authority]; + + assert_eq!( + effective_user_input_from_messages("real task", &messages), + "real task" + ); + assert_eq!( + latest_user_input_from_messages("real task", &messages), + "real task" + ); + } + #[test] fn effective_user_input_uses_last_matching_primary_line() { let messages = vec![ diff --git a/crates/astra-turn-core/src/cache_diagnostics.rs b/crates/astra-turn-core/src/cache_diagnostics.rs index 08498945eb..7e871fc7be 100644 --- a/crates/astra-turn-core/src/cache_diagnostics.rs +++ b/crates/astra-turn-core/src/cache_diagnostics.rs @@ -27,6 +27,13 @@ pub const DEFAULT_SOURCE: &str = "main"; /// At cap=10 that's negligible; raising this above ~64 should switch /// `source_order` to `VecDeque` or an indexed linked structure. const MAX_TRACKED_SOURCES: usize = 10; +const MAX_TRACKED_PROVIDER_ATTEMPTS: usize = 128; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderAttemptCacheIdentity { + pub request_id: String, + pub attempt: u32, +} pub const MAX_WARM_CACHE_READ_SHARE_DROP: f64 = 0.05; /// Rollout gate derived from provider-reported warm-cache read share. @@ -227,9 +234,50 @@ pub struct PromptStateSnapshot { pub timestamp_secs: u64, /// Total estimated cache-eligible tokens (system + tools). pub cache_eligible_tokens: usize, + /// Provider-final component identity captured from the immutable body + /// receipt. This is optional only for backward-compatible restoration of + /// snapshots written before provider-final receipts existed. + #[serde(default)] + pub provider_final_fingerprint: Option, +} + +/// Content-free identity of the exact provider payload components. +/// +/// The transport computes these hashes from the same sanitized JSON value it +/// serializes for HTTP. They deliberately do not carry provider body content +/// or infer semantics from provider/model labels. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderFinalPromptFingerprint { + pub message_sequence_sha256: String, + pub system_sequence_sha256: String, + pub cache_key_system_sha256: String, + pub conversation_sequence_sha256: String, + pub tool_schema_sequence_sha256: String, + pub cache_key_tool_schema_sequence_sha256: String, + #[serde(default)] + pub cache_capability: crate::cache_placement::CacheCapability, + #[serde(default)] + pub cache_key_tool_schema_items: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderFinalToolFingerprint { + pub name: Option, + pub sha256: String, } impl PromptStateSnapshot { + /// Attach the immutable provider-final fingerprint that owns structural + /// cache-break attribution for this snapshot. Planned hashes remain only + /// as human-readable attribution detail when the final receipt proves a + /// component changed. + pub fn attach_provider_final_fingerprint( + &mut self, + fingerprint: ProviderFinalPromptFingerprint, + ) { + self.provider_final_fingerprint = Some(fingerprint); + } + /// Replace candidate tool fingerprints with the exact provider-wire /// schemas after runtime stabilization and cache annotation. /// @@ -352,6 +400,7 @@ impl PromptStateSnapshot { model: model.to_string(), timestamp_secs: now, cache_eligible_tokens, + provider_final_fingerprint: None, } } } @@ -403,10 +452,42 @@ pub fn prompt_snapshot_from_messages( model: &str, cache_eligible_tokens: usize, ) -> Option { + prompt_snapshot_from_messages_with_cache_capability( + messages, + tool_schemas, + provider, + model, + cache_eligible_tokens, + None, + ) +} + +/// Build a cache snapshot using the same provider capability that shaped the +/// final message list. +/// +/// The caller must pass the final provider message shape. Every remaining +/// The cache-keyed system identity follows the final resolved wire shape. +/// Prefix providers fingerprint only the contiguous leading system header; +/// strict-history shapes that fold system context fingerprint every system +/// block; marker protocols fingerprint through their last explicit marker. +/// Deployments that require append-only runtime control project it as a typed +/// runtime-owned conversation frame, so it never becomes a system mutation. +pub fn prompt_snapshot_from_messages_with_cache_capability( + messages: &[serde_json::Value], + tool_schemas: &[serde_json::Value], + provider: &str, + model: &str, + cache_eligible_tokens: usize, + explicit_cache_capability: Option, +) -> Option { + let cache_capability = crate::cache_placement::CacheCapability::from_explicit_or_provider( + explicit_cache_capability, + provider, + ); let system_prompt_text = prompt_snapshot_system_text_from_messages(messages); let snapshot = PromptStateSnapshot::capture_with_hashes( hash_str(&system_prompt_text), - prompt_snapshot_fingerprint_system_blocks(messages), + prompt_snapshot_fingerprint_system_blocks(messages, cache_capability), tool_schemas, provider, model, @@ -469,11 +550,49 @@ fn prompt_snapshot_content_value_text(value: &serde_json::Value) -> String { fn prompt_snapshot_fingerprint_system_blocks( messages: &[serde_json::Value], + cache_capability: crate::cache_placement::CacheCapability, ) -> Vec { - prompt_snapshot_selected_message_contents(messages) - .into_iter() - .flat_map(prompt_snapshot_content_value_blocks) - .collect() + use crate::cache_placement::{CacheProtocol, VolatilePlacement}; + + let mut out = Vec::new(); + let mut leading_system_prefix_open = true; + for message in messages { + if message.get("role").and_then(serde_json::Value::as_str) != Some("system") { + leading_system_prefix_open = false; + continue; + } + let Some(content) = message.get("content") else { + continue; + }; + let mut blocks = prompt_snapshot_content_value_blocks(content); + let visible = match cache_capability.volatile_placement { + VolatilePlacement::CurrentUserOnly => true, + VolatilePlacement::MarkerIsolated => true, + VolatilePlacement::TailSuffix | VolatilePlacement::AppendOnlyUserTail => { + leading_system_prefix_open + } + VolatilePlacement::Free => false, + }; + if !visible { + for block in &mut blocks { + block.scope = "None".to_string(); + } + } + out.extend(blocks); + } + + if matches!( + cache_capability.protocol, + CacheProtocol::MarkerExplicit | CacheProtocol::BedrockCachePoint + ) { + let last_marker = out.iter().rposition(|block| block.cache_control_hash != 0); + for (index, block) in out.iter_mut().enumerate() { + if last_marker.is_none_or(|last_marker| index > last_marker) { + block.scope = "None".to_string(); + } + } + } + out } fn prompt_snapshot_content_value_blocks(value: &serde_json::Value) -> Vec { @@ -573,10 +692,18 @@ const CACHE_TTL_1HOUR_SECS: u64 = 3_600; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CacheBreakDetectorState { pub per_source: HashMap, + /// Last usage-bearing provider attempt per source. `None` denotes a + /// pre-field snapshot; restoration seeds it from the legacy turn baseline. + #[serde(default)] + pub usage_per_source: Option>, pub source_order: Vec, pub stats: CacheStats, #[serde(default)] pub diff_seq: u32, + /// Recently consumed durable provider-attempt identities. Persisting this + /// bounded set makes receipt ingestion idempotent across session restore. + #[serde(default)] + pub observed_provider_attempts: VecDeque, } /// In-memory cache-break detector for prompt caching systems. @@ -597,6 +724,11 @@ pub struct CacheBreakDetector { /// `source_order` vector tracks insertion/refresh order (back = most /// recent); eviction drops the front. per_source: HashMap, + /// Attribution baseline advances only when the provider supplied usage. + /// It is intentionally separate from the last-dispatched structural + /// baseline so an unavailable retry cannot erase the cause later reported + /// by a usage-bearing terminal. + usage_per_source: HashMap, /// Insertion/refresh order for LRU eviction. Kept in sync with /// `per_source`: every write to a source appends/refreshes its key /// here; eviction pops from the front. @@ -612,6 +744,7 @@ pub struct CacheBreakDetector { /// cache just break?" without re-running the session. diff_dir: Option, diff_seq: u32, + observed_provider_attempts: VecDeque, } /// Running cache hit/miss statistics. @@ -643,12 +776,17 @@ impl CacheBreakDetector { #[must_use] pub fn from_state(state: CacheBreakDetectorState) -> Self { + let usage_per_source = state + .usage_per_source + .unwrap_or_else(|| state.per_source.clone()); Self { per_source: state.per_source, + usage_per_source, source_order: state.source_order, stats: state.stats, diff_dir: None, diff_seq: state.diff_seq, + observed_provider_attempts: state.observed_provider_attempts, } } @@ -656,12 +794,90 @@ impl CacheBreakDetector { pub fn snapshot_state(&self) -> CacheBreakDetectorState { CacheBreakDetectorState { per_source: self.per_source.clone(), + usage_per_source: Some(self.usage_per_source.clone()), source_order: self.source_order.clone(), stats: self.stats.clone(), diff_seq: self.diff_seq, + observed_provider_attempts: self.observed_provider_attempts.clone(), } } + /// Record one dispatched physical provider attempt from its immutable + /// final-body receipt. + /// + /// An attempt without provider usage still advances the structural + /// baseline, because the body crossed the dispatch boundary, but does not + /// fabricate a cache hit or miss. Returns `(accepted, event)`; duplicate + /// durable attempt identities are ignored idempotently. + pub fn record_provider_attempt_for_source( + &mut self, + source: &str, + attempt_identity: &ProviderAttemptCacheIdentity, + current: PromptStateSnapshot, + actual_cache_read_tokens: Option, + ) -> (bool, Option) { + if self + .observed_provider_attempts + .iter() + .any(|observed| observed == attempt_identity) + { + return (false, None); + } + self.observed_provider_attempts + .push_back(attempt_identity.clone()); + while self.observed_provider_attempts.len() > MAX_TRACKED_PROVIDER_ATTEMPTS { + self.observed_provider_attempts.pop_front(); + } + + let structural_previous = self.per_source.get(source).cloned(); + let structural_event = structural_previous + .as_ref() + .and_then(|previous| self.detect_break(previous, ¤t, None)); + let usage_previous = + actual_cache_read_tokens.and_then(|_| self.usage_per_source.get(source).cloned()); + let event = if let Some(cache_read_tokens) = actual_cache_read_tokens { + usage_previous + .as_ref() + .and_then(|previous| self.detect_break(previous, ¤t, Some(cache_read_tokens))) + } else { + structural_event + }; + + if actual_cache_read_tokens.is_some() { + self.stats.total_turns = self.stats.total_turns.saturating_add(1); + if usage_previous.is_none() || event.is_some() { + self.stats.cache_misses = self.stats.cache_misses.saturating_add(1); + } else { + self.stats.cache_hits = self.stats.cache_hits.saturating_add(1); + } + if let Some(event) = event.as_ref() { + self.stats.total_miss_tokens = self + .stats + .total_miss_tokens + .saturating_add(event.estimated_token_impact); + self.stats.recent_breaks.push_back(event.clone()); + if self.stats.recent_breaks.len() > 10 { + self.stats.recent_breaks.pop_front(); + } + if let Some(dir) = self.diff_dir.clone() { + self.diff_seq = self.diff_seq.wrapping_add(1); + spawn_diff_artifact_write( + dir, + self.diff_seq, + usage_previous, + current.clone(), + event.clone(), + ); + } + } + self.usage_per_source + .insert(source.to_string(), current.clone()); + } + + self.write_source_snapshot(source, current); + (true, event) + } + /// Enable per-break diagnostic artifact emission to `dir`. The directory /// is created lazily on the first break. Errors during directory create /// or file write are swallowed to avoid perturbing the live turn — this @@ -743,6 +959,8 @@ impl CacheBreakDetector { self.stats.cache_hits += 1; } + self.usage_per_source + .insert(source.to_string(), current.clone()); self.write_source_snapshot(source, current); event } @@ -751,6 +969,7 @@ impl CacheBreakDetector { /// event such as compaction or native provider history clearing. pub fn reset_all_sources(&mut self) { self.per_source.clear(); + self.usage_per_source.clear(); self.source_order.clear(); } @@ -767,6 +986,7 @@ impl CacheBreakDetector { while self.source_order.len() > MAX_TRACKED_SOURCES { let evicted = self.source_order.remove(0); self.per_source.remove(&evicted); + self.usage_per_source.remove(&evicted); } } @@ -808,46 +1028,132 @@ impl CacheBreakDetector { }); } - // 2. System prompt change - if effective_prefix_system_prompt_hash(prev) != effective_prefix_system_prompt_hash(curr) { + // 2. System prompt change. Once both sides have immutable provider + // receipts, only the exact post-projection component identity owns + // this decision. A mixed legacy/exact pair is an authority migration, + // not evidence that the provider-visible prefix changed. + let system_changed = match ( + prev.provider_final_fingerprint.as_ref(), + curr.provider_final_fingerprint.as_ref(), + ) { + (Some(prev), Some(curr)) => { + prev.cache_key_system_sha256 != curr.cache_key_system_sha256 + } + (None, None) => { + effective_prefix_system_prompt_hash(prev) + != effective_prefix_system_prompt_hash(curr) + } + _ => false, + }; + if system_changed { reasons.push(CacheBreakReason::SystemPromptChanged); } // 2b. Cache-control / stable-boundary change - if prev.cache_control_hash != curr.cache_control_hash { + // Provider-final system/tool component identities already include + // their protocol-native cache markers. Detailed cache-control + // attribution is available only to the legacy typed block projection; + // never scan arbitrary JSON keys to guess marker semantics. + let cache_control_changed = match ( + prev.provider_final_fingerprint.as_ref(), + curr.provider_final_fingerprint.as_ref(), + ) { + (Some(prev), Some(curr)) => { + prev.cache_capability.protocol != curr.cache_capability.protocol + } + (None, None) => prev.cache_control_hash != curr.cache_control_hash, + _ => false, + }; + if cache_control_changed { reasons.push(CacheBreakReason::CacheControlChanged); } // 3. Tool schemas change — diff which tools changed - if prev.tools_hash != curr.tools_hash { - let prev_map: std::collections::HashMap<&str, u64> = prev - .per_tool_hashes - .iter() - .map(|(n, h)| (n.as_str(), *h)) - .collect(); - let curr_map: std::collections::HashMap<&str, u64> = curr - .per_tool_hashes - .iter() - .map(|(n, h)| (n.as_str(), *h)) - .collect(); - - let mut added: Vec = curr_map - .keys() - .filter(|n| !prev_map.contains_key(*n)) - .map(|s| s.to_string()) - .collect(); - let mut removed: Vec = prev_map - .keys() - .filter(|n| !curr_map.contains_key(*n)) - .map(|s| s.to_string()) - .collect(); - let mut changed: Vec = curr_map - .iter() - .filter_map(|(n, h)| match prev_map.get(n) { - Some(prev_h) if prev_h != h => Some(n.to_string()), - _ => None, - }) - .collect(); + let exact_tool_pair = prev + .provider_final_fingerprint + .as_ref() + .zip(curr.provider_final_fingerprint.as_ref()); + let tools_changed = match exact_tool_pair { + Some((prev, curr)) => { + prev.cache_key_tool_schema_sequence_sha256 + != curr.cache_key_tool_schema_sequence_sha256 + } + None if prev.provider_final_fingerprint.is_none() + && curr.provider_final_fingerprint.is_none() => + { + prev.tools_hash != curr.tools_hash + } + None => false, + }; + if tools_changed { + let (mut added, mut removed, mut changed) = if let Some((prev, curr)) = exact_tool_pair + { + let prev_map: std::collections::HashMap<&str, &str> = prev + .cache_key_tool_schema_items + .iter() + .filter_map(|tool| { + tool.name + .as_deref() + .map(|name| (name, tool.sha256.as_str())) + }) + .collect(); + let curr_map: std::collections::HashMap<&str, &str> = curr + .cache_key_tool_schema_items + .iter() + .filter_map(|tool| { + tool.name + .as_deref() + .map(|name| (name, tool.sha256.as_str())) + }) + .collect(); + let added: Vec = curr_map + .keys() + .filter(|name| !prev_map.contains_key(*name)) + .map(|name| (*name).to_string()) + .collect(); + let removed: Vec = prev_map + .keys() + .filter(|name| !curr_map.contains_key(*name)) + .map(|name| (*name).to_string()) + .collect(); + let changed: Vec = curr_map + .iter() + .filter_map(|(name, hash)| match prev_map.get(name) { + Some(previous) if previous != hash => Some((*name).to_string()), + _ => None, + }) + .collect(); + (added, removed, changed) + } else { + let prev_map: std::collections::HashMap<&str, u64> = prev + .per_tool_hashes + .iter() + .map(|(name, hash)| (name.as_str(), *hash)) + .collect(); + let curr_map: std::collections::HashMap<&str, u64> = curr + .per_tool_hashes + .iter() + .map(|(name, hash)| (name.as_str(), *hash)) + .collect(); + let added: Vec = curr_map + .keys() + .filter(|name| !prev_map.contains_key(*name)) + .map(|name| (*name).to_string()) + .collect(); + let removed: Vec = prev_map + .keys() + .filter(|name| !curr_map.contains_key(*name)) + .map(|name| (*name).to_string()) + .collect(); + let changed: Vec = curr_map + .iter() + .filter_map(|(name, hash)| match prev_map.get(name) { + Some(previous) if previous != hash => Some((*name).to_string()), + _ => None, + }) + .collect(); + (added, removed, changed) + }; added.sort(); removed.sort(); changed.sort(); @@ -1486,6 +1792,181 @@ mod tests { s } + fn exact_fingerprint(system: &str, tools: &[(&str, &str)]) -> ProviderFinalPromptFingerprint { + ProviderFinalPromptFingerprint { + message_sequence_sha256: format!("messages-{system}"), + system_sequence_sha256: format!("raw-system-{system}"), + cache_key_system_sha256: format!("cache-system-{system}"), + conversation_sequence_sha256: "conversation".to_string(), + tool_schema_sequence_sha256: tools + .iter() + .map(|(name, hash)| format!("{name}:{hash}")) + .collect::>() + .join("|"), + cache_key_tool_schema_sequence_sha256: tools + .iter() + .map(|(name, hash)| format!("{name}:{hash}")) + .collect::>() + .join("|"), + cache_capability: crate::cache_placement::CacheCapability { + protocol: crate::cache_placement::CacheProtocol::OpenAiAutoPrefix, + volatile_placement: crate::cache_placement::VolatilePlacement::TailSuffix, + volatile_delivery: crate::cache_placement::VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: Some(crate::cache_placement::CacheReuseScope::ConversationTurns), + }, + cache_key_tool_schema_items: tools + .iter() + .map(|(name, hash)| ProviderFinalToolFingerprint { + name: Some((*name).to_string()), + sha256: (*hash).to_string(), + }) + .collect(), + } + } + + #[test] + fn provider_final_receipt_owns_structure_and_names_changed_tools() { + let mut detector = CacheBreakDetector::new(); + let mut first = snap("planned-system-one", &make_tools(&["planned-a"]), "m"); + first.attach_provider_final_fingerprint(exact_fingerprint("stable", &[("bash", "v1")])); + let first_identity = ProviderAttemptCacheIdentity { + request_id: "request-1".to_string(), + attempt: 0, + }; + let (accepted, event) = + detector.record_provider_attempt_for_source("main", &first_identity, first, Some(0)); + assert!(accepted); + assert!(event.is_none()); + + let mut metadata_only = snap("different-planned-system", &make_tools(&["planned-b"]), "m"); + let mut metadata_only_fingerprint = exact_fingerprint("stable", &[("bash", "v1")]); + metadata_only_fingerprint.cache_capability.reuse_scope = + Some(crate::cache_placement::CacheReuseScope::IntraTurnRounds); + metadata_only.attach_provider_final_fingerprint(metadata_only_fingerprint); + let second_identity = ProviderAttemptCacheIdentity { + request_id: "request-2".to_string(), + attempt: 0, + }; + let (_, event) = detector.record_provider_attempt_for_source( + "main", + &second_identity, + metadata_only, + Some(20_000), + ); + assert!( + event.is_none(), + "planned-only drift cannot override an equal provider-final receipt" + ); + + let mut changed = snap("same-planned-system", &make_tools(&["planned-b"]), "m"); + changed.attach_provider_final_fingerprint(exact_fingerprint("stable", &[("bash", "v2")])); + let third_identity = ProviderAttemptCacheIdentity { + request_id: "request-3".to_string(), + attempt: 0, + }; + let (_, event) = + detector.record_provider_attempt_for_source("main", &third_identity, changed, Some(0)); + let event = event.expect("final tool schema change"); + assert!(matches!( + event.reason, + CacheBreakReason::ToolSchemasChanged { + ref changed, + ref added, + ref removed + } if changed == &["bash"] && added.is_empty() && removed.is_empty() + )); + } + + #[test] + fn provider_attempt_without_usage_advances_baseline_without_counting_or_duplication() { + let mut detector = CacheBreakDetector::new(); + let mut first = snap("planned", &[], "m"); + first.attach_provider_final_fingerprint(exact_fingerprint("stable", &[])); + let identity = ProviderAttemptCacheIdentity { + request_id: "request-no-usage".to_string(), + attempt: 2, + }; + let (accepted, event) = + detector.record_provider_attempt_for_source("main", &identity, first, None); + assert!(accepted); + assert!(event.is_none()); + assert_eq!(detector.stats.total_turns, 0); + let mut duplicate = snap("different", &[], "m"); + duplicate.attach_provider_final_fingerprint(exact_fingerprint("changed", &[])); + let (accepted, event) = + detector.record_provider_attempt_for_source("main", &identity, duplicate, Some(0)); + assert!(!accepted); + assert!(event.is_none()); + assert_eq!(detector.stats.total_turns, 0); + assert_eq!( + detector + .snapshot_for_source("main") + .and_then(|snapshot| snapshot.provider_final_fingerprint.as_ref()) + .map(|fingerprint| fingerprint.cache_key_system_sha256.as_str()), + Some("cache-system-stable") + ); + } + + #[test] + fn retry_usage_compares_with_last_usage_baseline_and_trusts_full_hit() { + for (terminal_cache_read, expected_reason, expected_misses, expected_hits) in [ + (0, Some(CacheBreakReason::SystemPromptChanged), 2_u64, 0_u64), + (20_000, None, 1_u64, 1_u64), + ] { + let mut detector = CacheBreakDetector::new(); + + let mut baseline = snap("planned-a", &[], "m"); + baseline.attach_provider_final_fingerprint(exact_fingerprint("a", &[])); + let (_, first_event) = detector.record_provider_attempt_for_source( + "main", + &ProviderAttemptCacheIdentity { + request_id: format!("request-a-{terminal_cache_read}"), + attempt: 0, + }, + baseline, + Some(0), + ); + assert!(first_event.is_none()); + + let mut retry_without_usage = snap("planned-b", &[], "m"); + retry_without_usage.attach_provider_final_fingerprint(exact_fingerprint("b", &[])); + let (_, dispatch_event) = detector.record_provider_attempt_for_source( + "main", + &ProviderAttemptCacheIdentity { + request_id: format!("request-b-{terminal_cache_read}"), + attempt: 0, + }, + retry_without_usage, + None, + ); + assert!(matches!( + dispatch_event.map(|event| event.reason), + Some(CacheBreakReason::SystemPromptChanged) + )); + assert_eq!(detector.stats.total_turns, 1); + + let mut terminal_retry = snap("planned-b", &[], "m"); + terminal_retry.attach_provider_final_fingerprint(exact_fingerprint("b", &[])); + let (_, terminal_event) = detector.record_provider_attempt_for_source( + "main", + &ProviderAttemptCacheIdentity { + request_id: format!("request-b-{terminal_cache_read}"), + attempt: 1, + }, + terminal_retry, + Some(terminal_cache_read), + ); + assert_eq!( + terminal_event.map(|event| event.reason), + expected_reason, + "usage must compare to the prior usage-bearing request, never the unavailable retry" + ); + assert_eq!(detector.stats.total_turns, 2); + assert_eq!(detector.stats.cache_misses, expected_misses); + assert_eq!(detector.stats.cache_hits, expected_hits); + } + } + #[test] fn prompt_snapshot_from_messages_prefers_system_role_and_flattens_structured_content() { let messages = vec![ @@ -1550,6 +2031,99 @@ mod tests { assert_eq!(snapshot.system_prompt_hash, hash_str("Prompt")); } + #[test] + fn auto_prefix_snapshot_excludes_post_history_system_tail_from_leading_identity() { + let messages = |runtime: &str| { + vec![ + json!({"role": "system", "content": "stable"}), + json!({"role": "user", "content": "do the work"}), + json!({"role": "assistant", "content": "working"}), + json!({"role": "system", "content": runtime}), + ] + }; + let first = prompt_snapshot_from_messages( + &messages("completion settlement revision 1"), + &[], + "openai", + "deepseek-v4-flash", + 42, + ) + .expect("first snapshot"); + let second = prompt_snapshot_from_messages( + &messages("completion settlement revision 2"), + &[], + "openai", + "deepseek-v4-flash", + 42, + ) + .expect("second snapshot"); + + assert_ne!(first.system_prompt_hash, second.system_prompt_hash); + assert_eq!( + effective_prefix_system_prompt_hash(&first), + effective_prefix_system_prompt_hash(&second), + "a preserved post-history system tail diverges after the leading cache prefix" + ); + assert_eq!(first.system_blocks[0].scope, "provider_visible"); + assert_eq!(first.system_blocks[1].scope, "None"); + + let mut detector = CacheBreakDetector::default(); + assert!(detector.record_turn(first, None).is_none()); + let event = detector.record_turn(second, None); + assert!( + event + .as_ref() + .is_none_or(|event| event.reason != CacheBreakReason::SystemPromptChanged), + "a post-history system suffix is outside the leading cache identity: {event:?}" + ); + } + + #[test] + fn strict_history_snapshot_keeps_runtime_system_change_in_cache_identity() { + let capability = crate::cache_placement::CacheCapability { + protocol: crate::cache_placement::CacheProtocol::StrictHistoryMatch, + volatile_placement: crate::cache_placement::VolatilePlacement::CurrentUserOnly, + volatile_delivery: crate::cache_placement::VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: None, + }; + let messages = |runtime: &str| { + vec![ + json!({"role": "system", "content": "stable"}), + json!({"role": "user", "content": "do the work"}), + json!({"role": "system", "content": runtime}), + ] + }; + let first = prompt_snapshot_from_messages_with_cache_capability( + &messages("authority 1"), + &[], + "openai", + "gateway-alias", + 42, + Some(capability), + ) + .expect("first snapshot"); + let second = prompt_snapshot_from_messages_with_cache_capability( + &messages("authority 2"), + &[], + "openai", + "gateway-alias", + 42, + Some(capability), + ) + .expect("second snapshot"); + + assert_ne!( + effective_prefix_system_prompt_hash(&first), + effective_prefix_system_prompt_hash(&second) + ); + assert!( + first + .system_blocks + .iter() + .all(|block| block.scope != "None") + ); + } + #[test] fn prompt_snapshot_from_messages_matches_serialized_cache_control_fingerprint() { use crate::section_types::{CacheScope, SectionKind}; diff --git a/crates/astra-turn-core/src/cache_placement.rs b/crates/astra-turn-core/src/cache_placement.rs index 663c48ca0d..174f63449f 100644 --- a/crates/astra-turn-core/src/cache_placement.rs +++ b/crates/astra-turn-core/src/cache_placement.rs @@ -12,26 +12,29 @@ //! mechanism, so volatile bytes in the wrong place poison the whole //! cache entry. //! -//! Different providers have different prefix-cache semantics, and +//! Different deployments have different prefix-cache semantics, and //! getting this wrong is expensive — session 986a553e observed //! MiniMax's tool-loop cache_read collapse from 7680 to 0 across six //! rounds because the Self-Awareness block (carrying the live turn //! counter) lived in a synthetic user-role preamble that re-rendered //! every round. //! -//! This module classifies providers along two orthogonal axes: +//! This module represents deployment capabilities along three orthogonal axes: //! 1. **Protocol** — how the provider signals "end of cacheable //! prefix": explicit marker (Anthropic / Bedrock) vs implicit //! byte-prefix matching (OpenAI / MiniMax / others). //! 2. **Volatile placement policy** — given the protocol, where in //! the request volatile content may safely live without breaking //! cache. +//! 3. **Volatile delivery policy** — whether optional, round-specific +//! context should be sent at all. Required lifecycle authority is +//! never hidden by this policy. //! -//! The runtime calls [`CacheCapability::for_provider_and_model`] once +//! The runtime calls [`CacheCapability::for_provider`] once //! per round and threads the result through the volatile-placement //! pipeline. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; /// How the provider signals "end of cacheable prefix." /// @@ -99,28 +102,21 @@ pub enum VolatilePlacement { /// letting later tool rounds reuse the accumulated conversation prefix /// without rewriting any conversation message. TailSuffix, - /// Strict-history providers (MiniMax): any byte change mid-history - /// destroys the full cache entry. **Volatile content is suppressed - /// on EVERY round** — even round 0. + /// Append required runtime authority as a provenance-tagged `user` frame + /// and retain that frame in conversation order. This is an explicit + /// deployment wire shape for providers which support prefix reuse for + /// appended conversation messages but treat every `system` message as + /// part of one global, cache-keyed system header. /// - /// The round-0-only variant was tried and rejected: prepending - /// volatile to msg[1] on round 0 but not on round 1+ still - /// produces different bytes at msg[1] across rounds (round 0's - /// msg[1] = preamble + user_q; round 1's msg[1] = user_q only), - /// and MiniMax's cache sees that as a total miss. The only way - /// to keep history byte-stable for strict-history providers is - /// to never inject volatile at all on this path. The agent - /// loses Self-Awareness signals in exchange for - /// usable cache — observed collapse was 100% of cache reads for - /// six consecutive tool-loop rounds in session 986a553e. - /// - /// **Empirical confirmation** (2026-05-08): a controlled API probe - /// at `tests/fixtures/minimax_cache_probe.py` compared "advancing - /// preamble" vs "frozen preamble" across 4 rounds of a tool loop. - /// Advancing: cache_read = 576, 0, 0, 0. Frozen: cache_read = 443, - /// 443, 0*, 443. Suppression recovers ~75% of possible cache reads - /// on a 4-round loop. Re-run the probe if you doubt this strategy; - /// see the `StrictHistoryMatch` variant doc for the full table. + /// Optional volatile delivery remains controlled independently by + /// [`VolatileDeliveryPolicy`]. The runtime provenance marker, rather than + /// the physical provider role, keeps this frame out of human user intent. + AppendOnlyUserTail, + /// Put runtime context at the current-user boundary. Providers which + /// reject mid-history system messages later consolidate required runtime + /// authority into the leading system message. Whether optional volatile + /// content is sent is controlled independently by + /// [`VolatileDeliveryPolicy`]. CurrentUserOnly, /// No cache to break. Volatile content goes anywhere convenient — /// we pick "in system" for consistency with marker-based output. @@ -128,7 +124,23 @@ pub enum VolatilePlacement { Free, } -/// How far prompt-cache reuse survives for this provider/model path. +/// Which runtime-owned volatile classes are projected onto the provider wire. +/// +/// Delivery and placement are deliberately separate. A prefix-cached provider +/// may accept a required system tail while still benefiting from suppressing +/// duplicated active-turn/advisory snapshots. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum VolatileDeliveryPolicy { + /// Send both optional and required runtime context. + #[default] + All, + /// Suppress optional/advisory snapshots while retaining every required + /// lifecycle or authority context. A byte-stable focus policy replaces + /// the duplicated active-turn frame. + RequiredOnly, +} + +/// How far prompt-cache reuse survives for this deployment path. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum CacheReuseScope { /// Cache can survive across later user turns when the stable prefix matches. @@ -138,105 +150,119 @@ pub enum CacheReuseScope { } /// The combined classification the runtime consumes. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] pub struct CacheCapability { pub protocol: CacheProtocol, pub volatile_placement: VolatilePlacement, + pub volatile_delivery: VolatileDeliveryPolicy, pub reuse_scope: Option, } +/// Deserialize capabilities at the trace/wire boundary. An omitted delivery +/// axis retains the pre-axis behavior (`All`); placement never implies a +/// different delivery policy. +impl<'de> Deserialize<'de> for CacheCapability { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct WireCapability { + protocol: CacheProtocol, + volatile_placement: VolatilePlacement, + #[serde(default)] + volatile_delivery: Option, + #[serde(default)] + reuse_scope: Option, + } + + let wire = WireCapability::deserialize(deserializer)?; + let volatile_delivery = wire + .volatile_delivery + .unwrap_or(VolatileDeliveryPolicy::All); + if matches!( + wire.volatile_placement, + VolatilePlacement::AppendOnlyUserTail + ) && (!matches!(volatile_delivery, VolatileDeliveryPolicy::RequiredOnly) + || !matches!(wire.protocol, CacheProtocol::OpenAiAutoPrefix)) + { + return Err(serde::de::Error::custom( + "append_only_user_tail requires open_ai_auto_prefix with volatile_delivery=required_only", + )); + } + Ok(Self { + protocol: wire.protocol, + volatile_placement: wire.volatile_placement, + volatile_delivery, + reuse_scope: wire.reuse_scope, + }) + } +} + impl CacheCapability { - /// Resolve the capability for a given (provider, model) pair. + /// Append-only history can extend a provider prefix only when every + /// admitted volatile message is durable. Today only required authority is + /// durable, so optional volatile delivery is an incoherent combination. + #[must_use] + pub fn is_valid(self) -> bool { + !matches!( + self.volatile_placement, + VolatilePlacement::AppendOnlyUserTail + ) || (matches!(self.protocol, CacheProtocol::OpenAiAutoPrefix) + && matches!(self.volatile_delivery, VolatileDeliveryPolicy::RequiredOnly)) + } + + /// Resolve the transport-level default for a provider. /// - /// Provider takes precedence over model so a Claude-named model - /// served through an OpenAI-compatible proxy (e.g., some LiteLLM - /// deployments) gets the prefix semantics, not the marker ones. + /// Concrete deployments that differ from this baseline must declare an + /// explicit capability in model metadata. Model names are intentionally + /// absent: an alias cannot prove cache semantics, accepted role shapes, or + /// reuse scope. #[must_use] - pub fn for_provider_and_model(provider: &str, model: &str) -> Self { + pub fn for_provider(provider: &str) -> Self { let provider = provider.trim().to_ascii_lowercase(); - let model_lower = model.trim().to_ascii_lowercase(); match provider.as_str() { "anthropic" => Self { protocol: CacheProtocol::MarkerExplicit, volatile_placement: VolatilePlacement::MarkerIsolated, + volatile_delivery: VolatileDeliveryPolicy::All, reuse_scope: None, }, - // Bedrock multiplexes Anthropic Claude (cachePoint marker - // semantics) and non-Claude families (Nova, Titan, Cohere) - // that do NOT support Anthropic-style cache_control. Mirror - // the runtime's authoritative classification in - // `runtime::turn::prompt_cache::provider_cache_policy_for` - // and the substring detection used by - // `microcompact::ProviderCacheStrategy::from_provider_hint` - // (`claude` / `anthropic`) — when those checks miss, fall - // back to `None` so the volatile placement pipeline emits - // the simple stable-text system block both classifiers - // agree on. Without this guard, Nova traffic was getting an - // Anthropic-shaped multi-block system message (no - // cache_control to back it up) instead of the prefix-cache- - // friendly text shape `microcompact` expects. - "bedrock" if model_lower.contains("claude") || model_lower.contains("anthropic") => { - Self { - protocol: CacheProtocol::BedrockCachePoint, - volatile_placement: VolatilePlacement::MarkerIsolated, - reuse_scope: None, - } - } + // Bedrock multiplexes incompatible model families. The provider + // name alone cannot prove cachePoint support, so the undeclared + // baseline deliberately emits no cache markers. Claude-on-Bedrock + // deployments declare `BedrockCachePoint` in model metadata. "bedrock" => Self { protocol: CacheProtocol::None, volatile_placement: VolatilePlacement::Free, + volatile_delivery: VolatileDeliveryPolicy::All, reuse_scope: None, }, - // Vendor-specific: MiniMax is a known strict-history provider - // (see session 986a553e regression). Detect via model-id - // substring so e.g. `MiniMax-M2.7` or future `MiniMax-M3` - // variants served under provider=openai still get the right - // placement. - // - // DeepSeek v4 on the OpenAI-compatible MOI gateway showed the - // same operational symptom under harness/live sessions - // (`cache_provider_matrix_regression`, session - // eeea6ec6-cb33-46b5-9932-b2d34a081b0a): once the volatile tail - // expanded to the "long" reminder shape, the next round's - // `cached_input_tokens` collapsed from ~10k to 0 even though the - // stable prefix before the tail was unchanged. Treating those - // models as `TailSuffix` reintroduces avoidable cache misses; the - // safer contract is the same total volatile suppression we use for - // other strict-history providers. - "openai" - if model_lower.contains("minimax") - || model_lower.contains("deepseek-v4-flash") - || model_lower.contains("deepseek-v4-pro") => - { - Self { - protocol: CacheProtocol::StrictHistoryMatch, - volatile_placement: VolatilePlacement::CurrentUserOnly, - reuse_scope: None, - } - } + // OpenAI-compatible transport defaults to prefix reuse and the + // role shape accepted by that transport. A strict-history gateway + // or an operator-selected required-only policy is an explicit + // deployment capability, never inferred from the model id. "openai" => Self { protocol: CacheProtocol::OpenAiAutoPrefix, volatile_placement: VolatilePlacement::TailSuffix, + volatile_delivery: VolatileDeliveryPolicy::All, reuse_scope: None, }, // Unknown providers: conservative — no cache assumed. _ => Self { protocol: CacheProtocol::None, volatile_placement: VolatilePlacement::Free, + volatile_delivery: VolatileDeliveryPolicy::All, reuse_scope: None, }, } } - /// Resolve capability from explicit model metadata when available, - /// otherwise fall back to provider/model heuristics. + /// Resolve an explicit deployment capability or the provider transport + /// baseline. This never guesses behavior from a model name. #[must_use] - pub fn from_explicit_or_provider_model( - explicit: Option, - provider: &str, - model: &str, - ) -> Self { - explicit.unwrap_or_else(|| Self::for_provider_and_model(provider, model)) + pub fn from_explicit_or_provider(explicit: Option, provider: &str) -> Self { + explicit.unwrap_or_else(|| Self::for_provider(provider)) } #[must_use] @@ -247,18 +273,11 @@ impl CacheCapability { /// Shortcut used by call sites that only care whether volatile /// content should be injected on the current LLM round. /// - /// `MarkerIsolated` / `TailSuffix` / `Free`: always true. - /// `CurrentUserOnly`: always **false** — see the variant's doc - /// for why round-0-only didn't work and we had to suppress - /// volatile entirely for strict-history providers. + /// Required authority contexts are handled separately by the wire + /// assembler and are never suppressed by this decision. #[must_use] pub fn should_inject_volatile_on_round(&self, _round_within_turn: u32) -> bool { - match self.volatile_placement { - VolatilePlacement::CurrentUserOnly => false, - VolatilePlacement::MarkerIsolated - | VolatilePlacement::TailSuffix - | VolatilePlacement::Free => true, - } + matches!(self.volatile_delivery, VolatileDeliveryPolicy::All) } } @@ -268,69 +287,41 @@ mod tests { #[test] fn anthropic_provider_gets_marker_isolated() { - let c = CacheCapability::for_provider_and_model("anthropic", "claude-sonnet-4"); + let c = CacheCapability::for_provider("anthropic"); assert_eq!(c.protocol, CacheProtocol::MarkerExplicit); assert_eq!(c.volatile_placement, VolatilePlacement::MarkerIsolated); } #[test] fn anthropic_provider_is_case_insensitive() { - let c = CacheCapability::for_provider_and_model("Anthropic", "claude-sonnet-4"); + let c = CacheCapability::for_provider("Anthropic"); assert_eq!(c.volatile_placement, VolatilePlacement::MarkerIsolated); } #[test] - fn bedrock_provider_gets_bedrock_cachepoint() { - let c = - CacheCapability::for_provider_and_model("bedrock", "us.anthropic.claude-sonnet-4-6"); - assert_eq!(c.protocol, CacheProtocol::BedrockCachePoint); - assert_eq!(c.volatile_placement, VolatilePlacement::MarkerIsolated); + fn undeclared_bedrock_is_conservative_regardless_of_model_alias() { + let baseline = CacheCapability::for_provider("bedrock"); + assert_eq!(baseline.protocol, CacheProtocol::None); + assert_eq!(baseline.volatile_placement, VolatilePlacement::Free); } #[test] - fn bedrock_non_claude_models_skip_marker_protocol() { - // Non-Claude Bedrock models (Nova, Titan, Cohere) do NOT support - // Anthropic-style cache_control markers — see - // `bridge_provider_policy_keeps_non_claude_bedrock_prefix_only` - // in `runtime::turn::prompt_cache::tests`. Routing them through - // `BedrockCachePoint` here disagrees with - // `microcompact::ProviderCacheStrategy::from_provider_and_model`, - // which correctly falls back to `Prefix`. The two classifiers - // are consumed by the same volatile placement / system layout - // logic, so the disagreement leaks Anthropic-shaped multi-block - // system content into Nova traffic. Conservative: treat - // non-Claude Bedrock as Free placement so the runtime emits the - // simpler stable-text system block both classifiers agree on. - let nova = CacheCapability::for_provider_and_model("bedrock", "us.amazon.nova-micro-v1:0"); - assert_eq!(nova.protocol, CacheProtocol::None); - assert_eq!(nova.volatile_placement, VolatilePlacement::Free); - - let titan = CacheCapability::for_provider_and_model("bedrock", "amazon.titan-text-v1"); - assert_eq!(titan.protocol, CacheProtocol::None); - assert_eq!(titan.volatile_placement, VolatilePlacement::Free); - - let cohere = CacheCapability::for_provider_and_model("bedrock", "cohere.command-r-plus"); - assert_eq!(cohere.protocol, CacheProtocol::None); - assert_eq!(cohere.volatile_placement, VolatilePlacement::Free); - } - - #[test] - fn explicit_capability_overrides_provider_model_fallback() { + fn explicit_capability_overrides_provider_baseline() { let explicit = CacheCapability { protocol: CacheProtocol::StrictHistoryMatch, volatile_placement: VolatilePlacement::CurrentUserOnly, + volatile_delivery: VolatileDeliveryPolicy::RequiredOnly, reuse_scope: Some(CacheReuseScope::ConversationTurns), }; - let c = - CacheCapability::from_explicit_or_provider_model(Some(explicit), "openai", "gpt-4o"); + let c = CacheCapability::from_explicit_or_provider(Some(explicit), "openai"); assert_eq!(c, explicit); } #[test] fn missing_explicit_capability_preserves_openai_default() { - let c = CacheCapability::from_explicit_or_provider_model(None, "openai", "gpt-4o"); + let c = CacheCapability::from_explicit_or_provider(None, "openai"); assert_eq!(c.protocol, CacheProtocol::OpenAiAutoPrefix); assert_eq!(c.volatile_placement, VolatilePlacement::TailSuffix); @@ -338,53 +329,80 @@ mod tests { #[test] fn openai_provider_gets_tail_suffix() { - let c = CacheCapability::for_provider_and_model("openai", "gpt-4o"); + let c = CacheCapability::for_provider("openai"); assert_eq!(c.protocol, CacheProtocol::OpenAiAutoPrefix); assert_eq!(c.volatile_placement, VolatilePlacement::TailSuffix); } #[test] - fn minimax_model_overrides_openai_provider_to_strict_history() { - // MiniMax is served under provider=openai in astra's registry. - // The model-id substring disambiguates. - let c = CacheCapability::for_provider_and_model("openai", "MiniMax-M2.7"); - assert_eq!(c.protocol, CacheProtocol::StrictHistoryMatch); - assert_eq!(c.volatile_placement, VolatilePlacement::CurrentUserOnly); + fn openai_transport_baseline_is_prefix_tail_with_full_delivery() { + let baseline = CacheCapability::for_provider("openai"); + assert_eq!(baseline.protocol, CacheProtocol::OpenAiAutoPrefix); + assert_eq!(baseline.volatile_placement, VolatilePlacement::TailSuffix); + assert_eq!(baseline.volatile_delivery, VolatileDeliveryPolicy::All); } #[test] - fn minimax_detected_case_insensitively() { - let c = CacheCapability::for_provider_and_model("openai", "minimax-m3-preview"); - assert_eq!(c.volatile_placement, VolatilePlacement::CurrentUserOnly); + fn omitted_delivery_retains_pre_axis_all_without_placement_inference() { + let capability: CacheCapability = serde_json::from_value(serde_json::json!({ + "protocol": "StrictHistoryMatch", + "volatile_placement": "CurrentUserOnly", + "reuse_scope": "ConversationTurns", + })) + .unwrap(); + + assert_eq!(capability.volatile_delivery, VolatileDeliveryPolicy::All); } #[test] - fn deepseek_v4_flash_openai_routes_to_current_user_only() { - let c = CacheCapability::for_provider_and_model("openai", "deepseek-v4-flash"); - assert_eq!(c.protocol, CacheProtocol::StrictHistoryMatch); - assert_eq!(c.volatile_placement, VolatilePlacement::CurrentUserOnly); + fn legacy_non_strict_placement_deserializes_to_full_delivery_at_boundary() { + let capability: CacheCapability = serde_json::from_value(serde_json::json!({ + "protocol": "OpenAiAutoPrefix", + "volatile_placement": "TailSuffix", + })) + .unwrap(); + + assert_eq!(capability.volatile_delivery, VolatileDeliveryPolicy::All); } #[test] - fn deepseek_v4_pro_openai_routes_to_current_user_only() { - let c = CacheCapability::for_provider_and_model("openai", "DEEPSEEK-V4-PRO"); - assert_eq!(c.protocol, CacheProtocol::StrictHistoryMatch); - assert_eq!(c.volatile_placement, VolatilePlacement::CurrentUserOnly); + fn explicit_delivery_is_preserved() { + let capability: CacheCapability = serde_json::from_value(serde_json::json!({ + "protocol": "StrictHistoryMatch", + "volatile_placement": "CurrentUserOnly", + "volatile_delivery": "All", + })) + .unwrap(); + + assert_eq!(capability.volatile_delivery, VolatileDeliveryPolicy::All); } #[test] - fn deepseek_v4_registry_suffix_routes_to_current_user_only() { - let c = CacheCapability::for_provider_and_model( - "openai", - "deepseek-v4-pro-official(thinking:high)", - ); - assert_eq!(c.protocol, CacheProtocol::StrictHistoryMatch); - assert_eq!(c.volatile_placement, VolatilePlacement::CurrentUserOnly); + fn append_only_requires_prefix_protocol_and_required_only_delivery() { + for value in [ + serde_json::json!({ + "protocol": "OpenAiAutoPrefix", + "volatile_placement": "AppendOnlyUserTail", + }), + serde_json::json!({ + "protocol": "OpenAiAutoPrefix", + "volatile_placement": "AppendOnlyUserTail", + "volatile_delivery": "All", + }), + serde_json::json!({ + "protocol": "MarkerExplicit", + "volatile_placement": "AppendOnlyUserTail", + "volatile_delivery": "RequiredOnly", + }), + ] { + let error = serde_json::from_value::(value).unwrap_err(); + assert!(error.to_string().contains("append_only_user_tail")); + } } #[test] fn unknown_provider_defaults_to_none_and_free() { - let c = CacheCapability::for_provider_and_model("some-new-vendor", "model-xyz"); + let c = CacheCapability::for_provider("some-new-vendor"); assert_eq!(c.protocol, CacheProtocol::None); assert_eq!(c.volatile_placement, VolatilePlacement::Free); } @@ -392,21 +410,17 @@ mod tests { // ── should_inject_volatile_on_round ───────────────────────────────── #[test] - fn current_user_only_never_injects_on_any_round() { - // Strict-history providers: injecting on round 0 but not after - // still makes msg[1] bytes differ across rounds (round 0's - // msg[1] includes the preamble, round 1+ doesn't). MiniMax - // sees that as a total cache miss. So CurrentUserOnly - // suppresses volatile entirely. - let minimax = CacheCapability { + fn required_only_never_injects_optional_volatile_on_any_round() { + let strict = CacheCapability { protocol: CacheProtocol::StrictHistoryMatch, volatile_placement: VolatilePlacement::CurrentUserOnly, + volatile_delivery: VolatileDeliveryPolicy::RequiredOnly, reuse_scope: None, }; for round in 0..=10 { assert!( - !minimax.should_inject_volatile_on_round(round), - "CurrentUserOnly must skip round {round}", + !strict.should_inject_volatile_on_round(round), + "RequiredOnly must skip optional volatile on round {round}", ); } } @@ -416,6 +430,7 @@ mod tests { let anthropic = CacheCapability { protocol: CacheProtocol::MarkerExplicit, volatile_placement: VolatilePlacement::MarkerIsolated, + volatile_delivery: VolatileDeliveryPolicy::All, reuse_scope: None, }; // Marker providers are safe every round — the marker isolates @@ -430,6 +445,7 @@ mod tests { let openai = CacheCapability { protocol: CacheProtocol::OpenAiAutoPrefix, volatile_placement: VolatilePlacement::TailSuffix, + volatile_delivery: VolatileDeliveryPolicy::All, reuse_scope: None, }; // Tail-suffix providers can safely re-append volatile every @@ -445,6 +461,7 @@ mod tests { let capability = CacheCapability { protocol: CacheProtocol::OpenAiAutoPrefix, volatile_placement: VolatilePlacement::TailSuffix, + volatile_delivery: VolatileDeliveryPolicy::All, reuse_scope: Some(CacheReuseScope::IntraTurnRounds), }; assert!(capability.prefers_intra_turn_batching()); @@ -457,20 +474,4 @@ mod tests { assert!(unknown.should_inject_volatile_on_round(0)); assert!(unknown.should_inject_volatile_on_round(5)); } - - // ── Regression fingerprints from real sessions ────────────────────── - - #[test] - fn minimax_m27_session_986a553e_routes_to_current_user_only() { - // Pin the exact model id observed in the regression session so a - // future provider/model normalization change doesn't silently - // route MiniMax back to TailSuffix and reopen the cache hole. - // With CurrentUserOnly's total-suppression contract every round - // — including round 0 — must be silent. - let c = CacheCapability::for_provider_and_model("openai", "MiniMax-M2.7"); - assert_eq!(c.volatile_placement, VolatilePlacement::CurrentUserOnly); - assert!(!c.should_inject_volatile_on_round(0)); - assert!(!c.should_inject_volatile_on_round(1)); - assert!(!c.should_inject_volatile_on_round(6)); - } } diff --git a/crates/astra-turn-core/src/cloud/compact_prompt.rs b/crates/astra-turn-core/src/cloud/compact_prompt.rs index 459759bd8f..38be1f7ed5 100644 --- a/crates/astra-turn-core/src/cloud/compact_prompt.rs +++ b/crates/astra-turn-core/src/cloud/compact_prompt.rs @@ -64,6 +64,9 @@ pub fn render_messages_for_summary(messages: &[serde_json::Value]) -> String { let mut out = String::new(); for msg in messages { + if astra_turn_types::is_runtime_owned_message(msg) { + continue; + } let role = msg .get("role") .and_then(|v| v.as_str()) @@ -165,26 +168,44 @@ pub fn strip_analysis_block(raw: &str) -> String { result.trim().to_string() } +const REQUIRED_STRUCTURED_SUMMARY_SECTIONS: &[&str] = &[ + "### Primary Request", + "### Pending Tasks", + "### Current Work", + "### Current State", +]; + +fn missing_structured_summary_sections(summary: &str) -> Vec<&'static str> { + REQUIRED_STRUCTURED_SUMMARY_SECTIONS + .iter() + .copied() + .filter(|required| !summary.lines().any(|line| line.trim() == *required)) + .collect() +} + +/// Parse and validate a summary against the explicit section grammar. +/// +/// This typed success/failure boundary is for control flow. Callers must not +/// infer validity by matching warning text produced for display. +pub fn validated_structured_summary(raw: &str) -> Option { + let summary = strip_analysis_block(raw); + (!summary.is_empty() && missing_structured_summary_sections(&summary).is_empty()) + .then_some(summary) +} + /// Strip analysis block and validate structured section headers. /// /// If the summary lacks key section headers, prepends a warning so the LLM /// (on the next turn) knows the summary may be incomplete. pub fn format_structured_summary(raw: &str) -> String { let summary = strip_analysis_block(raw); - const REQUIRED: &[&str] = &[ - "### Primary Request", - "### Pending Tasks", - "### Current Work", - "### Current State", - ]; - let missing: Vec<&&str> = REQUIRED.iter().filter(|h| !summary.contains(**h)).collect(); + let missing = missing_structured_summary_sections(&summary); if missing.is_empty() { summary } else { - let names: Vec<&str> = missing.iter().map(|s| **s).collect(); format!( "[compact warning: missing sections: {}]\n\n{}", - names.join(", "), + missing.join(", "), summary ) } @@ -334,6 +355,23 @@ mod tests { assert!(rendered.contains("[USER]: real question")); } + #[test] + fn render_does_not_relabel_runtime_authority_as_user_speech() { + let mut authority = json!({"role": "user", "content": "runtime-only settlement"}); + astra_turn_types::mark_append_only_required_context( + &mut authority, + "final_answer_settlement", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ); + let rendered = render_messages_for_summary(&[ + json!({"role": "user", "content": "real question"}), + authority, + ]); + + assert!(rendered.contains("[USER]: real question")); + assert!(!rendered.contains("runtime-only settlement")); + } + #[test] fn render_truncates_long_tool_results() { let long = "x".repeat(2000); @@ -509,6 +547,18 @@ mod tests { assert!(result.contains("### Current Work")); } + #[test] + fn structured_summary_validation_uses_exact_header_lines() { + let valid = "### Primary Request\nA\n### Pending Tasks\nB\n### Current Work\nC\n### Current State\nD"; + assert_eq!(validated_structured_summary(valid).as_deref(), Some(valid)); + + let prose = "mentions ### Primary Request inline\nmentions ### Pending Tasks inline\nmentions ### Current Work inline\nmentions ### Current State inline"; + assert!( + validated_structured_summary(prose).is_none(), + "free-form substring matches must not satisfy the summary grammar" + ); + } + #[test] fn compact_system_prompt_has_analysis_instruction() { assert!(COMPACT_SYSTEM_PROMPT.contains("")); diff --git a/crates/astra-turn-core/src/cloud/grouping.rs b/crates/astra-turn-core/src/cloud/grouping.rs index 64e4d9fdc9..b0a0ebd1be 100644 --- a/crates/astra-turn-core/src/cloud/grouping.rs +++ b/crates/astra-turn-core/src/cloud/grouping.rs @@ -1,7 +1,7 @@ //! Message grouping by API round. //! -//! Groups conversation messages into logical "rounds" — one group per LLM -//! response (identified by the assistant message that ends each round). +//! Groups conversation messages into logical human turns while retaining the +//! exact assistant/tool response order inside each turn. //! This enables PTL (prompt-too-long) retry to drop complete rounds //! atomically, preserving tool_use/tool_result pairing integrity. @@ -30,23 +30,52 @@ fn include_grouped_clone_measurement(measurement: &mut Option<(u64, u64)>, value /// response, and any tool messages that followed. #[derive(Debug, Clone)] pub struct ApiRound { - /// User messages that started this round (may include system injections). - pub user_messages: Vec, - /// The assistant response message. - pub assistant_message: Option, - /// Tool result messages following the assistant response. - pub tool_messages: Vec, + /// Single owned copy of the exact conversation order for this human turn. + /// Runtime-owned user-role controls remain here for provider-prefix reuse, + /// but do not start a round and are excluded by `summary_messages`. + ordered_messages: Vec, } impl ApiRound { - /// Flattened ordered messages for this round. - pub fn messages(&self) -> Vec { - let mut out = self.user_messages.clone(); - if let Some(asst) = &self.assistant_message { - out.push(asst.clone()); + fn empty() -> Self { + Self { + ordered_messages: Vec::new(), } - out.extend(self.tool_messages.iter().cloned()); - out + } + + /// Exact ordered provider-history projection for this round. + pub fn messages(&self) -> &[Value] { + &self.ordered_messages + } + + /// Human-authored user messages only. + pub fn user_messages(&self) -> impl Iterator { + self.ordered_messages + .iter() + .filter(|message| astra_turn_types::is_human_user_message(message)) + } + + /// Most recent assistant response in this round. + pub fn assistant_message(&self) -> Option<&Value> { + self.ordered_messages + .iter() + .rev() + .find(|message| message.get("role").and_then(Value::as_str) == Some("assistant")) + } + + /// Tool result messages in exact order. + pub fn tool_messages(&self) -> impl Iterator { + self.ordered_messages + .iter() + .filter(|message| message.get("role").and_then(Value::as_str) == Some("tool")) + } + + /// Semantic summary/learning projection. Runtime-owned frames never + /// become user intent merely because their provider wire role is `user`. + pub fn summary_messages(&self) -> impl Iterator { + self.ordered_messages + .iter() + .filter(|message| !astra_turn_types::is_runtime_owned_message(message)) } /// Total character count for this round (for budget estimation). @@ -65,9 +94,11 @@ impl ApiRound { /// Group a flat message list into API rounds. /// -/// Each round starts with user message(s) and ends when the next user -/// message (or end of list) is encountered. System messages are collected -/// as a leading preamble and returned separately. +/// Each round starts with a human-authored user message and ends when the next +/// human-authored user message (or end of list) is encountered. A +/// runtime-owned `role=user` frame is a provider transport shape, not a new +/// turn, and is excluded from the summary projection. System messages are +/// collected as a leading preamble and returned separately. /// /// Returns `(system_messages, rounds)`. pub fn group_by_api_round(messages: &[Value]) -> (Vec, Vec) { @@ -91,43 +122,45 @@ pub fn group_by_api_round(messages: &[Value]) -> (Vec, Vec) { // system messages mid-conversation are treated as user-side injections } "user" => { + if !astra_turn_types::is_human_user_message(msg) { + let cloned = msg.clone(); + include_grouped_clone_measurement(&mut clone_measurement, &cloned); + current_round + .get_or_insert_with(ApiRound::empty) + .ordered_messages + .push(cloned); + continue; + } // A new user message starts a new round (flush the current one) if let Some(round) = current_round.take() { rounds.push(round); } - current_round - .get_or_insert_with(|| ApiRound { - user_messages: Vec::new(), - assistant_message: None, - tool_messages: Vec::new(), - }) - .user_messages - .push({ - let cloned = msg.clone(); - include_grouped_clone_measurement(&mut clone_measurement, &cloned); - cloned - }); + current_round.get_or_insert_with(ApiRound::empty); + let cloned = msg.clone(); + include_grouped_clone_measurement(&mut clone_measurement, &cloned); + let round = current_round + .as_mut() + .expect("human user initialized the current round"); + round.ordered_messages.push(cloned); } "assistant" => { let sanitized = crate::chat_history_openai::sanitize_empty_assistant_tool_calls_cloned(msg); include_grouped_clone_measurement(&mut clone_measurement, &sanitized); if let Some(round) = current_round.as_mut() { - round.assistant_message = Some(sanitized); + round.ordered_messages.push(sanitized); } else { // assistant without a preceding user (shouldn't happen, but handle it) - current_round = Some(ApiRound { - user_messages: Vec::new(), - assistant_message: Some(sanitized), - tool_messages: Vec::new(), - }); + let mut round = ApiRound::empty(); + round.ordered_messages.push(sanitized); + current_round = Some(round); } } "tool" => { if let Some(round) = current_round.as_mut() { let cloned = msg.clone(); include_grouped_clone_measurement(&mut clone_measurement, &cloned); - round.tool_messages.push(cloned); + round.ordered_messages.push(cloned); } // tool without a round context is ignored } @@ -156,7 +189,7 @@ pub fn group_by_api_round(messages: &[Value]) -> (Vec, Vec) { pub fn flatten_rounds(system_messages: &[Value], rounds: &[ApiRound]) -> Vec { let mut out = system_messages.to_vec(); for round in rounds { - out.extend(round.messages()); + out.extend(round.messages().iter().cloned()); } out } @@ -210,9 +243,9 @@ mod tests { let (_, rounds) = group_by_api_round(&msgs); assert_eq!(rounds.len(), 1); let r = &rounds[0]; - assert_eq!(r.user_messages.len(), 1); - assert!(r.assistant_message.is_some()); - assert_eq!(r.tool_messages.len(), 1); + assert_eq!(r.user_messages().count(), 1); + assert!(r.assistant_message().is_some()); + assert_eq!(r.tool_messages().count(), 1); } #[test] @@ -226,8 +259,36 @@ mod tests { ]; let (_, rounds) = group_by_api_round(&msgs); assert_eq!(rounds.len(), 2); - assert_eq!(rounds[0].tool_messages.len(), 1); - assert!(rounds[1].tool_messages.is_empty()); + assert_eq!(rounds[0].tool_messages().count(), 1); + assert_eq!(rounds[1].tool_messages().count(), 0); + } + + #[test] + fn runtime_user_frame_does_not_start_a_human_round_and_keeps_wire_order() { + let mut authority = user("runtime settlement"); + astra_turn_types::mark_append_only_required_context( + &mut authority, + "final_answer_settlement", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ); + let msgs = vec![ + user("real goal"), + assistant("tool decision"), + tool("result"), + authority, + assistant("final response"), + ]; + + let (_, rounds) = group_by_api_round(&msgs); + assert_eq!(rounds.len(), 1); + assert_eq!(rounds[0].user_messages().count(), 1); + let flattened = rounds[0].messages(); + assert_eq!(flattened.len(), 5); + assert_eq!(flattened[0]["content"], "real goal"); + assert_eq!(flattened[1]["content"], "tool decision"); + assert_eq!(flattened[2]["content"], "result"); + assert!(astra_turn_types::is_runtime_owned_message(&flattened[3])); + assert_eq!(flattened[4]["content"], "final response"); } #[test] @@ -258,7 +319,7 @@ mod tests { json!({"role": "assistant", "content": "a", "tool_calls": []}), ]; let (_, rounds) = group_by_api_round(&msgs); - let assistant = rounds[0].assistant_message.as_ref().unwrap(); + let assistant = rounds[0].assistant_message().unwrap(); assert!(assistant.get("tool_calls").is_none(), "{assistant:?}"); } @@ -289,11 +350,11 @@ mod tests { assert_eq!(system_messages[0]["content"], "系统🙂"); assert_eq!( - rounds[0].user_messages[0]["metadata"]["nested"][1]["answer"], + rounds[0].user_messages().next().unwrap()["metadata"]["nested"][1]["answer"], "乙" ); assert_eq!( - rounds[0].assistant_message.as_ref().unwrap()["tool_calls"][0]["function"]["name"], + rounds[0].assistant_message().unwrap()["tool_calls"][0]["function"]["name"], "lookup" ); } @@ -329,7 +390,12 @@ mod tests { let (_, rounds) = group_by_api_round(&msgs); let kept = drop_oldest_rounds(&rounds, 1, 1); assert_eq!(kept.len(), 2); - assert_eq!(kept[0].user_messages[0]["content"].as_str().unwrap(), "q2"); + assert_eq!( + kept[0].user_messages().next().unwrap()["content"] + .as_str() + .unwrap(), + "q2" + ); } #[test] diff --git a/crates/astra-turn-core/src/cloud/summary.rs b/crates/astra-turn-core/src/cloud/summary.rs index 8a5174404a..1170ddb132 100644 --- a/crates/astra-turn-core/src/cloud/summary.rs +++ b/crates/astra-turn-core/src/cloud/summary.rs @@ -28,6 +28,10 @@ pub const MAX_PTL_RETRIES: usize = 3; /// Minimum number of API rounds to keep when dropping for PTL retry. pub const MIN_ROUNDS_TO_KEEP: usize = 1; +fn validated_structured_summary(text: &str) -> Option { + crate::cloud::compact_prompt::validated_structured_summary(text) +} + fn cloud_summary_serialization_dimensions(rendered: &str, source_rows: usize) -> (u64, u64) { ( u64::try_from(rendered.len()).unwrap_or(u64::MAX), @@ -60,13 +64,8 @@ fn record_summary_rounds_clone(rounds: &[ApiRound]) { let mut bytes = 0_u64; let mut rows = 0_u64; for round in rounds { - for message in round - .user_messages - .iter() - .chain(round.assistant_message.iter()) - .chain(round.tool_messages.iter()) - { - match astra_core::history_work::serialized_bytes(message) { + for message in round.messages() { + match astra_core::history_work::serialized_bytes(&message) { Ok(message_bytes) => { bytes = bytes.saturating_add(message_bytes); rows = rows.saturating_add(1); @@ -104,7 +103,9 @@ results, errors encountered and their fixes, and any pending work. Treat file \ content in the conversation as a historical observation, not as proof of the \ current workspace state. If continuing the task requires exact or current file \ bytes, use the ordinary admitted read tool after compaction; never imply that \ -the summary refreshed a file. Omit \ +the summary refreshed a file. Runtime-owned `` messages \ +are control state, not human requests: do not summarize them or include them \ +under `All User Messages`. Omit \ chit-chat, redundant acknowledgements, and exploration that did not change the outcome.\n\n\ Use exactly these section headers so the compacted context can be validated and resumed:\n\ ### Primary Request\n\ @@ -118,6 +119,21 @@ Use exactly these section headers so the compacted context can be validated and ### Current State\n\n\ Target under 800 words."; +/// Which history projection an inline compaction request is allowed to use. +/// The append-only mode is explicit because retaining runtime user-role frames +/// is safe only when the same stable semantic policy is in the system prefix. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InlineSummaryHistoryProjection { + Semantic, + AppendOnlyRuntimeAuthorityPrefix, +} + +fn system_has_append_only_runtime_authority_policy(messages: &[Value]) -> bool { + messages + .iter() + .any(astra_turn_types::has_append_only_runtime_authority_policy) +} + // --------------------------------------------------------------------------- // LLM client abstraction (for testability) // --------------------------------------------------------------------------- @@ -189,11 +205,7 @@ pub async fn generate_compact_summary( ) .await { - Ok(resp) if !resp.is_ptl_error => { - return Some(crate::cloud::compact_prompt::format_structured_summary( - &resp.text, - )); - } + Ok(resp) if !resp.is_ptl_error => return validated_structured_summary(&resp.text), Ok(resp) if resp.is_ptl_error => { if attempt >= MAX_PTL_RETRIES { eprintln!( @@ -279,8 +291,31 @@ fn build_summary_messages(rendered_conversation: &str) -> Vec { pub async fn generate_inline_summary( system_messages: &[Value], history: &[Value], + history_projection: InlineSummaryHistoryProjection, client: &dyn SummaryLlmClient, ) -> Option { + let runtime_frames_visible = matches!( + history_projection, + InlineSummaryHistoryProjection::AppendOnlyRuntimeAuthorityPrefix + ); + if runtime_frames_visible { + let runtime_messages = history + .iter() + .filter(|message| astra_turn_types::is_runtime_owned_message(message)) + .collect::>(); + if (!runtime_messages.is_empty() + && !system_has_append_only_runtime_authority_policy(system_messages)) + || runtime_messages.iter().any(|message| { + astra_turn_types::runtime_message_delivery(message) + != Some(astra_turn_types::RuntimeMessageDelivery::AppendOnlyRequiredContext) + }) + { + eprintln!( + "[inline_summary] refusing runtime-owned history without the append-only semantic contract" + ); + return None; + } + } let mut rounds = group_by_api_round(history).1; let min_keep = MIN_ROUNDS_TO_KEEP; @@ -290,8 +325,10 @@ pub async fn generate_inline_summary( Vec::with_capacity(system_messages.len() + history.len() + 1); messages.extend(system_messages.iter().cloned()); for round in &rounds { - for msg in round.messages() { - messages.push(msg); + if runtime_frames_visible { + messages.extend(round.messages().iter().cloned()); + } else { + messages.extend(round.summary_messages().cloned()); } } messages.push(json!({ @@ -307,11 +344,7 @@ pub async fn generate_inline_summary( ) .await { - Ok(resp) if !resp.is_ptl_error => { - return Some(crate::cloud::compact_prompt::format_structured_summary( - &resp.text, - )); - } + Ok(resp) if !resp.is_ptl_error => return validated_structured_summary(&resp.text), Ok(resp) if resp.is_ptl_error => { if attempt >= MAX_PTL_RETRIES { eprintln!( @@ -472,6 +505,10 @@ mod tests { .collect() } + fn valid_summary() -> &'static str { + "### Primary Request\nDo the work\n### Pending Tasks\nNone\n### Current Work\nDone\n### Current State\nVerified" + } + #[tokio::test] async fn success_on_first_attempt() { let body = "### Primary Request\nDoing stuff\n### Pending Tasks\nNone\n### Current Work\nIn progress\n### Current State\nDone"; @@ -584,13 +621,18 @@ mod tests { json!({"role": "system", "content": "runtime contract"}), ]; let history = make_messages(2); - let client = MockSummaryClient::success("current state"); - - let summary = generate_inline_summary(&system_messages, &history, &client) - .await - .expect("inline summary should succeed"); - - assert!(summary.contains("current state")); + let client = MockSummaryClient::success(valid_summary()); + + let summary = generate_inline_summary( + &system_messages, + &history, + InlineSummaryHistoryProjection::Semantic, + &client, + ) + .await + .expect("inline summary should succeed"); + + assert!(summary.contains("### Current State")); let requests = client.recorded_requests(); assert_eq!(requests.len(), 1); let request = &requests[0]; @@ -615,6 +657,85 @@ mod tests { ); } + #[tokio::test] + async fn inline_summary_semantic_projection_excludes_runtime_user_frames() { + let system_messages = vec![json!({"role": "system", "content": "stable"})]; + let mut authority = json!({"role": "user", "content": "runtime settlement"}); + astra_turn_types::mark_append_only_required_context( + &mut authority, + "final_answer_settlement", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ); + let history = vec![ + json!({"role": "user", "content": "real request"}), + authority, + json!({"role": "assistant", "content": "working"}), + ]; + let client = MockSummaryClient::success(valid_summary()); + + generate_inline_summary( + &system_messages, + &history, + InlineSummaryHistoryProjection::Semantic, + &client, + ) + .await + .expect("semantic summary"); + + let request = client.recorded_requests().pop().unwrap(); + assert!(request.iter().all(|message| { + !astra_turn_types::is_runtime_owned_message(message) + && message.get("content").and_then(Value::as_str) != Some("runtime settlement") + })); + } + + #[tokio::test] + async fn inline_summary_append_prefix_requires_policy_and_preserves_exact_history() { + let mut authority = json!({"role": "user", "content": "runtime settlement"}); + astra_turn_types::mark_append_only_required_context( + &mut authority, + "final_answer_settlement", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ); + let history = vec![ + json!({"role": "user", "content": "real request"}), + authority, + ]; + let missing_policy_client = MockSummaryClient::success(valid_summary()); + assert!( + generate_inline_summary( + &[json!({"role": "system", "content": "stable"})], + &history, + InlineSummaryHistoryProjection::AppendOnlyRuntimeAuthorityPrefix, + &missing_policy_client, + ) + .await + .is_none() + ); + assert_eq!(missing_policy_client.call_count.load(Ordering::SeqCst), 0); + + let mut system_message = json!({ + "role": "system", + "content": format!("stable\n{}", astra_turn_types::APPEND_ONLY_RUNTIME_AUTHORITY_POLICY), + }); + astra_turn_types::mark_append_only_runtime_authority_policy(&mut system_message); + let system_messages = vec![system_message]; + let client = MockSummaryClient::success(valid_summary()); + generate_inline_summary( + &system_messages, + &history, + InlineSummaryHistoryProjection::AppendOnlyRuntimeAuthorityPrefix, + &client, + ) + .await + .expect("policy makes exact append history safe for summary semantics"); + let request = client.recorded_requests().pop().unwrap(); + assert_eq!( + &request[system_messages.len()..system_messages.len() + history.len()], + history.as_slice() + ); + } + #[tokio::test] async fn ptl_retry_with_minimum_rounds() { // Exactly 2 messages (1 round) — can't drop below minimum, returns None @@ -628,4 +749,13 @@ mod tests { // Should give up quickly — can't drop the only round assert!(client.call_count.load(Ordering::SeqCst) <= 2); } + + #[tokio::test] + async fn incomplete_or_empty_summary_fails_closed() { + let messages = make_messages(2); + for response in ["", "plain text", "### Primary Request\nOnly one section"] { + let client = MockSummaryClient::success(response); + assert!(generate_compact_summary(&messages, &client).await.is_none()); + } + } } diff --git a/crates/astra-turn-core/src/compression_types.rs b/crates/astra-turn-core/src/compression_types.rs index d9ae980d19..00a613b3b1 100644 --- a/crates/astra-turn-core/src/compression_types.rs +++ b/crates/astra-turn-core/src/compression_types.rs @@ -276,6 +276,7 @@ impl From for Value { k.as_str(), astra_turn_types::USER_TURN_SEMANTICS_FIELD | astra_turn_types::TURN_MESSAGE_PROVENANCE_FIELD + | astra_turn_types::RUNTIME_MESSAGE_PROVENANCE_FIELD ) { map.insert(k, v); @@ -291,6 +292,13 @@ impl Message { if self.role != "user" { return false; } + if self + .extra + .get(astra_turn_types::RUNTIME_MESSAGE_PROVENANCE_FIELD) + .is_some_and(astra_turn_types::is_runtime_owned_provenance) + { + return false; + } // Anthropic tool_result arrays are never real user tasks. if self.content_is_tool_result { return false; @@ -756,6 +764,40 @@ mod tests { assert!(!m.is_plain_user_task()); } + #[test] + fn append_only_runtime_user_preserves_provenance_and_is_not_a_user_task() { + let mut value = json!({"role": "user", "content": "runtime authority"}); + astra_turn_types::mark_append_only_required_context( + &mut value, + "final_answer_settlement", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ); + + let message = Message::from(value); + assert!(!message.is_plain_user_task()); + let round_trip = Value::from(message); + assert!(astra_turn_types::is_runtime_owned_message(&round_trip)); + assert!(!astra_turn_types::is_human_user_message(&round_trip)); + } + + #[test] + fn unknown_runtime_delivery_round_trips_without_becoming_a_user_task() { + let value = json!({ + "role": "user", + "content": "future runtime control", + astra_turn_types::RUNTIME_MESSAGE_PROVENANCE_FIELD: { + "producer": "runtime", + "delivery": "future_delivery", + }, + }); + + let message = Message::from(value); + assert!(!message.is_plain_user_task()); + let round_trip = Value::from(message); + assert!(astra_turn_types::is_runtime_owned_message(&round_trip)); + assert!(!astra_turn_types::is_human_user_message(&round_trip)); + } + #[test] fn synthetic_round_trips_through_value() { let v = json!({"role": "user", "content": "hi", "_synthetic": true}); diff --git a/crates/astra-turn-core/src/context/optimizer.rs b/crates/astra-turn-core/src/context/optimizer.rs index 215b577a25..fd5fa0f274 100644 --- a/crates/astra-turn-core/src/context/optimizer.rs +++ b/crates/astra-turn-core/src/context/optimizer.rs @@ -308,7 +308,7 @@ fn drop_oldest_rounds(messages: &mut Vec, pressure: f64) -> u32 { .and_then(Value::as_str) .filter(|turn_chain_id| !turn_chain_id.is_empty()); let starts_unit = units.is_empty() - || (role == Some("user") + || (astra_turn_types::is_human_user_message(message) && (turn_provenance.is_none() || turn_provenance != active_turn_chain)); if starts_unit { units.push(Vec::new()); diff --git a/crates/astra-turn-core/src/fork/prefix.rs b/crates/astra-turn-core/src/fork/prefix.rs index fc87673101..be5417b22e 100644 --- a/crates/astra-turn-core/src/fork/prefix.rs +++ b/crates/astra-turn-core/src/fork/prefix.rs @@ -114,10 +114,9 @@ pub enum ProviderKind { impl ProviderKind { /// Infer a [`ProviderKind`] from a provider-or-model hint string. /// - /// Mirrors the shape of - /// `microcompact::ProviderCacheStrategy::from_provider_hint`, but - /// produces fine-grained variants instead of a binary - /// cache-capability classification. + /// This legacy identity parser produces fine-grained provider variants for + /// fork-prefix bookkeeping. It is not a prompt-cache capability source; + /// cache behavior comes from explicit deployment metadata. /// /// Known mappings (case-insensitive): /// - `"claude*"` / `"anthropic*"` → Anthropic diff --git a/crates/astra-turn-core/src/introspect/cache_diagnosis.rs b/crates/astra-turn-core/src/introspect/cache_diagnosis.rs index 5bb37d4dd8..c8f00e3233 100644 --- a/crates/astra-turn-core/src/introspect/cache_diagnosis.rs +++ b/crates/astra-turn-core/src/introspect/cache_diagnosis.rs @@ -37,6 +37,11 @@ pub struct RoundSnapshot { pub round: u32, pub provider: String, pub model: String, + /// Exact resolved cache shape used to assemble this request. Old captures + /// may not contain it; shape-dependent rules must then stay silent rather + /// than guess from provider/model names. + #[serde(default)] + pub cache_capability: Option, pub cache_read_tokens: u64, pub cache_creation_tokens: u64, /// Count of tool schemas sent in this request. @@ -122,6 +127,11 @@ pub fn snapshot_from_capture_json(v: &serde_json::Value) -> RoundSnapshot { .and_then(Value::as_str) .unwrap_or("") .to_string(); + let cache_capability = v + .get("cache_capability") + .or_else(|| v.pointer("/trace/cache_capability")) + .cloned() + .and_then(|value| serde_json::from_value(value).ok()); let usage = v.get("response").and_then(|r| r.get("usage")); let cache_read_tokens = usage .and_then(|u| u.get("cached_input_tokens")) @@ -224,6 +234,7 @@ pub fn snapshot_from_capture_json(v: &serde_json::Value) -> RoundSnapshot { round, provider, model, + cache_capability, cache_read_tokens, cache_creation_tokens, tool_count, @@ -641,19 +652,18 @@ fn rule_cache_creation_waste(rounds: &[RoundSnapshot]) -> Option { /// - `TailSuffix` providers (OpenAI auto-prefix): volatile content /// must be in the LAST message only. Earlier positions are inside /// the auto-prefix and break on every change. -/// - `CurrentUserOnly` providers (MiniMax strict history): volatile -/// content may only appear on round 0 of a visible turn. Session -/// 986a553e observed volatile bytes at msg[7] in every tool-loop -/// round, causing cache_read to collapse from 7680 to 0 for six -/// consecutive rounds. +/// - `CurrentUserOnly` placement keeps any admitted runtime context at +/// the current-user boundary. Whether optional content is admitted is +/// a separate delivery policy and cannot be inferred from placement. /// - `Free` / unknown: not enforced. /// -/// Signal is provider+model aware; wrong-placement gets Critical, +/// Signal is driven by the exact capability captured from the resolved model +/// offering; wrong-placement gets Critical, /// matching-placement is silent. If `volatile_msg_indices` is empty, /// the rule has nothing to check → silent. #[must_use] fn rule_volatile_in_cached_prefix(rounds: &[RoundSnapshot]) -> Option { - use crate::cache_placement::{CacheCapability, VolatilePlacement}; + use crate::cache_placement::VolatilePlacement; // Take the most recent round with any volatile signal at a // message position we actually police. System messages carry // their own block-level cache_control layout (runtime owns that); @@ -665,7 +675,7 @@ fn rule_volatile_in_cached_prefix(rounds: &[RoundSnapshot]) -> Option Option None, + // Append-only required controls become durable conversation frames; + // their consumed historical positions are intentionally not the tail. + // This legacy snapshot rule has no lifetime/provenance facts with + // which to diagnose that explicit deployment shape. + VolatilePlacement::AppendOnlyUserTail | VolatilePlacement::Free => None, } } @@ -1003,6 +1017,9 @@ mod tests { round, provider: provider.into(), model: "test-model".into(), + cache_capability: Some(crate::cache_placement::CacheCapability::for_provider( + provider, + )), cache_read_tokens: cr, cache_creation_tokens: cc, tool_count, @@ -1056,6 +1073,9 @@ mod tests { round, provider: provider.into(), model: model.into(), + cache_capability: Some(crate::cache_placement::CacheCapability::for_provider( + provider, + )), cache_read_tokens: 0, cache_creation_tokens: 0, tool_count: 0, @@ -1518,7 +1538,7 @@ mod tests { fn volatile_rule_fires_on_minimax_tool_loop_round() { // Session 986a553e fingerprint: MiniMax tool-loop round 1+ with // `## Self-Awareness` injected at a mid-history user message. - let rs = vec![snap_with_volatile( + let mut sample = snap_with_volatile( 4, 1, "openai", @@ -1526,7 +1546,14 @@ mod tests { /* msg_cc */ &[], /* volatile */ &[7], /* message_count */ 11, - )]; + ); + sample.cache_capability = Some(crate::cache_placement::CacheCapability { + protocol: crate::cache_placement::CacheProtocol::StrictHistoryMatch, + volatile_placement: crate::cache_placement::VolatilePlacement::CurrentUserOnly, + volatile_delivery: crate::cache_placement::VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: None, + }); + let rs = vec![sample]; let findings = evaluate_all(&rs); let f = findings .iter() @@ -1545,15 +1572,14 @@ mod tests { // Updated contract (see CurrentUserOnly docs): even round 0 // volatile injection on MiniMax is a cache-miss trigger, // because round 1+ won't have it and bytes at msg[1] differ. - let rs = vec![snap_with_volatile( - 4, - 0, - "openai", - "MiniMax-M2.7", - &[], - &[7], - 8, - )]; + let mut sample = snap_with_volatile(4, 0, "openai", "MiniMax-M2.7", &[], &[7], 8); + sample.cache_capability = Some(crate::cache_placement::CacheCapability { + protocol: crate::cache_placement::CacheProtocol::StrictHistoryMatch, + volatile_placement: crate::cache_placement::VolatilePlacement::CurrentUserOnly, + volatile_delivery: crate::cache_placement::VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: None, + }); + let rs = vec![sample]; let findings = evaluate_all(&rs); assert!( findings @@ -1693,7 +1719,7 @@ mod tests { /// This is the bug we're actually guarding against. #[test] fn volatile_rule_still_fires_on_user_mid_history() { - let rs = vec![snap_with_volatile_and_roles( + let mut sample = snap_with_volatile_and_roles( 5, 0, "bedrock", @@ -1714,8 +1740,14 @@ mod tests { "assistant", "user", ], - )]; - let findings = evaluate_all(&rs); + ); + sample.cache_capability = Some(crate::cache_placement::CacheCapability { + protocol: crate::cache_placement::CacheProtocol::BedrockCachePoint, + volatile_placement: crate::cache_placement::VolatilePlacement::MarkerIsolated, + volatile_delivery: crate::cache_placement::VolatileDeliveryPolicy::All, + reuse_scope: None, + }); + let findings = evaluate_all(&[sample]); assert!( findings .iter() @@ -1744,6 +1776,33 @@ mod tests { ); } + #[test] + fn volatile_rule_silent_without_captured_capability() { + let mut sample = snap_with_volatile(1, 0, "openai", "alias", &[], &[3], 6); + sample.cache_capability = None; + assert!( + !evaluate_all(&[sample]) + .iter() + .any(|finding| finding.rule_id == "volatile_in_cached_prefix") + ); + } + + #[test] + fn volatile_rule_accepts_append_only_runtime_history_positions() { + let mut sample = snap_with_volatile(2, 3, "openai", "alias", &[], &[4], 9); + sample.cache_capability = Some(crate::cache_placement::CacheCapability { + protocol: crate::cache_placement::CacheProtocol::OpenAiAutoPrefix, + volatile_placement: crate::cache_placement::VolatilePlacement::AppendOnlyUserTail, + volatile_delivery: crate::cache_placement::VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: None, + }); + assert!( + !evaluate_all(&[sample]) + .iter() + .any(|finding| finding.rule_id == "volatile_in_cached_prefix") + ); + } + // ── content-pattern detection (parser layer) ─────────────────────── #[test] diff --git a/crates/astra-turn-core/src/microcompact.rs b/crates/astra-turn-core/src/microcompact.rs index abd0cad693..e55a37712a 100644 --- a/crates/astra-turn-core/src/microcompact.rs +++ b/crates/astra-turn-core/src/microcompact.rs @@ -84,90 +84,29 @@ impl ProviderCacheStrategy { } } - /// Derive provider cache capabilities from a provider or model hint. - /// - /// This is intentionally capability-shaped rather than placeholder-shaped: - /// OpenAI-compatible providers keep stable local placeholders for prefix - /// caching, while Anthropic-compatible providers prefer protocol-level - /// cache metadata and minimal local mutation. - pub fn from_provider_hint(provider_or_model: &str) -> Self { - let lower = provider_or_model.to_ascii_lowercase(); - if lower.contains("claude") || lower.contains("anthropic") { - Self { - prompt_cache_protocol: PromptCacheProtocol::AnthropicCacheControl, - compact_strategy: CompactStrategy::Minimal, - supports_cache_control: true, - } - } else { - Self::default() - } - } - - /// Derive provider cache capabilities with an explicit provider taking - /// precedence over model name. This avoids misclassifying OpenAI-compatible - /// proxies that serve Claude-named models. - pub fn from_provider_and_model(provider: Option<&str>, model: Option<&str>) -> Self { - if let Some(provider) = provider.filter(|value| !value.trim().is_empty()) { - let from_provider = Self::from_provider_hint(provider); - // If the provider is explicitly Anthropic, trust it. - if from_provider.prompt_cache_protocol == PromptCacheProtocol::AnthropicCacheControl { - return from_provider; - } - // If the provider is a known non-Anthropic API (OpenAI, Gemini, etc.), - // respect that even when the model name contains "claude" — the caller - // is explicitly routing through a non-Anthropic endpoint. - // Unknown providers (e.g. openrouter, litellm) fall through to model - // detection so that Claude models served via proxy get the right protocol. - let lower = provider.to_ascii_lowercase(); - let is_known_non_anthropic = lower.contains("openai") - || lower.contains("gemini") - || lower.contains("google") - || lower.contains("mistral") - || lower.contains("cohere") - || lower.contains("groq") - || lower.contains("together") - || lower.contains("deepseek") - || lower.contains("qwen") - || lower.contains("ollama"); - if is_known_non_anthropic { - return from_provider; - } - } - model.map(Self::from_provider_hint).unwrap_or_default() - } - + /// Resolve compaction behavior from an explicit deployment capability or + /// the provider transport baseline. Model aliases are intentionally absent: + /// they do not prove the cache protocol used by a concrete endpoint. #[must_use] - pub fn from_explicit_or_provider_model( + pub fn from_explicit_or_provider( explicit: Option, provider: Option<&str>, - model: Option<&str>, ) -> Self { - explicit - .map(Self::from_cache_capability) - .unwrap_or_else(|| Self::from_provider_and_model(provider, model)) + let capability = crate::cache_placement::CacheCapability::from_explicit_or_provider( + explicit, + provider.unwrap_or_default(), + ); + Self::from_cache_capability(capability) } } impl CompactStrategy { - /// Derive strategy from provider/model name. - /// Anthropic (claude) → Minimal; everything else → Normalized. - pub fn from_provider_hint(provider_or_model: &str) -> Self { - ProviderCacheStrategy::from_provider_hint(provider_or_model).compact_strategy - } - - /// Derive strategy from explicit provider plus model fallback. - pub fn from_provider_and_model(provider: Option<&str>, model: Option<&str>) -> Self { - ProviderCacheStrategy::from_provider_and_model(provider, model).compact_strategy - } - #[must_use] - pub fn from_explicit_or_provider_model( + pub fn from_explicit_or_provider( explicit: Option, provider: Option<&str>, - model: Option<&str>, ) -> Self { - ProviderCacheStrategy::from_explicit_or_provider_model(explicit, provider, model) - .compact_strategy + ProviderCacheStrategy::from_explicit_or_provider(explicit, provider).compact_strategy } } @@ -902,15 +841,15 @@ mod tests { } #[test] - fn explicit_cache_capability_overrides_provider_model_strategy() { - let strategy = ProviderCacheStrategy::from_explicit_or_provider_model( + fn explicit_cache_capability_overrides_provider_transport_baseline() { + let strategy = ProviderCacheStrategy::from_explicit_or_provider( Some(crate::cache_placement::CacheCapability { protocol: crate::cache_placement::CacheProtocol::MarkerExplicit, volatile_placement: crate::cache_placement::VolatilePlacement::MarkerIsolated, + volatile_delivery: crate::cache_placement::VolatileDeliveryPolicy::All, reuse_scope: Some(crate::cache_placement::CacheReuseScope::ConversationTurns), }), Some("openai"), - Some("proxy-claude"), ); assert_eq!( strategy.prompt_cache_protocol, @@ -2395,36 +2334,25 @@ mod tests { // ── Provider-aware strategy tests ── #[test] - fn strategy_from_provider_hint() { - assert_eq!( - CompactStrategy::from_provider_hint("claude-sonnet-4-20250514"), - CompactStrategy::Minimal - ); - assert_eq!( - CompactStrategy::from_provider_hint("anthropic"), - CompactStrategy::Minimal - ); - assert_eq!( - CompactStrategy::from_provider_hint("gpt-4o"), - CompactStrategy::Normalized - ); + fn strategy_uses_provider_transport_not_model_alias() { assert_eq!( - CompactStrategy::from_provider_hint("glm-4-plus"), - CompactStrategy::Normalized + CompactStrategy::from_explicit_or_provider(None, Some("anthropic")), + CompactStrategy::Minimal, ); assert_eq!( - CompactStrategy::from_provider_hint("deepseek-chat"), - CompactStrategy::Normalized + CompactStrategy::from_explicit_or_provider(None, Some("openai")), + CompactStrategy::Normalized, ); assert_eq!( - CompactStrategy::from_provider_hint(""), - CompactStrategy::Normalized + CompactStrategy::from_explicit_or_provider(None, Some("claude-shaped-alias")), + CompactStrategy::Normalized, + "an opaque provider alias must not be reinterpreted as a model family", ); } #[test] fn provider_cache_strategy_exposes_provider_capabilities() { - let anthropic = ProviderCacheStrategy::from_provider_hint("anthropic/claude-sonnet-4"); + let anthropic = ProviderCacheStrategy::from_explicit_or_provider(None, Some("anthropic")); assert_eq!( anthropic.prompt_cache_protocol, PromptCacheProtocol::AnthropicCacheControl @@ -2432,33 +2360,19 @@ mod tests { assert_eq!(anthropic.compact_strategy, CompactStrategy::Minimal); assert!(anthropic.supports_cache_control); - let openai = ProviderCacheStrategy::from_provider_hint("openai/gpt-4o"); + let openai = ProviderCacheStrategy::from_explicit_or_provider(None, Some("openai")); assert_eq!(openai.prompt_cache_protocol, PromptCacheProtocol::Prefix); assert_eq!(openai.compact_strategy, CompactStrategy::Normalized); assert!(!openai.supports_cache_control); } #[test] - fn explicit_provider_takes_precedence_over_claude_named_model() { - // Known non-Anthropic providers override model name - assert_eq!( - CompactStrategy::from_provider_and_model(Some("openai"), Some("claude-sonnet-4")), - CompactStrategy::Normalized - ); - assert_eq!( - ProviderCacheStrategy::from_provider_and_model(Some("anthropic"), Some("gpt-4o")) - .prompt_cache_protocol, - PromptCacheProtocol::AnthropicCacheControl - ); - // Unknown proxy providers (openrouter, litellm) fall through to model detection - assert_eq!( - ProviderCacheStrategy::from_provider_and_model( - Some("openrouter"), - Some("claude-sonnet-4-20250514") - ) - .prompt_cache_protocol, - PromptCacheProtocol::AnthropicCacheControl - ); + fn unknown_provider_does_not_gain_cache_semantics_from_its_name() { + let strategy = + ProviderCacheStrategy::from_explicit_or_provider(None, Some("openrouter-claude-route")); + assert_eq!(strategy.prompt_cache_protocol, PromptCacheProtocol::Prefix); + assert_eq!(strategy.compact_strategy, CompactStrategy::Normalized); + assert!(!strategy.supports_cache_control); } #[test] diff --git a/crates/astra-turn-core/src/observer.rs b/crates/astra-turn-core/src/observer.rs index 141bbc38b3..7838a17cb8 100644 --- a/crates/astra-turn-core/src/observer.rs +++ b/crates/astra-turn-core/src/observer.rs @@ -42,7 +42,7 @@ pub fn is_memory_tool_message(message: &Value) -> bool { } fn is_user_turn_start(message: &Value) -> bool { - message.get("role").and_then(Value::as_str) == Some("user") + astra_turn_types::is_human_user_message(message) && !message .get("content") .and_then(Value::as_array) @@ -68,12 +68,20 @@ pub fn filter_memory_operation_turns(messages: &[Value]) -> Vec { let mut filtered = Vec::with_capacity(messages.len()); let mut segment_start = 0usize; + let append_observer_safe_segment = |filtered: &mut Vec, segment: &[Value]| { + filtered.extend( + segment + .iter() + .filter(|message| !astra_turn_types::is_runtime_owned_message(message)) + .cloned(), + ); + }; for (index, message) in messages.iter().enumerate() { if index > segment_start && is_user_turn_start(message) { let segment = &messages[segment_start..index]; if !segment.iter().any(is_memory_tool_message) { - filtered.extend_from_slice(segment); + append_observer_safe_segment(&mut filtered, segment); } segment_start = index; } @@ -81,7 +89,7 @@ pub fn filter_memory_operation_turns(messages: &[Value]) -> Vec { let segment = &messages[segment_start..]; if !segment.iter().any(is_memory_tool_message) { - filtered.extend_from_slice(segment); + append_observer_safe_segment(&mut filtered, segment); } filtered @@ -201,6 +209,29 @@ mod tests { assert_eq!(filtered, messages); } + #[test] + fn runtime_owned_user_role_messages_never_reach_the_observer() { + let mut authority = serde_json::json!({ + "role": "user", + "content": "runtime settlement" + }); + astra_turn_types::mark_append_only_required_context( + &mut authority, + "final_answer_settlement", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ); + let messages = vec![ + serde_json::json!({"role":"user","content":"real task"}), + authority, + serde_json::json!({"role":"assistant","content":"done"}), + ]; + + let filtered = filter_memory_operation_turns(&messages); + assert_eq!(filtered.len(), 2); + assert_eq!(filtered[0]["content"], "real task"); + assert_eq!(filtered[1]["content"], "done"); + } + #[test] fn memory_name_without_a_tool_message_shape_is_not_filtered() { let messages = vec![ diff --git a/crates/astra-turn-core/src/pipeline/session.rs b/crates/astra-turn-core/src/pipeline/session.rs index 3e2fca47a2..ebcdd1174c 100644 --- a/crates/astra-turn-core/src/pipeline/session.rs +++ b/crates/astra-turn-core/src/pipeline/session.rs @@ -87,6 +87,8 @@ pub struct PipelineSession { pending_prompt_snapshot: Option, turns_completed: u32, latest_runtime_feedback: Option, + provider_cache_observed_since_feedback: bool, + pending_provider_cache_break: Option, pending_audits: Vec, } @@ -104,6 +106,52 @@ pub(crate) struct PendingPromptSnapshot { section_fingerprints: Vec<(crate::section_types::SectionKind, u64)>, } +/// One exact, dispatched physical provider attempt ready for cache diagnosis. +/// The runtime constructs this only from the immutable prepared-body receipt +/// and an explicitly available (or unavailable) provider usage fact. +#[derive(Debug, Clone)] +pub struct ProviderAttemptCacheObservation { + pub attempt_identity: crate::cache_diagnostics::ProviderAttemptCacheIdentity, + pub dispatched: bool, + pub fingerprint: crate::cache_diagnostics::ProviderFinalPromptFingerprint, + pub cache_read_tokens: Option, +} + +/// Merge cache-break evidence from multiple physical attempts belonging to +/// one logical feedback frame. Reasons are typed and deduplicated by value; +/// a later stable retry must never erase an earlier observed break. +fn merge_cache_break_reason( + current: Option, + incoming: Option, +) -> Option { + use crate::cache_diagnostics::CacheBreakReason; + + fn append_unique(target: &mut Vec, reason: CacheBreakReason) { + match reason { + CacheBreakReason::Multiple(reasons) => { + for reason in reasons { + append_unique(target, reason); + } + } + reason if !target.contains(&reason) => target.push(reason), + _ => {} + } + } + + let mut reasons = Vec::new(); + if let Some(reason) = current { + append_unique(&mut reasons, reason); + } + if let Some(reason) = incoming { + append_unique(&mut reasons, reason); + } + match reasons.len() { + 0 => None, + 1 => reasons.pop(), + _ => Some(CacheBreakReason::Multiple(reasons)), + } +} + impl PipelineSession { /// Create a new session with the given pipeline configuration. #[must_use] @@ -130,6 +178,8 @@ impl PipelineSession { pending_prompt_snapshot: None, turns_completed: 0, latest_runtime_feedback: None, + provider_cache_observed_since_feedback: false, + pending_provider_cache_break: None, pending_audits: Vec::new(), } } @@ -153,6 +203,8 @@ impl PipelineSession { pending_prompt_snapshot: None, turns_completed: 0, latest_runtime_feedback: None, + provider_cache_observed_since_feedback: false, + pending_provider_cache_break: None, pending_audits: Vec::new(), } } @@ -182,6 +234,8 @@ impl PipelineSession { pending_prompt_snapshot: None, turns_completed: 0, latest_runtime_feedback: None, + provider_cache_observed_since_feedback: false, + pending_provider_cache_break: None, pending_audits: Vec::new(), } } @@ -289,6 +343,12 @@ impl PipelineSession { explain, metrics, }; + // A new pipeline request supersedes any orphaned observation + // aggregation left by a prior request that failed after dispatch but + // before runtime feedback. Host-internal physical retries and + // continuations do not re-enter this boundary. + self.provider_cache_observed_since_feedback = false; + self.pending_provider_cache_break = None; self.pending_prompt_snapshot = Some(PendingPromptSnapshot::capture( query_source, session, @@ -298,21 +358,85 @@ impl PipelineSession { Ok(output) } - /// Align cache-break diagnostics with the exact provider-visible tool - /// schemas after runtime stabilization and cache annotation. + /// Align planned cache diagnostics with the runtime's pre-client message + /// and tool projection. This is not provider-final authority; transports + /// that own an immutable prepared-body receipt must call + /// [`Self::record_provider_attempt_cache_observation`] before feedback. /// /// Returns `false` only when no pipeline request is awaiting feedback. - pub fn replace_pending_wire_tool_schemas( + pub fn replace_pending_planned_wire_prompt( + &mut self, + messages: &[serde_json::Value], + tool_schemas: &[serde_json::Value], + ) -> bool { + self.replace_pending_planned_wire_prompt_with_cache_capability(messages, tool_schemas, None) + } + + /// Capability-aware counterpart used by provider-owning runtimes after + /// final message consolidation. Keeping the capability and the exact wire + /// projection together prevents a volatile system tail from being + /// misdiagnosed as a leading-system mutation on prefix-cache providers. + pub fn replace_pending_planned_wire_prompt_with_cache_capability( &mut self, + messages: &[serde_json::Value], tool_schemas: &[serde_json::Value], + cache_capability: Option, ) -> bool { let Some(pending) = self.pending_prompt_snapshot.as_mut() else { return false; }; - pending.snapshot.replace_tool_schemas(tool_schemas); + let provider = pending.snapshot.provider.clone(); + let model = pending.snapshot.model.clone(); + let cache_eligible_tokens = pending.snapshot.cache_eligible_tokens; + let Some(snapshot) = + crate::cache_diagnostics::prompt_snapshot_from_messages_with_cache_capability( + messages, + tool_schemas, + &provider, + &model, + cache_eligible_tokens, + cache_capability, + ) + else { + return false; + }; + pending.snapshot = snapshot; true } + /// Consume one dispatched physical attempt from the immutable provider + /// receipt. Each durable attempt identity is accepted at most once. + /// Provider usage is optional: absent usage advances only the structural + /// baseline and never fabricates a hit or miss. + pub fn record_provider_attempt_cache_observation( + &mut self, + query_source: &str, + observation: ProviderAttemptCacheObservation, + ) -> bool { + if !observation.dispatched { + return false; + } + let Some(pending) = self.pending_prompt_snapshot.as_ref() else { + return false; + }; + let mut snapshot = pending.snapshot.clone(); + snapshot.attach_provider_final_fingerprint(observation.fingerprint); + let (accepted, event) = self.cache_detector.record_provider_attempt_for_source( + query_source, + &observation.attempt_identity, + snapshot, + observation.cache_read_tokens, + ); + if accepted { + self.provider_cache_observed_since_feedback = true; + self.pending_provider_cache_break = merge_cache_break_reason( + self.pending_provider_cache_break.take(), + event.map(|event| event.reason), + ); + } + accepted + } + /// Run the pipeline in shadow mode: produce pipeline output AND compare /// against a pre-computed legacy `ContextOptimized` for diffing. pub fn run_turn_shadow( @@ -342,6 +466,9 @@ impl PipelineSession { feedback: &mut ContextFeedback, turn_output: Option<&TurnOutput>, ) { + let provider_final_observed = + std::mem::take(&mut self.provider_cache_observed_since_feedback); + let provider_final_break = self.pending_provider_cache_break.take(); let pending_snapshot = self.pending_prompt_snapshot.take(); let recorded_pending_sections = pending_snapshot.as_ref().is_some_and(|pending| { !pending.section_usage.is_empty() || !pending.section_fingerprints.is_empty() @@ -351,32 +478,38 @@ impl PipelineSession { self.stats .record_section_fingerprint_hashes(&pending.section_fingerprints); } - let had_cache_baseline = pending_snapshot.as_ref().is_some_and(|pending| { - self.cache_detector - .snapshot_for_source(&pending.query_source) - .is_some() - }); - if let Some(pending) = pending_snapshot { - if let Some(event) = self.cache_detector.record_turn_for_source( - &pending.query_source, - pending.snapshot, - Some(feedback.tokens.cache_read), - ) { - feedback.attribute_cache_break(event.reason); - } else if !had_cache_baseline { - // First-turn / post-compaction cold starts are expected. + if provider_final_observed { + if let Some(reason) = provider_final_break { + feedback.attribute_cache_break(reason); + } + } else { + let had_cache_baseline = pending_snapshot.as_ref().is_some_and(|pending| { + self.cache_detector + .snapshot_for_source(&pending.query_source) + .is_some() + }); + if let Some(pending) = pending_snapshot { + if let Some(event) = self.cache_detector.record_turn_for_source( + &pending.query_source, + pending.snapshot, + Some(feedback.tokens.cache_read), + ) { + feedback.attribute_cache_break(event.reason); + } else if !had_cache_baseline { + // First-turn / post-compaction cold starts are expected. + } else { + feedback.detect_cache_break( + self.stats.turns_executed + 1, + DEFAULT_MIN_CACHE_BREAK_TOKENS, + ); + } } else { + let _ = query_source; feedback.detect_cache_break( self.stats.turns_executed + 1, DEFAULT_MIN_CACHE_BREAK_TOKENS, ); } - } else { - let _ = query_source; - feedback.detect_cache_break( - self.stats.turns_executed + 1, - DEFAULT_MIN_CACHE_BREAK_TOKENS, - ); } self.stats.record(model_id, query_source, feedback); @@ -424,6 +557,10 @@ impl PipelineSession { return false; } let Some(request_usage) = frame.request_usage else { + if std::mem::take(&mut self.provider_cache_observed_since_feedback) { + frame.cache_break_detected = self.pending_provider_cache_break.take(); + self.pending_prompt_snapshot = None; + } self.latest_runtime_feedback = Some(frame.clone()); return true; }; @@ -671,6 +808,8 @@ impl PipelineSession { pending_prompt_snapshot: self.pending_prompt_snapshot.clone(), turns_completed: self.turns_completed, latest_runtime_feedback: self.latest_runtime_feedback.clone(), + provider_cache_observed_since_feedback: self.provider_cache_observed_since_feedback, + pending_provider_cache_break: self.pending_provider_cache_break.clone(), session_current_date: Some(self.session_current_date.clone()), } } @@ -693,6 +832,8 @@ impl PipelineSession { pending_prompt_snapshot, turns_completed, latest_runtime_feedback, + provider_cache_observed_since_feedback, + pending_provider_cache_break, session_current_date, } = snapshot; let mut recovery = recovery; @@ -716,6 +857,8 @@ impl PipelineSession { pending_prompt_snapshot, turns_completed, latest_runtime_feedback: latest_runtime_feedback.filter(RuntimeFeedbackFrame::is_valid), + provider_cache_observed_since_feedback, + pending_provider_cache_break, pending_audits: Vec::new(), } } @@ -743,6 +886,10 @@ pub struct PipelineSessionSnapshot { pub turns_completed: u32, #[serde(default, skip_serializing_if = "Option::is_none")] pub latest_runtime_feedback: Option, + #[serde(default)] + pub provider_cache_observed_since_feedback: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pending_provider_cache_break: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub session_current_date: Option, } @@ -860,6 +1007,77 @@ mod tests { } } + #[test] + fn physical_retry_break_aggregation_is_typed_deduplicated_and_monotonic() { + use crate::cache_diagnostics::CacheBreakReason; + + let system = CacheBreakReason::SystemPromptChanged; + assert_eq!( + merge_cache_break_reason(Some(system.clone()), None), + Some(system.clone()), + "a stable retry cannot erase an earlier physical-attempt break" + ); + assert_eq!( + merge_cache_break_reason(Some(system.clone()), Some(system.clone())), + Some(system.clone()), + "the same typed reason from two attempts is reported once" + ); + assert_eq!( + merge_cache_break_reason( + Some(system.clone()), + Some(CacheBreakReason::CacheControlChanged), + ), + Some(CacheBreakReason::Multiple(vec![ + system, + CacheBreakReason::CacheControlChanged, + ])) + ); + } + + #[test] + fn pending_cache_snapshot_is_replaced_with_final_wire_prompt() { + let mut session = PipelineSession::new(PipelineConfig::default()); + session.pending_prompt_snapshot = Some(PendingPromptSnapshot { + query_source: "test".to_string(), + snapshot: PromptStateSnapshot::capture( + "pipeline candidate", + &[], + "deepseek-v4-flash", + 7_225, + ), + section_usage: std::collections::HashMap::new(), + section_fingerprints: Vec::new(), + }); + let messages = vec![ + serde_json::json!({"role": "system", "content": "stable system"}), + serde_json::json!({"role": "system", "content": "final wire runtime context"}), + serde_json::json!({"role": "user", "content": "hello"}), + ]; + let tools = vec![serde_json::json!({ + "type": "function", + "function": {"name": "bash", "parameters": {"type": "object"}} + })]; + let expected = crate::cache_diagnostics::prompt_snapshot_from_messages( + &messages, + &tools, + "unknown", + "deepseek-v4-flash", + 7_225, + ) + .expect("wire snapshot"); + + assert!(session.replace_pending_planned_wire_prompt(&messages, &tools)); + let actual = &session + .pending_prompt_snapshot + .as_ref() + .expect("pending snapshot") + .snapshot; + assert_eq!(actual.system_prompt_hash, expected.system_prompt_hash); + assert_eq!(actual.system_blocks, expected.system_blocks); + assert_eq!(actual.tools_hash, expected.tools_hash); + assert_eq!(actual.cache_eligible_tokens, 7_225); + } + fn test_external() -> ExternalSources { ExternalSources { memory_entries: vec![], @@ -875,6 +1093,39 @@ mod tests { assert!(!sess.should_abort()); } + #[test] + fn new_pipeline_request_clears_orphaned_provider_observation_aggregation() { + let mut sess = PipelineSession::new(PipelineConfig::default()); + sess.provider_cache_observed_since_feedback = true; + sess.pending_provider_cache_break = + Some(crate::cache_diagnostics::CacheBreakReason::UnknownColdStart); + let statics = test_statics(); + let agent = AgentContext::default(); + let session = test_session_context(); + let turn = test_turn_state(2); + let external = test_external(); + let limits = OptimizeLimits::default(); + + sess.run_turn(TurnInput { + statics: &statics, + agent: &agent, + session: &session, + turn: &turn, + external: &external, + optimize_limits: &limits, + model_id: "m", + query_source: "repl", + }) + .expect("new pipeline request"); + + assert!(!sess.provider_cache_observed_since_feedback); + assert!(sess.pending_provider_cache_break.is_none()); + assert!( + sess.pending_prompt_snapshot.is_some(), + "a later pre-dispatch failure may retain only the new planned request" + ); + } + #[test] fn run_turn_produces_valid_output() { let mut sess = PipelineSession::new(PipelineConfig::default()); diff --git a/crates/astra-turn-core/src/pipeline/session_serde.rs b/crates/astra-turn-core/src/pipeline/session_serde.rs index d0333e9e62..8270afc317 100644 --- a/crates/astra-turn-core/src/pipeline/session_serde.rs +++ b/crates/astra-turn-core/src/pipeline/session_serde.rs @@ -170,6 +170,8 @@ mod tests { session_current_date: Some("2026-05-25".to_string()), turns_completed: 0, latest_runtime_feedback: None, + provider_cache_observed_since_feedback: false, + pending_provider_cache_break: None, }, "1999-12-31", ); @@ -294,6 +296,8 @@ mod tests { session_current_date: Some("2026-05-25".to_string()), turns_completed: 0, latest_runtime_feedback: None, + provider_cache_observed_since_feedback: false, + pending_provider_cache_break: None, }, "1999-12-31", ); diff --git a/crates/astra-turn-core/src/prompt_facing.rs b/crates/astra-turn-core/src/prompt_facing.rs index 8765d71723..24b1634c02 100644 --- a/crates/astra-turn-core/src/prompt_facing.rs +++ b/crates/astra-turn-core/src/prompt_facing.rs @@ -7,7 +7,10 @@ //! must retain raw runtime history. use crate::conversation_log::SessionStateCompact; -use astra_turn_types::{RuntimeMessageDelivery, is_runtime_owned_message, runtime_owned_message}; +use astra_turn_types::{ + RuntimeMessageDelivery, is_runtime_owned_message, runtime_message_delivery, + runtime_owned_message, +}; use serde_json::{Value, json}; const MAX_PROMPT_FACING_MESSAGES: usize = 40; @@ -154,8 +157,10 @@ pub fn sanitize_prompt_facing_messages_with_state( /// Unlike the compact prompt-facing transcript above, a continuation is fed /// back through the context optimizer. It therefore retains completed tool /// call/result groups as model evidence instead of deleting them before the -/// optimizer can make a pressure-aware decision. Runtime-owned controls and -/// orphaned tool frames are still removed at this trust boundary. +/// optimizer can make a pressure-aware decision. Durable append-only required +/// controls retain their exact position and provenance for provider-prefix +/// reconstruction; other runtime-owned controls and orphaned tool frames are +/// still removed at this trust boundary. pub fn sanitize_canonical_continuation_messages_with_turn_semantics( messages: Vec, ) -> Result, astra_turn_types::UserTurnSemanticsError> { @@ -197,8 +202,10 @@ fn sanitize_canonical_continuation_messages_impl( .into_iter() .skip(start) .filter_map(|mut message| { + let append_only_required = runtime_message_delivery(&message) + == Some(RuntimeMessageDelivery::AppendOnlyRequiredContext); let keep = message.get("_compact_boundary").and_then(Value::as_bool) != Some(true) - && !is_runtime_owned_message(&message); + && (append_only_required || !is_runtime_owned_message(&message)); if !keep { return None; } @@ -212,6 +219,13 @@ fn sanitize_canonical_continuation_messages_impl( let mut has_user_context = false; while index < messages.len() { let message = &messages[index]; + if runtime_message_delivery(message) + == Some(RuntimeMessageDelivery::AppendOnlyRequiredContext) + { + out.push(message.clone()); + index += 1; + continue; + } let role = message.get("role").and_then(Value::as_str).unwrap_or(""); match role { "user" | "system" => { @@ -267,9 +281,16 @@ pub fn sanitize_completed_canonical_turn_messages_with_turn_semantics( let mut out = Vec::new(); let mut has_user_context = false; for message in messages.into_iter().skip(start) { - if message.get("_compact_boundary").and_then(Value::as_bool) == Some(true) - || is_runtime_owned_message(&message) + if message.get("_compact_boundary").and_then(Value::as_bool) == Some(true) { + continue; + } + if runtime_message_delivery(&message) + == Some(RuntimeMessageDelivery::AppendOnlyRequiredContext) { + out.push(message); + continue; + } + if is_runtime_owned_message(&message) { continue; } let role = message.get("role").and_then(Value::as_str).unwrap_or(""); @@ -667,11 +688,16 @@ fn trim_to_recent_messages(mut messages: Vec) -> Vec { mod tests { use super::{ recover_canonical_continuation_messages_with_turn_semantics, runtime_recap_message, - sanitize_canonical_continuation_messages_with_state, sanitize_prompt_facing_messages, - sanitize_prompt_facing_messages_with_state, sanitize_user_visible_messages, + sanitize_canonical_continuation_messages_with_state, + sanitize_completed_canonical_turn_messages_with_turn_semantics, + sanitize_prompt_facing_messages, sanitize_prompt_facing_messages_with_state, + sanitize_user_visible_messages, }; use crate::conversation_log::{DelegationCompact, SessionStateCompact}; - use astra_turn_types::{RuntimeMessageDelivery, runtime_owned_message}; + use astra_turn_types::{ + RuntimeAuthorityLifetime, RuntimeMessageDelivery, mark_append_only_required_context, + runtime_owned_message, + }; use serde_json::{Value, json}; #[test] @@ -866,6 +892,36 @@ mod tests { ); } + #[test] + fn canonical_continuation_preserves_append_only_authority_without_making_it_user_visible() { + let mut authority = json!({ + "role": "user", + "content": "\nsettlement\n" + }); + mark_append_only_required_context( + &mut authority, + "final_answer_settlement", + RuntimeAuthorityLifetime::NextAssistantDecision, + ); + let messages = vec![ + json!({"role": "user", "content": "finish the change"}), + authority.clone(), + json!({"role": "assistant", "content": "I need one verification"}), + ]; + + let continuation = sanitize_canonical_continuation_messages_with_state( + messages.clone(), + &SessionStateCompact::default(), + ) + .expect("valid canonical history"); + assert_eq!(continuation[1], authority); + let completed = + sanitize_completed_canonical_turn_messages_with_turn_semantics(messages.clone()) + .expect("valid completed history"); + assert_eq!(completed[1], authority); + assert_eq!(sanitize_prompt_facing_messages(messages).len(), 2); + } + #[test] fn canonical_continuation_normalizes_structured_tool_results_to_provider_neutral_text() { let messages = vec![ diff --git a/crates/astra-turn-core/src/resume_hydration.rs b/crates/astra-turn-core/src/resume_hydration.rs index 6eff801e90..dfe0482d55 100644 --- a/crates/astra-turn-core/src/resume_hydration.rs +++ b/crates/astra-turn-core/src/resume_hydration.rs @@ -267,6 +267,9 @@ fn objective_context_from_entries(entries: &[PromptEntry]) -> Vec Result, UserTurnSemanticsError> { let mut entries = Vec::new(); for message in messages { + if astra_turn_types::is_runtime_owned_message(message) { + continue; + } let Some(role) = message.get("role").and_then(Value::as_str) else { continue; }; diff --git a/crates/astra-turn-core/src/runtime_scaffolding.rs b/crates/astra-turn-core/src/runtime_scaffolding.rs index 7a193e014e..beffaab02f 100644 --- a/crates/astra-turn-core/src/runtime_scaffolding.rs +++ b/crates/astra-turn-core/src/runtime_scaffolding.rs @@ -23,16 +23,32 @@ pub fn normalize_prompt_facing_runtime_messages( continue; } - if let Some(delivery) = runtime_message_delivery(&message) { - if delivery == RuntimeMessageDelivery::RequiredContext - && let Some(content) = message.get("content").and_then(Value::as_str) - && !content.trim().is_empty() - { - normalized - .required_runtime_texts - .push(content.trim().to_string()); + if is_runtime_owned_message(&message) { + let Some(delivery) = runtime_message_delivery(&message) else { + // Unknown runtime protocol data is never conversational user + // input. Provider assembly reports the contract violation; + // prompt-facing projections fail closed by excluding it. + continue; + }; + match delivery { + RuntimeMessageDelivery::RequiredContext => { + if let Some(content) = message.get("content").and_then(Value::as_str) + && !content.trim().is_empty() + { + normalized + .required_runtime_texts + .push(content.trim().to_string()); + } + continue; + } + RuntimeMessageDelivery::AppendOnlyRequiredContext => { + normalized.messages.push(message); + continue; + } + RuntimeMessageDelivery::EphemeralControl | RuntimeMessageDelivery::Projection => { + continue; + } } - continue; } normalized.messages.push(message); @@ -47,7 +63,10 @@ pub fn sanitize_recoverable_runtime_messages(messages: Vec) -> Vec messages .into_iter() .filter(|message| { - !is_runtime_owned_message(message) && !is_internal_skill_auto_route_message(message) + runtime_message_delivery(message) + == Some(RuntimeMessageDelivery::AppendOnlyRequiredContext) + || (!is_runtime_owned_message(message) + && !is_internal_skill_auto_route_message(message)) }) .collect(), ) @@ -247,11 +266,20 @@ mod tests { "another arbitrary payload", RuntimeMessageDelivery::EphemeralControl, ); + let append_only = runtime_owned_message( + "user", + "durable runtime authority", + RuntimeMessageDelivery::AppendOnlyRequiredContext, + ); - let got = - normalize_prompt_facing_runtime_messages(vec![ordinary.clone(), required, ephemeral]); + let got = normalize_prompt_facing_runtime_messages(vec![ + ordinary.clone(), + required, + ephemeral, + append_only.clone(), + ]); - assert_eq!(got.messages, vec![ordinary]); + assert_eq!(got.messages, vec![ordinary, append_only]); assert_eq!( got.required_runtime_texts, vec!["required payload without a magic prefix"] @@ -273,6 +301,44 @@ mod tests { ); } + #[test] + fn recovery_preserves_append_only_required_context_in_place() { + let first = json!({"role": "user", "content": "do the work"}); + let runtime = runtime_owned_message( + "user", + "\nlatest authority\n", + RuntimeMessageDelivery::AppendOnlyRequiredContext, + ); + let assistant = json!({"role": "assistant", "content": "continuing"}); + + assert_eq!( + sanitize_recoverable_runtime_messages(vec![ + first.clone(), + runtime.clone(), + assistant.clone(), + ]), + vec![first, runtime, assistant] + ); + } + + #[test] + fn prompt_facing_normalization_drops_unknown_runtime_delivery() { + let malformed = json!({ + "role": "user", + "content": "future runtime control", + astra_turn_types::RUNTIME_MESSAGE_PROVENANCE_FIELD: { + "producer": "runtime", + "delivery": "future_delivery", + }, + }); + let human = json!({"role": "user", "content": "real request"}); + + let got = normalize_prompt_facing_runtime_messages(vec![malformed, human.clone()]); + + assert_eq!(got.messages, vec![human]); + assert!(got.required_runtime_texts.is_empty()); + } + #[test] fn internal_auto_route_roundtrip_is_removed_by_protocol_identity() { let messages = vec![ diff --git a/crates/astra-turn-core/src/state.rs b/crates/astra-turn-core/src/state.rs index c450481f56..11065134d1 100644 --- a/crates/astra-turn-core/src/state.rs +++ b/crates/astra-turn-core/src/state.rs @@ -20,7 +20,8 @@ pub fn resolve_turn_identifiers( ) -> (String, String) { let latest_conversation_role = messages.iter().rev().find_map(|message| { match message.get("role").and_then(Value::as_str) { - Some("user" | "assistant" | "tool") => message.get("role").and_then(Value::as_str), + Some("user") if astra_turn_types::is_human_user_message(message) => Some("user"), + Some("assistant" | "tool") => message.get("role").and_then(Value::as_str), _ => None, } }); diff --git a/crates/astra-turn-core/src/view.rs b/crates/astra-turn-core/src/view.rs index 8bccb8cc92..b84d2e368c 100644 --- a/crates/astra-turn-core/src/view.rs +++ b/crates/astra-turn-core/src/view.rs @@ -9,7 +9,7 @@ pub fn extract_latest_user_query(messages: &[Value]) -> String { .rev() .find_map(|message| { let object = message.as_object()?; - if object.get("role").and_then(Value::as_str) == Some("user") { + if astra_turn_types::is_human_user_message(message) { object .get("content") .and_then(Value::as_str) diff --git a/crates/astra-turn-core/src/xml_tool_call_fallback.rs b/crates/astra-turn-core/src/xml_tool_call_fallback.rs index f3631173ad..23ec026dc6 100644 --- a/crates/astra-turn-core/src/xml_tool_call_fallback.rs +++ b/crates/astra-turn-core/src/xml_tool_call_fallback.rs @@ -195,6 +195,110 @@ fn dsml_tool_calls_close_regex() -> &'static Regex { }) } +/// Incrementally removes explicit DSML tool-call envelopes from text shown to +/// users while leaving the original provider response available to the +/// fallback parser. +/// +/// Providers may split the opening or closing tag at any UTF-8 boundary. The +/// filter therefore retains only a possible tag suffix between chunks and +/// suppresses everything from a confirmed opening envelope through its +/// closing envelope. An unfinished envelope is dropped by [`Self::finish`]. +#[derive(Debug, Default)] +pub struct DsmlToolCallStreamFilter { + pending: String, + suppressing: bool, +} + +impl DsmlToolCallStreamFilter { + /// Consume one provider text chunk and return only bytes safe to publish. + pub fn push(&mut self, chunk: &str) -> String { + self.pending.push_str(chunk); + let mut visible = String::new(); + + loop { + if self.suppressing { + if let Some(close) = dsml_tool_calls_close_regex().find(&self.pending) { + self.pending.drain(..close.end()); + self.suppressing = false; + continue; + } + if let Some(start) = possible_dsml_envelope_suffix_start(&self.pending, true) { + self.pending.drain(..start); + } else { + self.pending.clear(); + } + break; + } + + if let Some(open) = dsml_tool_calls_open_regex().find(&self.pending) { + visible.push_str(&self.pending[..open.start()]); + self.pending.drain(..open.end()); + self.suppressing = true; + continue; + } + + if let Some(start) = possible_dsml_envelope_suffix_start(&self.pending, false) { + visible.push_str(&self.pending[..start]); + self.pending.drain(..start); + } else { + visible.push_str(&self.pending); + self.pending.clear(); + } + break; + } + + visible + } + + /// Finish the stream. Visible text held only because it resembled a tag + /// prefix is released; an incomplete confirmed DSML envelope is discarded. + pub fn finish(&mut self) -> String { + if self.suppressing { + self.pending.clear(); + String::new() + } else { + std::mem::take(&mut self.pending) + } + } +} + +fn possible_dsml_envelope_suffix_start(text: &str, closing: bool) -> Option { + const OPEN_FULLWIDTH: &str = "<||dsml||tool_calls>"; + const OPEN_ASCII: &str = "<||dsml||tool_calls>"; + const CLOSE_FULLWIDTH: &str = ""; + const CLOSE_ASCII: &str = ""; + let candidates = if closing { + [CLOSE_FULLWIDTH, CLOSE_ASCII] + } else { + [OPEN_FULLWIDTH, OPEN_ASCII] + }; + + text.char_indices().rev().find_map(|(start, ch)| { + if ch != '<' { + return None; + } + let compact = text[start..] + .chars() + .filter(|ch| !ch.is_whitespace()) + .flat_map(char::to_lowercase) + .collect::(); + candidates + .iter() + .any(|candidate| candidate.starts_with(&compact)) + .then_some(start) + }) +} + +/// Remove explicit DSML envelopes from a complete or partial provider payload +/// before it is rendered or embedded in an interruption message. +#[must_use] +pub fn filter_dsml_tool_call_markup_for_display(text: &str) -> String { + let mut filter = DsmlToolCallStreamFilter::default(); + let mut visible = filter.push(text); + visible.push_str(&filter.finish()); + visible +} + // ─── Fallback ──────────────────────────────────────────────────── /// Try to extract tool calls from `` blocks in `text`. @@ -754,7 +858,8 @@ pub fn strip_degraded_tool_calls(text: &str) -> String { let parsed = parse_degraded_response(text); let after_accepted = remove_ranges(text, &parsed.strip_ranges); let after_truncated_tail = strip_truncated_degraded_tool_call_tail(&after_accepted); - strip_residual_xml_fragments(&after_truncated_tail) + let after_residual_fragments = strip_residual_xml_fragments(&after_truncated_tail); + filter_dsml_tool_call_markup_for_display(&after_residual_fragments) } fn strip_truncated_degraded_tool_call_tail(text: &str) -> String { @@ -894,6 +999,51 @@ fn extract_attr(tag: &str, attr: &str) -> Option { mod tests { use super::*; + #[test] + fn dsml_stream_filter_hides_envelope_across_chunk_boundaries() { + let mut filter = DsmlToolCallStreamFilter::default(); + let chunks = [ + "visible before <||DS", + "ML||tool_calls><||DSML||invoke name=\"bash\">secret", + " visible after", + ]; + let mut visible = chunks + .into_iter() + .map(|chunk| filter.push(chunk)) + .collect::(); + visible.push_str(&filter.finish()); + + assert_eq!(visible, "visible before visible after"); + assert!(!visible.contains("DSML")); + assert!(!visible.contains("secret")); + } + + #[test] + fn dsml_stream_filter_hides_ascii_and_unfinished_envelopes() { + let complete = "before< ||DSML||tool_calls >< ||DSML||invoke name=\"bash\">hiddenafter"; + assert_eq!( + filter_dsml_tool_call_markup_for_display(complete), + "beforeafter" + ); + + let incomplete = + "safe prefix<||DSML||tool_calls><||DSML||invoke name=\"bash\">hidden"; + assert_eq!( + filter_dsml_tool_call_markup_for_display(incomplete), + "safe prefix" + ); + } + + #[test] + fn dsml_stream_filter_preserves_non_protocol_angle_brackets() { + assert_eq!( + filter_dsml_tool_call_markup_for_display("explain literally"), + "explain literally" + ); + assert_eq!(filter_dsml_tool_call_markup_for_display("2 <"), "2 <"); + } + #[test] fn parse_single_invoke_with_params() { let xml = r#" diff --git a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/README.md b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/README.md index c6c70eb07c..48638a1514 100644 --- a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/README.md +++ b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/README.md @@ -17,11 +17,10 @@ matching**: any byte change mid-history invalidates the entire cache hit, unlike OpenAI's auto-prefix which would still match the stable portion. -The fix lands in a new `cache_placement` module (see the `C1` commit -on `improve_promts`) that classifies MiniMax as `StrictHistoryMatch` -with `VolatilePlacement::CurrentUserOnly` — volatile content gets -injected only on round 0 of a visible turn, and skipped on tool-loop -continuations. +The offering used by this capture declared `StrictHistoryMatch` with +`VolatilePlacement::CurrentUserOnly`. The scrubbed fixtures retain that typed +capability explicitly. Diagnostics must consume this request fact and must not +reconstruct cache semantics from the provider or model label. ## What this fixture preserves diff --git a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t2_r0.json b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t2_r0.json index 8fa0e5bdf5..778518fb1b 100644 --- a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t2_r0.json +++ b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t2_r0.json @@ -1,4 +1,5 @@ { + "cache_capability": {"protocol":"StrictHistoryMatch","volatile_placement":"CurrentUserOnly","volatile_delivery":"RequiredOnly","reuse_scope":"IntraTurnRounds"}, "model": "MiniMax-M2.7", "outcome": "success", "provider": "openai", @@ -186,4 +187,4 @@ "round": 0, "session_id": "986a553e-SCRUBBED", "turn": 2 -} \ No newline at end of file +} diff --git a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t3_r0.json b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t3_r0.json index e5c73421d3..ea1b25b883 100644 --- a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t3_r0.json +++ b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t3_r0.json @@ -1,4 +1,5 @@ { + "cache_capability": {"protocol":"StrictHistoryMatch","volatile_placement":"CurrentUserOnly","volatile_delivery":"RequiredOnly","reuse_scope":"IntraTurnRounds"}, "model": "MiniMax-M2.7", "outcome": "success", "provider": "openai", @@ -194,4 +195,4 @@ "round": 0, "session_id": "986a553e-SCRUBBED", "turn": 3 -} \ No newline at end of file +} diff --git a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r0.json b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r0.json index 7fdd9f2f61..c047f542c2 100644 --- a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r0.json +++ b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r0.json @@ -1,4 +1,5 @@ { + "cache_capability": {"protocol":"StrictHistoryMatch","volatile_placement":"CurrentUserOnly","volatile_delivery":"RequiredOnly","reuse_scope":"IntraTurnRounds"}, "model": "MiniMax-M2.7", "outcome": "success", "provider": "openai", @@ -202,4 +203,4 @@ "round": 0, "session_id": "986a553e-SCRUBBED", "turn": 4 -} \ No newline at end of file +} diff --git a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r1.json b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r1.json index 4474ef67b3..6e0f4dac21 100644 --- a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r1.json +++ b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r1.json @@ -1,4 +1,5 @@ { + "cache_capability": {"protocol":"StrictHistoryMatch","volatile_placement":"CurrentUserOnly","volatile_delivery":"RequiredOnly","reuse_scope":"IntraTurnRounds"}, "model": "MiniMax-M2.7", "outcome": "success", "provider": "openai", @@ -227,4 +228,4 @@ "round": 1, "session_id": "986a553e-SCRUBBED", "turn": 4 -} \ No newline at end of file +} diff --git a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r2.json b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r2.json index f81ecde9c7..078eb81326 100644 --- a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r2.json +++ b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r2.json @@ -1,4 +1,5 @@ { + "cache_capability": {"protocol":"StrictHistoryMatch","volatile_placement":"CurrentUserOnly","volatile_delivery":"RequiredOnly","reuse_scope":"IntraTurnRounds"}, "model": "MiniMax-M2.7", "outcome": "success", "provider": "openai", @@ -248,4 +249,4 @@ "round": 2, "session_id": "986a553e-SCRUBBED", "turn": 4 -} \ No newline at end of file +} diff --git a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r3.json b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r3.json index 0575af41c3..caabdf16b2 100644 --- a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r3.json +++ b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r3.json @@ -1,4 +1,5 @@ { + "cache_capability": {"protocol":"StrictHistoryMatch","volatile_placement":"CurrentUserOnly","volatile_delivery":"RequiredOnly","reuse_scope":"IntraTurnRounds"}, "model": "MiniMax-M2.7", "outcome": "success", "provider": "openai", @@ -307,4 +308,4 @@ "round": 3, "session_id": "986a553e-SCRUBBED", "turn": 4 -} \ No newline at end of file +} diff --git a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r4.json b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r4.json index 009d5b1c10..4a0bdc2b04 100644 --- a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r4.json +++ b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r4.json @@ -1,4 +1,5 @@ { + "cache_capability": {"protocol":"StrictHistoryMatch","volatile_placement":"CurrentUserOnly","volatile_delivery":"RequiredOnly","reuse_scope":"IntraTurnRounds"}, "model": "MiniMax-M2.7", "outcome": "success", "provider": "openai", @@ -351,4 +352,4 @@ "round": 4, "session_id": "986a553e-SCRUBBED", "turn": 4 -} \ No newline at end of file +} diff --git a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r5.json b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r5.json index d212d89977..cf9ac8f5e9 100644 --- a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r5.json +++ b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r5.json @@ -1,4 +1,5 @@ { + "cache_capability": {"protocol":"StrictHistoryMatch","volatile_placement":"CurrentUserOnly","volatile_delivery":"RequiredOnly","reuse_scope":"IntraTurnRounds"}, "model": "MiniMax-M2.7", "outcome": "success", "provider": "openai", @@ -395,4 +396,4 @@ "round": 5, "session_id": "986a553e-SCRUBBED", "turn": 4 -} \ No newline at end of file +} diff --git a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r6.json b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r6.json index d99a81cc63..dae2e76109 100644 --- a/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r6.json +++ b/crates/astra-turn-core/tests/fixtures/cache_diagnosis_986a553e/t4_r6.json @@ -1,4 +1,5 @@ { + "cache_capability": {"protocol":"StrictHistoryMatch","volatile_placement":"CurrentUserOnly","volatile_delivery":"RequiredOnly","reuse_scope":"IntraTurnRounds"}, "model": "MiniMax-M2.7", "outcome": "success", "provider": "openai", @@ -439,4 +440,4 @@ "round": 6, "session_id": "986a553e-SCRUBBED", "turn": 4 -} \ No newline at end of file +} diff --git a/crates/astra-turn-types/src/lib.rs b/crates/astra-turn-types/src/lib.rs index bb44a19fa8..4555bc8bd5 100644 --- a/crates/astra-turn-types/src/lib.rs +++ b/crates/astra-turn-types/src/lib.rs @@ -13,6 +13,7 @@ mod inference; mod memory_ranking; mod memory_structure; mod phase_receipt; +mod provider_canonical_transition; mod provider_contract; mod result_quality; mod resume; @@ -59,6 +60,13 @@ pub use phase_receipt::{ TURN_PHASE_EVENT_TYPE, TURN_PHASE_SCHEMA_VERSION, TurnPhaseKindV1, TurnPhaseOutcomeV1, TurnPhaseReceiptV1, }; +pub use provider_canonical_transition::{ + CanonicalPrefixIdentityV1, MAX_PROVIDER_CANONICAL_RECOVERY_BYTES, + MAX_PROVIDER_CANONICAL_TRANSITION_BYTES, MAX_PROVIDER_CANONICAL_TRANSITION_DURABLE_BYTES, + PROVIDER_CANONICAL_TRANSITION_SCHEMA_VERSION, ProviderCanonicalRecoveryModeV1, + ProviderCanonicalTransitionApply, ProviderCanonicalTransitionError, + ProviderCanonicalTransitionV1, +}; pub use provider_contract::{ DescriptorVersion, NativeToolId, PROVIDER_INTERACTION_REQUEST_METADATA_KEY, PROVIDER_INTERACTION_RESPONSE_METADATA_KEY, ProviderBindingRef, ProviderCallOutcome, @@ -83,8 +91,16 @@ pub use resume::{ ResumeSourceV1, cursor_relation, select_resume_bundle, select_resume_candidate_index, }; pub use runtime_scaffolding::{ - RUNTIME_MESSAGE_PROVENANCE_FIELD, RuntimeMessageDelivery, is_runtime_owned_message, - mark_runtime_owned_message, runtime_message_delivery, runtime_owned_message, + APPEND_ONLY_RUNTIME_AUTHORITY_POLICY, APPEND_ONLY_RUNTIME_AUTHORITY_POLICY_FIELD, + ParsedRuntimeAuthorityFrame, RUNTIME_MESSAGE_PROVENANCE_FIELD, RuntimeAuthorityFrameError, + RuntimeAuthorityLifetime, RuntimeMessageDelivery, + active_append_only_authority_protected_suffix_start, append_only_runtime_authority_is_active, + has_append_only_runtime_authority_policy, is_human_user_message, is_runtime_owned_message, + is_runtime_owned_provenance, mark_append_only_required_context, + mark_append_only_runtime_authority_policy, mark_runtime_owned_message, + parse_append_only_runtime_authority_frame, render_append_only_runtime_authority_frame, + runtime_authority_kind, runtime_authority_lifetime, runtime_message_delivery, + runtime_message_delivery_from_provenance, runtime_owned_message, }; pub use semantic_read_cache::{ SEMANTIC_READ_CACHE_CONTRACT_VERSION, SEMANTIC_READ_CONDITION_ACK_METADATA_KEY, diff --git a/crates/astra-turn-types/src/provider_canonical_transition.rs b/crates/astra-turn-types/src/provider_canonical_transition.rs new file mode 100644 index 0000000000..e76bd253e3 --- /dev/null +++ b/crates/astra-turn-types/src/provider_canonical_transition.rs @@ -0,0 +1,731 @@ +//! Write-ahead identity for canonical messages that a provider request owns. +//! +//! A transition is committed with physical-attempt admission before HTTP is +//! authorized. It is intentionally independent of provider request JSON: +//! canonical recovery must never infer semantic ownership from a wire role, +//! model name, prompt text, or transport error. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::{canonical_conversation_identity, parse_append_only_runtime_authority_frame}; + +pub const PROVIDER_CANONICAL_TRANSITION_SCHEMA_VERSION: u32 = 1; +pub const MAX_PROVIDER_CANONICAL_TRANSITION_BYTES: u64 = 512 * 1024; +pub const MAX_PROVIDER_CANONICAL_RECOVERY_BYTES: u64 = 16 * 1024 * 1024; +pub const MAX_PROVIDER_CANONICAL_TRANSITION_DURABLE_BYTES: u64 = 40 * 1024 * 1024; +const TRANSITION_ID_DOMAIN: &[u8] = b"astra.provider-canonical-transition.v1\0"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct CanonicalPrefixIdentityV1 { + pub message_count: u32, + pub root_hash: String, +} + +impl CanonicalPrefixIdentityV1 { + pub fn from_messages(messages: &[Value]) -> Result { + Ok(Self { + message_count: u32::try_from(messages.len()) + .map_err(|_| ProviderCanonicalTransitionError::MessageCountOverflow)?, + root_hash: canonical_conversation_identity(messages).0, + }) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProviderCanonicalRecoveryModeV1 { + AppendFromDurableBase, + ReplaceFromDurableBase, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ProviderCanonicalTransitionV1 { + pub schema_version: u32, + pub transition_id: String, + /// Exact causal predecessor in the per-turn provider WAL. `None` is valid + /// only for the first transition admitted against the durable turn base. + pub parent_transition_id: Option, + pub durable_base: CanonicalPrefixIdentityV1, + pub recovery_mode: ProviderCanonicalRecoveryModeV1, + pub replacement_compaction_generation: Option, + pub recovery_messages: Vec, + pub predecessor: CanonicalPrefixIdentityV1, + pub result: CanonicalPrefixIdentityV1, + pub appended_messages: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderCanonicalTransitionApply { + Applied, + AlreadyApplied, +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum ProviderCanonicalTransitionError { + #[error("unsupported provider canonical transition schema {0}")] + UnsupportedSchema(u32), + #[error("provider canonical transition message count overflow")] + MessageCountOverflow, + #[error("provider canonical transition exceeds its serialized-byte bound")] + TooManyBytes, + #[error("provider canonical transition contains an invalid append shape")] + InvalidAppendShape, + #[error("provider canonical transition recovery suffix exceeds its serialized-byte bound")] + TooManyRecoveryBytes, + #[error("provider canonical transition exceeds its total durable-byte bound")] + TooManyDurableBytes, + #[error("provider canonical transition contains invalid canonical recovery messages")] + InvalidCanonicalRecovery, + #[error("provider canonical transition recovery counts do not reconstruct its predecessor")] + RecoveryCountMismatch, + #[error("provider canonical append predecessor does not preserve its durable base")] + DurableBaseNotPrefix, + #[error("provider canonical replacement has invalid compaction authorization evidence")] + InvalidReplacementAuthorization, + #[error("provider canonical replacement does not match its predecessor identity")] + RecoveryRootMismatch, + #[error("provider canonical transition contains an invalid runtime authority")] + InvalidRuntimeAuthority, + #[error("provider canonical transition contains an invalid hash")] + InvalidHash, + #[error("provider canonical transition identity does not match its content")] + IdentityMismatch, + #[error("provider canonical transition result count does not extend its predecessor")] + ResultCountMismatch, + #[error("canonical history does not match the transition predecessor or result prefix")] + PrefixConflict, + #[error("provider canonical transition result root could not be reproduced")] + ResultRootMismatch, +} + +impl ProviderCanonicalTransitionV1 { + pub fn new( + parent_transition_id: Option, + predecessor_messages: &[Value], + appended_messages: Vec, + ) -> Result { + Self::new_from_durable_base( + parent_transition_id, + CanonicalPrefixIdentityV1::from_messages(predecessor_messages)?, + predecessor_messages, + appended_messages, + ) + } + + pub fn new_from_durable_base( + parent_transition_id: Option, + durable_base: CanonicalPrefixIdentityV1, + predecessor_messages: &[Value], + appended_messages: Vec, + ) -> Result { + validate_appended_messages(&appended_messages)?; + let durable_base_count = usize::try_from(durable_base.message_count) + .map_err(|_| ProviderCanonicalTransitionError::MessageCountOverflow)?; + let base_is_preserved = predecessor_messages.len() >= durable_base_count + && canonical_conversation_identity(&predecessor_messages[..durable_base_count]).0 + == durable_base.root_hash; + if !base_is_preserved { + return Err(ProviderCanonicalTransitionError::DurableBaseNotPrefix); + } + Self::new_with_recovery( + parent_transition_id, + durable_base, + ProviderCanonicalRecoveryModeV1::AppendFromDurableBase, + None, + predecessor_messages[durable_base_count..].to_vec(), + predecessor_messages, + appended_messages, + ) + } + + /// Construct a recovery replacement only with an explicit compaction + /// generation issued by the runtime's canonical rewrite proof. Prefix + /// mismatch alone is never replacement authority. + pub fn new_replacement_from_durable_base( + parent_transition_id: Option, + durable_base: CanonicalPrefixIdentityV1, + replacement_compaction_generation: u64, + predecessor_messages: &[Value], + appended_messages: Vec, + ) -> Result { + validate_appended_messages(&appended_messages)?; + Self::new_with_recovery( + parent_transition_id, + durable_base, + ProviderCanonicalRecoveryModeV1::ReplaceFromDurableBase, + Some(replacement_compaction_generation), + predecessor_messages.to_vec(), + predecessor_messages, + appended_messages, + ) + } + + fn new_with_recovery( + parent_transition_id: Option, + durable_base: CanonicalPrefixIdentityV1, + recovery_mode: ProviderCanonicalRecoveryModeV1, + replacement_compaction_generation: Option, + recovery_messages: Vec, + predecessor_messages: &[Value], + appended_messages: Vec, + ) -> Result { + let predecessor = CanonicalPrefixIdentityV1::from_messages(predecessor_messages)?; + validate_recovery_messages(&recovery_messages)?; + let mut result_messages = Vec::with_capacity( + predecessor_messages + .len() + .saturating_add(appended_messages.len()), + ); + result_messages.extend_from_slice(predecessor_messages); + result_messages.extend(appended_messages.iter().cloned()); + crate::validate_canonical_tool_pairing(&result_messages) + .map_err(|_| ProviderCanonicalTransitionError::InvalidCanonicalRecovery)?; + let result = CanonicalPrefixIdentityV1::from_messages(&result_messages)?; + let mut transition = Self { + schema_version: PROVIDER_CANONICAL_TRANSITION_SCHEMA_VERSION, + transition_id: String::new(), + parent_transition_id, + durable_base, + recovery_mode, + replacement_compaction_generation, + recovery_messages, + predecessor, + result, + appended_messages, + }; + transition.transition_id = transition_identity(&transition); + transition.validate()?; + Ok(transition) + } + + pub fn validate(&self) -> Result<(), ProviderCanonicalTransitionError> { + if self.schema_version != PROVIDER_CANONICAL_TRANSITION_SCHEMA_VERSION { + return Err(ProviderCanonicalTransitionError::UnsupportedSchema( + self.schema_version, + )); + } + validate_hash(&self.predecessor.root_hash)?; + validate_hash(&self.result.root_hash)?; + validate_hash(&self.durable_base.root_hash)?; + validate_hash(&self.transition_id)?; + if let Some(parent_transition_id) = self.parent_transition_id.as_deref() { + validate_hash(parent_transition_id)?; + } + validate_recovery_messages(&self.recovery_messages)?; + validate_appended_messages(&self.appended_messages)?; + let recovery_count = u32::try_from(self.recovery_messages.len()) + .map_err(|_| ProviderCanonicalTransitionError::MessageCountOverflow)?; + match self.recovery_mode { + ProviderCanonicalRecoveryModeV1::AppendFromDurableBase => { + if self.replacement_compaction_generation.is_some() { + return Err(ProviderCanonicalTransitionError::InvalidReplacementAuthorization); + } + if self.durable_base.message_count.checked_add(recovery_count) + != Some(self.predecessor.message_count) + { + return Err(ProviderCanonicalTransitionError::RecoveryCountMismatch); + } + } + ProviderCanonicalRecoveryModeV1::ReplaceFromDurableBase => { + if self.replacement_compaction_generation.is_none() { + return Err(ProviderCanonicalTransitionError::InvalidReplacementAuthorization); + } + if recovery_count != self.predecessor.message_count { + return Err(ProviderCanonicalTransitionError::RecoveryCountMismatch); + } + if CanonicalPrefixIdentityV1::from_messages(&self.recovery_messages)? + != self.predecessor + { + return Err(ProviderCanonicalTransitionError::RecoveryRootMismatch); + } + } + } + let append_count = u32::try_from(self.appended_messages.len()) + .map_err(|_| ProviderCanonicalTransitionError::MessageCountOverflow)?; + if self.predecessor.message_count.checked_add(append_count) + != Some(self.result.message_count) + { + return Err(ProviderCanonicalTransitionError::ResultCountMismatch); + } + if self.transition_id != transition_identity(self) { + return Err(ProviderCanonicalTransitionError::IdentityMismatch); + } + let durable_bytes = crate::json_serialized_len(self) + .map_err(|_| ProviderCanonicalTransitionError::TooManyDurableBytes)?; + if durable_bytes > MAX_PROVIDER_CANONICAL_TRANSITION_DURABLE_BYTES { + return Err(ProviderCanonicalTransitionError::TooManyDurableBytes); + } + Ok(()) + } + + pub fn reconstruct_predecessor_from_durable_base( + &self, + durable_base_messages: &[Value], + ) -> Result, ProviderCanonicalTransitionError> { + self.validate()?; + if CanonicalPrefixIdentityV1::from_messages(durable_base_messages)? != self.durable_base { + return Err(ProviderCanonicalTransitionError::PrefixConflict); + } + let predecessor = match self.recovery_mode { + ProviderCanonicalRecoveryModeV1::AppendFromDurableBase => { + let mut messages = durable_base_messages.to_vec(); + messages.extend(self.recovery_messages.iter().cloned()); + messages + } + ProviderCanonicalRecoveryModeV1::ReplaceFromDurableBase => { + self.recovery_messages.clone() + } + }; + if CanonicalPrefixIdentityV1::from_messages(&predecessor)? != self.predecessor { + return Err(ProviderCanonicalTransitionError::RecoveryRootMismatch); + } + Ok(predecessor) + } + + /// Apply to a WAL-owned history prefix. Callers must detach any fresh + /// post-crash suffix at the durable-base boundary before replay; message + /// equality cannot prove whether repeated user input is old or fresh. + pub fn apply_to( + &self, + messages: &mut Vec, + ) -> Result { + self.validate()?; + let predecessor_count = usize::try_from(self.predecessor.message_count) + .map_err(|_| ProviderCanonicalTransitionError::MessageCountOverflow)?; + let result_count = usize::try_from(self.result.message_count) + .map_err(|_| ProviderCanonicalTransitionError::MessageCountOverflow)?; + + if messages.len() == result_count + && canonical_conversation_identity(messages).0 == self.result.root_hash + { + return Ok(ProviderCanonicalTransitionApply::AlreadyApplied); + } + let mut candidate = messages.clone(); + let predecessor_is_present = candidate.len() == predecessor_count + && canonical_conversation_identity(&candidate).0 == self.predecessor.root_hash; + if !predecessor_is_present { + candidate = self.reconstruct_predecessor_from_durable_base(&candidate)?; + } + candidate.splice( + predecessor_count..predecessor_count, + self.appended_messages.iter().cloned(), + ); + if canonical_conversation_identity(&candidate[..result_count]).0 != self.result.root_hash { + return Err(ProviderCanonicalTransitionError::ResultRootMismatch); + } + crate::validate_canonical_tool_pairing(&candidate[..result_count]) + .map_err(|_| ProviderCanonicalTransitionError::InvalidCanonicalRecovery)?; + *messages = candidate; + Ok(ProviderCanonicalTransitionApply::Applied) + } +} + +fn validate_recovery_messages(messages: &[Value]) -> Result<(), ProviderCanonicalTransitionError> { + let bytes = crate::json_serialized_len(messages) + .map_err(|_| ProviderCanonicalTransitionError::TooManyRecoveryBytes)?; + if bytes > MAX_PROVIDER_CANONICAL_RECOVERY_BYTES { + return Err(ProviderCanonicalTransitionError::TooManyRecoveryBytes); + } + for message in messages { + let Some(object) = message.as_object() else { + return Err(ProviderCanonicalTransitionError::InvalidCanonicalRecovery); + }; + if !matches!( + object.get("role").and_then(Value::as_str), + Some("system" | "user" | "assistant" | "tool") + ) { + return Err(ProviderCanonicalTransitionError::InvalidCanonicalRecovery); + } + if crate::is_runtime_owned_message(message) { + match crate::runtime_message_delivery(message) { + Some(crate::RuntimeMessageDelivery::AppendOnlyRequiredContext) => { + parse_append_only_runtime_authority_frame(message) + .map_err(|_| ProviderCanonicalTransitionError::InvalidCanonicalRecovery)?; + } + Some(_) | None => { + return Err(ProviderCanonicalTransitionError::InvalidCanonicalRecovery); + } + } + } + } + Ok(()) +} + +fn validate_appended_messages(messages: &[Value]) -> Result<(), ProviderCanonicalTransitionError> { + let bytes = crate::json_serialized_len(messages) + .map_err(|_| ProviderCanonicalTransitionError::TooManyBytes)?; + if bytes > MAX_PROVIDER_CANONICAL_TRANSITION_BYTES { + return Err(ProviderCanonicalTransitionError::TooManyBytes); + } + + let Some(first) = messages.first() else { + return Ok(()); + }; + let authority_start = if first.get("role").and_then(Value::as_str) == Some("assistant") { + if messages.len() < 2 { + return Err(ProviderCanonicalTransitionError::InvalidAppendShape); + } + 1 + } else { + 0 + }; + if messages[authority_start..] + .iter() + .any(|message| !valid_runtime_authority(message)) + { + return Err(ProviderCanonicalTransitionError::InvalidRuntimeAuthority); + } + Ok(()) +} + +fn valid_runtime_authority(message: &Value) -> bool { + parse_append_only_runtime_authority_frame(message).is_ok() +} + +fn validate_hash(hash: &str) -> Result<(), ProviderCanonicalTransitionError> { + if hash.len() == 64 + && hash + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Ok(()) + } else { + Err(ProviderCanonicalTransitionError::InvalidHash) + } +} + +fn transition_identity(transition: &ProviderCanonicalTransitionV1) -> String { + let recovery_root = canonical_conversation_identity(&transition.recovery_messages).0; + let appended_root = canonical_conversation_identity(&transition.appended_messages).0; + let mut digest = Sha256::new(); + digest.update(TRANSITION_ID_DOMAIN); + digest.update(PROVIDER_CANONICAL_TRANSITION_SCHEMA_VERSION.to_be_bytes()); + match transition.parent_transition_id.as_deref() { + Some(parent_transition_id) => { + digest.update([1]); + digest.update(parent_transition_id.as_bytes()); + } + None => digest.update([0]), + } + digest.update(transition.durable_base.message_count.to_be_bytes()); + digest.update(transition.durable_base.root_hash.as_bytes()); + digest.update([match transition.recovery_mode { + ProviderCanonicalRecoveryModeV1::AppendFromDurableBase => 0, + ProviderCanonicalRecoveryModeV1::ReplaceFromDurableBase => 1, + }]); + match transition.replacement_compaction_generation { + Some(generation) => { + digest.update([1]); + digest.update(generation.to_be_bytes()); + } + None => digest.update([0]), + } + digest.update(recovery_root.as_bytes()); + digest.update(transition.predecessor.message_count.to_be_bytes()); + digest.update(transition.predecessor.root_hash.as_bytes()); + digest.update(transition.result.message_count.to_be_bytes()); + digest.update(transition.result.root_hash.as_bytes()); + digest.update(appended_root.as_bytes()); + format!("{:x}", digest.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + RuntimeAuthorityLifetime, mark_append_only_required_context, + render_append_only_runtime_authority_frame, + }; + use serde_json::json; + + fn authority(kind: &str) -> Value { + let content = render_append_only_runtime_authority_frame( + kind, + RuntimeAuthorityLifetime::NextAssistantDecision, + &format!("opaque {kind}"), + ) + .expect("frame"); + let mut message = json!({"role": "user", "content": content}); + mark_append_only_required_context( + &mut message, + kind, + RuntimeAuthorityLifetime::NextAssistantDecision, + ); + message + } + + #[test] + fn transition_applies_once_before_a_fresh_user_suffix() { + let base = vec![json!({"role": "user", "content": "goal"})]; + let transition = ProviderCanonicalTransitionV1::new( + None, + &base, + vec![authority("work"), authority("budget")], + ) + .unwrap(); + let fresh = json!({"role": "user", "content": "fresh follow-up"}); + let mut restored = base.clone(); + assert_eq!( + transition.apply_to(&mut restored).unwrap(), + ProviderCanonicalTransitionApply::Applied + ); + assert_eq!(restored[1], transition.appended_messages[0]); + assert_eq!(restored[2], transition.appended_messages[1]); + assert_eq!( + transition.apply_to(&mut restored).unwrap(), + ProviderCanonicalTransitionApply::AlreadyApplied + ); + restored.push(fresh); + + let mut ambiguous = vec![base[0].clone(), base[0].clone()]; + assert_eq!( + transition.apply_to(&mut ambiguous), + Err(ProviderCanonicalTransitionError::PrefixConflict), + "the transition layer must not guess where a repeated fresh suffix begins" + ); + } + + #[test] + fn assistant_and_authority_are_one_atomic_transition() { + let base = vec![json!({"role": "user", "content": "goal"})]; + let appended = vec![ + json!({"role": "assistant", "content": "partial"}), + authority("continue"), + ]; + let transition = ProviderCanonicalTransitionV1::new(None, &base, appended.clone()).unwrap(); + let mut restored = base; + transition.apply_to(&mut restored).unwrap(); + assert_eq!(&restored[1..], appended.as_slice()); + assert!( + ProviderCanonicalTransitionV1::new( + None, + &restored[..1], + vec![json!({"role": "assistant", "content": "orphan"})], + ) + .is_err() + ); + } + + #[test] + fn valid_authority_cardinality_is_bounded_by_bytes_not_an_arbitrary_count() { + let base = vec![json!({"role": "user", "content": "goal"})]; + let authorities = (0..24).map(|_| authority("skill")).collect::>(); + ProviderCanonicalTransitionV1::new(None, &base, authorities) + .expect("all valid prompt authorities must fit when the byte budget fits"); + + let content = render_append_only_runtime_authority_frame( + "skill", + RuntimeAuthorityLifetime::NextAssistantDecision, + &"x".repeat(MAX_PROVIDER_CANONICAL_TRANSITION_BYTES as usize), + ) + .unwrap(); + let mut oversized = json!({"role": "user", "content": content}); + mark_append_only_required_context( + &mut oversized, + "skill", + RuntimeAuthorityLifetime::NextAssistantDecision, + ); + assert_eq!( + ProviderCanonicalTransitionV1::new(None, &base, vec![oversized]), + Err(ProviderCanonicalTransitionError::TooManyBytes) + ); + } + + #[test] + fn transition_identity_rejects_tampering_without_inspecting_text() { + let base = vec![json!({"role": "user", "content": "goal"})]; + let mut transition = + ProviderCanonicalTransitionV1::new(None, &base, vec![authority("work")]).unwrap(); + let content = transition.appended_messages[0]["content"] + .as_str() + .expect("framed content") + .replace("opaque work", "changed"); + transition.appended_messages[0]["content"] = Value::String(content); + assert_eq!( + transition.validate(), + Err(ProviderCanonicalTransitionError::IdentityMismatch) + ); + } + + #[test] + fn parent_identity_is_explicit_and_covered_by_the_transition_hash() { + let base = vec![json!({"role": "user", "content": "goal"})]; + let first = + ProviderCanonicalTransitionV1::new(None, &base, vec![authority("work")]).unwrap(); + let mut predecessor = base; + predecessor.extend(first.appended_messages.iter().cloned()); + let mut child = ProviderCanonicalTransitionV1::new( + Some(first.transition_id.clone()), + &predecessor, + vec![authority("budget")], + ) + .unwrap(); + assert_eq!( + child.parent_transition_id.as_deref(), + Some(first.transition_id.as_str()) + ); + + child.parent_transition_id = Some("0".repeat(64)); + assert_eq!( + child.validate(), + Err(ProviderCanonicalTransitionError::IdentityMismatch) + ); + } + + #[test] + fn wrong_result_root_never_partially_mutates_history() { + let base = vec![json!({"role": "user", "content": "goal"})]; + let mut transition = + ProviderCanonicalTransitionV1::new(None, &base, vec![authority("budget")]).unwrap(); + transition.result.root_hash = "0".repeat(64); + transition.transition_id = transition_identity(&transition); + let mut history = base; + let before = history.clone(); + + assert_eq!( + transition.apply_to(&mut history), + Err(ProviderCanonicalTransitionError::ResultRootMismatch) + ); + assert_eq!(history, before); + } + + #[test] + fn crash_before_checkpoint_recovers_lossless_turn_before_fresh_user_input() { + let durable_head = vec![ + json!({"role": "user", "content": "older request"}), + json!({"role": "assistant", "content": "older answer"}), + ]; + let mut predecessor = durable_head.clone(); + predecessor.extend([ + json!({"role": "user", "content": "run the command"}), + json!({ + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call-1", + "type": "function", + "function": {"name": "bash", "arguments": "{}"} + }] + }), + json!({"role": "tool", "tool_call_id": "call-1", "content": "done"}), + ]); + let authority = authority("budget"); + let transition = ProviderCanonicalTransitionV1::new_from_durable_base( + None, + CanonicalPrefixIdentityV1::from_messages(&durable_head).unwrap(), + &predecessor, + vec![authority.clone()], + ) + .unwrap(); + assert_eq!( + transition.recovery_mode, + ProviderCanonicalRecoveryModeV1::AppendFromDurableBase + ); + + // Simulate process loss after provider delivery authorization but + // before any step/canonical checkpoint, followed by a fresh `hi`. + let fresh_user = json!({"role": "user", "content": "hi"}); + let mut restored = durable_head.clone(); + transition.apply_to(&mut restored).unwrap(); + restored.push(fresh_user.clone()); + + let mut old_wire_suffix = predecessor; + old_wire_suffix.push(authority); + assert!(restored.starts_with(&old_wire_suffix)); + assert_eq!(restored.last(), Some(&fresh_user)); + } + + #[test] + fn recovery_only_transition_preserves_a_request_without_runtime_frames() { + let durable_head = vec![json!({"role": "assistant", "content": "ready"})]; + let mut predecessor = durable_head.clone(); + predecessor.push(json!({"role": "user", "content": "old request"})); + let transition = ProviderCanonicalTransitionV1::new_from_durable_base( + None, + CanonicalPrefixIdentityV1::from_messages(&durable_head).unwrap(), + &predecessor, + Vec::new(), + ) + .unwrap(); + assert_eq!(transition.predecessor, transition.result); + + let mut restored = durable_head; + assert_eq!( + transition.apply_to(&mut restored).unwrap(), + ProviderCanonicalTransitionApply::Applied + ); + assert_eq!(restored, predecessor); + } + + #[test] + fn authorized_rewrite_recovers_as_explicit_base_replacement() { + let durable_head = vec![ + json!({"role": "user", "content": "large old request"}), + json!({"role": "assistant", "content": "large old answer"}), + ]; + let rewritten = vec![ + json!({"role": "system", "content": "typed compacted summary"}), + json!({"role": "user", "content": "current request"}), + ]; + let authority = authority("continue"); + assert_eq!( + ProviderCanonicalTransitionV1::new_from_durable_base( + None, + CanonicalPrefixIdentityV1::from_messages(&durable_head).unwrap(), + &rewritten, + vec![authority.clone()], + ), + Err(ProviderCanonicalTransitionError::DurableBaseNotPrefix) + ); + let transition = ProviderCanonicalTransitionV1::new_replacement_from_durable_base( + None, + CanonicalPrefixIdentityV1::from_messages(&durable_head).unwrap(), + 1, + &rewritten, + vec![authority.clone()], + ) + .unwrap(); + assert_eq!( + transition.recovery_mode, + ProviderCanonicalRecoveryModeV1::ReplaceFromDurableBase + ); + + let fresh_user = json!({"role": "user", "content": "hi"}); + let mut restored = durable_head; + transition.apply_to(&mut restored).unwrap(); + restored.push(fresh_user.clone()); + assert_eq!(&restored[..rewritten.len()], rewritten.as_slice()); + assert_eq!(restored[rewritten.len()], authority); + assert_eq!(restored.last(), Some(&fresh_user)); + } + + #[test] + fn non_append_runtime_controls_cannot_enter_canonical_recovery() { + let durable_base = CanonicalPrefixIdentityV1::from_messages(&[]).unwrap(); + for delivery in [ + crate::RuntimeMessageDelivery::EphemeralControl, + crate::RuntimeMessageDelivery::RequiredContext, + crate::RuntimeMessageDelivery::Projection, + ] { + let recovery = vec![crate::runtime_owned_message( + "system", + "must remain process local", + delivery, + )]; + assert_eq!( + ProviderCanonicalTransitionV1::new_from_durable_base( + None, + durable_base.clone(), + &recovery, + vec![authority("budget")], + ), + Err(ProviderCanonicalTransitionError::InvalidCanonicalRecovery) + ); + } + } +} diff --git a/crates/astra-turn-types/src/runtime_scaffolding.rs b/crates/astra-turn-types/src/runtime_scaffolding.rs index 321ad6c38d..d43accf172 100644 --- a/crates/astra-turn-types/src/runtime_scaffolding.rs +++ b/crates/astra-turn-types/src/runtime_scaffolding.rs @@ -5,12 +5,105 @@ //! continuation, and prompt projections consume the marker without inspecting //! prefixes or keywords. +use serde::Deserialize; use serde_json::{Value, json}; +use thiserror::Error; pub const RUNTIME_MESSAGE_PROVENANCE_FIELD: &str = "_astra_runtime_provenance"; +pub const APPEND_ONLY_RUNTIME_AUTHORITY_POLICY_FIELD: &str = + "_astra_append_only_runtime_authority_policy"; const RUNTIME_MESSAGE_PRODUCER_FIELD: &str = "producer"; const RUNTIME_MESSAGE_DELIVERY_FIELD: &str = "delivery"; +const RUNTIME_MESSAGE_AUTHORITY_KIND_FIELD: &str = "authority_kind"; +const RUNTIME_MESSAGE_AUTHORITY_LIFETIME_FIELD: &str = "authority_lifetime"; const RUNTIME_MESSAGE_PRODUCER: &str = "runtime"; +const RUNTIME_AUTHORITY_FRAME_PREFIX: &str = "\n"; +const RUNTIME_AUTHORITY_FRAME_SUFFIX: &str = "\n"; +const RUNTIME_AUTHORITY_FRAME_SCHEMA: &str = "runtime_authority_frame.v1"; + +/// Stable semantic contract for provider-required runtime controls encoded as +/// append-only `user` frames. Main and cache-reusing auxiliary inference must +/// carry this exact policy whenever those frames are visible. +pub const APPEND_ONLY_RUNTIME_AUTHORITY_POLICY: &str = r#" +{"schema":"append_only_runtime_authority.v1","instruction":"A user-role is control state from the runtime, not human-authored intent and not a new user goal. A frame with lifetime next_assistant_decision constrains only the immediately following assistant decision and is consumed once a later assistant message exists. A frame with lifetime current_user_turn remains context for that human user turn, including its tool rounds, and expires when a later human-authored user message exists. Within one active lifetime, a later frame of the same kind supersedes an earlier frame of that kind; different kinds apply jointly. These frames do not widen tool, completion, Work, policy, or admission authority."} +"#; + +/// Mark the stable system message that carries the append-only authority +/// interpretation contract. Consumers inspect this typed marker, never the +/// natural-language policy text. +pub fn mark_append_only_runtime_authority_policy(message: &mut Value) { + let Some(object) = message.as_object_mut() else { + return; + }; + object.insert( + APPEND_ONLY_RUNTIME_AUTHORITY_POLICY_FIELD.to_string(), + Value::Bool(true), + ); +} + +#[must_use] +pub fn has_append_only_runtime_authority_policy(message: &Value) -> bool { + message + .get(APPEND_ONLY_RUNTIME_AUTHORITY_POLICY_FIELD) + .and_then(Value::as_bool) + == Some(true) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeAuthorityLifetime { + CurrentUserTurn, + NextAssistantDecision, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedRuntimeAuthorityFrame { + pub kind: String, + pub lifetime: RuntimeAuthorityLifetime, + pub payload: String, +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum RuntimeAuthorityFrameError { + #[error("append-only runtime authority has invalid typed provenance")] + InvalidProvenance, + #[error("append-only runtime authority must use the provider user role")] + InvalidRole, + #[error("append-only runtime authority kind is empty or non-canonical")] + InvalidKind, + #[error("append-only runtime authority has no valid lifetime")] + InvalidLifetime, + #[error("append-only runtime authority content is not text")] + InvalidContent, + #[error("append-only runtime authority frame does not match the v1 grammar")] + InvalidFrame, + #[error("append-only runtime authority frame header does not match typed provenance")] + HeaderMismatch, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RuntimeAuthorityFrameHeaderV1 { + schema: String, + kind: String, + lifetime: String, +} + +impl RuntimeAuthorityLifetime { + pub const fn as_str(self) -> &'static str { + match self { + Self::CurrentUserTurn => "current_user_turn", + Self::NextAssistantDecision => "next_assistant_decision", + } + } + + fn from_str(value: &str) -> Option { + match value { + "current_user_turn" => Some(Self::CurrentUserTurn), + "next_assistant_decision" => Some(Self::NextAssistantDecision), + _ => None, + } + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RuntimeMessageDelivery { @@ -18,6 +111,14 @@ pub enum RuntimeMessageDelivery { EphemeralControl, /// Context that must be re-routed to the typed required-context lane. RequiredContext, + /// Required runtime authority encoded as an append-only conversation + /// frame for a deployment that cannot cache later `system` messages. + /// + /// The provider wire role may be `user`, but typed runtime provenance + /// remains the semantic owner. User-intent, display, and memory consumers + /// must therefore continue to exclude this message from human-authored + /// turns while prompt reconstruction preserves its exact position. + AppendOnlyRequiredContext, /// A synthetic projection such as a fresh runtime recap. Projection, } @@ -27,6 +128,7 @@ impl RuntimeMessageDelivery { match self { Self::EphemeralControl => "ephemeral_control", Self::RequiredContext => "required_context", + Self::AppendOnlyRequiredContext => "append_only_required_context", Self::Projection => "projection", } } @@ -35,6 +137,7 @@ impl RuntimeMessageDelivery { match value { "ephemeral_control" => Some(Self::EphemeralControl), "required_context" => Some(Self::RequiredContext), + "append_only_required_context" => Some(Self::AppendOnlyRequiredContext), "projection" => Some(Self::Projection), _ => None, } @@ -54,6 +157,28 @@ pub fn mark_runtime_owned_message(message: &mut Value, delivery: RuntimeMessageD ); } +pub fn mark_append_only_required_context( + message: &mut Value, + authority_kind: &str, + lifetime: RuntimeAuthorityLifetime, +) { + mark_runtime_owned_message(message, RuntimeMessageDelivery::AppendOnlyRequiredContext); + let Some(provenance) = message + .get_mut(RUNTIME_MESSAGE_PROVENANCE_FIELD) + .and_then(Value::as_object_mut) + else { + return; + }; + provenance.insert( + RUNTIME_MESSAGE_AUTHORITY_KIND_FIELD.to_string(), + Value::String(authority_kind.to_string()), + ); + provenance.insert( + RUNTIME_MESSAGE_AUTHORITY_LIFETIME_FIELD.to_string(), + Value::String(lifetime.as_str().to_string()), + ); +} + #[must_use] pub fn runtime_owned_message( role: &str, @@ -68,20 +193,193 @@ pub fn runtime_owned_message( #[must_use] pub fn runtime_message_delivery(message: &Value) -> Option { let provenance = message.get(RUNTIME_MESSAGE_PROVENANCE_FIELD)?; - (provenance - .get(RUNTIME_MESSAGE_PRODUCER_FIELD) - .and_then(Value::as_str) - == Some(RUNTIME_MESSAGE_PRODUCER)) - .then_some(())?; + runtime_message_delivery_from_provenance(provenance) +} + +/// Validate a standalone provenance value without relying on the provider +/// message role. Compression layers use this before reconstructing a full +/// JSON message so runtime-owned user-role frames cannot become human turns. +#[must_use] +pub fn runtime_message_delivery_from_provenance( + provenance: &Value, +) -> Option { + is_runtime_owned_provenance(provenance).then_some(())?; provenance .get(RUNTIME_MESSAGE_DELIVERY_FIELD) .and_then(Value::as_str) .and_then(RuntimeMessageDelivery::from_str) } +#[must_use] +pub fn is_runtime_owned_provenance(provenance: &Value) -> bool { + provenance + .get(RUNTIME_MESSAGE_PRODUCER_FIELD) + .and_then(Value::as_str) + == Some(RUNTIME_MESSAGE_PRODUCER) +} + #[must_use] pub fn is_runtime_owned_message(message: &Value) -> bool { - runtime_message_delivery(message).is_some() + message + .get(RUNTIME_MESSAGE_PROVENANCE_FIELD) + .is_some_and(is_runtime_owned_provenance) +} + +/// True only for a provider `user` message whose semantic author is the +/// human. Provider role is a wire-shape property: runtime-owned context may +/// deliberately use `role=user` without creating user intent or a turn +/// boundary. Semantic consumers must use this predicate instead of inspecting +/// the role alone. +#[must_use] +pub fn is_human_user_message(message: &Value) -> bool { + message.get("role").and_then(Value::as_str) == Some("user") + && !is_runtime_owned_message(message) +} + +#[must_use] +pub fn runtime_authority_kind(message: &Value) -> Option<&str> { + (runtime_message_delivery(message) == Some(RuntimeMessageDelivery::AppendOnlyRequiredContext)) + .then_some(())?; + message + .get(RUNTIME_MESSAGE_PROVENANCE_FIELD)? + .get(RUNTIME_MESSAGE_AUTHORITY_KIND_FIELD)? + .as_str() +} + +#[must_use] +pub fn runtime_authority_lifetime(message: &Value) -> Option { + (runtime_message_delivery(message) == Some(RuntimeMessageDelivery::AppendOnlyRequiredContext)) + .then_some(())?; + message + .get(RUNTIME_MESSAGE_PROVENANCE_FIELD)? + .get(RUNTIME_MESSAGE_AUTHORITY_LIFETIME_FIELD)? + .as_str() + .and_then(RuntimeAuthorityLifetime::from_str) +} + +/// Render the exact content grammar carried by a typed append-only authority +/// message. This is a protocol codec, not a natural-language classifier: the +/// fixed delimiters and strict JSON header are validated symmetrically by +/// [`parse_append_only_runtime_authority_frame`]. +pub fn render_append_only_runtime_authority_frame( + kind: &str, + lifetime: RuntimeAuthorityLifetime, + payload: &str, +) -> Result { + if kind.is_empty() || kind.trim() != kind { + return Err(RuntimeAuthorityFrameError::InvalidKind); + } + if payload.trim().is_empty() { + return Err(RuntimeAuthorityFrameError::InvalidContent); + } + let header = json!({ + "kind": kind, + "lifetime": lifetime.as_str(), + "schema": RUNTIME_AUTHORITY_FRAME_SCHEMA, + }); + Ok(format!( + "{RUNTIME_AUTHORITY_FRAME_PREFIX}{header}\n{payload}{RUNTIME_AUTHORITY_FRAME_SUFFIX}" + )) +} + +/// Parse and validate one append-only authority message at a persistence or +/// provider boundary. Both the typed provenance and the exact frame grammar +/// must agree; arbitrary user text or a partially corrupted WAL row cannot +/// acquire runtime authority. +pub fn parse_append_only_runtime_authority_frame( + message: &Value, +) -> Result { + if runtime_message_delivery(message) != Some(RuntimeMessageDelivery::AppendOnlyRequiredContext) + { + return Err(RuntimeAuthorityFrameError::InvalidProvenance); + } + if message.get("role").and_then(Value::as_str) != Some("user") { + return Err(RuntimeAuthorityFrameError::InvalidRole); + } + let kind = runtime_authority_kind(message).ok_or(RuntimeAuthorityFrameError::InvalidKind)?; + if kind.is_empty() || kind.trim() != kind { + return Err(RuntimeAuthorityFrameError::InvalidKind); + } + let lifetime = + runtime_authority_lifetime(message).ok_or(RuntimeAuthorityFrameError::InvalidLifetime)?; + let content = message + .get("content") + .and_then(Value::as_str) + .ok_or(RuntimeAuthorityFrameError::InvalidContent)?; + let framed = content + .strip_prefix(RUNTIME_AUTHORITY_FRAME_PREFIX) + .and_then(|value| value.strip_suffix(RUNTIME_AUTHORITY_FRAME_SUFFIX)) + .ok_or(RuntimeAuthorityFrameError::InvalidFrame)?; + let (header, payload) = framed + .split_once('\n') + .ok_or(RuntimeAuthorityFrameError::InvalidFrame)?; + let header: RuntimeAuthorityFrameHeaderV1 = + serde_json::from_str(header).map_err(|_| RuntimeAuthorityFrameError::InvalidFrame)?; + if header.schema != RUNTIME_AUTHORITY_FRAME_SCHEMA + || header.kind != kind + || header.lifetime != lifetime.as_str() + { + return Err(RuntimeAuthorityFrameError::HeaderMismatch); + } + if payload.trim().is_empty() { + return Err(RuntimeAuthorityFrameError::InvalidContent); + } + Ok(ParsedRuntimeAuthorityFrame { + kind: kind.to_string(), + lifetime, + payload: payload.to_string(), + }) +} + +/// Whether one validated append-only authority frame still governs the +/// current canonical suffix. Unknown provenance is never active; provider +/// trust boundaries reject it separately instead of treating it as a user. +#[must_use] +pub fn append_only_runtime_authority_is_active(history: &[Value], index: usize) -> bool { + let Some(authority) = history.get(index) else { + return false; + }; + let Some(kind) = runtime_authority_kind(authority) else { + return false; + }; + let Some(lifetime) = runtime_authority_lifetime(authority) else { + return false; + }; + let suffix = &history[index + 1..]; + if suffix.iter().any(|message| { + runtime_message_delivery(message) == Some(RuntimeMessageDelivery::AppendOnlyRequiredContext) + && runtime_authority_kind(message) == Some(kind) + }) || suffix.iter().any(is_human_user_message) + { + return false; + } + match lifetime { + RuntimeAuthorityLifetime::CurrentUserTurn => true, + RuntimeAuthorityLifetime::NextAssistantDecision => !suffix + .iter() + .any(|message| message.get("role").and_then(Value::as_str) == Some("assistant")), + } +} + +/// Earliest canonical suffix boundary that must survive a history rewrite so +/// active append-only authority remains attached to the human turn it governs. +/// +/// The boundary is the nearest preceding human-authored user message for the +/// earliest active frame. If a runtime reconciliation has no human anchor, +/// the frame itself is the protected boundary. Callers may compact earlier +/// history, but must preserve this suffix in order and as one provider-valid +/// conversation span. +#[must_use] +pub fn active_append_only_authority_protected_suffix_start(history: &[Value]) -> Option { + let first_active = history.iter().enumerate().find_map(|(index, _)| { + append_only_runtime_authority_is_active(history, index).then_some(index) + })?; + Some( + history[..=first_active] + .iter() + .rposition(is_human_user_message) + .unwrap_or(first_active), + ) } #[cfg(test)] @@ -123,4 +421,112 @@ mod tests { assert!(!is_runtime_owned_message(&message)); } } + + #[test] + fn unknown_runtime_delivery_never_becomes_human_intent() { + let message = json!({ + "role": "user", + "content": "future runtime frame", + RUNTIME_MESSAGE_PROVENANCE_FIELD: { + RUNTIME_MESSAGE_PRODUCER_FIELD: RUNTIME_MESSAGE_PRODUCER, + RUNTIME_MESSAGE_DELIVERY_FIELD: "future_delivery", + }, + }); + + assert!(is_runtime_owned_message(&message)); + assert_eq!(runtime_message_delivery(&message), None); + assert!(!is_human_user_message(&message)); + } + + #[test] + fn append_only_authority_has_typed_identity_and_lifetime() { + let mut message = json!({"role": "user", "content": "runtime control"}); + mark_append_only_required_context( + &mut message, + "final_answer_settlement", + RuntimeAuthorityLifetime::NextAssistantDecision, + ); + + assert_eq!( + runtime_message_delivery(&message), + Some(RuntimeMessageDelivery::AppendOnlyRequiredContext) + ); + assert_eq!( + runtime_authority_kind(&message), + Some("final_answer_settlement") + ); + assert_eq!( + runtime_authority_lifetime(&message), + Some(RuntimeAuthorityLifetime::NextAssistantDecision) + ); + assert!(!is_human_user_message(&message)); + assert!(is_human_user_message( + &json!({"role": "user", "content": "real request"}) + )); + } + + #[test] + fn append_only_authority_frame_uses_strict_typed_grammar() { + let lifetime = RuntimeAuthorityLifetime::NextAssistantDecision; + let content = + render_append_only_runtime_authority_frame("work", lifetime, "do work").expect("frame"); + let mut message = json!({"role": "user", "content": content}); + mark_append_only_required_context(&mut message, "work", lifetime); + + assert_eq!( + parse_append_only_runtime_authority_frame(&message).expect("valid frame"), + ParsedRuntimeAuthorityFrame { + kind: "work".to_string(), + lifetime, + payload: "do work".to_string(), + } + ); + + let mut mismatched = message.clone(); + mismatched[RUNTIME_MESSAGE_PROVENANCE_FIELD][RUNTIME_MESSAGE_AUTHORITY_KIND_FIELD] = + Value::String("other".to_string()); + assert_eq!( + parse_append_only_runtime_authority_frame(&mismatched), + Err(RuntimeAuthorityFrameError::HeaderMismatch) + ); + + let mut extra_header = message; + extra_header["content"] = Value::String( + "\n{\"schema\":\"runtime_authority_frame.v1\",\"kind\":\"work\",\"lifetime\":\"next_assistant_decision\",\"extra\":true}\ndo work\n" + .to_string(), + ); + assert_eq!( + parse_append_only_runtime_authority_frame(&extra_header), + Err(RuntimeAuthorityFrameError::InvalidFrame) + ); + } + + #[test] + fn active_authority_protects_its_human_turn_suffix() { + let mut old = json!({"role": "user", "content": "expired"}); + mark_append_only_required_context( + &mut old, + "old", + RuntimeAuthorityLifetime::CurrentUserTurn, + ); + let mut active = json!({"role": "user", "content": "active"}); + mark_append_only_required_context( + &mut active, + "work", + RuntimeAuthorityLifetime::CurrentUserTurn, + ); + let history = vec![ + json!({"role": "user", "content": "old human"}), + old, + json!({"role": "assistant", "content": "old answer"}), + json!({"role": "user", "content": "current human"}), + active, + json!({"role": "assistant", "content": "tool loop"}), + ]; + + assert_eq!( + active_append_only_authority_protected_suffix_start(&history), + Some(3) + ); + } } diff --git a/crates/runtime/src/messaging/e2e_loop_tests.rs b/crates/runtime/src/messaging/e2e_loop_tests.rs index 3692beff51..bc99991df5 100644 --- a/crates/runtime/src/messaging/e2e_loop_tests.rs +++ b/crates/runtime/src/messaging/e2e_loop_tests.rs @@ -231,6 +231,8 @@ mod tests { budget_wrapup_injected: false, context_compression_triggered: false, canonical_rewrite_state: Default::default(), + provider_canonical_wal_base: None, + provider_canonical_wal_head_transition_id: None, budget_wrapup_ignored_rounds: 0, compact_tier_applied: astra_turn_core::compaction_types::CompactionTier::Normal, skill_produced_output: false, diff --git a/crates/runtime/src/server/run/lifecycle/mod.rs b/crates/runtime/src/server/run/lifecycle/mod.rs index 4941364862..af543341bb 100644 --- a/crates/runtime/src/server/run/lifecycle/mod.rs +++ b/crates/runtime/src/server/run/lifecycle/mod.rs @@ -4910,6 +4910,7 @@ fn fresh_request_admission_bytes(request: &ChatRequestData) -> Result, + inference_pool: SharedPool, lease: astra_turn_types::ConversationWriterLeaseV1, reservation: astra_turn_types::TurnReservationV1, prior_messages: Vec, @@ -5413,6 +5414,7 @@ impl AgenticRunLifecycleService { }); Ok(Some(CanonicalTurnAdmission { coordinator, + inference_pool: pool.clone(), lease, reservation, prior_messages, @@ -5499,6 +5501,27 @@ impl AgenticRunLifecycleService { } } .await; + if result.as_ref().is_ok_and(Option::is_some) + && let Err(error) = astra_services::retire_inference_canonical_transitions_through_turn( + &admission.inference_pool, + &admission.lease.key.owner_user_id, + &admission.lease.key.session_id, + admission.reservation.reserved_turn, + ) + .await + { + // The canonical commit is authoritative and must not be reported + // as failed because payload retirement is lagging. A later + // session boundary retries cleanup for all absorbed turns. + tracing::warn!( + target: "astra_runtime::canonical_wal", + user_id = %admission.lease.key.owner_user_id, + session_id = %admission.lease.key.session_id, + turn = admission.reservation.reserved_turn, + %error, + "canonical commit succeeded but provider WAL payload retirement is pending" + ); + } if admission.release_writer_on_finish { let _ = admission.coordinator.release_writer(&admission.lease).await; } @@ -11039,6 +11062,8 @@ impl AgenticRunLifecycleService { budget_wrapup_injected: false, context_compression_triggered: false, canonical_rewrite_state: Default::default(), + provider_canonical_wal_base: None, + provider_canonical_wal_head_transition_id: None, budget_wrapup_ignored_rounds: 0, compact_tier_applied: astra_turn_core::compaction_types::CompactionTier::Normal, skill_produced_output: false, @@ -11055,9 +11080,16 @@ impl AgenticRunLifecycleService { memory_extraction_service, observation_journal: Default::default(), session_memory_state: Default::default(), - compact_strategy: astra_turn_core::microcompact::CompactStrategy::from_provider_hint( - request.model.as_deref().unwrap_or(""), - ), + compact_strategy: request + .admitted_model_execution + .as_ref() + .map(|execution| { + crate::turn::llm::context::compact_strategy_from_model_metadata( + execution.cache_capability, + &execution.provider, + ) + }) + .unwrap_or_default(), approval_overrides: None, confidence_trend: Default::default(), last_confidence_diagnosis: None, @@ -12877,6 +12909,9 @@ impl RunLifecycleService for AgenticRunLifecycleService { Some(admission) => admission.reservation.reserved_turn, None => infer_session_turn(self.shared_pool.as_ref(), &user_id, &session_id).await, }; + if let Some(admission) = canonical_turn.as_ref() { + loop_state.initialize_provider_canonical_wal_base(&admission.prior_messages); + } if let Some(admission) = canonical_turn.as_ref() && admission.had_canonical_head { @@ -14882,6 +14917,9 @@ impl RunLifecycleService for AgenticRunLifecycleService { &runtime_capabilities, tool_runtime_workspace.is_some(), )?; + if let Some(admission) = canonical_turn.as_ref() { + state.initialize_provider_canonical_wal_base(&admission.prior_messages); + } // Inject user_id into the harness sink used by DB-persistence tests. #[cfg(feature = "harness")] state.harness.set_user_id(&user_id); @@ -20776,9 +20814,15 @@ impl SubRunExecutor for ServerSubRunExecutor { }); // Build edge profile from agent's system prompt and metadata. - let compact_strategy = astra_turn_core::microcompact::CompactStrategy::from_provider_hint( - child_model_name.as_deref().unwrap_or(""), - ); + let compact_strategy = admitted_model_execution + .as_ref() + .map(|execution| { + crate::turn::llm::context::compact_strategy_from_model_metadata( + execution.cache_capability, + &execution.provider, + ) + }) + .unwrap_or_default(); let mut edge_profile = Map::new(); if let Some(prompt) = &config.agent_profile.system_prompt { edge_profile.insert( @@ -21106,6 +21150,8 @@ impl SubRunExecutor for ServerSubRunExecutor { budget_wrapup_injected: false, context_compression_triggered: false, canonical_rewrite_state: Default::default(), + provider_canonical_wal_base: None, + provider_canonical_wal_head_transition_id: None, budget_wrapup_ignored_rounds: 0, compact_tier_applied: astra_turn_core::compaction_types::CompactionTier::Normal, skill_produced_output: false, diff --git a/crates/runtime/src/server/run/lifecycle/tests.rs b/crates/runtime/src/server/run/lifecycle/tests.rs index 371c1ce121..6e9070f593 100644 --- a/crates/runtime/src/server/run/lifecycle/tests.rs +++ b/crates/runtime/src/server/run/lifecycle/tests.rs @@ -7936,15 +7936,25 @@ async fn root_live_terminal_precedes_blocked_remote_descendant_convergence() { ); let blocked = descendant_entered.notified(); tokio::pin!(blocked); - assert!( - AgenticRunLifecycleService::schedule_durable_user_cancelled_run_descendants( - engine.clone(), - "user-1", - session_id, - root_id, - false, - ) + // A process-global scheduler is bound to the production runtime's + // lifetime. Unit tests create and destroy independent Tokio runtimes, so + // sharing that singleton across tests can leave a supervisor attached to + // an already-closed runtime. Exercise the same scheduler contract with an + // explicitly owned test instance. + let scheduler = DescendantCancellationScheduler::new( + DESCENDANT_CANCELLATION_JOB_CONCURRENCY, + DESCENDANT_CANCELLATION_QUEUE_CAPACITY, + DESCENDANT_CANCELLATION_JOB_DEADLINE, ); + assert!(scheduler.enqueue(DescendantCancellationJob { + key: DescendantCancellationJobKey { + user_id: "user-1".to_string(), + session_id: session_id.to_string(), + parent_run_id: root_id.to_string(), + }, + run_engine: engine.clone(), + verify_outermost_scope: false, + })); tokio::time::timeout(Duration::from_secs(2), &mut blocked) .await .expect("background durable descendant sweep must reach the remote row"); diff --git a/crates/runtime/src/server/server_loop_host.rs b/crates/runtime/src/server/server_loop_host.rs index cf2247a5ad..280bc0360e 100644 --- a/crates/runtime/src/server/server_loop_host.rs +++ b/crates/runtime/src/server/server_loop_host.rs @@ -101,6 +101,7 @@ const PROVIDER_ACTION_CONVERGENCE_BUDGET: Duration = Duration::from_secs(30); #[derive(Clone, Copy, Debug)] struct RunExecutionTimeBudget { deadline: tokio::time::Instant, + deadline_unix_ms: u64, } impl RunExecutionTimeBudget { @@ -109,14 +110,32 @@ impl RunExecutionTimeBudget { } fn new_at(snapshot: ExecutionTimeBudget, now: tokio::time::Instant) -> Self { + let wall_now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX); Self { deadline: now + Duration::from_secs(snapshot.remaining_seconds), + deadline_unix_ms: wall_now_ms + .saturating_add(snapshot.remaining_seconds.saturating_mul(1_000)), } } fn tighten_at(&mut self, snapshot: ExecutionTimeBudget, now: tokio::time::Instant) { let proposed = now + Duration::from_secs(snapshot.remaining_seconds); - self.deadline = self.deadline.min(proposed); + if proposed < self.deadline { + self.deadline = proposed; + let wall_now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX); + self.deadline_unix_ms = + wall_now_ms.saturating_add(snapshot.remaining_seconds.saturating_mul(1_000)); + } } fn remaining(self) -> Duration { @@ -127,9 +146,9 @@ impl RunExecutionTimeBudget { self.deadline.saturating_duration_since(now) } - fn snapshot(self) -> ExecutionTimeBudget { - ExecutionTimeBudget { - remaining_seconds: self.remaining().as_secs(), + fn authority_snapshot(self) -> ExecutionDeadlineAuthority { + ExecutionDeadlineAuthority { + deadline_unix_ms: self.deadline_unix_ms, } } @@ -139,6 +158,11 @@ impl RunExecutionTimeBudget { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ExecutionDeadlineAuthority { + deadline_unix_ms: u64, +} + /// Execution-owned provider-work slice derived from the run's monotonic /// deadline. It is deliberately distinct from the admitted endpoint's /// response-start timeout. @@ -151,6 +175,15 @@ impl ProviderWorkBudget { } } +/// One no-await dispatch snapshot. The model-visible remaining seconds and +/// client timeout are derived from this same value, so preparation latency can +/// neither replenish the hard deadline nor make the prompt overstate it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ProviderDispatchBudget { + prompt_snapshot: Option, + client_timeout: Option, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct ProviderAttemptBoundary { convergence: bool, @@ -656,6 +689,14 @@ fn settlement_wire_tool_schemas( }) } +fn preserve_text_only_wire_surface( + text_only: bool, + ignored_text_only_rounds: u32, + provider: &str, +) -> bool { + text_only && ignored_text_only_rounds == 0 && provider_supports_no_tool_choice(provider) +} + /// Select the tool surface that versions both the provider's schemas and its /// cross-tool prompt contract. Runtime execution authority may be narrower at /// a settlement boundary, but the provider-visible pair must never disagree. @@ -763,12 +804,25 @@ fn system_next_work_item_call(session_turn: u32, round: u32) -> Value { }) } +fn pre_turn_summary_spill_count(messages: &[Value]) -> usize { + let keep_recent = (messages.len() / 4).max(4); + let proposed = crate::turn::agentic_loop::execution_phase::adjust_spill_boundary_for_tool_pairs( + messages, + messages.len().saturating_sub(keep_recent), + ); + astra_turn_types::active_append_only_authority_protected_suffix_start(messages) + .map_or(proposed, |protected_start| proposed.min(protected_start)) +} + fn apply_pre_turn_summary( state: &mut AgenticLoopState, pressure: f64, summary_text: String, -) -> CompactionEvent { - let rewrite_permit = state.begin_canonical_rewrite(); + spill_count: usize, +) -> Option { + if spill_count == 0 || spill_count > state.messages.len() { + return None; + } let max_tokens = state.max_turn_input_tokens; let (tokens_before, old_count) = ( crate::turn::agentic_loop::lifecycle::estimate_context_pressure( @@ -779,37 +833,35 @@ fn apply_pre_turn_summary( .1, state.messages.len(), ); - let keep_recent = (old_count / 4).max(4); - let spill_count = - crate::turn::agentic_loop::execution_phase::adjust_spill_boundary_for_tool_pairs( - &state.messages, - old_count.saturating_sub(keep_recent), - ); - let spilled_count = state.messages.drain(..spill_count).count(); - state.messages.insert( - 0, - serde_json::json!({ - "role": "system", - "content": format!( - "[Conversation compacted — {} messages summarized]\n\n{}", - spilled_count, - summary_text, - ) - }), - ); - state.finish_canonical_rewrite(rewrite_permit); - state.compact_tier_applied = CompactionTier::CompactHistory; - state.context_compression_triggered = true; - + let mut compacted = Vec::with_capacity(old_count.saturating_sub(spill_count) + 1); + compacted.push(serde_json::json!({ + "role": "system", + "content": format!( + "[Conversation compacted — {} messages summarized]\n\n{}", + spill_count, + summary_text, + ) + })); + compacted.extend(state.messages[spill_count..].iter().cloned()); let tokens_after = crate::turn::agentic_loop::lifecycle::estimate_context_pressure( - &state.messages, + &compacted, state.pinned_tool_schema_tokens as usize, max_tokens, ) .1; + if tokens_after >= tokens_before { + return None; + } + + let rewrite_permit = state.begin_canonical_rewrite(); + state.messages = compacted; + state.finish_canonical_rewrite(rewrite_permit); + state.compact_tier_applied = CompactionTier::CompactHistory; + state.context_compression_triggered = true; + let tokens_freed = tokens_before.saturating_sub(tokens_after); let messages_removed = old_count.saturating_sub(state.messages.len()); - CompactionEvent::new( + Some(CompactionEvent::new( CompactionKind::PreTurnSummary, pressure, tokens_freed, @@ -818,7 +870,7 @@ fn apply_pre_turn_summary( messages_removed, state.messages.len(), vec!["llm_summary".to_string()], - ) + )) } fn insert_event_fields(event: &mut Map, fields: &Map) { @@ -1162,17 +1214,27 @@ fn merge_output_cap_continuation(prefix: &str, continuation: &str) -> String { format!("{prefix}\n{continuation}") } -fn append_output_cap_continuation_context(messages: &mut Vec, partial_text: &str) { - if !partial_text.is_empty() { - messages.push(json!({ - "role": "assistant", - "content": partial_text, - })); +fn provider_retry_assistant(result: &LlmCallResult) -> Value { + let mut assistant = json!({ + "role": "assistant", + "content": result.full_text, + }); + let Some(object) = assistant.as_object_mut() else { + return assistant; + }; + if !result.reasoning.is_empty() { + object.insert( + "reasoning_content".to_string(), + Value::String(result.reasoning.clone()), + ); } - messages.push(json!({ - "role": "user", - "content": output_cap_continuation_prompt(), - })); + if !result.reasoning_signature.is_empty() { + object.insert( + "reasoning_signature".to_string(), + Value::String(result.reasoning_signature.clone()), + ); + } + assistant } fn output_cap_action_first_context() -> Option { @@ -1386,10 +1448,12 @@ fn record_full_llm_request_event( source: &str, model: &str, provider: &str, + cache_capability: astra_turn_core::cache_placement::CacheCapability, attempt: u32, messages: &[Value], tools: &[Value], max_output_tokens: Option, + provider_attempts: &[crate::turn::llm::durable::DurableProviderAttemptFact], ) { if session_id.is_empty() || !full_llm_capture { return; @@ -1398,7 +1462,7 @@ fn record_full_llm_request_event( return; }; let round = buf.current_round(); - let prompt_request_plan = + let mut prompt_request_plan = astra_services::plan_prompt_request(astra_services::PromptRequestPlanInput { user_id, session_id, @@ -1411,10 +1475,20 @@ fn record_full_llm_request_event( max_output_tokens, }) .ok(); + if let Some(summary) = prompt_request_plan + .as_mut() + .and_then(|plan| plan.summary_json.as_object_mut()) + { + summary.insert( + "projection_authority".to_string(), + Value::String("planned_pre_client_projection_v1".to_string()), + ); + } let trace = crate::turn::llm::exchange_capture::CaptureTrace { session_turn_source: Some("state"), turn_chain_id: None, user_query_event_id: None, + cache_capability: Some(cache_capability), }; let mut evt = astra_services::session_journal::JournalEvent::llm_request_full( Some(session_id), @@ -1424,13 +1498,36 @@ fn record_full_llm_request_event( "source": source, "model": model, "provider": provider, + "cache_capability": cache_capability, "attempt": attempt, + "request_projection_authority": "planned_pre_client_projection_v1", + "provider_final_request_receipts": provider_attempts.iter().map(|attempt| json!({ + "authority": "exact_serialized_provider_body_v1", + "transport_stage": if attempt.dispatch_started { + "dispatch_started" + } else { + "prepared_and_admitted" + }, + "request_id": attempt.request.request_id, + "request_hash": attempt.request.request_hash, + "attempt": attempt.request.attempt, + "protocol": attempt.request.protocol.as_str(), + "serialized_bytes": attempt.request.provider_wire_bytes, + "message_sequence_sha256": attempt.request.fingerprints.message_sequence_sha256, + "system_sequence_sha256": attempt.request.fingerprints.system_sequence_sha256, + "cache_key_system_sha256": attempt.request.fingerprints.cache_key_system_sha256, + "conversation_sequence_sha256": attempt.request.fingerprints.conversation_sequence_sha256, + "tool_schema_sequence_sha256": attempt.request.fingerprints.tool_schema_sequence_sha256, + "cache_key_tool_schema_sequence_sha256": attempt.request.fingerprints.cache_key_tool_schema_sequence_sha256, + "cache_capability": attempt.request.fingerprints.cache_capability, + })).collect::>(), "trace": { "session_turn": state.session_turn, "round": round, "session_turn_source": trace.session_turn_source, "turn_chain_id": trace.turn_chain_id, "user_query_event_id": trace.user_query_event_id, + "cache_capability": trace.cache_capability, }, "request": build_server_capture_request_json( messages, @@ -1453,6 +1550,40 @@ fn record_full_llm_request_event( buf.record(evt); } +fn record_provider_attempt_cache_observations( + state: &mut AgenticLoopState, + attempts: &[crate::turn::llm::durable::DurableProviderAttemptFact], +) { + let Some(pipeline_session) = state.pipeline_session.as_mut() else { + return; + }; + for attempt in attempts.iter().filter(|attempt| attempt.dispatch_started) { + let Some(fingerprint) = attempt.request.fingerprints.cache_diagnostic_fingerprint() else { + continue; + }; + let cache_read_tokens = attempt.terminal.as_ref().and_then(|terminal| { + (!matches!( + terminal.usage_status, + astra_services::InferenceUsageStatus::Unavailable + )) + .then_some(terminal.usage.input.cache_read_tokens) + }); + pipeline_session.record_provider_attempt_cache_observation( + "agentic_loop", + astra_turn_core::pipeline_session::ProviderAttemptCacheObservation { + attempt_identity: + astra_turn_core::cache_diagnostics::ProviderAttemptCacheIdentity { + request_id: attempt.request.request_id.clone(), + attempt: attempt.request.attempt, + }, + dispatched: true, + fingerprint, + cache_read_tokens, + }, + ); + } +} + fn record_full_llm_response_event( state: &mut AgenticLoopState, full_llm_capture: bool, @@ -1460,6 +1591,7 @@ fn record_full_llm_response_event( source: &str, model: &str, provider: &str, + cache_capability: astra_turn_core::cache_placement::CacheCapability, attempt: u32, outcome: &str, response: Value, @@ -1475,6 +1607,7 @@ fn record_full_llm_response_event( session_turn_source: Some("state"), turn_chain_id: None, user_query_event_id: None, + cache_capability: Some(cache_capability), }; let mut evt = astra_services::session_journal::JournalEvent::llm_response_full( Some(session_id), @@ -1484,6 +1617,7 @@ fn record_full_llm_response_event( "source": source, "model": model, "provider": provider, + "cache_capability": cache_capability, "attempt": attempt, "trace": { "session_turn": state.session_turn, @@ -1491,6 +1625,7 @@ fn record_full_llm_response_event( "session_turn_source": trace.session_turn_source, "turn_chain_id": trace.turn_chain_id, "user_query_event_id": trace.user_query_event_id, + "cache_capability": trace.cache_capability, }, "response": crate::turn::llm::exchange_capture::build_capture_response_json( outcome, @@ -1877,6 +2012,11 @@ pub struct CapturedLlmRequest { /// Conversation messages after `add_message_cache_breakpoint` was applied /// (for Anthropic) or a clone of `state.messages` (otherwise). pub messages: Vec, + /// Exact message array after provider-specific system consolidation and + /// internal-marker stripping. This is the shape handed to the request-body + /// builder; cache regressions must assert on this field rather than the + /// earlier assembly representation. + pub provider_messages: Vec, /// Number of `cache_control` blocks present in `system_primary` content. pub system_cache_control_count: usize, /// Whether the last tool schema carries a `cache_control` marker. @@ -1902,6 +2042,110 @@ pub struct CapturedLlmRequest { pub message_sha256: Vec, } +#[derive(Debug)] +struct ProviderCanonicalHydrationOutcome { + reconciled_transitions: usize, + head_transition_id: Option, + replacement: Option, +} + +fn sanitize_provider_canonical_wal_snapshot( + durable_base: &astra_turn_types::CanonicalPrefixIdentityV1, + messages: &[Value], +) -> Vec { + let base_count = usize::try_from(durable_base.message_count).ok(); + if let Some(base_count) = base_count + && messages.len() >= base_count + && astra_turn_types::canonical_conversation_root(&messages[..base_count]) + == durable_base.root_hash + { + let mut sanitized = messages[..base_count].to_vec(); + sanitized.extend( + astra_turn_core::runtime_scaffolding::sanitize_durable_message_values( + messages[base_count..].to_vec(), + ), + ); + sanitized + } else { + astra_turn_core::runtime_scaffolding::sanitize_durable_message_values(messages.to_vec()) + } +} + +fn apply_provider_canonical_transition_receipts( + messages: &mut Vec, + receipts: Vec, +) -> Result { + if receipts.is_empty() { + return Ok(ProviderCanonicalHydrationOutcome { + reconciled_transitions: 0, + head_transition_id: None, + replacement: None, + }); + } + if receipts.len() != 1 || receipts[0].transitions.len() != 1 { + return Err(astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + "provider canonical WAL loader returned more than its unique-head contract", + )); + } + let leaf = receipts + .into_iter() + .next() + .and_then(|receipt| receipt.transitions.into_iter().next()) + .expect("the unique-head cardinality was checked above"); + leaf.validate().map_err(|error| { + astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + format!("validate provider canonical WAL head: {error}"), + ) + })?; + let mut candidate = messages.clone(); + leaf.apply_to(&mut candidate).map_err(|error| { + astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + format!("reconcile provider canonical transition WAL leaf: {error}"), + ) + })?; + let replacement = (leaf.recovery_mode + == astra_turn_types::ProviderCanonicalRecoveryModeV1::ReplaceFromDurableBase) + .then_some(leaf.clone()); + let head_transition_id = Some(leaf.transition_id.clone()); + *messages = candidate; + Ok(ProviderCanonicalHydrationOutcome { + reconciled_transitions: 1, + head_transition_id, + replacement, + }) +} + +fn hydrate_provider_canonical_transition_receipts( + messages: &mut Vec, + durable_base: &astra_turn_types::CanonicalPrefixIdentityV1, + receipts: Vec, +) -> Result { + let durable_count = usize::try_from(durable_base.message_count).map_err(|_| { + astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + "provider canonical WAL durable-base count overflow", + ) + })?; + if messages.len() < durable_count + || astra_turn_types::canonical_conversation_root(&messages[..durable_count]) + != durable_base.root_hash + { + return Err(astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + "provider canonical WAL durable base is absent from restored history", + )); + } + let fresh_suffix = messages[durable_count..].to_vec(); + let mut recovered = messages[..durable_count].to_vec(); + let outcome = apply_provider_canonical_transition_receipts(&mut recovered, receipts)?; + recovered.extend(fresh_suffix); + *messages = recovered; + Ok(outcome) +} + #[cfg(feature = "e2e-hooks")] fn value_has_cache_control(v: &Value) -> bool { v.get("cache_control") @@ -2070,6 +2314,7 @@ fn build_captured_llm_request( system_msgs: &[Value], tools: &[Value], messages: &[Value], + provider_messages: &[Value], breakdown: &astra_turn_core::context_assembly_trace::SystemPromptBreakdown, ) -> CapturedLlmRequest { let _ = breakdown; // retained in case future assertions want it @@ -2142,6 +2387,7 @@ fn build_captured_llm_request( system_dynamic: dynamic, tools: tools.to_vec(), messages: messages.to_vec(), + provider_messages: provider_messages.to_vec(), system_cache_control_count, last_tool_has_cache_control, last_message_has_cache_control, @@ -2185,6 +2431,10 @@ pub struct ServerAgenticLoopHost { summary_attempt_allocator: DurableSummaryAttemptAllocator, /// Monotonic wall-clock authority for this process-local run host. execution_time_budget: Option, + /// Recovery gate for provider-attempt-owned canonical append WAL. It is + /// opened exactly once, after session history restoration and before any + /// real provider dispatch owned by this host. + canonical_transition_hydrated: bool, // ── Context ── /// Final prompt-visible tool schemas after provider declaration, binding, @@ -2420,7 +2670,6 @@ pub struct ServerAgenticLoopHost { /// Attempt identity that already received its one start-of-task contract. /// This is a runtime reminder, not a second task state machine; the /// durable assignment and settlement remain authoritative. - work_attempt_started_context: Option, /// Attempt identity that already received one server-owned assignment /// replay after a text-only coordinator response. `run_next_work_item` /// returns the same active attempt in this state; replaying it again is @@ -2449,6 +2698,8 @@ pub struct ServerAgenticLoopHost { /// leaves `PromptCacheConfig::default()` behavior (annotations off). #[cfg(feature = "e2e-hooks")] mock_provider: Option<(String, String)>, + #[cfg(feature = "e2e-hooks")] + mock_cache_capability: Option, /// Per-turn captured payloads for assertion in tests. #[cfg(feature = "e2e-hooks")] llm_request_capture: Option>>>, @@ -2726,6 +2977,8 @@ pub struct ServerAgenticLoopHostBuilder { #[cfg(feature = "e2e-hooks")] mock_provider: Option<(String, String)>, #[cfg(feature = "e2e-hooks")] + mock_cache_capability: Option, + #[cfg(feature = "e2e-hooks")] llm_request_capture: Option>>>, capabilities: astra_turn_core::capability::CapabilitySet, work_planning_bound: bool, @@ -2801,6 +3054,8 @@ impl ServerAgenticLoopHostBuilder { #[cfg(feature = "e2e-hooks")] mock_provider: None, #[cfg(feature = "e2e-hooks")] + mock_cache_capability: None, + #[cfg(feature = "e2e-hooks")] llm_request_capture: None, capabilities: crate::capabilities::full_server_capabilities_for_tests(), work_planning_bound: false, @@ -3038,6 +3293,17 @@ impl ServerAgenticLoopHostBuilder { self } + /// **Test-only.** Declare the cache/request shape independently from the + /// provider and model labels, matching production model metadata. + #[cfg(feature = "e2e-hooks")] + pub fn with_mock_cache_capability( + mut self, + capability: astra_turn_core::cache_placement::CacheCapability, + ) -> Self { + self.mock_cache_capability = Some(capability); + self + } + /// **Test-only.** Attach an `Arc>>`; every /// invocation of `execute_mock_turn` appends a snapshot of the materials /// that would be sent to a real LLM (system messages with cache_control @@ -3313,6 +3579,7 @@ impl ServerAgenticLoopHostBuilder { resolved_llm_config_at: None, summary_attempt_allocator: DurableSummaryAttemptAllocator::default(), execution_time_budget: self.execution_time_budget, + canonical_transition_hydrated: false, tool_schemas, admission_tool_schemas, deferred_tool_schemas, @@ -3381,7 +3648,6 @@ impl ServerAgenticLoopHostBuilder { progress_filter, turn_start_lifecycle_summary: None, turn_start_plan_resume_hint: None, - work_attempt_started_context: None, work_attempt_scheduler_replayed: None, execution_metadata: self.execution_bindings.as_ref().map(|snapshot| { Value::Object(binding_event_fields( @@ -3403,6 +3669,8 @@ impl ServerAgenticLoopHostBuilder { #[cfg(feature = "e2e-hooks")] mock_provider: self.mock_provider, #[cfg(feature = "e2e-hooks")] + mock_cache_capability: self.mock_cache_capability, + #[cfg(feature = "e2e-hooks")] llm_request_capture: self.llm_request_capture, #[cfg(feature = "e2e-hooks")] emitted_tool_call_ids: self.shared_dedup_state.unwrap_or_else(|| { @@ -3844,6 +4112,7 @@ impl ServerAgenticLoopHost { ) } + #[cfg(test)] fn execution_provider_work_budget( execution_time_budget: Option, ) -> Result, astra_core::ClassifiedError> { @@ -3857,6 +4126,7 @@ impl ServerAgenticLoopHost { Ok(Some(ProviderWorkBudget(remaining))) } + #[cfg(test)] fn provider_work_budget_at_client_boundary( execution_time_budget: Option, boundary: ProviderAttemptBoundary, @@ -3865,48 +4135,145 @@ impl ServerAgenticLoopHost { .provider_work_budget(Self::execution_provider_work_budget(execution_time_budget)?)) } + fn provider_dispatch_budget( + execution_time_budget: Option, + boundary: ProviderAttemptBoundary, + ) -> Result { + let Some(execution_time_budget) = execution_time_budget else { + return Ok(ProviderDispatchBudget { + prompt_snapshot: None, + client_timeout: boundary.provider_work_budget(None), + }); + }; + // The wire schema is second-granular. Clamp the actual provider slice + // to those same whole seconds instead of advertising a smaller value + // while silently granting the fractional remainder. + let remaining_seconds = execution_time_budget.remaining().as_secs(); + if remaining_seconds == 0 { + return Err(Self::execution_time_budget_error()); + } + let snapshot = execution_time_budget.authority_snapshot(); + let client_timeout = boundary.provider_work_budget(Some(ProviderWorkBudget( + Duration::from_secs(remaining_seconds), + ))); + Ok(ProviderDispatchBudget { + prompt_snapshot: Some(snapshot), + client_timeout, + }) + } + fn clamp_execution_timeout(&self, requested: Duration) -> Option { self.execution_time_budget .map_or(Some(requested), |budget| budget.clamp_timeout(requested)) } - fn append_execution_time_budget_tail( - messages: &mut Vec, - snapshot: ExecutionTimeBudget, + fn execution_time_budget_context( + snapshot: ExecutionDeadlineAuthority, round_index: u32, - ) { + ) -> Option { let injection = astra_turn_core::chat_turn_edge_profile::RuntimeVolatileInjection { kind: "execution_time_budget".to_string(), delivery_class: astra_turn_core::chat_turn_edge_profile::VolatileDeliveryClass::RequiredContext, payload: json!({ - "schema": "execution_time_budget.v1", - "remaining_seconds": snapshot.remaining_seconds, - "status": if snapshot.remaining_seconds == 0 { "exhausted" } else { "active" }, - "instruction": "This is the current runtime-enforced wall-clock remainder. Finish or hand off within it; do not start work that cannot complete inside the remaining time.", + "schema": "execution_time_deadline.v2", + "deadline_unix_ms": snapshot.deadline_unix_ms, + "status": "active", + "instruction": "This is the immutable wall-clock deadline paired with the runtime's monotonic hard timeout. Time continues to elapse during provider admission and transport; finish or hand off before this deadline and do not treat it as a replenishable per-request allowance.", }), round_index, }; - if let Some(content) = injection.render_for_prompt() { - // This synthetic current-request tail is never written back to - // state.messages and is appended after cache annotation. - messages.push(json!({ - "role": "user", - "content": content, - })); + injection.render_for_prompt() + } + + fn project_required_runtime_authority( + provider_messages: &[Value], + canonical_history: &[Value], + content: &str, + kind: crate::turn::wire_assembly::RuntimeAuthorityKind, + lifetime: astra_turn_types::RuntimeAuthorityLifetime, + _provider: &str, + cache_capability: astra_turn_core::cache_placement::CacheCapability, + ) -> Result<(Vec, Option), astra_core::ClassifiedError> { + let mut projected = provider_messages.to_vec(); + let mut new_append_only_runtime_message = None; + if matches!( + cache_capability.volatile_placement, + astra_turn_core::cache_placement::VolatilePlacement::AppendOnlyUserTail + ) { + let frame = crate::turn::wire_assembly::required_append_only_runtime_authority_message( + content, kind, lifetime, + ) + .map_err(|error| { + astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + error.to_string(), + ) + })? + .ok_or_else(|| { + astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + "required runtime authority produced empty content", + ) + })?; + if !crate::turn::wire_assembly::append_only_runtime_authority_is_redundant( + canonical_history, + &frame, + ) { + projected.push(frame.clone()); + new_append_only_runtime_message = Some(frame); + } + } else if let Some(message) = + crate::turn::wire_assembly::required_runtime_preamble_message(content, kind, lifetime) + { + projected.push(message); } + Ok((projected, new_append_only_runtime_message)) } fn messages_with_current_execution_time_budget( &self, - messages: &[Value], + provider_messages: &[Value], + canonical_history: &[Value], round_index: u32, - ) -> Option> { - self.execution_time_budget.map(|budget| { - let mut messages = messages.to_vec(); - Self::append_execution_time_budget_tail(&mut messages, budget.snapshot(), round_index); - messages - }) + provider: &str, + cache_capability: astra_turn_core::cache_placement::CacheCapability, + ) -> Result, Option)>, astra_core::ClassifiedError> { + let Some(budget) = self.execution_time_budget else { + return Ok(None); + }; + self.messages_with_execution_time_budget_snapshot( + provider_messages, + canonical_history, + round_index, + provider, + cache_capability, + budget.authority_snapshot(), + ) + .map(Some) + } + + fn messages_with_execution_time_budget_snapshot( + &self, + provider_messages: &[Value], + canonical_history: &[Value], + round_index: u32, + provider: &str, + cache_capability: astra_turn_core::cache_placement::CacheCapability, + snapshot: ExecutionDeadlineAuthority, + ) -> Result<(Vec, Option), astra_core::ClassifiedError> { + let Some(content) = Self::execution_time_budget_context(snapshot, round_index) else { + return Ok((provider_messages.to_vec(), None)); + }; + Self::project_required_runtime_authority( + provider_messages, + canonical_history, + &content, + crate::turn::wire_assembly::RuntimeAuthorityKind::ExecutionTimeBudget, + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + provider, + cache_capability, + ) } fn selected_edge_ledger_executor_id(&self) -> Result<&str, String> { @@ -4480,15 +4847,11 @@ impl ServerAgenticLoopHost { /// hard call-count limit: a long task may still use as many focused tools /// as its evidence requires. It simply makes the expected-result boundary /// visible before broad exploration can start. - fn active_work_attempt_start_context(&mut self, state: &AgenticLoopState) -> Option { + fn active_work_attempt_start_context(&self, state: &AgenticLoopState) -> Option { let active = state .runtime_tool_executor .as_deref()? .active_primary_work_attempt()?; - if self.work_attempt_started_context.as_deref() == Some(active.attempt_id.as_str()) { - return None; - } - self.work_attempt_started_context = Some(active.attempt_id.clone()); crate::turn::wire_assembly::required_runtime_preamble_message( &json!({ "schema": "active_work_attempt_start.v1", @@ -4498,6 +4861,8 @@ impl ServerAgenticLoopHost { "instruction": "Execute only this assigned WorkItem. Use the smallest focused evidence path and keep the investigation inside the objective. Before outcome=delivered, compare direct evidence literally with every payload and verification field in expected_result: every field must be present in both the evidence and settlement summary. Every explicit conjunct, including a named behavior check, command, test, or observable workflow, needs direct successful evidence; an unrun or failed check remains a gap, and compilation, imports, or adjacent smoke checks do not substitute for it. Reachability, an index/home page, a category list, or a claim that an action ran never substitutes for a requested item, value, article, result, or source. If a field is missing, continue the focused evidence path; if it cannot be obtained, report the truthful blocked or failed outcome. Settle immediately once the exact boundary is satisfied. Do not create, delegate, or broaden the task." }) .to_string(), + crate::turn::wire_assembly::RuntimeAuthorityKind::ActiveWorkAttemptStart, + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, ) } @@ -4515,6 +4880,8 @@ impl ServerAgenticLoopHost { "instruction": "Answer the user's outcome, not an internal execution transcript. Re-check every explicit requested payload against direct evidence, not settlement labels or summaries. A collection root, index/home page, category list, or reachability does not satisfy a requested member/item. If direct evidence is incomplete, do the smallest corrective work or truthfully disclose the gap; never call it delivered. Unless the user explicitly asks for debugging internals, omit tool names, revision numbers, rejected attempts, runtime gates, and instructions for operating Astra. State only the concise user-visible results and any material limitation." }) .to_string(), + crate::turn::wire_assembly::RuntimeAuthorityKind::FinalWorkSynthesis, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, ) } @@ -4681,6 +5048,13 @@ impl ServerAgenticLoopHost { "instruction": instruction }) .to_string(), + crate::turn::wire_assembly::RuntimeAuthorityKind::PendingWorkGraphMutations, + // This obligation is conditional on the host's current durable + // pending set. One assistant decision consumes the frame; while + // the source remains pending the next request reconstructs it. + // Once an accepted exact proposal clears the source, no canonical + // append-only frame can keep the obsolete obligation alive. + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, ) } @@ -4700,6 +5074,8 @@ impl ServerAgenticLoopHost { ] }) .to_string(), + crate::turn::wire_assembly::RuntimeAuthorityKind::ReadOnlyEffectBoundary, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, ) }) .flatten() @@ -5194,6 +5570,8 @@ impl ServerAgenticLoopHost { "instruction": "The previous response did not establish canonical Work. Do not answer the user or call another tool yet. Call start_work now with the bounded task graph that realizes the user goal. After it succeeds, wait for the runtime-owned task assignment." }) .to_string(), + crate::turn::wire_assembly::RuntimeAuthorityKind::CanonicalWorkEstablishmentRetry, + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, ) .expect("non-empty canonical Work retry contract") } @@ -6936,9 +7314,23 @@ impl ServerAgenticLoopHost { || json!({ "full_text": "[mock rounds exhausted]", "tool_calls": [], "usage": {} }), ); let started = Instant::now(); - let result = self.execute_mock_turn(state, &round, started).await?; - state.llm_rounds_completed = state.llm_rounds_completed.saturating_add(1); - Ok(result) + let result = self.execute_mock_turn(state, &round, started).await; + match result { + Ok(result) => { + // `run_one_mock_turn_for_test` bypasses the production + // execution-phase owner, so it must close the same volatile + // attempt transaction at this boundary. Keeping the lease + // semantics identical prevents tests from silently weakening + // failed-attempt replay or successful-attempt consumption. + state.commit_volatile_attempt_lease(); + state.llm_rounds_completed = state.llm_rounds_completed.saturating_add(1); + Ok(result) + } + Err(error) => { + state.restore_volatile_attempt_lease(); + Err(error) + } + } } /// Execute a mock LLM turn from `test_llm_rounds` (e2e-hooks only). @@ -6973,7 +7365,7 @@ impl ServerAgenticLoopHost { let cache_cfg = match &self.mock_provider { Some((provider, model)) => { self.resolved_model_name = Some(model.clone()); - PromptCacheConfig::latch(provider, model) + PromptCacheConfig::from_cache_capability(self.mock_cache_capability, provider) } None => PromptCacheConfig::default(), }; @@ -6986,11 +7378,15 @@ impl ServerAgenticLoopHost { None => ("openai".to_string(), "server-loop-mock".to_string()), }; let user_content = state.message.clone(); - let mock_pipeline = self.run_turn_pipeline( + let mock_pipeline = self.run_turn_pipeline_with_cache_capability_and_session_memory( state, &tool_schemas_snapshot, &provider_name, &model_name_for_pipeline, + None, + self.mock_cache_capability, + None, + &[], &user_content, )?; state.last_llm_context_manifest_trace = Some(mock_pipeline.manifest_trace.to_json()); @@ -7010,9 +7406,6 @@ impl ServerAgenticLoopHost { &cache_cfg, &self.always_load_tool_names, ); - if let Some(pipeline_session) = state.pipeline_session.as_mut() { - pipeline_session.replace_pending_wire_tool_schemas(&annotated_tools); - } self.sync_valid_tools_to_wire_surface_for_state(&annotated_tools, state); self.last_turn_tool_schemas = clone_server_fork_tool_schemas(&annotated_tools); let (provider, model) = self @@ -7026,7 +7419,7 @@ impl ServerAgenticLoopHost { api_key: String::new(), base_url: String::new(), fallback_chain: Vec::new(), - cache_capability: None, + cache_capability: self.mock_cache_capability, thinking_capability: None, header_overrides: HashMap::new(), request_body_overrides: None, @@ -7043,11 +7436,24 @@ impl ServerAgenticLoopHost { &mock_llm_cfg, &cache_cfg, false, - ); + )?; + let provider_wire_messages = + crate::turn::llm::client::consolidate_system_messages_for_provider( + &wire_messages, + &provider, + mock_llm_cfg.cache_capability, + ); + if let Some(pipeline_session) = state.pipeline_session.as_mut() { + pipeline_session.replace_pending_planned_wire_prompt_with_cache_capability( + &provider_wire_messages, + &annotated_tools, + mock_llm_cfg.cache_capability, + ); + } if let Some(trace) = state.last_llm_context_manifest_trace.as_mut() { crate::turn::llm::context::augment_manifest_trace_with_wire_detail( trace, - &wire_messages, + &provider_wire_messages, &annotated_tools, if self.full_llm_capture { crate::turn::llm::context::WireTraceDetail::Debug @@ -7096,6 +7502,7 @@ impl ServerAgenticLoopHost { &system_msgs, &annotated_tools, &annotated_messages, + &provider_wire_messages, &mock_pipeline.breakdown, ); if let Ok(mut guard) = cap.lock() { @@ -7139,6 +7546,12 @@ impl ServerAgenticLoopHost { session_turn_source: Some("state"), turn_chain_id: None, user_query_event_id: None, + cache_capability: Some( + astra_turn_core::cache_placement::CacheCapability::from_explicit_or_provider( + mock_llm_cfg.cache_capability, + &provider, + ), + ), }), ) .await; @@ -7303,6 +7716,12 @@ impl ServerAgenticLoopHost { session_turn_source: Some("state"), turn_chain_id: None, user_query_event_id: None, + cache_capability: Some( + astra_turn_core::cache_placement::CacheCapability::from_explicit_or_provider( + mock_llm_cfg.cache_capability, + &provider, + ), + ), }), ) .await; @@ -10366,6 +10785,7 @@ impl ServerAgenticLoopHost { } } + #[cfg(test)] fn run_turn_pipeline( &mut self, state: &mut AgenticLoopState, @@ -10387,6 +10807,7 @@ impl ServerAgenticLoopHost { ) } + #[cfg(any(test, feature = "e2e-hooks"))] fn run_turn_pipeline_with_cache_capability_and_session_memory( &mut self, state: &mut AgenticLoopState, @@ -10482,8 +10903,7 @@ impl ServerAgenticLoopHost { model_name, model_context_window, ); - let cache_cfg = - PromptCacheConfig::from_cache_capability(cache_capability, provider, model_name); + let cache_cfg = PromptCacheConfig::from_cache_capability(cache_capability, provider); crate::turn::llm::context::assemble_context_pipeline( crate::turn::llm::context::LlmContextAssemblyInput { state, @@ -10564,7 +10984,7 @@ impl ServerAgenticLoopHost { llm_cfg: &ResolvedTurnLlmConfig, cache_cfg: &PromptCacheConfig, compaction_boundary_hit: bool, - ) -> Vec { + ) -> Result, astra_core::ClassifiedError> { // Per-turn skill listing (ranked shortlist) now flows through the // pipeline as an `extra_dynamic_sections` entry (RuntimeVolatile, // None scope). See `context_pipeline_adapter` — post-hoc injection @@ -10690,6 +11110,82 @@ impl ServerAgenticLoopHost { error_kind: None, })) } + + async fn hydrate_provider_canonical_transitions( + &mut self, + state: &mut AgenticLoopState, + ) -> Result<(), astra_core::ClassifiedError> { + if self.canonical_transition_hydrated { + return Ok(()); + } + if !state.owns_provider_canonical_transition_wal() { + // A subagent can share the parent's durable run/session scope for + // evidence custody while owning an independent prompt history. + // It must neither consume nor publish root canonical WAL rows. + self.canonical_transition_hydrated = true; + return Ok(()); + } + let Some(pool) = self.shared_pool.as_ref() else { + // A host without the durable inference ledger cannot have + // attempt-owned canonical WAL rows to recover. + self.canonical_transition_hydrated = true; + return Ok(()); + }; + let receipts = astra_services::load_inference_canonical_transitions_for_session( + pool, + &self.user_id, + &self.session_id, + state.session_turn, + ) + .await + .map_err(|error| { + astra_core::ClassifiedError::new( + astra_core::ErrorKind::DatabaseError, + format!("load provider canonical transition WAL: {error}"), + ) + })?; + + // A crashed reservation is reissued at the same reserved turn because + // only canonical commit advances the coordinator head. The admitted + // durable-base count is therefore the ownership boundary: detach the + // fresh request suffix, replay only on H, then restore that suffix. + // Comparing values cannot distinguish repeated input such as two + // identical `continue` messages across the crash boundary. + let durable_base = state.provider_canonical_wal_base.clone().ok_or_else(|| { + astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + "provider canonical WAL hydration has no admitted durable base", + ) + })?; + let mut recovered = state.messages.clone(); + let outcome = hydrate_provider_canonical_transition_receipts( + &mut recovered, + &durable_base, + receipts, + )?; + if let Some(replacement) = outcome.replacement.as_ref() { + state + .recover_provider_canonical_replacement(replacement, &recovered) + .map_err(|error| { + astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + format!("recover provider canonical rewrite proof: {error}"), + ) + })?; + } + state.provider_canonical_wal_head_transition_id = outcome.head_transition_id.clone(); + tracing::debug!( + target: "astra_runtime::canonical_wal", + session_id = %self.session_id, + turn = state.session_turn, + reconciled_transitions = outcome.reconciled_transitions, + replacement = outcome.replacement.is_some(), + "hydrated provider canonical transition WAL" + ); + state.messages = recovered; + self.canonical_transition_hydrated = true; + Ok(()) + } } #[async_trait] @@ -11180,7 +11676,6 @@ impl AgenticLoopHost for ServerAgenticLoopHost { self.work_admission_topology_authoritative = false; self.work_admission_conflict = None; self.work_admission_capabilities.clear(); - self.work_attempt_started_context = None; self.deferred_work_surface_turn = None; self.deferred_tools_block_cache = None; if let Some(content) = crate::turn::run_control::user_intent_content(&event.input) { @@ -11265,6 +11760,16 @@ impl AgenticLoopHost for ServerAgenticLoopHost { } } + // This is the single recovery gate shared by background and SSE run + // lifecycles: both have completed history restoration before the host + // can execute a real turn. The service first recovers expired + // pre-delivery owners and rejects every live or delivery-unknown old + // invocation. Run-generation fencing stops future old admissions, but + // only this delivery boundary prevents a duplicate after an already + // authorized HTTP request. Failure precedes model resolution, + // provider-attempt admission, and all new HTTP I/O. + self.hydrate_provider_canonical_transitions(state).await?; + // Reconcile a fast semantic preflight before the primary request so // its typed optional capabilities (for example `agent_fanout`) are // projected onto the provider surface. Do not materialize Required @@ -11404,7 +11909,7 @@ impl AgenticLoopHost for ServerAgenticLoopHost { .messages .iter() .rev() - .find(|m| m.get("role").and_then(Value::as_str) == Some("user")) + .find(|m| astra_turn_types::is_human_user_message(m)) .and_then(|m| m.get("content").and_then(Value::as_str)) .unwrap_or("") .to_string(); @@ -11412,10 +11917,9 @@ impl AgenticLoopHost for ServerAgenticLoopHost { let final_answer_settlement_text_only = state.hooks.completion_settlement.text_only; let work_settlement_only = state.hooks.completion_settlement.work_settlement_only; let cache_cap = - astra_turn_core::cache_placement::CacheCapability::from_explicit_or_provider_model( + astra_turn_core::cache_placement::CacheCapability::from_explicit_or_provider( llm_cfg.cache_capability, &llm_cfg.provider, - &llm_cfg.model_name, ); // A Work-bound run keeps one stable declaration surface for strict- // history providers. This is presentation/cache state only; the @@ -11428,11 +11932,24 @@ impl AgenticLoopHost for ServerAgenticLoopHost { ) && state.runtime_tool_executor.as_deref().is_some_and( crate::server::runtime_tool_executor::RuntimeToolExecutor::has_work_binding, ); - let preserve_text_only_tool_surface = final_answer_settlement_text_only - && provider_supports_no_tool_choice(&llm_cfg.provider); - let preserve_settlement_wire_surface = work_settlement_only - || preserve_text_only_tool_surface - || preserve_final_synthesis_wire_surface; + let preserve_text_only_tool_surface = preserve_text_only_wire_surface( + final_answer_settlement_text_only, + state.budget_wrapup_ignored_rounds, + &llm_cfg.provider, + ); + // A provider that ignored the first explicit text-only request has + // demonstrated that schema preservation is not a reliable boundary. + // On the one allowed repair request, physically remove declarations + // so degraded text protocols cannot request another tool. Work-only + // settlement is a different typed boundary and retains its sole + // settlement capability. + let retrying_ignored_text_only_boundary = final_answer_settlement_text_only + && !work_settlement_only + && state.budget_wrapup_ignored_rounds > 0; + let preserve_settlement_wire_surface = !retrying_ignored_text_only_boundary + && (work_settlement_only + || preserve_text_only_tool_surface + || preserve_final_synthesis_wire_surface); let effective_restricted = self.compute_effective_restricted(state, true, preserve_text_only_tool_surface); tracing::debug!( @@ -11476,11 +11993,8 @@ impl AgenticLoopHost for ServerAgenticLoopHost { // Latch prompt cache config from provider info (once per turn is fine; // provider doesn't change within a turn). - let cache_cfg = PromptCacheConfig::from_cache_capability( - llm_cfg.cache_capability, - &llm_cfg.provider, - &llm_cfg.model_name, - ); + let cache_cfg = + PromptCacheConfig::from_cache_capability(llm_cfg.cache_capability, &llm_cfg.provider); self.remember_resolved_llm_config(&llm_cfg); // ── 2b. Run the context pipeline ───────────────────────────────── @@ -11645,7 +12159,13 @@ impl AgenticLoopHost for ServerAgenticLoopHost { state.last_llm_context_manifest_trace = Some(final_manifest_trace.to_json()); } final_volatile_preamble.extend(compact_result.runtime_contexts.iter().filter_map( - |context| crate::turn::wire_assembly::required_runtime_preamble_message(context), + |context| { + crate::turn::wire_assembly::required_runtime_preamble_message( + context, + crate::turn::wire_assembly::RuntimeAuthorityKind::CompactedConversationSummary, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, + ) + }, )); self.reconcile_pending_work_graph_mutations(state); if let Some(context) = self.active_work_attempt_start_context(state) { @@ -11703,6 +12223,10 @@ impl AgenticLoopHost for ServerAgenticLoopHost { &mut compacted_messages, compact_result.boundary.is_some(), ); + // Canonical messages appended by the wire assembler are staged from + // this exact prefix. A matching WAL transition is bound to provider + // attempt admission before HTTP is authorized. + let mut durable_canonical_cursor = state.messages.len(); let mut llm_messages = self.assemble_llm_messages( final_system_messages, final_volatile_preamble, @@ -11711,7 +12235,7 @@ impl AgenticLoopHost for ServerAgenticLoopHost { &llm_cfg, &cache_cfg, compaction_boundary_hit, - ); + )?; // ── 3. Call LLM ───────────────────────────────────────────────── let budget = crate::prompts::budget_for_model_with_metadata( @@ -11721,13 +12245,13 @@ impl AgenticLoopHost for ServerAgenticLoopHost { ); let max_output_tokens = crate::prompts::capped_output_tokens(&budget); - // A text-only settlement changes execution authority, not the - // provider-visible schema prefix. Keep the schemas stable for cache - // reuse, but use the protocol's explicit no-tool choice whenever the - // provider supports it. The local admission gate remains a defense in - // depth for providers that ignore the wire choice or return malformed - // tool calls. - let use_no_tool_choice = preserve_text_only_tool_surface; + // The first text-only settlement keeps the provider-visible schema + // prefix and uses the protocol's explicit no-tool choice. If that + // request was ignored, the bounded repair request above fails closed + // with an empty schema surface instead of repeating an ineffective + // tool_choice-only hint. + let use_no_tool_choice = final_answer_settlement_text_only + && provider_supports_no_tool_choice(&llm_cfg.provider); tracing::debug!( target: "astra::tool_surface", run_id = state.current_run_id.as_deref().unwrap_or_default(), @@ -11740,6 +12264,15 @@ impl AgenticLoopHost for ServerAgenticLoopHost { &cache_cfg, &self.always_load_tool_names, ); + // From this boundary onward, budget estimation and planned prompt + // diagnostics consume one shared pre-client projection. The client + // remains the sole provider-final projector: its immutable prepared + // body receipt is attached after durable admission/dispatch below. + llm_messages = crate::turn::llm::client::consolidate_system_messages_for_provider( + &llm_messages, + &llm_cfg.provider, + llm_cfg.cache_capability, + ); let final_wire_budget_status = if let Some(trace) = state.last_llm_context_manifest_trace.as_mut() { crate::turn::llm::context::augment_manifest_trace_with_wire_detail( @@ -11790,7 +12323,11 @@ impl AgenticLoopHost for ServerAgenticLoopHost { state.sticky_tool_schemas = final_tools.clone(); } if let Some(pipeline_session) = state.pipeline_session.as_mut() { - pipeline_session.replace_pending_wire_tool_schemas(&final_tools); + pipeline_session.replace_pending_planned_wire_prompt_with_cache_capability( + &llm_messages, + &final_tools, + llm_cfg.cache_capability, + ); } // Runtime admission must mirror the exact tool schemas sent on the // wire. Pipeline pruning, sticky schema stabilization, and cache @@ -11825,7 +12362,7 @@ impl AgenticLoopHost for ServerAgenticLoopHost { let provider_attempt_boundary = ProviderAttemptBoundary::new(force_provider_convergence, use_no_tool_choice); let primary_thinking = if canonical_work_establishment_pending - || preserve_text_only_tool_surface + || final_answer_settlement_text_only || provider_attempt_boundary.forces_thinking_off() { ThinkingConfig::Off @@ -11849,7 +12386,8 @@ impl AgenticLoopHost for ServerAgenticLoopHost { ); let mut attempt_in_round = 0_u32; let mut last_length_output_tokens: Option = None; - let mut output_cap_continuations = 0_u8; + let mut output_cap_continuations = + state.hooks.completion_settlement.output_cap_continuations; let mut output_cap_partial_text = String::new(); let mut output_cap_partial_reasoning = String::new(); // A logical host turn may contain a bounded provider retry. Keep @@ -11881,8 +12419,11 @@ impl AgenticLoopHost for ServerAgenticLoopHost { || semantic_admission_pending; let started_with_action_window = self.terminal_handoff_window.is_open(); let mut action_window_updates = Vec::new(); - let mut canonical_work_establishment_retries = 0_u32; - let mut result = loop { + let mut canonical_work_establishment_retries = state + .hooks + .completion_settlement + .canonical_work_establishment_retries; + let mut result = loop { let repeated_provider_output_cap; // A retry begins a new physical provider attempt. Its preparation // receipt must not inherit the elapsed inference time of the @@ -11896,12 +12437,29 @@ impl AgenticLoopHost for ServerAgenticLoopHost { let attempt_label = llm_main_attempt_label(attempt_in_round); // Admission still accounts for the volatile tail's tokens, but // this early estimate is never reused as provider wire context. - let admission_budgeted_llm_messages = self.messages_with_current_execution_time_budget( - &llm_messages, - state.current_round_index, - ); + let admission_budgeted_llm_messages = match self + .messages_with_current_execution_time_budget( + &llm_messages, + &state.messages, + state.current_round_index, + &llm_cfg.provider, + cache_cap, + ) { + Ok(messages) => messages, + Err(error) => { + self.complete_request_preparation_phase( + state, + request_attempt_started_at, + attempt_in_round, + &mut request_preparation_recorded_attempts, + TurnPhaseOutcome::Failed, + ); + return Err(error); + } + }; let admission_llm_messages = admission_budgeted_llm_messages - .as_deref() + .as_ref() + .map(|(messages, _)| messages.as_slice()) .unwrap_or(llm_messages.as_slice()); let admission_estimated_tokens = crate::prompts::estimate_tokens( admission_llm_messages, @@ -12090,22 +12648,167 @@ impl AgenticLoopHost for ServerAgenticLoopHost { "host advanced to the authoritative recovered inference identity" ); } - // This tail is volatile request context. Sample it only after the - // provider and durable-invocation admission waits above, then use - // that exact snapshot for both prompt truth and the wire request. - // The hard client budget is sampled again at the later call edge. - let budgeted_llm_messages = self.messages_with_current_execution_time_budget( - &llm_messages, - state.current_round_index, - ); - let attempt_llm_messages = budgeted_llm_messages - .as_deref() - .unwrap_or(llm_messages.as_slice()); + // All admission awaits are complete. Sample one second-granular + // dispatch budget and derive both the model-visible authority and + // the hard client timeout from it. No await is allowed between + // this snapshot and provider dispatch. + let dispatch_budget = match Self::provider_dispatch_budget( + self.execution_time_budget, + provider_attempt_boundary, + ) { + Ok(budget) => budget, + Err(error) => { + self.complete_request_preparation_phase( + state, + request_attempt_started_at, + attempt_in_round, + &mut request_preparation_recorded_attempts, + TurnPhaseOutcome::Failed, + ); + durable_invocation.finish_error(&error).await?; + return Err(error); + } + }; + let budgeted_llm_messages = match dispatch_budget.prompt_snapshot { + Some(snapshot) => self + .messages_with_execution_time_budget_snapshot( + &llm_messages, + &state.messages, + state.current_round_index, + &llm_cfg.provider, + cache_cap, + snapshot, + ) + .map(Some), + None => Ok(None), + }; + let (attempt_llm_messages_owned, new_budget_append_only_runtime_message) = + match budgeted_llm_messages { + Ok(messages) => messages.unwrap_or_else(|| (llm_messages.clone(), None)), + Err(error) => { + self.complete_request_preparation_phase( + state, + request_attempt_started_at, + attempt_in_round, + &mut request_preparation_recorded_attempts, + TurnPhaseOutcome::Failed, + ); + durable_invocation.finish_error(&error).await?; + return Err(error); + } + }; + let attempt_llm_messages = attempt_llm_messages_owned.as_slice(); + let provider_canonical_transitions = if state.owns_provider_canonical_transition_wal() + && matches!( + cache_cap.volatile_placement, + astra_turn_core::cache_placement::VolatilePlacement::AppendOnlyUserTail + ) { + if durable_canonical_cursor > state.messages.len() { + let error = astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + "provider canonical transition cursor exceeds canonical history", + ); + durable_invocation.finish_error(&error).await?; + return Err(error); + } + let mut appended = state.messages[durable_canonical_cursor..].to_vec(); + if let Some(frame) = new_budget_append_only_runtime_message.as_ref() { + appended.push(frame.clone()); + } + { + if !crate::turn::llm::client::provider_request_preserves_projected_canonical_suffix( + attempt_llm_messages, + &appended, + ) { + let error = astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + "provider request does not preserve its staged canonical append suffix", + ); + durable_invocation.finish_error(&error).await?; + return Err(error); + } + let Some(durable_base) = state.provider_canonical_wal_base.clone() else { + let error = astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + "provider canonical transition has no admitted durable base", + ); + durable_invocation.finish_error(&error).await?; + return Err(error); + }; + let predecessor_messages = &state.messages[..durable_canonical_cursor]; + let durable_predecessor = sanitize_provider_canonical_wal_snapshot( + &durable_base, + predecessor_messages, + ); + let durable_appended = + astra_turn_core::runtime_scaffolding::sanitize_durable_message_values( + appended.clone(), + ); + let transition_result = match + astra_turn_types::ProviderCanonicalTransitionV1::new_from_durable_base( + state.provider_canonical_wal_head_transition_id.clone(), + durable_base.clone(), + &durable_predecessor, + durable_appended.clone(), + ) { + Err( + astra_turn_types::ProviderCanonicalTransitionError::DurableBaseNotPrefix, + ) => { + let Some(authorization) = state + .provider_canonical_replacement_authorization( + &durable_base, + predecessor_messages, + ) + else { + let error = astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + "provider canonical replacement lacks a valid rewrite proof", + ); + durable_invocation.finish_error(&error).await?; + return Err(error); + }; + astra_turn_types::ProviderCanonicalTransitionV1::new_replacement_from_durable_base( + state.provider_canonical_wal_head_transition_id.clone(), + durable_base.clone(), + authorization.generation, + &durable_predecessor, + durable_appended, + ) + } + other => other, + }; + let transition = match transition_result { + Ok(transition) => transition, + Err(source) => { + let error = astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + format!( + "failed to construct provider canonical transition: {source}" + ), + ); + durable_invocation.finish_error(&error).await?; + return Err(error); + } + }; + vec![transition] + } + } else { + Vec::new() + }; + let provider_canonical_transition_id = provider_canonical_transitions + .first() + .map(|transition| transition.transition_id.clone()); + if let Err(error) = durable_invocation + .bind_provider_canonical_transitions(provider_canonical_transitions) + { + durable_invocation.finish_error(&error).await?; + return Err(error); + } // Attempt-keyed prompt truth is created only after durable // admission chooses the authoritative logical identity. The // cancelled N recovery fact must never own the request artifact // that is physically sent as N+1. - let prompt_request_plan = + let mut prompt_request_plan = match astra_services::plan_prompt_request(astra_services::PromptRequestPlanInput { user_id: &self.user_id, session_id: &self.session_id, @@ -12140,36 +12843,12 @@ impl AgenticLoopHost for ServerAgenticLoopHost { return Err(error); } }; - record_full_llm_request_event( - state, - self.full_llm_capture, - &self.user_id, - &self.session_id, - "server_loop_host", - &llm_cfg.model_name, - &llm_cfg.provider, - attempt_in_round, - attempt_llm_messages, - &final_tools, - Some(effective_max_output), - ); - crate::turn::llm::exchange_capture::persist_prompt_request_plan_or_log( - "server_loop_host", - self.shared_pool.clone(), - astra_services::PromptRequestPersistInput { - session_id: self.session_id.clone(), - user_id: self.user_id.clone(), - run_id: state.current_run_id.clone(), - turn: state.session_turn, - round: prompt_round, - attempt: attempt_in_round, - source: "server_loop_host".to_string(), - model: llm_cfg.model_name.clone(), - provider: llm_cfg.provider.clone(), - }, - prompt_request_plan, - ) - .await; + if let Some(summary) = prompt_request_plan.summary_json.as_object_mut() { + summary.insert( + "projection_authority".to_string(), + Value::String("planned_pre_client_projection_v1".to_string()), + ); + } let attempt_label = llm_main_attempt_label(attempt_in_round); state .step_recorder @@ -12182,13 +12861,8 @@ impl AgenticLoopHost for ServerAgenticLoopHost { &mut request_preparation_recorded_attempts, TurnPhaseOutcome::Succeeded, ); - let llm_cancel = llm_cancel_for_state(state); let mut attempt_first_stream_update_ms: Option = None; let mut attempt_first_visible_text_ms: Option = None; - // Carry the absolute monotonic deadline across callback construction; - // conversion to a relative client budget happens only immediately - // before the call below. - let execution_time_budget = self.execution_time_budget; let r = { let mut attempt_text = String::new(); let mut attempt_reasoning = String::new(); @@ -12353,7 +13027,7 @@ impl AgenticLoopHost for ServerAgenticLoopHost { purpose: state.inference_purpose, messages: attempt_llm_messages, tools: &final_tools, - cache_capability: llm_cfg.cache_capability, + cache_capability: Some(cache_cap), route: LlmExecutionRoute { model_name: &llm_cfg.model_name, wire_model_name: llm_cfg.wire_model_name.as_deref(), @@ -12371,24 +13045,8 @@ impl AgenticLoopHost for ServerAgenticLoopHost { has_fallback, thinking: &primary_thinking, }; - // Take the relative duration at the last common boundary before - // invoking the provider. Prompt preparation and durable-attempt - // admission above may await; sampling earlier would replenish - // that elapsed time even though the run deadline is monotonic. - let provider_work_budget = match Self::provider_work_budget_at_client_boundary( - execution_time_budget, - provider_attempt_boundary, - ) { - Ok(budget) => budget, - Err(error) => { - // Durable admission already owns this invocation. If - // the run expired during preparation, terminalize that - // exact identity without sending a provider request. - durable_invocation.finish_error(&error).await?; - return Err(error); - } - }; - match (provider_work_budget, use_no_tool_choice) { + let llm_cancel = llm_cancel_for_state(state); + match (dispatch_budget.client_timeout, use_no_tool_choice) { (Some(budget), true) => { call_llm_and_collect_with_stream_callback_and_budget_and_no_tool_choice( call, @@ -12470,6 +13128,71 @@ impl AgenticLoopHost for ServerAgenticLoopHost { } provider_result }; + let provider_attempts = durable_invocation.provider_attempt_facts().await; + if let Some(admitted_transition_id) = + durable_invocation.admitted_canonical_transition_id() + { + if provider_canonical_transition_id.as_deref() + != Some(admitted_transition_id.as_str()) + { + let error = astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + "durable provider admission returned a different canonical WAL head", + ); + durable_invocation.finish_error(&error).await?; + return Err(error); + } + state.provider_canonical_wal_head_transition_id = Some(admitted_transition_id); + } + record_provider_attempt_cache_observations(state, &provider_attempts); + if durable_invocation.provider_dispatch_started() { + if let Some(frame) = new_budget_append_only_runtime_message { + if let Err(error) = state.extend_append_only_runtime_messages([frame]) { + durable_invocation.finish_error(&error).await?; + return Err(error); + } + // A request that actually entered transport now owns this + // exact canonical frame. Any bounded retry extends it. + llm_messages = attempt_llm_messages_owned.clone(); + } + durable_canonical_cursor = state.messages.len(); + record_full_llm_request_event( + state, + self.full_llm_capture, + &self.user_id, + &self.session_id, + "server_loop_host", + &llm_cfg.model_name, + &llm_cfg.provider, + cache_cap, + attempt_in_round, + attempt_llm_messages, + &final_tools, + Some(effective_max_output), + &provider_attempts, + ); + // Prompt-delta persistence describes only a request that + // crossed the provider dispatch boundary. Keeping this await + // after the call prevents durable-admission expiry from + // creating a phantom sent-request artifact. + crate::turn::llm::exchange_capture::persist_prompt_request_plan_or_log( + "server_loop_host", + self.shared_pool.clone(), + astra_services::PromptRequestPersistInput { + session_id: self.session_id.clone(), + user_id: self.user_id.clone(), + run_id: state.current_run_id.clone(), + turn: state.session_turn, + round: prompt_round, + attempt: attempt_in_round, + source: "server_loop_host".to_string(), + model: llm_cfg.model_name.clone(), + provider: llm_cfg.provider.clone(), + }, + prompt_request_plan, + ) + .await; + } complete_turn_phase( self, state, @@ -12485,7 +13208,6 @@ impl AgenticLoopHost for ServerAgenticLoopHost { format!("model_inference_{prompt_round}_{attempt_in_round}"), ); - let provider_attempts = durable_invocation.provider_attempt_facts().await; if !provider_attempts.is_empty() { if let Some(trace) = state.last_llm_context_manifest_trace.as_mut() { crate::turn::llm::context::augment_manifest_trace_with_provider_attempts( @@ -12535,6 +13257,7 @@ impl AgenticLoopHost for ServerAgenticLoopHost { "server_loop_host", &llm_cfg.model_name, &llm_cfg.provider, + cache_cap, attempt_in_round, "fallback_required", llm_capture_error_response(&e), @@ -12561,6 +13284,7 @@ impl AgenticLoopHost for ServerAgenticLoopHost { "server_loop_host", &llm_cfg.model_name, &llm_cfg.provider, + cache_cap, attempt_in_round, "context_window_error", llm_capture_error_response(e), @@ -12613,6 +13337,7 @@ impl AgenticLoopHost for ServerAgenticLoopHost { session_turn_source: Some("state"), turn_chain_id: None, user_query_event_id: None, + cache_capability: Some(cache_cap), }), ) .await; @@ -12651,6 +13376,7 @@ impl AgenticLoopHost for ServerAgenticLoopHost { "server_loop_host", &llm_cfg.model_name, &llm_cfg.provider, + cache_cap, attempt_in_round, "error", llm_capture_error_response(&e), @@ -12703,6 +13429,7 @@ impl AgenticLoopHost for ServerAgenticLoopHost { session_turn_source: Some("state"), turn_chain_id: None, user_query_event_id: None, + cache_capability: Some(cache_cap), }), ) .await; @@ -12770,6 +13497,7 @@ impl AgenticLoopHost for ServerAgenticLoopHost { "server_loop_host", &llm_cfg.model_name, &llm_cfg.provider, + cache_cap, attempt_in_round, llm_attempt_outcome, json!({ @@ -12814,12 +13542,59 @@ impl AgenticLoopHost for ServerAgenticLoopHost { canonical_work_establishment_retries, ) { canonical_work_establishment_retries += 1; - llm_messages.insert( - 0, - Self::canonical_work_establishment_retry_preamble( - canonical_work_establishment_retries, - ), + state + .hooks + .completion_settlement + .canonical_work_establishment_retries = canonical_work_establishment_retries; + // The repair request is a strict extension of the physical + // request that just completed: first append that provider's + // assistant result, then project the required Work authority + // through the same deployment shape as every other control. + let retry = Self::canonical_work_establishment_retry_preamble( + canonical_work_establishment_retries, ); + let retry_content = + retry + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| { + astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + "canonical Work retry authority is missing content", + ) + })?; + let retry_assistant = provider_retry_assistant(&r); + llm_messages.push(retry_assistant.clone()); + let mut retry_canonical_prefix = state.messages.clone(); + retry_canonical_prefix.push(retry_assistant.clone()); + let (projected, new_frame) = Self::project_required_runtime_authority( + &llm_messages, + &retry_canonical_prefix, + retry_content, + crate::turn::wire_assembly::RuntimeAuthorityKind::CanonicalWorkEstablishmentRetry, + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + &llm_cfg.provider, + cache_cap, + )?; + if matches!( + cache_cap.volatile_placement, + astra_turn_core::cache_placement::VolatilePlacement::AppendOnlyUserTail + ) { + let frame = new_frame.ok_or_else(|| { + astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + "append-only Work retry did not produce a fresh canonical authority", + ) + })?; + state.append_provider_retry_transition(retry_assistant, frame)?; + } else { + state.push_prompt_history_message(retry_assistant); + state.push_volatile_payload_for_active_attempt( + crate::turn::agentic_loop::host::VolatileKind::CanonicalWorkEstablishmentRetry, + Value::String(retry_content.to_string()), + ); + } + llm_messages = projected; attempt_in_round = attempt_in_round.checked_add(1).ok_or_else(|| { astra_core::ClassifiedError::new( astra_core::ErrorKind::ContractViolation, @@ -12846,8 +13621,41 @@ impl AgenticLoopHost for ServerAgenticLoopHost { { output_cap_partial_text = r.full_text.clone(); output_cap_partial_reasoning = r.reasoning.clone(); - append_output_cap_continuation_context(&mut llm_messages, &r.full_text); + let partial_assistant = provider_retry_assistant(&r); + llm_messages.push(partial_assistant.clone()); + let mut continuation_canonical_prefix = state.messages.clone(); + continuation_canonical_prefix.push(partial_assistant.clone()); + let (projected, new_frame) = Self::project_required_runtime_authority( + &llm_messages, + &continuation_canonical_prefix, + output_cap_continuation_prompt(), + crate::turn::wire_assembly::RuntimeAuthorityKind::OutputCapContinuation, + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + &llm_cfg.provider, + cache_cap, + )?; + if matches!( + cache_cap.volatile_placement, + astra_turn_core::cache_placement::VolatilePlacement::AppendOnlyUserTail + ) { + let frame = new_frame.ok_or_else(|| { + astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + "append-only output continuation did not produce fresh canonical authority", + ) + })?; + state.append_provider_retry_transition(partial_assistant, frame)?; + } else { + state.push_prompt_history_message(partial_assistant); + state.push_volatile_payload_for_active_attempt( + crate::turn::agentic_loop::host::VolatileKind::OutputCapContinuation, + Value::String(output_cap_continuation_prompt().to_string()), + ); + } + llm_messages = projected; output_cap_continuations = output_cap_continuations.saturating_add(1); + state.hooks.completion_settlement.output_cap_continuations = + output_cap_continuations; attempt_in_round = attempt_in_round.checked_add(1).ok_or_else(|| { astra_core::ClassifiedError::new( astra_core::ErrorKind::ContractViolation, @@ -12986,6 +13794,7 @@ impl AgenticLoopHost for ServerAgenticLoopHost { session_turn_source: Some("state"), turn_chain_id: None, user_query_event_id: None, + cache_capability: Some(cache_cap), }), ) .await; @@ -13308,23 +14117,33 @@ impl AgenticLoopHost for ServerAgenticLoopHost { .messages .iter() .rev() - .find(|m| m.get("role").and_then(Value::as_str) == Some("user")) + .find(|m| astra_turn_types::is_human_user_message(m)) .and_then(|m| m.get("content").and_then(Value::as_str)) .unwrap_or("") .to_string(); let effective_restricted = self.compute_effective_restricted(state, false, false); let visible_tools = self.filtered_runtime_ready_turn_tools(&effective_restricted, state); - // We only need the system messages here — the inline summary call - // reuses the main turn's system prefix, not its tools. - let system_messages = match self.run_turn_pipeline( + // Rebuild the matching stable system projection. The tool projection + // comes from the preceding provider-final request when available. + let cache_capability = + astra_turn_core::cache_placement::CacheCapability::from_explicit_or_provider( + config.cache_capability, + &config.provider, + ); + let pipeline = match self.run_turn_pipeline_with_model_limits_and_session_memory( state, &visible_tools, &config.provider, &config.model_name, + config.context_window, + config.max_completion_tokens, + Some(cache_capability), + None, + &[], &user_content, ) { - Ok(outcome) => outcome.system_messages, + Ok(outcome) => outcome, Err(error) => { astra_core::agent_warn!( "pipeline", @@ -13334,15 +14153,56 @@ impl AgenticLoopHost for ServerAgenticLoopHost { return None; } }; - let client = self.durable_summary_client(&config, 4096, state, "pre_turn_compaction")?; + let system_messages = pipeline.system_messages; + let summary_tools = if state.sticky_tool_schemas.is_empty() { + pipeline.tool_schemas + } else { + // Main inference commits this only after stabilization and cache + // annotation. Recomputing tools from post-compaction pressure can + // select a different schema prefix and defeat inline reuse. + state.sticky_tool_schemas.clone() + }; + let client = self + .durable_summary_client(&config, 4096, state, "pre_turn_compaction")? + .with_prompt_cache_context(summary_tools, cache_capability); + let spill_count = pre_turn_summary_spill_count(&state.messages); + if spill_count == 0 { + state.compaction_effectiveness.record_futile(); + tracing::debug!( + target: "astra::pre_turn_compaction", + pressure, + session_id = %self.session_id, + "pre-turn compaction skipped because active canonical authority protects the entire history" + ); + return None; + } if let Some(summary_text) = astra_turn_core::cloud_summary::generate_inline_summary( &system_messages, - &state.messages, + &state.messages[..spill_count], + if matches!( + cache_capability.volatile_placement, + astra_turn_core::cache_placement::VolatilePlacement::AppendOnlyUserTail + ) { + astra_turn_core::cloud_summary::InlineSummaryHistoryProjection::AppendOnlyRuntimeAuthorityPrefix + } else { + astra_turn_core::cloud_summary::InlineSummaryHistoryProjection::Semantic + }, &client, ) .await { - Some(apply_pre_turn_summary(state, pressure, summary_text)) + let event = apply_pre_turn_summary(state, pressure, summary_text, spill_count); + if event.is_none() { + state.compaction_effectiveness.record_futile(); + tracing::warn!( + target: "astra::pre_turn_compaction", + pressure, + session_id = %self.session_id, + spill_count, + "pre-turn compaction rejected because it did not reduce estimated context tokens" + ); + } + event } else { state.compaction_effectiveness.record_futile(); tracing::warn!( @@ -14143,6 +15003,17 @@ mod tests { use astra_turn_core::sse_stream_host::EdgeToolExecResult; use std::ffi::OsString; + fn append_only_test_cache_capability() -> astra_turn_core::cache_placement::CacheCapability { + astra_turn_core::cache_placement::CacheCapability { + protocol: astra_turn_core::cache_placement::CacheProtocol::OpenAiAutoPrefix, + volatile_placement: + astra_turn_core::cache_placement::VolatilePlacement::AppendOnlyUserTail, + volatile_delivery: + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: Some(astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns), + } + } + #[test] fn execution_time_budget_retry_can_only_tighten_deadline() { let start = tokio::time::Instant::now(); @@ -14229,7 +15100,7 @@ mod tests { } #[tokio::test(start_paused = true)] - async fn provider_work_budget_is_sampled_after_admission_delay() { + async fn provider_dispatch_budget_is_sampled_after_admission_delay() { let mut host = ServerAgenticLoopHostBuilder::new( mock_matrixone(), mock_encryptor(), @@ -14243,12 +15114,14 @@ mod tests { let ordinary = ProviderAttemptBoundary::new(false, false); assert_eq!( - ServerAgenticLoopHost::provider_work_budget_at_client_boundary( - host.execution_time_budget, - ordinary, - ) - .expect("initial provider budget"), - Some(Duration::from_secs(10)) + ServerAgenticLoopHost::provider_dispatch_budget(host.execution_time_budget, ordinary,) + .expect("initial provider budget"), + ProviderDispatchBudget { + prompt_snapshot: host + .execution_time_budget + .map(|budget| budget.authority_snapshot()), + client_timeout: Some(Duration::from_secs(10)), + } ); // Model prompt construction, durable admission, and ledger work can @@ -14257,25 +15130,21 @@ mod tests { // relative-duration snapshot. tokio::time::advance(Duration::from_secs(4)).await; assert_eq!( - ServerAgenticLoopHost::provider_work_budget_at_client_boundary( - host.execution_time_budget, - ordinary, - ) - .expect("post-admission provider budget"), + ServerAgenticLoopHost::provider_dispatch_budget(host.execution_time_budget, ordinary,) + .expect("post-admission provider budget") + .client_timeout, Some(Duration::from_secs(6)) ); tokio::time::advance(Duration::from_secs(6)).await; - let error = ServerAgenticLoopHost::provider_work_budget_at_client_boundary( - host.execution_time_budget, - ordinary, - ) - .expect_err("an exhausted deadline must reject before client invocation"); + let error = + ServerAgenticLoopHost::provider_dispatch_budget(host.execution_time_budget, ordinary) + .expect_err("an exhausted deadline must reject before client invocation"); assert_eq!(error.kind, astra_core::ErrorKind::BudgetExhausted); } #[tokio::test(start_paused = true)] - async fn volatile_budget_tail_is_resampled_after_durable_admission_delay() { + async fn volatile_budget_tail_uses_one_immutable_deadline_across_admission_delay() { let mut host = ServerAgenticLoopHostBuilder::new( mock_matrixone(), mock_encryptor(), @@ -14287,70 +15156,110 @@ mod tests { remaining_seconds: 10, })); let stable = vec![json!({"role": "system", "content": "stable prefix"})]; + let capability = append_only_test_cache_capability(); let admission_estimate = host - .messages_with_current_execution_time_budget(&stable, 1) + .messages_with_current_execution_time_budget(&stable, &[], 1, "openai", capability) + .expect("valid budget projection") .expect("admission estimate tail"); - assert!(admission_estimate.last().is_some_and(|message| { - message - .get("content") - .and_then(Value::as_str) - .is_some_and(|content| content.contains("\"remaining_seconds\":10")) - })); + let first_content = admission_estimate.0.last().unwrap()["content"] + .as_str() + .unwrap() + .to_string(); + assert!(first_content.contains("\"deadline_unix_ms\":")); tokio::time::advance(Duration::from_secs(4)).await; let post_admission_wire = host - .messages_with_current_execution_time_budget(&stable, 1) + .messages_with_current_execution_time_budget(&stable, &[], 1, "openai", capability) + .expect("valid budget projection") .expect("post-admission wire tail"); - assert!(post_admission_wire.last().is_some_and(|message| { - message - .get("content") - .and_then(Value::as_str) - .is_some_and(|content| content.contains("\"remaining_seconds\":6")) - })); - assert_eq!(post_admission_wire[0], stable[0]); + assert_eq!( + post_admission_wire.0.last().unwrap()["content"].as_str(), + Some(first_content.as_str()) + ); + assert_eq!( + ServerAgenticLoopHost::provider_dispatch_budget( + host.execution_time_budget, + ProviderAttemptBoundary::new(false, false), + ) + .unwrap() + .client_timeout, + Some(Duration::from_secs(6)) + ); + assert_eq!(post_admission_wire.0[0], stable[0]); + assert_eq!( + astra_turn_types::runtime_authority_kind( + post_admission_wire.1.as_ref().expect("canonical frame") + ), + Some("execution_time_budget") + ); } - #[test] - fn execution_time_budget_is_a_fresh_volatile_wire_tail() { + #[tokio::test(start_paused = true)] + async fn execution_time_budget_is_typed_and_extends_append_only_history() { let base = vec![json!({"role": "system", "content": "stable prefix"})]; - let mut first = base.clone(); - let mut second = base.clone(); - ServerAgenticLoopHost::append_execution_time_budget_tail( - &mut first, - ExecutionTimeBudget { - remaining_seconds: 9, - }, - 1, - ); - ServerAgenticLoopHost::append_execution_time_budget_tail( - &mut second, - ExecutionTimeBudget { - remaining_seconds: 4, + let capability = append_only_test_cache_capability(); + let first_content = ServerAgenticLoopHost::execution_time_budget_context( + ExecutionDeadlineAuthority { + deadline_unix_ms: 1, }, 1, + ) + .expect("budget context"); + let first_frame = + crate::turn::wire_assembly::required_append_only_runtime_authority_message( + &first_content, + crate::turn::wire_assembly::RuntimeAuthorityKind::ExecutionTimeBudget, + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ) + .unwrap() + .unwrap(); + let mut canonical = vec![json!({"role": "user", "content": "do the work"})]; + canonical.push(first_frame.clone()); + let mut host = ServerAgenticLoopHostBuilder::new( + mock_matrixone(), + mock_encryptor(), + "u-budget-tail".to_string(), + "s-budget-tail".to_string(), + ) + .build(); + host.execution_time_budget = Some(RunExecutionTimeBudget::new(ExecutionTimeBudget { + remaining_seconds: 4, + })); + let mut prior_internal = base.clone(); + prior_internal.push(first_frame); + let prior_provider = crate::turn::llm::client::consolidate_system_messages_for_provider( + &prior_internal, + "openai", + Some(capability), ); + let second = host + .messages_with_current_execution_time_budget( + &prior_provider, + &canonical, + 1, + "openai", + capability, + ) + .expect("valid budget projection") + .expect("budget projection"); - assert_eq!(first[0], base[0]); - assert_eq!(second[0], base[0]); - assert!(first.last().is_some_and(|message| { - message - .get("content") - .and_then(Value::as_str) - .is_some_and(|content| { - content.contains("runtime-required-context") - && content.contains("\"remaining_seconds\":9") - }) - })); - assert!(second.last().is_some_and(|message| { + assert!(second.0.starts_with(&prior_provider)); + assert!(second.0.last().is_some_and(|message| { message .get("content") .and_then(Value::as_str) .is_some_and(|content| { content.contains("runtime-required-context") - && content.contains("\"remaining_seconds\":4") + && content.contains("\"deadline_unix_ms\":") }) })); + let canonical_frame = second.1.expect("new canonical frame"); + assert!(!astra_turn_types::is_human_user_message(&canonical_frame)); + assert_eq!( + astra_turn_types::runtime_authority_kind(&canonical_frame), + Some("execution_time_budget") + ); } #[test] @@ -15202,8 +16111,8 @@ mod tests { } #[test] - fn active_work_attempt_start_contract_is_once_per_attempt() { - let mut host = ServerAgenticLoopHostBuilder::new( + fn active_work_attempt_contract_is_reconstructible_for_every_provider_shape() { + let host = ServerAgenticLoopHostBuilder::new( mock_matrixone(), mock_encryptor(), "u-pacing".to_string(), @@ -15239,7 +16148,7 @@ mod tests { ); let start_context = host .active_work_attempt_start_context(&state) - .expect("a new assignment gets one start contract"); + .expect("an active assignment gets its execution contract"); let start_payload: Value = serde_json::from_str( start_context["content"] .as_str() @@ -15263,9 +16172,10 @@ mod tests { .is_some_and(|instruction| instruction .contains("named behavior check, command, test, or observable workflow")) ); - assert!( - host.active_work_attempt_start_context(&state).is_none(), - "the start contract is emitted once per attempt" + assert_eq!( + host.active_work_attempt_start_context(&state), + Some(start_context), + "provider revalidation may change cache shape between requests; active Work authority must be rebuilt from canonical execution state, not a one-shot host latch" ); state.hooks.completion_settlement.work_settlement_only = true; let restricted = host.compute_effective_restricted(&mut state, true, false); @@ -20184,117 +21094,658 @@ mod tests { } #[test] - fn repeated_explicit_length_cap_stops_retry_without_tools() { - let mut result = LlmCallResult { - finish_reason: Some("length".to_string()), - usage: Map::from_iter([("output_tokens".to_string(), json!(8192))]), - ..Default::default() - }; + fn repeated_explicit_length_cap_stops_retry_without_tools() { + let mut result = LlmCallResult { + finish_reason: Some("length".to_string()), + usage: Map::from_iter([("output_tokens".to_string(), json!(8192))]), + ..Default::default() + }; + + assert!(reconcile_repeated_provider_output_cap( + &mut result, + Some(8192) + )); + assert_eq!(result.finish_reason.as_deref(), Some("length")); + } + + #[test] + fn output_cap_reconciliation_requires_prior_exact_typed_evidence() { + for (prior, output_tokens, has_tool_call) in [ + (None, 4096, false), + (Some(8192), 4096, false), + (Some(4096), 4096, true), + ] { + let mut result = LlmCallResult { + finish_reason: Some("stop".to_string()), + usage: Map::from_iter([("output_tokens".to_string(), json!(output_tokens))]), + tool_calls: has_tool_call + .then(|| json!({"id":"call-1"})) + .into_iter() + .collect(), + ..Default::default() + }; + assert!(!reconcile_repeated_provider_output_cap(&mut result, prior)); + assert_eq!(result.finish_reason.as_deref(), Some("stop")); + } + } + + #[test] + fn exhausted_output_cap_is_execution_incomplete_not_completed() { + let mut state = create_test_state(); + let result = LlmCallResult { + finish_reason: Some("length".to_string()), + full_text: "partial sentence".to_string(), + ..Default::default() + }; + + assert!(preserve_exhausted_output_cap_as_interruption( + &mut state, &result + )); + let interruption = state.interruption.expect("interruption"); + assert_eq!( + interruption.kind, + astra_turn_core::interruption::InterruptionKind::ExecutionIncomplete + ); + assert_eq!( + interruption.resume_action, + astra_turn_core::interruption::ResumeAction::ContinueImmediately + ); + } + + #[test] + fn output_cap_interruption_requires_text_only_length_terminal() { + let cases = [ + LlmCallResult { + finish_reason: Some("stop".to_string()), + ..Default::default() + }, + LlmCallResult { + finish_reason: Some("length".to_string()), + tool_calls: vec![json!({"id":"call-1"})], + ..Default::default() + }, + ]; + + for result in cases { + let mut state = create_test_state(); + assert!(!preserve_exhausted_output_cap_as_interruption( + &mut state, &result + )); + assert!(state.interruption.is_none()); + } + } + + #[test] + fn output_cap_continuation_merges_suffix_without_duplication() { + assert_eq!( + merge_output_cap_continuation("partial answer", " and the rest"), + "partial answer and the rest" + ); + assert_eq!( + merge_output_cap_continuation("partial answer", "partial answer and the rest"), + "partial answer and the rest" + ); + assert_eq!( + merge_output_cap_continuation("prefix: result", "result is verified"), + "prefix: result is verified" + ); + } + + #[test] + fn output_cap_continuation_is_a_typed_append_only_authority() { + let mut messages = vec![json!({"role":"user", "content":"finish the task"})]; + messages.push(provider_retry_assistant(&LlmCallResult { + full_text: "partial result".to_string(), + reasoning: "captured reasoning".to_string(), + reasoning_signature: "captured signature".to_string(), + ..Default::default() + })); + let capability = append_only_test_cache_capability(); + let (messages, frame) = ServerAgenticLoopHost::project_required_runtime_authority( + &messages, + &[], + output_cap_continuation_prompt(), + crate::turn::wire_assembly::RuntimeAuthorityKind::OutputCapContinuation, + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + "openai", + capability, + ) + .expect("valid continuation projection"); + assert_eq!(messages.len(), 3); + assert_eq!(messages[1]["role"], "assistant"); + assert_eq!(messages[1]["content"], "partial result"); + assert_eq!(messages[1]["reasoning_content"], "captured reasoning"); + assert_eq!(messages[1]["reasoning_signature"], "captured signature"); + assert_eq!(messages[2]["role"], "user"); + assert!( + messages[2]["content"] + .as_str() + .expect("continuation prompt") + .contains("next concrete tool call or give a concise final result") + ); + let frame = frame.expect("canonical typed continuation frame"); + assert!(!astra_turn_types::is_human_user_message(&frame)); + assert_eq!( + astra_turn_types::runtime_authority_kind(&frame), + Some("output_cap_continuation") + ); + } + + #[test] + fn non_append_retry_uses_required_system_wire_without_append_canonical_frame() { + let messages = vec![ + json!({"role":"user", "content":"finish the task"}), + provider_retry_assistant(&LlmCallResult { + full_text: "partial result".to_string(), + ..Default::default() + }), + ]; + let capability = astra_turn_core::cache_placement::CacheCapability { + protocol: astra_turn_core::cache_placement::CacheProtocol::OpenAiAutoPrefix, + volatile_placement: astra_turn_core::cache_placement::VolatilePlacement::TailSuffix, + volatile_delivery: + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: None, + }; + let (wire, canonical_frame) = ServerAgenticLoopHost::project_required_runtime_authority( + &messages, + &messages, + output_cap_continuation_prompt(), + crate::turn::wire_assembly::RuntimeAuthorityKind::OutputCapContinuation, + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + "openai", + capability, + ) + .expect("valid non-append projection"); + + assert!(canonical_frame.is_none()); + assert!(wire.iter().any(|message| { + message.get("role").and_then(Value::as_str) == Some("system") + && crate::turn::wire_assembly::is_required_runtime_preamble(message) + })); + assert!(wire.iter().all(|message| { + astra_turn_types::runtime_message_delivery(message) + != Some(astra_turn_types::RuntimeMessageDelivery::AppendOnlyRequiredContext) + })); + } + + #[test] + fn provider_retry_transition_is_atomic_and_resume_reconstructs_the_wire_prefix() { + let mut state = create_test_state(); + state.messages = vec![json!({"role": "user", "content": "do the work"})]; + let assistant = json!({"role": "assistant", "content": "partial result"}); + let frame = crate::turn::wire_assembly::required_append_only_runtime_authority_message( + output_cap_continuation_prompt(), + crate::turn::wire_assembly::RuntimeAuthorityKind::OutputCapContinuation, + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ) + .unwrap() + .unwrap(); + state + .append_provider_retry_transition(assistant.clone(), frame.clone()) + .expect("atomic retry transition"); + + assert_eq!(state.messages[1], assistant); + assert_eq!(state.messages[2], frame); + let resumed = state.messages.clone(); + assert_eq!(resumed[1]["role"], "assistant"); + assert_eq!( + astra_turn_types::runtime_authority_kind(&resumed[2]), + Some("output_cap_continuation") + ); + + let invalid = json!({"role": "system", "content": "not an assistant"}); + let before = state.messages.clone(); + assert!( + state + .append_provider_retry_transition(invalid, resumed[2].clone()) + .is_err() + ); + assert_eq!(state.messages, before); + } + + #[test] + fn provider_admission_matches_staged_authority_through_the_wire_projection() { + let mut state = create_test_state(); + state.messages = vec![json!({"role": "user", "content": "do the work"})]; + let durable_canonical_cursor = state.messages.len(); + let settlement = + crate::turn::wire_assembly::required_append_only_runtime_authority_message( + "synthesize the completed work", + crate::turn::wire_assembly::RuntimeAuthorityKind::FinalWorkSynthesis, + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ) + .unwrap() + .unwrap(); + state + .extend_append_only_runtime_messages([settlement]) + .expect("wire assembly stages typed canonical authority"); + + let capability = append_only_test_cache_capability(); + let mut attempt_messages = + crate::turn::llm::client::consolidate_system_messages_for_provider( + &state.messages, + "openai", + Some(capability), + ); + let dispatch_budget = + crate::turn::wire_assembly::required_append_only_runtime_authority_message( + "finish before the admitted deadline", + crate::turn::wire_assembly::RuntimeAuthorityKind::ExecutionTimeBudget, + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ) + .unwrap() + .unwrap(); + attempt_messages.push(dispatch_budget.clone()); + + let mut canonical_appended = state.messages[durable_canonical_cursor..].to_vec(); + canonical_appended.push(dispatch_budget); + assert!(!attempt_messages.ends_with(&canonical_appended)); + assert!( + crate::turn::llm::client::provider_request_preserves_projected_canonical_suffix( + &attempt_messages, + &canonical_appended, + ) + ); + } + + #[test] + fn provider_transition_wal_hydrates_ordered_pairs_once_before_fresh_user_suffix() { + let durable_head = vec![ + json!({"role": "user", "content": "older request"}), + json!({"role": "assistant", "content": "older answer"}), + ]; + let mut base = durable_head.clone(); + base.push(json!({"role": "user", "content": "do the work"})); + let durable_base = + astra_turn_types::CanonicalPrefixIdentityV1::from_messages(&durable_head).unwrap(); + let first_authority = + crate::turn::wire_assembly::required_append_only_runtime_authority_message( + "opaque first authority", + crate::turn::wire_assembly::RuntimeAuthorityKind::EdgeRequiredContext, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, + ) + .unwrap() + .unwrap(); + let first = astra_turn_types::ProviderCanonicalTransitionV1::new_from_durable_base( + None, + durable_base.clone(), + &base, + vec![first_authority.clone()], + ) + .unwrap(); + let mut after_first = base.clone(); + after_first.push(first_authority.clone()); + let assistant = json!({"role": "assistant", "content": "partial result"}); + let retry_authority = + crate::turn::wire_assembly::required_append_only_runtime_authority_message( + output_cap_continuation_prompt(), + crate::turn::wire_assembly::RuntimeAuthorityKind::OutputCapContinuation, + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ) + .unwrap() + .unwrap(); + let second = astra_turn_types::ProviderCanonicalTransitionV1::new_from_durable_base( + Some(first.transition_id.clone()), + durable_base.clone(), + &after_first, + vec![assistant.clone(), retry_authority.clone()], + ) + .unwrap(); + let receipt = + |physical_attempt, transitions| astra_services::InferenceCanonicalTransitionReceipt { + turn: 1, + round: 0, + logical_attempt: 0, + physical_attempt, + transitions, + }; + let fresh_user = json!({"role": "user", "content": "fresh follow-up"}); + let mut restored = durable_head; + restored.push(fresh_user.clone()); + let outcome = hydrate_provider_canonical_transition_receipts( + &mut restored, + &durable_base, + vec![receipt(1, vec![second.clone()])], + ) + .unwrap(); + + assert_eq!(outcome.reconciled_transitions, 1); + assert_eq!(outcome.head_transition_id, Some(second.transition_id)); + assert_eq!(restored[2], base[2]); + assert_eq!(restored[3], first_authority); + assert_eq!(restored[4], assistant); + assert_eq!(restored[5], retry_authority); + assert_eq!(restored[6], fresh_user); + } + + #[test] + fn provider_transition_wal_uses_the_durable_boundary_for_repeated_fresh_input() { + let durable_head = vec![json!({"role": "assistant", "content": "ready"})]; + let repeated = json!({"role": "user", "content": "continue"}); + let mut predecessor = durable_head.clone(); + predecessor.push(repeated.clone()); + let durable_base = + astra_turn_types::CanonicalPrefixIdentityV1::from_messages(&durable_head).unwrap(); + let authority = crate::turn::wire_assembly::required_append_only_runtime_authority_message( + "continue safely", + crate::turn::wire_assembly::RuntimeAuthorityKind::EdgeRequiredContext, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, + ) + .unwrap() + .unwrap(); + let transition = astra_turn_types::ProviderCanonicalTransitionV1::new_from_durable_base( + None, + durable_base.clone(), + &predecessor, + vec![authority.clone()], + ) + .unwrap(); + let mut restored = durable_head; + restored.push(repeated.clone()); + hydrate_provider_canonical_transition_receipts( + &mut restored, + &durable_base, + vec![astra_services::InferenceCanonicalTransitionReceipt { + turn: 1, + round: 0, + logical_attempt: 0, + physical_attempt: 0, + transitions: vec![transition], + }], + ) + .unwrap(); - assert!(reconcile_repeated_provider_output_cap( - &mut result, - Some(8192) - )); - assert_eq!(result.finish_reason.as_deref(), Some("length")); + assert_eq!(restored[1], repeated); + assert_eq!(restored[2], authority); + assert_eq!(restored[3], repeated); } #[test] - fn output_cap_reconciliation_requires_prior_exact_typed_evidence() { - for (prior, output_tokens, has_tool_call) in [ - (None, 4096, false), - (Some(8192), 4096, false), - (Some(4096), 4096, true), - ] { - let mut result = LlmCallResult { - finish_reason: Some("stop".to_string()), - usage: Map::from_iter([("output_tokens".to_string(), json!(output_tokens))]), - tool_calls: has_tool_call - .then(|| json!({"id":"call-1"})) - .into_iter() - .collect(), - ..Default::default() - }; - assert!(!reconcile_repeated_provider_output_cap(&mut result, prior)); - assert_eq!(result.finish_reason.as_deref(), Some("stop")); - } + fn provider_transition_wal_recovers_a_request_with_zero_runtime_frames() { + let durable_head = vec![json!({"role": "assistant", "content": "ready"})]; + let old_user = json!({"role": "user", "content": "old request"}); + let mut predecessor = durable_head.clone(); + predecessor.push(old_user.clone()); + let durable_base = + astra_turn_types::CanonicalPrefixIdentityV1::from_messages(&durable_head).unwrap(); + let transition = astra_turn_types::ProviderCanonicalTransitionV1::new_from_durable_base( + None, + durable_base.clone(), + &predecessor, + Vec::new(), + ) + .unwrap(); + let fresh = json!({"role": "user", "content": "hi"}); + let mut restored = durable_head; + restored.push(fresh.clone()); + hydrate_provider_canonical_transition_receipts( + &mut restored, + &durable_base, + vec![astra_services::InferenceCanonicalTransitionReceipt { + turn: 1, + round: 0, + logical_attempt: 0, + physical_attempt: 0, + transitions: vec![transition], + }], + ) + .unwrap(); + assert_eq!( + restored, + vec![ + json!({"role": "assistant", "content": "ready"}), + old_user, + fresh + ] + ); } #[test] - fn exhausted_output_cap_is_execution_incomplete_not_completed() { - let mut state = create_test_state(); - let result = LlmCallResult { - finish_reason: Some("length".to_string()), - full_text: "partial sentence".to_string(), - ..Default::default() - }; + fn provider_transition_wal_redacts_uncommitted_credentials_before_persistence() { + let durable_head = vec![json!({"role": "assistant", "content": "ready"})]; + let durable_base = + astra_turn_types::CanonicalPrefixIdentityV1::from_messages(&durable_head).unwrap(); + let secret = "hf_abcdefghijklmnopqrstuvwxyz123456"; + let mut snapshot = durable_head.clone(); + snapshot.push(json!({ + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call-secret", + "type": "function", + "function": { + "name": "bash", + "arguments": format!("{{\"command\":\"tool --token {secret}\"}}") + } + }] + })); - assert!(preserve_exhausted_output_cap_as_interruption( - &mut state, &result - )); - let interruption = state.interruption.expect("interruption"); - assert_eq!( - interruption.kind, - astra_turn_core::interruption::InterruptionKind::ExecutionIncomplete - ); - assert_eq!( - interruption.resume_action, - astra_turn_core::interruption::ResumeAction::ContinueImmediately - ); + let durable = sanitize_provider_canonical_wal_snapshot(&durable_base, &snapshot); + assert_eq!(durable[0], durable_head[0]); + assert!(serde_json::to_string(&snapshot).unwrap().contains(secret)); + assert!(!serde_json::to_string(&durable).unwrap().contains(secret)); } #[test] - fn output_cap_interruption_requires_text_only_length_terminal() { - let cases = [ - LlmCallResult { - finish_reason: Some("stop".to_string()), - ..Default::default() - }, - LlmCallResult { - finish_reason: Some("length".to_string()), - tool_calls: vec![json!({"id":"call-1"})], - ..Default::default() - }, + fn provider_transition_wal_collapses_post_compaction_and_second_rewrite_lineage() { + let durable_head = vec![ + json!({"role": "user", "content": "old goal"}), + json!({"role": "assistant", "content": "old answer"}), ]; + let durable_base = + astra_turn_types::CanonicalPrefixIdentityV1::from_messages(&durable_head).unwrap(); + let authority = |text, kind| { + crate::turn::wire_assembly::required_append_only_runtime_authority_message( + text, + kind, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, + ) + .unwrap() + .unwrap() + }; + let old_user = json!({"role": "user", "content": "work"}); + let mut pre_rewrite = durable_head.clone(); + pre_rewrite.push(old_user); + let first_authority = authority( + "first", + crate::turn::wire_assembly::RuntimeAuthorityKind::EdgeRequiredContext, + ); + let append = astra_turn_types::ProviderCanonicalTransitionV1::new_from_durable_base( + None, + durable_base.clone(), + &pre_rewrite, + vec![first_authority.clone()], + ) + .unwrap(); + let summary = json!({"role": "system", "content": "summary one"}); + let second_authority = authority( + "second", + crate::turn::wire_assembly::RuntimeAuthorityKind::ExecutionTimeBudget, + ); + let first_replacement = + astra_turn_types::ProviderCanonicalTransitionV1::new_replacement_from_durable_base( + Some(append.transition_id.clone()), + durable_base.clone(), + 1, + std::slice::from_ref(&summary), + vec![second_authority.clone()], + ) + .unwrap(); - for result in cases { - let mut state = create_test_state(); - assert!(!preserve_exhausted_output_cap_as_interruption( - &mut state, &result - )); - assert!(state.interruption.is_none()); - } - } + let ordinary_response = json!({"role": "assistant", "content": "intermediate"}); + let third_authority = authority( + "third", + crate::turn::wire_assembly::RuntimeAuthorityKind::OutputCapContinuation, + ); + let post_compaction_predecessor = vec![ + summary.clone(), + second_authority.clone(), + ordinary_response.clone(), + ]; + let ordinary_post_compaction = + astra_turn_types::ProviderCanonicalTransitionV1::new_replacement_from_durable_base( + Some(first_replacement.transition_id.clone()), + durable_base.clone(), + 1, + &post_compaction_predecessor, + vec![third_authority.clone()], + ) + .unwrap(); - #[test] - fn output_cap_continuation_merges_suffix_without_duplication() { - assert_eq!( - merge_output_cap_continuation("partial answer", " and the rest"), - "partial answer and the rest" + let summary_two = json!({"role": "system", "content": "summary two"}); + let final_authority = authority( + "final", + crate::turn::wire_assembly::RuntimeAuthorityKind::EdgeRequiredContext, ); + let second_replacement = + astra_turn_types::ProviderCanonicalTransitionV1::new_replacement_from_durable_base( + Some(ordinary_post_compaction.transition_id.clone()), + durable_base.clone(), + 1, + std::slice::from_ref(&summary_two), + vec![final_authority.clone()], + ) + .unwrap(); + let receipt = + |physical_attempt, transitions| astra_services::InferenceCanonicalTransitionReceipt { + turn: 1, + round: 0, + logical_attempt: 0, + physical_attempt, + transitions, + }; + + let fresh = json!({"role": "user", "content": "fresh"}); + let mut after_one_rewrite = durable_head.clone(); + after_one_rewrite.push(fresh.clone()); + let one_rewrite = hydrate_provider_canonical_transition_receipts( + &mut after_one_rewrite, + &durable_base, + vec![receipt(2, vec![ordinary_post_compaction.clone()])], + ) + .unwrap(); + assert_eq!(one_rewrite.reconciled_transitions, 1); assert_eq!( - merge_output_cap_continuation("partial answer", "partial answer and the rest"), - "partial answer and the rest" - ); + one_rewrite.replacement, + Some(ordinary_post_compaction.clone()) + ); + assert_eq!(after_one_rewrite[0], summary); + assert_eq!(after_one_rewrite[1], second_authority); + assert_eq!(after_one_rewrite[2], ordinary_response); + assert_eq!(after_one_rewrite[3], third_authority); + assert_eq!(after_one_rewrite[4], fresh); + + let mut after_second_rewrite = durable_head; + after_second_rewrite.push(fresh.clone()); + let two_rewrites = hydrate_provider_canonical_transition_receipts( + &mut after_second_rewrite, + &durable_base, + vec![receipt(3, vec![second_replacement.clone()])], + ) + .unwrap(); + assert_eq!(two_rewrites.reconciled_transitions, 1); + assert_eq!(two_rewrites.replacement, Some(second_replacement)); assert_eq!( - merge_output_cap_continuation("prefix: result", "result is verified"), - "prefix: result is verified" + after_second_rewrite, + vec![summary_two, final_authority, fresh] ); } #[test] - fn output_cap_continuation_context_is_provider_only_and_typed() { - let mut messages = vec![json!({"role":"user", "content":"finish the task"})]; - append_output_cap_continuation_context(&mut messages, "partial result"); - assert_eq!(messages.len(), 3); - assert_eq!(messages[1]["role"], "assistant"); - assert_eq!(messages[1]["content"], "partial result"); - assert_eq!(messages[2]["role"], "user"); - assert!( - messages[2]["content"] - .as_str() - .expect("continuation prompt") - .contains("next concrete tool call or give a concise final result") - ); + fn provider_transition_wal_rejects_a_forked_predecessor() { + let base = vec![json!({"role": "user", "content": "do the work"})]; + let authority = |text, kind| { + crate::turn::wire_assembly::required_append_only_runtime_authority_message( + text, + kind, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, + ) + .unwrap() + .unwrap() + }; + let left = astra_turn_types::ProviderCanonicalTransitionV1::new( + None, + &base, + vec![authority( + "left", + crate::turn::wire_assembly::RuntimeAuthorityKind::EdgeRequiredContext, + )], + ) + .unwrap(); + let right = astra_turn_types::ProviderCanonicalTransitionV1::new( + None, + &base, + vec![authority( + "right", + crate::turn::wire_assembly::RuntimeAuthorityKind::ExecutionTimeBudget, + )], + ) + .unwrap(); + let mut restored = base; + let before = restored.clone(); + let error = apply_provider_canonical_transition_receipts( + &mut restored, + vec![astra_services::InferenceCanonicalTransitionReceipt { + turn: 1, + round: 0, + logical_attempt: 0, + physical_attempt: 0, + transitions: vec![left, right], + }], + ) + .unwrap_err(); + assert_eq!(error.kind, astra_core::ErrorKind::ContractViolation); + assert_eq!(restored, before); + } + + #[test] + fn provider_transition_wal_rejects_disconnected_valid_branches_atomically() { + let base = vec![json!({"role": "assistant", "content": "ready"})]; + let durable_base = + astra_turn_types::CanonicalPrefixIdentityV1::from_messages(&base).unwrap(); + let authority = |text| { + crate::turn::wire_assembly::required_append_only_runtime_authority_message( + text, + crate::turn::wire_assembly::RuntimeAuthorityKind::EdgeRequiredContext, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, + ) + .unwrap() + .unwrap() + }; + let mut left_predecessor = base.clone(); + left_predecessor.push(json!({"role": "user", "content": "left"})); + let left = astra_turn_types::ProviderCanonicalTransitionV1::new_from_durable_base( + None, + durable_base.clone(), + &left_predecessor, + vec![authority("left")], + ) + .unwrap(); + let mut right_predecessor = base.clone(); + right_predecessor.push(json!({"role": "user", "content": "right"})); + let right = astra_turn_types::ProviderCanonicalTransitionV1::new_from_durable_base( + None, + durable_base, + &right_predecessor, + vec![authority("right")], + ) + .unwrap(); + let mut restored = base; + let before = restored.clone(); + let error = apply_provider_canonical_transition_receipts( + &mut restored, + vec![astra_services::InferenceCanonicalTransitionReceipt { + turn: 1, + round: 0, + logical_attempt: 0, + physical_attempt: 0, + transitions: vec![left, right], + }], + ) + .unwrap_err(); + assert_eq!(error.kind, astra_core::ErrorKind::ContractViolation); + assert_eq!(restored, before); } #[test] @@ -20714,11 +22165,14 @@ mod tests { ) .1; + let spill_count = pre_turn_summary_spill_count(&state.messages); let event = apply_pre_turn_summary( &mut state, 0.82, "Preserve the structured facts and continue.".to_string(), - ); + spill_count, + ) + .expect("summary must reduce the spilled prefix"); let tokens_after = crate::turn::agentic_loop::lifecycle::estimate_context_pressure( &state.messages, state.pinned_tool_schema_tokens as usize, @@ -20751,6 +22205,59 @@ mod tests { assert!(!packs.is_empty()); } + #[test] + fn pre_turn_summary_does_not_rewrite_an_entirely_protected_active_turn() { + let mut state = create_test_state(); + state.max_turn_input_tokens = 20_000; + state.messages = vec![json!({"role": "user", "content": "long active work"})]; + for index in 0..12 { + state.messages.push(json!({ + "role": "assistant", + "content": format!("evidence {index}: {}", "detail ".repeat(40)), + })); + } + let mut authority = json!({"role": "user", "content": "continue active Work"}); + astra_turn_types::mark_append_only_required_context( + &mut authority, + "active_work_attempt_start", + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, + ); + state.messages.push(authority); + let before = state.messages.clone(); + + let spill_count = pre_turn_summary_spill_count(&state.messages); + assert_eq!(spill_count, 0); + assert!( + apply_pre_turn_summary(&mut state, 0.95, "summary".to_string(), spill_count).is_none() + ); + assert_eq!(state.messages, before); + assert_eq!(state.compact_tier_applied, CompactionTier::Normal); + assert!(!state.context_compression_triggered); + } + + #[test] + fn pre_turn_summary_rejects_a_summary_that_does_not_reduce_tokens() { + let mut state = create_test_state(); + state.max_turn_input_tokens = 20_000; + for index in 0..12 { + state.messages.push(json!({ + "role": if index % 2 == 0 { "user" } else { "assistant" }, + "content": format!("short {index}"), + })); + } + let before = state.messages.clone(); + let spill_count = pre_turn_summary_spill_count(&state.messages); + assert!(spill_count > 0); + + assert!( + apply_pre_turn_summary(&mut state, 0.9, "oversized ".repeat(2_000), spill_count,) + .is_none() + ); + assert_eq!(state.messages, before); + assert_eq!(state.compact_tier_applied, CompactionTier::Normal); + assert!(!state.context_compression_triggered); + } + #[tokio::test] async fn assemble_llm_messages_includes_system_and_user() { let host = ServerAgenticLoopHostBuilder::new( @@ -20788,15 +22295,17 @@ mod tests { context_window: None, max_completion_tokens: None, }; - let msgs = host.assemble_llm_messages( - vec![json!({"role": "system", "content": "system prompt text"})], - Vec::new(), - state.messages.clone(), - &mut state, - &llm_cfg, - &PromptCacheConfig::latch("openai", "gpt-4"), - false, - ); + let msgs = host + .assemble_llm_messages( + vec![json!({"role": "system", "content": "system prompt text"})], + Vec::new(), + state.messages.clone(), + &mut state, + &llm_cfg, + &PromptCacheConfig::latch("openai"), + false, + ) + .unwrap(); assert!(msgs.len() >= 2, "should have system + user messages"); assert_eq!(msgs[0]["role"], "system"); assert_eq!(msgs[0]["content"], "system prompt text"); @@ -21115,16 +22624,12 @@ mod tests { ); } - /// Session 986a553e observed MiniMax-M2.7 cache collapsing from - /// 7680 to 0 across six tool-loop rounds because volatile - /// content (Self-Awareness with live turn/token counters) was - /// being re-injected every round. The new `CacheCapability` - /// routing classifies MiniMax as `VolatilePlacement::CurrentUserOnly`; - /// `run_turn_pipeline` now consults it and emits an **empty** - /// `volatile_preamble` on rounds > 0 so the message history bytes - /// stay stable across the tool loop. + /// A deployment that explicitly admits required-only delivery at the + /// current-user boundary must not emit optional volatile preamble. The + /// arbitrary model alias proves this behavior comes from the typed + /// capability, not from model-name recognition. #[tokio::test] - async fn run_turn_pipeline_minimax_skips_volatile_on_tool_loop_round() { + async fn current_user_required_only_capability_skips_optional_volatile_context() { let mut host = ServerAgenticLoopHostBuilder::new( mock_matrixone(), mock_encryptor(), @@ -21149,27 +22654,43 @@ mod tests { )); let tools = host.tool_schemas.clone(); - // Updated contract: strict-history providers (MiniMax) must - // suppress volatile preamble on EVERY round, not just >0. - // Round-0-only injection still causes a byte mismatch at - // msg[1] vs round 1+ (round 0 has preamble+user_q, round 1 - // has only user_q), so the whole turn's cache misses. + let capability = astra_turn_core::cache_placement::CacheCapability { + protocol: astra_turn_core::cache_placement::CacheProtocol::StrictHistoryMatch, + volatile_placement: + astra_turn_core::cache_placement::VolatilePlacement::CurrentUserOnly, + volatile_delivery: + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: Some(astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns), + }; for round in [0u32, 1, 5] { state.current_round_index = round; let out = host - .run_turn_pipeline(&mut state, &tools, "openai", "MiniMax-M2.7", "hi") + .run_turn_pipeline_with_cache_capability_and_session_memory( + &mut state, + &tools, + "openai", + "arbitrary-current-user-deployment", + None, + Some(capability), + None, + &[], + "hi", + ) .expect("pipeline should succeed"); assert!( out.volatile_preamble.is_empty(), - "MiniMax must suppress volatile preamble on every round \ - (strict-history provider). round={round} preamble={:?}", + "required-only delivery must suppress optional volatile context on every round. \ + round={round} preamble={:?}", out.volatile_preamble, ); } } + /// Append-only delivery is a deployment wire contract. It suppresses the + /// optional preamble independently of the model alias; required authority + /// is carried later by typed append-only runtime frames. #[tokio::test] - async fn run_turn_pipeline_deepseek_v4_flash_skips_volatile_on_tool_loop_round() { + async fn append_only_required_context_capability_skips_optional_volatile_context() { let mut host = ServerAgenticLoopHostBuilder::new( mock_matrixone(), mock_encryptor(), @@ -21192,15 +22713,33 @@ mod tests { astra_turn_core::pipeline_config::PipelineConfig::default(), )); let tools = host.tool_schemas.clone(); + let capability = astra_turn_core::cache_placement::CacheCapability { + protocol: astra_turn_core::cache_placement::CacheProtocol::OpenAiAutoPrefix, + volatile_placement: + astra_turn_core::cache_placement::VolatilePlacement::AppendOnlyUserTail, + volatile_delivery: + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: Some(astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns), + }; for round in [0u32, 1, 5] { state.current_round_index = round; let out = host - .run_turn_pipeline(&mut state, &tools, "openai", "deepseek-v4-flash", "hi") + .run_turn_pipeline_with_cache_capability_and_session_memory( + &mut state, + &tools, + "openai", + "arbitrary-append-only-deployment", + None, + Some(capability), + None, + &[], + "hi", + ) .expect("pipeline should succeed"); assert!( out.volatile_preamble.is_empty(), - "DeepSeek v4 flash must suppress volatile preamble on every round. \ + "append-only required delivery must suppress optional volatile context. \ round={round} preamble={:?}", out.volatile_preamble, ); @@ -22663,10 +24202,25 @@ mod tests { ..Default::default() }); assert!(host.pending_work_graph_mutation_boundary_crossed(&state)); - let context = host + let context_message = host .pending_work_graph_mutation_context(&state) - .expect("post-settlement mutation context") - .to_string(); + .expect("post-settlement mutation context"); + let stale_frame = + crate::turn::wire_assembly::required_append_only_runtime_authority_message( + context_message["content"] + .as_str() + .expect("context payload"), + crate::turn::wire_assembly::RuntimeAuthorityKind::PendingWorkGraphMutations, + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ) + .expect("valid append frame") + .expect("non-empty append frame"); + assert_eq!( + astra_turn_types::runtime_authority_lifetime(&stale_frame), + Some(astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision), + "a source-conditioned mutation obligation must expire after the decision that acts on it" + ); + let context = context_message.to_string(); assert!(context.contains("scheduling_boundary_crossed")); assert!(context.contains("Do not execute or settle")); assert!(context.contains("task-2")); @@ -22850,6 +24404,40 @@ mod tests { }); host.reconcile_pending_work_graph_mutations(&state); assert!(host.pending_work_graph_mutations.is_empty()); + assert!( + host.pending_work_graph_mutation_context(&state).is_none(), + "an accepted exact proposal must remove the live source projection" + ); + + // Reconstruct the canonical append history as it would appear after + // the assistant proposed the accepted mutation. On resume the prior + // frame is present as immutable history, but its typed lifetime is no + // longer active. A provider-shape switch therefore re-homes nothing. + let mut resumed_history = vec![ + json!({"role": "user", "content": "change the remaining work"}), + stale_frame, + json!({"role": "assistant", "content": "", "tool_calls": [{ + "id": "exact-patch", + "type": "function", + "function": {"name": "propose_work_plan", "arguments": "{}"} + }]}), + json!({"role": "tool", "tool_call_id": "exact-patch", "content": "accepted"}), + ]; + assert!( + !astra_turn_types::append_only_runtime_authority_is_active(&resumed_history, 1), + "resume must not reactivate an obligation consumed by an assistant decision" + ); + let rehomed = + crate::turn::wire_assembly::rehome_append_only_runtime_authority(&mut resumed_history) + .expect("provider switch must accept valid canonical history"); + assert!( + rehomed.is_empty(), + "provider switch must not re-home stale authority" + ); + assert!(resumed_history.iter().all(|message| { + astra_turn_types::runtime_authority_kind(message) + != Some("pending_work_graph_mutations") + })); } #[test] @@ -23733,6 +25321,8 @@ mod tests { protocol: astra_turn_core::cache_placement::CacheProtocol::StrictHistoryMatch, volatile_placement: astra_turn_core::cache_placement::VolatilePlacement::CurrentUserOnly, + volatile_delivery: + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly, reuse_scope: Some( astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns, ), @@ -24595,6 +26185,8 @@ mod tests { budget_wrapup_injected: false, context_compression_triggered: false, canonical_rewrite_state: Default::default(), + provider_canonical_wal_base: None, + provider_canonical_wal_head_transition_id: None, budget_wrapup_ignored_rounds: 0, compact_tier_applied: CompactionTier::Normal, skill_produced_output: false, @@ -24947,6 +26539,30 @@ mod tests { ); } + #[test] + fn repeated_text_only_violation_removes_wire_surface() { + assert!(preserve_text_only_wire_surface(true, 0, "openai")); + assert!( + !preserve_text_only_wire_surface(true, 1, "openai"), + "the repair request must not repeat schemas after tool_choice was ignored" + ); + assert!(!preserve_text_only_wire_surface(false, 0, "openai")); + + let preceding = vec![json!({ + "type": "function", + "function": {"name": "bash"} + })]; + let selected = settlement_wire_tool_schemas( + true, + false, + preserve_text_only_wire_surface(true, 1, "openai"), + &preceding, + &[], + ) + .expect("text-only boundary owns wire schema selection"); + assert!(selected.is_empty()); + } + #[test] fn preserved_final_synthesis_wire_surface_does_not_widen_runtime_authority() { let mut host = ServerAgenticLoopHostBuilder::new( @@ -28206,6 +29822,29 @@ mod tests { llm_events[0]["metadata"]["trace"]["session_turn_source"].as_str(), Some("state") ); + let expected_cache_capability = + astra_turn_core::cache_placement::CacheCapability::for_provider("openai"); + assert_eq!( + serde_json::from_value::( + llm_events[0]["metadata"]["trace"]["cache_capability"].clone(), + ) + .unwrap(), + expected_cache_capability + ); + assert_eq!( + llm_events[0]["metadata"]["cache_capability"], + llm_events[1]["metadata"]["cache_capability"], + "request and response capture must report the same resolved shape" + ); + let diagnostic_snapshot = + astra_turn_core::introspect::cache_diagnosis::snapshot_from_capture_json( + &llm_events[0]["metadata"], + ); + assert_eq!( + diagnostic_snapshot.cache_capability, + Some(expected_cache_capability), + "harness/introspect parser must recover exact capability from journal metadata" + ); assert!( llm_events[0]["metadata"]["trace"]["turn_chain_id"].is_null(), "server-loop trace should not fabricate bridge correlation ids" @@ -29949,6 +31588,7 @@ mod tests { ], &tools, &messages, + &messages, &breakdown, ); assert!(captured.last_tool_has_cache_control); diff --git a/crates/runtime/src/server/server_skill_subrun.rs b/crates/runtime/src/server/server_skill_subrun.rs index f6b5f4498e..9f58122f92 100644 --- a/crates/runtime/src/server/server_skill_subrun.rs +++ b/crates/runtime/src/server/server_skill_subrun.rs @@ -874,9 +874,16 @@ impl SkillSubRunExecutor for ServerSkillSubRunExecutor { let execution_result: Result = async { let effective_model = self.default_model.clone(); - let compact_strategy = astra_turn_core::microcompact::CompactStrategy::from_provider_hint( - effective_model.as_deref().unwrap_or(""), - ); + let compact_strategy = self + .admitted_model_execution + .as_ref() + .map(|execution| { + crate::turn::llm::context::compact_strategy_from_model_metadata( + execution.cache_capability, + &execution.provider, + ) + }) + .unwrap_or_default(); let permission_context = crate::orchestration::PermissionSyncContext::shared(self.inherited_permissions.clone()); @@ -1131,6 +1138,8 @@ impl SkillSubRunExecutor for ServerSkillSubRunExecutor { budget_wrapup_injected: false, context_compression_triggered: false, canonical_rewrite_state: Default::default(), + provider_canonical_wal_base: None, + provider_canonical_wal_head_transition_id: None, budget_wrapup_ignored_rounds: 0, compact_tier_applied: astra_turn_core::compaction_types::CompactionTier::Normal, skill_produced_output: false, diff --git a/crates/runtime/src/turn/agentic/turn_intent.rs b/crates/runtime/src/turn/agentic/turn_intent.rs index ce3471aa04..31524af639 100644 --- a/crates/runtime/src/turn/agentic/turn_intent.rs +++ b/crates/runtime/src/turn/agentic/turn_intent.rs @@ -40,7 +40,10 @@ pub(crate) fn build_turn_intent_judge_context( let Some(content) = entry.get("content").and_then(serde_json::Value::as_str) else { continue; }; - if role == "user" && !skipped_current_user && content.trim() == message.trim() { + if astra_turn_types::is_human_user_message(entry) + && !skipped_current_user + && content.trim() == message.trim() + { skipped_current_user = true; continue; } @@ -48,7 +51,7 @@ pub(crate) fn build_turn_intent_judge_context( prior_assistant_message = Some(bounded_message(content)); continue; } - if role == "user" && prior_assistant_message.is_some() { + if astra_turn_types::is_human_user_message(entry) && prior_assistant_message.is_some() { prior_user_message = Some(bounded_message(content)); break; } diff --git a/crates/runtime/src/turn/agentic_loop/execution_phase.rs b/crates/runtime/src/turn/agentic_loop/execution_phase.rs index 492b701436..c543696c53 100644 --- a/crates/runtime/src/turn/agentic_loop/execution_phase.rs +++ b/crates/runtime/src/turn/agentic_loop/execution_phase.rs @@ -428,6 +428,8 @@ pub(crate) fn successful_post_mutation_observation(state: &AgenticLoopState) -> state.hooks.workspace_root_hint.as_deref(), ) }); + let full_scope_explicit_verification = + super::lifecycle::record_has_full_scope_explicit_workspace_verification_receipt(record); // Executing the delivered artifact is useful behavioral evidence even // when the interpreter emits incidental files (for example Python // bytecode), but an opaque script writer cannot prove its own change. @@ -458,7 +460,9 @@ pub(crate) fn successful_post_mutation_observation(state: &AgenticLoopState) -> }); if record.ok && super::lifecycle::record_can_observe_bound_workspace(state, record) - && (literal_script_command.is_none() || latest_epoch_delivered_artifact) + && (full_scope_explicit_verification + || literal_script_command.is_none() + || latest_epoch_delivered_artifact) { // A compound shell invocation may contain both the mutation and // its post-mutation receipt. Count the receipt before closing @@ -3808,8 +3812,12 @@ pub(crate) async fn execute_turn_and_ingest_phase( } } let mut turn_result = match turn_result { - Ok(turn_result) => turn_result, + Ok(turn_result) => { + state.commit_volatile_attempt_lease(); + turn_result + } Err(error) => { + state.restore_volatile_attempt_lease(); fold_provider_completion_error_usage(state, &error); if schedule_safe_provider_recovery(state, &error) { tracing::warn!( @@ -7831,6 +7839,209 @@ mod tests { ); } + fn typed_writer_record(path: &str) -> ToolCallRecord { + let args = serde_json::json!({"path": path, "content": "updated"}).to_string(); + let mut record = executed_record("write_file", true, Some(&args)); + record.runtime_args_full = Some(args); + record + } + + fn full_scope_verify_record() -> ToolCallRecord { + let args = serde_json::json!({ + "command": "ls -la /app/program.py /app/requirements.txt && python3 /app/program.py", + "mode": "verify", + }) + .to_string(); + let fields = astra_tools::workspace_observation::explicit_workspace_verification_receipt(); + ToolCallRecord { + name: "bash".into(), + ok: true, + disposition: Some(ToolCallDisposition::Executed), + args_full: Some(args.clone()), + runtime_args_full: Some(args), + workspace_mutation_scope: Some( + astra_tools::workspace_observation::BOUND_WORKSPACE_SCOPE.into(), + ), + workspace_mutation_receipt: fields + .get(astra_tools::workspace_observation::OBSERVATION_RECEIPT_FIELD) + .cloned(), + ..ToolCallRecord::default() + } + } + + #[test] + fn live_full_scope_verify_receipt_settles_multi_file_mutation_batch_in_either_order() { + for paths in [ + ["/app/program.py", "/app/requirements.txt"], + ["/app/requirements.txt", "/app/program.py"], + ] { + let mut state = make_state(); + mark_must_mutate(&mut state); + state.hooks.workspace_root_hint = Some("/app".into()); + state.stall.tool_call_records = vec![ + typed_writer_record(paths[0]), + typed_writer_record(paths[1]), + full_scope_verify_record(), + ]; + state.hooks.completion_settlement.completion_action_window = + Some(super::super::host::CompletionActionWindow { + action: CompletionAction::PostMutationObservation, + attempts_remaining: 0, + mismatch_corrections_remaining: 0, + consumed: true, + matched: true, + }); + + assert!(has_concrete_workspace_mutation(&state)); + assert!(successful_post_mutation_observation(&state)); + assert_eq!(pending_completion_action(&state), None); + assert_eq!( + enforce_completion_action_window_before_text_completion(&mut state), + CompletionActionBoundary::Settled + ); + assert!(state.interruption.is_none()); + } + } + + #[tokio::test] + async fn edge_callback_v2_metadata_survives_record_ingestion_and_settles_completion() { + let mut program_write = make_edge_tool("write_file", "program written"); + program_write.request_id = "edge-write-program".into(); + program_write.args = serde_json::json!({ + "path": "/app/program.py", + "content": "print('ok')", + }); + let mut requirements_write = make_edge_tool("write_file", "requirements written"); + requirements_write.request_id = "edge-write-requirements".into(); + requirements_write.args = serde_json::json!({ + "path": "/app/requirements.txt", + "content": "dependency>=1", + }); + + let mut verify = make_edge_tool("bash", "program and requirements verified"); + verify.request_id = "edge-verify-workspace".into(); + verify.args = serde_json::json!({ + "command": "ls -la /app/program.py /app/requirements.txt && python3 /app/program.py", + "mode": "verify", + }); + verify + .tool_result_fields + .as_mut() + .expect("edge callback fixture carries owner metadata") + .extend(astra_tools::workspace_observation::explicit_workspace_verification_receipt()); + + let mut host = MockHost::new(vec![ + edge_tool_result(vec![program_write, requirements_write], 20, 10, Some(30)), + text_result("The files are ready.", 20, 10, Some(30)), + edge_tool_result(vec![verify], 20, 10, Some(30)), + text_result("The files are ready and verified.", 20, 10, Some(30)), + ]) + .with_valid_tools(&["write_file", "bash"]); + let mut state = make_state(); + mark_must_mutate(&mut state); + state.hooks.workspace_root_hint = Some("/app".into()); + + let outcome = run_agentic_loop_with_host(&mut host, &mut state) + .await + .expect("typed edge receipt should settle the run"); + + assert!(matches!(outcome, AgenticLoopOutcome::Completed)); + assert_eq!(host.turn_count(), 4); + let verify_record = state + .stall + .tool_call_records + .iter() + .find(|record| record.tool_call_id.as_deref() == Some("edge-verify-workspace")) + .expect("edge callback must become a tool record"); + assert!(verify_record.runtime_args_full.is_some()); + assert!( + super::super::lifecycle::record_has_full_scope_explicit_workspace_verification_receipt( + verify_record + ) + ); + assert!(successful_post_mutation_observation(&state)); + assert_eq!(pending_completion_action(&state), None); + assert!(state.interruption.is_none()); + assert_eq!(state.final_text, "The files are ready and verified."); + } + + #[test] + fn full_scope_verify_receipt_fails_closed_on_invalid_or_stale_authority() { + fn state_with(record: ToolCallRecord) -> AgenticLoopState { + let mut state = make_state(); + mark_must_mutate(&mut state); + state.hooks.workspace_root_hint = Some("/app".into()); + state.stall.tool_call_records = vec![ + typed_writer_record("/app/program.py"), + typed_writer_record("/app/requirements.txt"), + record, + ]; + state + } + + let mut missing_receipt = full_scope_verify_record(); + missing_receipt.workspace_mutation_receipt = None; + + let mut wrong_scope = full_scope_verify_record(); + wrong_scope.workspace_mutation_scope = Some("declared_external_state".into()); + + let mut wrong_args = full_scope_verify_record(); + let args = serde_json::json!({ + "command": "ls -la /app/program.py /app/requirements.txt && python3 /app/program.py", + }) + .to_string(); + wrong_args.args_full = Some(args.clone()); + wrong_args.runtime_args_full = Some(args); + + let mut failed = full_scope_verify_record(); + failed.ok = false; + + let mut changed = full_scope_verify_record(); + changed.workspace_mutation_observed = Some(true); + + let mut restored = full_scope_verify_record(); + restored.runtime_args_full = None; + + for (record, reason) in [ + (missing_receipt, "missing v2 receipt"), + (wrong_scope, "wrong observation scope"), + (wrong_args, "wrong invocation arguments"), + (failed, "failed verification"), + (changed, "contradictory changed workspace"), + ( + restored, + "restored receipt without live invocation authority", + ), + ] { + let state = state_with(record); + assert!(!successful_post_mutation_observation(&state), "{reason}"); + assert_eq!( + pending_completion_action(&state), + Some(CompletionAction::PostMutationObservation), + "{reason}" + ); + } + + let mut stale = state_with(full_scope_verify_record()); + stale + .stall + .tool_call_records + .push(typed_writer_record("/app/later.txt")); + assert!(!successful_post_mutation_observation(&stale)); + assert_eq!( + pending_completion_action(&stale), + Some(CompletionAction::PostMutationObservation) + ); + + let mut quarantined = state_with(full_scope_verify_record()); + quarantined.stall.workspace_observation_quarantine = Some( + astra_pipeline::step_protocol::WorkspaceObservationQuarantineV1::partial_workspace_mutation( + Some("unsettled-call".into()), + ), + ); + assert!(!successful_post_mutation_observation(&quarantined)); + } + #[tokio::test] async fn shell_redirect_after_mutation_is_not_mistaken_for_observation() { let mut scratch_bash = make_edge_tool("bash", "created scratch fixture"); diff --git a/crates/runtime/src/turn/agentic_loop/finalization.rs b/crates/runtime/src/turn/agentic_loop/finalization.rs index 5b7e76f7a8..bd402df71c 100644 --- a/crates/runtime/src/turn/agentic_loop/finalization.rs +++ b/crates/runtime/src/turn/agentic_loop/finalization.rs @@ -697,9 +697,7 @@ fn materialize_terminal_text_message(state: &mut AgenticLoopState) { let current_turn_start = state .messages .iter() - .rposition(|message| { - message.get("role").and_then(serde_json::Value::as_str) == Some("user") - }) + .rposition(astra_turn_types::is_human_user_message) .unwrap_or(0); let already_materialized = state.messages[current_turn_start..] .iter() diff --git a/crates/runtime/src/turn/agentic_loop/host.rs b/crates/runtime/src/turn/agentic_loop/host.rs index 6a3a84687f..f824e0dfe0 100644 --- a/crates/runtime/src/turn/agentic_loop/host.rs +++ b/crates/runtime/src/turn/agentic_loop/host.rs @@ -1900,6 +1900,13 @@ pub struct StopHookState { /// Recovery state for a textless provider response. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct CompletionSettlementState { + /// Host-internal canonical Work establishment repairs already attempted in + /// this user turn. Keeping the counter in typed loop state preserves the + /// bounded-once contract across transport failure and resume. + pub canonical_work_establishment_retries: u32, + /// Host-internal output-cap continuations already attempted in this user + /// turn. This is independent of provider prose and survives retry/resume. + pub output_cap_continuations: u8, /// Number of same-turn recovery calls made after the provider returned a /// successful response with neither tool calls nor user-visible text. pub textless_response_retries: u32, @@ -2161,6 +2168,13 @@ pub struct VolatileInjection { /// Round index the injection was produced in (for introspect /// telemetry; not used by the wire layer). pub round_index: u32, + /// Internal delivery lease. A volatile authority remains pending until a + /// provider attempt has produced an assistant decision; transport and + /// admission failures release the lease so the exact same typed fact is + /// projected again on retry. This bit is control-plane state, never wire + /// provenance. + #[doc(hidden)] + pub attempt_leased: bool, } /// In-memory summary of one LLM round within the current session. @@ -2197,7 +2211,7 @@ pub const RECENT_ROUNDS_RING_CAPACITY: usize = 32; /// Taxonomy of runtime-produced volatile content. Add a new variant /// when introducing a new injection kind — both the producer and the /// drain path become compile-time-checked. -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum VolatileKind { /// Stall-reflection evidence (`build_stall_reflection`). @@ -2256,6 +2270,12 @@ pub enum VolatileKind { /// neither tool calls nor final text. Hosts pair this typed signal with a /// physically empty tool surface for the recovery call. FinalAnswerSettlement, + /// Required authority for the bounded retry that must establish canonical + /// Work after a provider response failed to do so. + CanonicalWorkEstablishmentRetry, + /// Required authority for the bounded continuation of a text-only response + /// that reached the provider output cap. + OutputCapContinuation, /// Required provenance boundary for one bounded retry when a runtime or /// session retrospective attempted to finish without live observation. RuntimeEvidenceRequired, @@ -2289,6 +2309,8 @@ impl VolatileKind { | Self::CompactResume | Self::CircuitBreaker | Self::FinalAnswerSettlement + | Self::CanonicalWorkEstablishmentRetry + | Self::OutputCapContinuation | Self::RuntimeEvidenceRequired | Self::StopHookEvidence | Self::SessionHookContext @@ -2318,6 +2340,8 @@ impl VolatileKind { | Self::ActiveWorkSnapshot | Self::UserIntentBoundary | Self::FinalAnswerSettlement + | Self::CanonicalWorkEstablishmentRetry + | Self::OutputCapContinuation | Self::RuntimeEvidenceRequired | Self::SessionHookContext | Self::PlanModeMarker @@ -2343,6 +2367,15 @@ impl VolatileKind { .and_then(|value| value.as_str().map(str::to_string)) .expect("unit enum serialization must produce a string") } + + /// Whether the serialized category denotes one replaceable snapshot. + /// Unknown extension kinds are conservatively accumulative: silently + /// treating a new multi-event producer as singleton would discard facts. + #[must_use] + pub(crate) fn wire_kind_is_singleton(kind: &str) -> bool { + serde_json::from_value::(Value::String(kind.to_string())) + .is_ok_and(Self::is_singleton) + } } fn volatile_payload_is_empty(payload: &Value) -> bool { @@ -3048,6 +3081,16 @@ pub struct AgenticLoopState { /// only by an explicit compaction operation. Observability flags must not /// authorize a canonical Replace commit. pub canonical_rewrite_state: CanonicalRewriteState, + /// Exact committed conversation prefix from which provider-attempt WAL + /// may reconstruct this uncommitted turn. Present only when the outer + /// canonical coordinator admitted the turn; local/subrun histories must + /// not invent this authority. + pub provider_canonical_wal_base: Option, + /// Latest transition durably admitted for this unfinished turn. Every new + /// transition names this exact id as its parent; recovery restores it from + /// the database-owned per-turn WAL head instead of inferring lineage from + /// message values. + pub provider_canonical_wal_head_transition_id: Option, /// Counts how many post-wrap-up rounds still emitted tool_calls. Task #43 /// hybrid enforcement: the first such round triggers a physical lockout /// (tool_calls dropped, `restricted_tools` populated, loop continues so the @@ -3400,12 +3443,45 @@ impl AgenticLoopState { )); } + pub(crate) fn initialize_provider_canonical_wal_base(&mut self, durable_prefix: &[Value]) { + self.provider_canonical_wal_base = + astra_turn_types::CanonicalPrefixIdentityV1::from_messages(durable_prefix).ok(); + self.provider_canonical_wal_head_transition_id = None; + } + pub(crate) fn canonical_rewrite_proof( &self, ) -> Option<&crate::turn::canonical_commit::CanonicalRewriteProof> { self.canonical_rewrite_state.proof.as_ref() } + pub(crate) fn provider_canonical_replacement_authorization( + &self, + durable_base: &astra_turn_types::CanonicalPrefixIdentityV1, + predecessor_messages: &[Value], + ) -> Option { + self.canonical_rewrite_state + .proof + .as_ref()? + .provider_wal_replacement_authorization(durable_base, predecessor_messages) + } + + pub(crate) fn recover_provider_canonical_replacement( + &mut self, + transition: &astra_turn_types::ProviderCanonicalTransitionV1, + recovered_messages: &[Value], + ) -> Result<(), String> { + let durable_base = self + .provider_canonical_wal_base + .as_ref() + .ok_or_else(|| "provider WAL recovery has no admitted durable base".to_string())?; + self.canonical_rewrite_state + .proof + .as_mut() + .ok_or_else(|| "provider WAL replacement has no admitted rewrite proof".to_string())? + .recover_provider_wal_replacement(durable_base, transition, recovered_messages) + } + pub(crate) fn begin_canonical_rewrite( &self, ) -> Option { @@ -3452,6 +3528,68 @@ impl AgenticLoopState { self.record_prompt_history_messages(std::iter::once(message)); } + /// Persist provider-visible runtime authority without claiming human-turn + /// provenance or adding it to a child run's conversational transcript. + /// This is the only runtime-context class allowed in canonical prompt + /// history because its physical append position is part of the provider + /// cache contract. + pub fn extend_append_only_runtime_messages( + &mut self, + messages: I, + ) -> Result<(), astra_core::ClassifiedError> + where + I: IntoIterator, + { + let messages = messages.into_iter().collect::>(); + if messages.iter().any(|message| { + astra_turn_types::runtime_message_delivery(message) + != Some(astra_turn_types::RuntimeMessageDelivery::AppendOnlyRequiredContext) + }) { + return Err(astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + "canonical append-only runtime history received a message from another delivery lane", + )); + } + self.messages.extend(messages); + Ok(()) + } + + /// Atomically append the provider response that triggered an internal + /// retry and the typed authority for that retry. + /// + /// Both values are validated before canonical state changes. A checkpoint + /// or resumed provider projection therefore observes either the whole + /// transition or neither half; it can never retain a continuation command + /// whose referenced assistant response is missing. + pub fn append_provider_retry_transition( + &mut self, + mut assistant: Value, + authority: Value, + ) -> Result<(), astra_core::ClassifiedError> { + if assistant.get("role").and_then(Value::as_str) != Some("assistant") { + return Err(astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + "provider retry transition requires an assistant response", + )); + } + if astra_turn_types::runtime_message_delivery(&authority) + != Some(astra_turn_types::RuntimeMessageDelivery::AppendOnlyRequiredContext) + { + return Err(astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + "provider retry transition requires typed append-only runtime authority", + )); + } + if let Some(turn_chain_id) = self.canonical_turn_chain_id.as_deref() { + astra_turn_types::mark_turn_message(&mut assistant, turn_chain_id); + } + self.messages.reserve(2); + self.messages.push(assistant.clone()); + self.messages.push(authority); + self.record_prompt_history_messages(std::iter::once(assistant)); + Ok(()) + } + /// Stamp and capture a suffix appended by a lower-level routine that had /// direct mutable access to `messages`. pub fn record_appended_prompt_history_from(&mut self, start: usize) { @@ -3522,6 +3660,16 @@ impl AgenticLoopState { self.recursion_depth == 0 && self.delegation_chain.is_empty() } + /// Whether this loop owns provider-attempt write-ahead transitions for + /// the canonical session conversation. Subagents and delegated loops may + /// share a session id for tool/evidence custody, but their private prompt + /// histories must never hydrate from or write into the root transcript. + #[must_use] + pub fn owns_provider_canonical_transition_wal(&self) -> bool { + self.owns_session_composite_snapshot() + && self.inference_purpose == astra_turn_types::InferencePurpose::PrimaryAgent + } + /// Provider-reported total tokens consumed by this loop. /// /// The four run-level token buckets are disjoint. Any budget, governor, or @@ -3581,16 +3729,37 @@ impl AgenticLoopState { /// Queue a structured runtime payload without flattening it to text at the /// process boundary. pub fn push_volatile_payload(&mut self, kind: VolatileKind, mut payload: Value) { - if let Value::String(text) = &mut payload { + self.push_volatile_payload_with_lease(kind, &mut payload, false); + } + + /// Queue authority already projected to an in-process provider retry. It + /// joins the active attempt lease: success commits it, while any failure + /// restores it for the next assembled request. + pub fn push_volatile_payload_for_active_attempt( + &mut self, + kind: VolatileKind, + mut payload: Value, + ) { + self.push_volatile_payload_with_lease(kind, &mut payload, true); + } + + fn push_volatile_payload_with_lease( + &mut self, + kind: VolatileKind, + payload: &mut Value, + attempt_leased: bool, + ) { + if let Value::String(text) = payload { *text = text.trim().to_string(); } - if volatile_payload_is_empty(&payload) { + if volatile_payload_is_empty(payload) { return; } let injection = VolatileInjection { kind, - payload, + payload: payload.clone(), round_index: self.current_round_index, + attempt_leased, }; if kind.is_singleton() { // Replace any prior entry of the same kind so the snapshot @@ -3778,12 +3947,47 @@ impl AgenticLoopState { ); } - /// Drain all pending volatile injections. Called by - /// `wire_assembly::assemble_llm_messages` once per LLM call. + /// Lease the current volatile authorities to one provider attempt. /// - /// Consumers (and tests inspecting runtime state) get an owned - /// list; the lane is empty afterward so the NEXT LLM call starts - /// from a clean slate. + /// The entries deliberately stay in `volatile_pending` until the caller + /// observes an assistant decision. This makes request construction + /// transactional: an admission, transport, timeout, or context-window + /// failure cannot consume required runtime authority. + pub fn lease_volatile_pending( + &mut self, + ) -> Result, astra_core::ClassifiedError> { + if self + .volatile_pending + .iter() + .any(|injection| injection.attempt_leased) + { + return Err(astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + "volatile runtime authority already has an unresolved provider-attempt lease", + )); + } + for injection in &mut self.volatile_pending { + injection.attempt_leased = true; + } + Ok(self.volatile_pending.clone()) + } + + /// Commit only the authorities actually leased to the completed attempt. + /// Facts queued after request construction remain pending. + pub fn commit_volatile_attempt_lease(&mut self) { + self.volatile_pending + .retain(|injection| !injection.attempt_leased); + } + + /// Release a failed provider attempt without consuming its authority. + pub fn restore_volatile_attempt_lease(&mut self) { + for injection in &mut self.volatile_pending { + injection.attempt_leased = false; + } + } + + /// Test/support escape hatch that consumes the whole pending lane without + /// creating a provider-attempt lease. #[must_use] pub fn take_volatile_pending(&mut self) -> Vec { std::mem::take(&mut self.volatile_pending) @@ -4549,6 +4753,8 @@ pub fn make_test_loop_state_for_model(model: Option<&str>) -> AgenticLoopState { budget_wrapup_injected: false, context_compression_triggered: false, canonical_rewrite_state: Default::default(), + provider_canonical_wal_base: None, + provider_canonical_wal_head_transition_id: None, budget_wrapup_ignored_rounds: 0, compact_tier_applied: CompactionTier::Normal, skill_produced_output: false, @@ -5165,13 +5371,21 @@ pub(crate) mod tests { fn only_root_loop_owns_session_composite_snapshot() { let mut state = make_state(); assert!(state.owns_session_composite_snapshot()); + assert!(state.owns_provider_canonical_transition_wal()); state.recursion_depth = 1; assert!(!state.owns_session_composite_snapshot()); + assert!(!state.owns_provider_canonical_transition_wal()); state.recursion_depth = 0; state.delegation_chain = vec!["orchestrator".to_string()]; assert!(!state.owns_session_composite_snapshot()); + assert!(!state.owns_provider_canonical_transition_wal()); + + state.delegation_chain.clear(); + state.inference_purpose = astra_turn_types::InferencePurpose::SubAgent; + assert!(state.owns_session_composite_snapshot()); + assert!(!state.owns_provider_canonical_transition_wal()); } /// Unwind-safe cleanup guard for tests that write under @@ -6094,6 +6308,8 @@ pub(crate) mod tests { budget_wrapup_injected: false, context_compression_triggered: false, canonical_rewrite_state: Default::default(), + provider_canonical_wal_base: None, + provider_canonical_wal_head_transition_id: None, budget_wrapup_ignored_rounds: 0, compact_tier_applied: CompactionTier::Normal, skill_produced_output: false, @@ -12736,6 +12952,25 @@ mod parallel_execution_tests { ); } + #[test] + fn volatile_attempt_lease_commits_only_after_decision_and_restores_on_failure() { + let mut state = make_state(); + state.push_volatile(VolatileKind::FinalAnswerSettlement, "settle with evidence"); + + let leased = state.lease_volatile_pending().expect("first attempt lease"); + assert_eq!(leased.len(), 1); + assert!(leased[0].attempt_leased); + assert_eq!(state.volatile_pending.len(), 1); + assert!(state.lease_volatile_pending().is_err()); + + state.restore_volatile_attempt_lease(); + assert!(!state.volatile_pending[0].attempt_leased); + let retry = state.lease_volatile_pending().expect("retry lease"); + assert_eq!(retry[0].payload, leased[0].payload); + state.commit_volatile_attempt_lease(); + assert!(state.volatile_pending.is_empty()); + } + #[test] fn different_volatile_kinds_coexist_on_wire() { // Different kinds (StallNudge vs ContextPressure) are NOT singletons diff --git a/crates/runtime/src/turn/agentic_loop/lifecycle.rs b/crates/runtime/src/turn/agentic_loop/lifecycle.rs index 911974b911..87e1b4fcb1 100644 --- a/crates/runtime/src/turn/agentic_loop/lifecycle.rs +++ b/crates/runtime/src/turn/agentic_loop/lifecycle.rs @@ -243,9 +243,7 @@ fn record_current_user_turn_semantics(state: &mut AgenticLoopState, intent: &Tur .enumerate() .rev() .find_map(|(index, message)| { - if message.get("role").and_then(Value::as_str) != Some("user") - || astra_turn_types::is_runtime_owned_message(message) - { + if !astra_turn_types::is_human_user_message(message) { return None; } let content = astra_turn_core::prompt_facing::extract_text_content(message)?; @@ -988,12 +986,18 @@ pub(crate) fn record_has_typed_workspace_observation_receipt( .unwrap_or(&Value::Null); record.was_executed() && record.ok + // An invocation cannot truthfully be both an unchanged observation + // and a workspace mutation. Fail closed on contradictory executor + // metadata instead of letting the observation half settle a newer + // mutation epoch. + && record.workspace_mutation_observed != Some(true) && ((astra_tools::workspace_observation::is_typed_workspace_observer(&record.name) && astra_tools::workspace_observation::is_typed_workspace_observation_receipt(receipt)) - || (astra_tools::workspace_observation::is_explicit_workspace_verification_request( - &record.name, - &args, - ) + || (record.runtime_args_full.is_some() + && astra_tools::workspace_observation::is_explicit_workspace_verification_request( + &record.name, + &args, + ) && astra_tools::workspace_observation::is_explicit_workspace_verification_receipt( receipt, ))) @@ -1001,6 +1005,24 @@ pub(crate) fn record_has_typed_workspace_observation_receipt( == Some(astra_tools::workspace_observation::BOUND_WORKSPACE_SCOPE) } +/// An executor-owned Bash verification receipt covers the whole bound +/// workspace for the live invocation that minted it. Keep this typed lane +/// distinct from legacy shell-shape observations: the latter may need an +/// exact delivered-artifact correlation, while a v2 receipt was produced only +/// after the owner held the workspace observation lease and proved an +/// unchanged pre/post fingerprint. +pub(crate) fn record_has_full_scope_explicit_workspace_verification_receipt( + record: &astra_services::session_journal::ToolCallRecord, +) -> bool { + record_has_typed_workspace_observation_receipt(record) + && astra_tools::workspace_observation::is_explicit_workspace_verification_receipt( + record + .workspace_mutation_receipt + .as_ref() + .unwrap_or(&Value::Null), + ) +} + fn recent_turns_are_repetitive(state: &AgenticLoopState) -> bool { let Some(last) = state.stall.turn_sigs.last() else { return false; @@ -4860,6 +4882,49 @@ mod tests { ..Default::default() }; assert!(record_has_typed_workspace_observation_receipt(&record)); + assert!(record_has_full_scope_explicit_workspace_verification_receipt(&record)); + + record.runtime_args_full = None; + assert!(!record_has_typed_workspace_observation_receipt(&record)); + record.runtime_args_full = record.args_full.clone(); + + record.workspace_mutation_observed = Some(true); + assert!(!record_has_typed_workspace_observation_receipt(&record)); + record.workspace_mutation_observed = None; + + let typed_observer_receipt = + astra_tools::workspace_observation::typed_workspace_observation_receipt(); + let typed_observer = ToolCallRecord { + name: "read_file".into(), + ok: true, + disposition: Some(astra_services::session_journal::ToolCallDisposition::Executed), + args_full: Some(json!({"path": "/workspace/result"}).to_string()), + runtime_args_full: Some(json!({"path": "/workspace/result"}).to_string()), + workspace_mutation_scope: Some( + astra_tools::workspace_observation::BOUND_WORKSPACE_SCOPE.into(), + ), + workspace_mutation_receipt: typed_observer_receipt + .get(astra_tools::workspace_observation::OBSERVATION_RECEIPT_FIELD) + .cloned(), + ..Default::default() + }; + assert!(record_has_typed_workspace_observation_receipt( + &typed_observer + )); + assert!( + !record_has_full_scope_explicit_workspace_verification_receipt(&typed_observer), + "a typed read receipt must not be promoted to full-scope Bash verification" + ); + + let mut wrong_tool_v2 = typed_observer; + wrong_tool_v2.workspace_mutation_receipt = record.workspace_mutation_receipt.clone(); + assert!(!record_has_typed_workspace_observation_receipt( + &wrong_tool_v2 + )); + assert!( + !record_has_full_scope_explicit_workspace_verification_receipt(&wrong_tool_v2), + "the v2 payload alone must not authorize a non-Bash tool" + ); record.args_full = Some(json!({"command": "pytest -q"}).to_string()); record.runtime_args_full = record.args_full.clone(); diff --git a/crates/runtime/src/turn/canonical_commit.rs b/crates/runtime/src/turn/canonical_commit.rs index ccf9552aa6..315655c12e 100644 --- a/crates/runtime/src/turn/canonical_commit.rs +++ b/crates/runtime/src/turn/canonical_commit.rs @@ -4,17 +4,23 @@ use serde_json::Value; pub(crate) struct CanonicalRewriteProof { base_root: String, base_compaction_generation: u64, + base_prefix_len: usize, authorized_prefix_len: usize, authorized_prefix_root: String, rewritten: bool, valid: bool, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub(crate) struct CanonicalRewritePermit { valid: bool, } +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ProviderWalReplacementAuthorization { + pub(crate) generation: u64, +} + impl CanonicalRewriteProof { pub(crate) fn new( admitted_prefix: &[Value], @@ -25,6 +31,7 @@ impl CanonicalRewriteProof { Self { base_root: base_root.to_string(), base_compaction_generation, + base_prefix_len: admitted_prefix.len(), authorized_prefix_len: admitted_prefix.len(), authorized_prefix_root: admitted_root.clone(), rewritten: false, @@ -33,13 +40,12 @@ impl CanonicalRewriteProof { } pub(crate) fn begin(&self, messages: &[Value]) -> CanonicalRewritePermit { - CanonicalRewritePermit { - valid: self.valid - && messages.len() >= self.authorized_prefix_len - && astra_turn_types::canonical_conversation_root( - &messages[..self.authorized_prefix_len], - ) == self.authorized_prefix_root, - } + let valid = self.valid + && messages.len() >= self.authorized_prefix_len + && astra_turn_types::canonical_conversation_root( + &messages[..self.authorized_prefix_len], + ) == self.authorized_prefix_root; + CanonicalRewritePermit { valid } } pub(crate) fn finish(&mut self, permit: CanonicalRewritePermit, messages: &[Value]) { @@ -82,6 +88,55 @@ impl CanonicalRewriteProof { pub(crate) fn base_root(&self) -> &str { &self.base_root } + + pub(crate) fn provider_wal_replacement_authorization( + &self, + durable_base: &astra_turn_types::CanonicalPrefixIdentityV1, + messages: &[Value], + ) -> Option { + let base_count = usize::try_from(durable_base.message_count).ok()?; + if base_count != self.base_prefix_len + || durable_base.root_hash != self.base_root + || !self.authorizes(messages) + { + return None; + } + Some(ProviderWalReplacementAuthorization { + generation: self.base_compaction_generation.saturating_add(1), + }) + } + + pub(crate) fn recover_provider_wal_replacement( + &mut self, + durable_base: &astra_turn_types::CanonicalPrefixIdentityV1, + transition: &astra_turn_types::ProviderCanonicalTransitionV1, + recovered_messages: &[Value], + ) -> Result<(), String> { + transition.validate().map_err(|error| error.to_string())?; + if transition.recovery_mode + != astra_turn_types::ProviderCanonicalRecoveryModeV1::ReplaceFromDurableBase + || &transition.durable_base != durable_base + || transition.replacement_compaction_generation + != Some(self.base_compaction_generation.saturating_add(1)) + || usize::try_from(durable_base.message_count).ok() != Some(self.base_prefix_len) + || durable_base.root_hash != self.base_root + { + return Err("provider WAL replacement does not match the admitted rewrite base".into()); + } + let result_count = usize::try_from(transition.result.message_count) + .map_err(|_| "provider WAL replacement result count overflow".to_string())?; + if recovered_messages.len() < result_count + || astra_turn_types::canonical_conversation_root(&recovered_messages[..result_count]) + != transition.result.root_hash + { + return Err("provider WAL replacement result is absent from recovered history".into()); + } + self.authorized_prefix_len = result_count; + self.authorized_prefix_root = transition.result.root_hash.clone(); + self.rewritten = true; + self.valid = true; + Ok(()) + } } pub(crate) fn canonical_commit_delta( @@ -209,3 +264,105 @@ fn is_structured_tool_result(message: &Value) -> bool { .all(|item| item.get("type").and_then(Value::as_str) == Some("tool_result")) }) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn authority() -> Value { + let content = astra_turn_types::render_append_only_runtime_authority_frame( + "continue", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + "continue safely", + ) + .unwrap(); + let mut message = json!({"role": "user", "content": content}); + astra_turn_types::mark_append_only_required_context( + &mut message, + "continue", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ); + message + } + + #[test] + fn provider_replacement_requires_a_valid_pre_mutation_rewrite_permit() { + let durable = vec![ + json!({"role": "user", "content": "old"}), + json!({"role": "assistant", "content": "answer"}), + ]; + let base = astra_turn_types::CanonicalPrefixIdentityV1::from_messages(&durable).unwrap(); + + let mut invalid = CanonicalRewriteProof::new(&durable, &base.root_hash, 4); + let rewritten = vec![json!({"role": "system", "content": "summary"})]; + let invalid_permit = invalid.begin(&rewritten); + invalid.finish(invalid_permit, &rewritten); + assert_eq!( + invalid.provider_wal_replacement_authorization(&base, &rewritten), + None + ); + + let mut valid = CanonicalRewriteProof::new(&durable, &base.root_hash, 4); + let permit = valid.begin(&durable); + valid.finish(permit, &rewritten); + let authorization = valid + .provider_wal_replacement_authorization(&base, &rewritten) + .expect("valid rewrite authorization"); + assert_eq!(authorization.generation, 5); + assert_eq!( + valid.provider_wal_replacement_authorization(&base, &durable), + None, + "authorization is bound to the exact rewritten predecessor" + ); + } + + #[test] + fn recovered_provider_replacement_advances_the_rewrite_proof() { + let durable = vec![ + json!({"role": "user", "content": "old"}), + json!({"role": "assistant", "content": "answer"}), + ]; + let base = astra_turn_types::CanonicalPrefixIdentityV1::from_messages(&durable).unwrap(); + let mut live = CanonicalRewriteProof::new(&durable, &base.root_hash, 7); + let mut source = durable.clone(); + source.push(json!({"role": "user", "content": "current"})); + let permit = live.begin(&source); + let rewritten = vec![json!({"role": "user", "content": "typed summary"})]; + live.finish(permit, &rewritten); + let authorization = live + .provider_wal_replacement_authorization(&base, &rewritten) + .unwrap(); + let transition = + astra_turn_types::ProviderCanonicalTransitionV1::new_replacement_from_durable_base( + None, + base.clone(), + authorization.generation, + &rewritten, + vec![authority()], + ) + .unwrap(); + + let mut recovered = durable.clone(); + transition.apply_to(&mut recovered).unwrap(); + let mut restored_proof = CanonicalRewriteProof::new(&durable, &base.root_hash, 7); + restored_proof + .recover_provider_wal_replacement(&base, &transition, &recovered) + .unwrap(); + let mut completed = recovered.clone(); + completed.push(json!({"role": "assistant", "content": "done"})); + let (mode, _) = + canonical_commit_delta(&durable, true, &completed, Some(&restored_proof), false) + .unwrap() + .expect("recovered replacement remains committable"); + assert_eq!(mode, astra_turn_types::CanonicalDeltaModeV1::Replace); + + let second_permit = restored_proof.begin(&completed); + let second_rewrite = vec![json!({"role": "user", "content": "summary two"})]; + restored_proof.finish(second_permit, &second_rewrite); + let second_authorization = restored_proof + .provider_wal_replacement_authorization(&base, &second_rewrite) + .unwrap(); + assert_eq!(second_authorization.generation, 8); + } +} diff --git a/crates/runtime/src/turn/cloud/compaction.rs b/crates/runtime/src/turn/cloud/compaction.rs index 88f6223ad8..c4436efeea 100644 --- a/crates/runtime/src/turn/cloud/compaction.rs +++ b/crates/runtime/src/turn/cloud/compaction.rs @@ -271,17 +271,17 @@ fn truncate_tool_results_to_serialized_budget( } fn prune_oldest_conversation_span(messages: &mut Vec) -> bool { + let protected_suffix_start = + astra_turn_types::active_append_only_authority_protected_suffix_start(messages); let first_user_idx = messages .iter() - .position(|message| message.get("role").and_then(Value::as_str) == Some("user")); + .position(astra_turn_types::is_human_user_message); let conversation_indices: Vec = messages .iter() .enumerate() .filter_map(|(index, message)| { - matches!( - message.get("role").and_then(Value::as_str), - Some("user" | "assistant") - ) + (astra_turn_types::is_human_user_message(message) + || message.get("role").and_then(Value::as_str) == Some("assistant")) .then_some(index) }) .collect(); @@ -302,11 +302,16 @@ fn prune_oldest_conversation_span(messages: &mut Vec) -> bool { .unwrap_or_default(); let Some(next_tail_start) = conversation_indices.iter().copied().find(|index| { *index > tail_start - && (tail_role != "user" - || messages[*index].get("role").and_then(Value::as_str) == Some("user")) + && (tail_role != "user" || astra_turn_types::is_human_user_message(&messages[*index])) }) else { return false; }; + if protected_suffix_start.is_some_and(|protected| next_tail_start > protected) { + // The proposed drain would split the current human turn from active + // append-only authority. The provider prefix and Work/control + // semantics require this suffix to survive as one ordered span. + return false; + } let before = messages.len(); *messages = messages @@ -640,15 +645,18 @@ pub(crate) fn compact_tiered_impl( } if tier == CompactionTier::AggressivePrune { + let protected_suffix_start = + astra_turn_types::active_append_only_authority_protected_suffix_start(&compacted); let first_user_idx = compacted .iter() - .position(|m| m.get("role").and_then(Value::as_str) == Some("user")); + .position(astra_turn_types::is_human_user_message); let conv_indices: Vec = compacted .iter() .enumerate() .filter_map(|(i, m)| { - let role = m.get("role").and_then(Value::as_str).unwrap_or(""); - (role == "user" || role == "assistant").then_some(i) + (astra_turn_types::is_human_user_message(m) + || m.get("role").and_then(Value::as_str) == Some("assistant")) + .then_some(i) }) .collect(); let keep_count = keep_recent_turns * 2; @@ -657,7 +665,11 @@ pub(crate) fn compact_tiered_impl( // messages in isolation. Tool results live between those control // messages; retaining them while deleting their assistant // `tool_calls` frame produces provider-invalid history. - let tail_start = conv_indices[conv_indices.len() - keep_count.max(1)]; + let tail_start = protected_suffix_start + .map(|protected| { + protected.min(conv_indices[conv_indices.len() - keep_count.max(1)]) + }) + .unwrap_or_else(|| conv_indices[conv_indices.len() - keep_count.max(1)]); compacted = compacted .into_iter() .enumerate() @@ -817,6 +829,97 @@ mod tests { } } + fn append_authority( + content: &str, + kind: &str, + lifetime: astra_turn_types::RuntimeAuthorityLifetime, + ) -> Value { + let mut message = json!({"role": "user", "content": content}); + astra_turn_types::mark_append_only_required_context(&mut message, kind, lifetime); + message + } + + #[test] + fn aggressive_prune_preserves_active_append_authority_with_its_human_turn() { + let mut messages = vec![ + user("current human goal"), + append_authority( + "active Work contract", + "active_work_attempt_start", + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, + ), + ]; + for index in 0..12 { + messages.push(json!({ + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": format!("active-{index}"), + "type": "function", + "function": {"name": "read_file", "arguments": "{}"} + }] + })); + messages.push(tool_with_id( + &format!("active-{index}"), + &format!("evidence {index}: {}", "x".repeat(500)), + )); + } + + let result = + compact_tiered_with_result(&messages, 1, 100, CompactionTier::AggressivePrune, 2); + + let human_index = result + .messages + .iter() + .position(|message| { + message.get("content").and_then(Value::as_str) == Some("current human goal") + }) + .expect("human turn anchor survives"); + let authority_index = result + .messages + .iter() + .position(|message| { + astra_turn_types::runtime_authority_kind(message) + == Some("active_work_attempt_start") + }) + .expect("active authority survives"); + assert!(human_index < authority_index); + assert_eq!( + astra_turn_types::active_append_only_authority_protected_suffix_start(&result.messages), + Some(human_index) + ); + assert!( + !prune_oldest_conversation_span(&mut result.messages.clone()), + "repeated budget pruning must stop before splitting the protected current-turn suffix" + ); + } + + #[test] + fn aggressive_prune_may_remove_append_authority_expired_by_later_human_turn() { + let messages = vec![ + user("session anchor"), + user("old goal"), + append_authority( + "expired control", + "old_work", + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, + ), + assistant("old answer"), + user("current goal"), + assistant("current answer"), + ]; + + let result = + compact_tiered_with_result(&messages, 1, 100, CompactionTier::AggressivePrune, 1); + + assert!(result.messages.iter().all(|message| { + message.get("content").and_then(Value::as_str) != Some("expired control") + })); + assert!(result.messages.iter().any(|message| { + message.get("content").and_then(Value::as_str) == Some("current goal") + })); + } + // --- CompactResult / CompactBoundary tests --- #[test] diff --git a/crates/runtime/src/turn/cloud/helpers.rs b/crates/runtime/src/turn/cloud/helpers.rs index 258dce1d91..be64833104 100644 --- a/crates/runtime/src/turn/cloud/helpers.rs +++ b/crates/runtime/src/turn/cloud/helpers.rs @@ -22,7 +22,7 @@ pub fn protected_head_end(messages: &[Message]) -> usize { } messages[sys_count..] .iter() - .position(|m| m.role == "user") + .position(Message::is_plain_user_task) .map(|i| sys_count + i + 1) .unwrap_or(sys_count) } diff --git a/crates/runtime/src/turn/cloud/memoria_compact.rs b/crates/runtime/src/turn/cloud/memoria_compact.rs index e0ea9e6d6b..866b8f4654 100644 --- a/crates/runtime/src/turn/cloud/memoria_compact.rs +++ b/crates/runtime/src/turn/cloud/memoria_compact.rs @@ -1004,7 +1004,7 @@ fn collapse_whitespace(s: &str) -> String { } fn message_user_text(m: &Value) -> Option { - if m.get("role").and_then(Value::as_str) != Some("user") { + if !astra_turn_types::is_human_user_message(m) { return None; } let c = m.get("content")?; @@ -1494,6 +1494,20 @@ mod tests { assert!(q.contains("current session memory")); } + #[test] + fn retrieve_query_ignores_user_role_runtime_authority() { + let mut authority = user("runtime settlement is not the task"); + astra_turn_types::mark_append_only_required_context( + &mut authority, + "final_answer_settlement", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ); + let q = memoria_compact_retrieve_query(&[user("fix the parser"), authority]); + + assert!(q.contains("fix the parser")); + assert!(!q.contains("runtime settlement")); + } + #[test] fn retrieve_query_dedupes_tool_names() { let tc = json!([ @@ -1910,8 +1924,9 @@ mod tests { summary_min_tier: CompactionTier::AggressivePrune, ..Default::default() }; - let summary_client = - MockSummaryClient::success("User discussed OAuth then switched to JWT auth."); + let summary_client = MockSummaryClient::success( + "### Primary Request\nImplement authentication.\n### Pending Tasks\nNone.\n### Current Work\nSwitched from OAuth to JWT auth.\n### Current State\nJWT auth selected.", + ); let result = compact_with_memoria( &msgs, @@ -1926,7 +1941,7 @@ mod tests { assert_eq!(result.messages, msgs, "summary must not become history"); assert_eq!(result.runtime_contexts.len(), 1); - assert!(result.runtime_contexts[0].contains("switched to JWT auth")); + assert!(result.runtime_contexts[0].contains("Switched from OAuth to JWT auth")); assert!( result.session_memory_context.is_none(), "raw legacy working text is not canonical session memory" diff --git a/crates/runtime/src/turn/context_pipeline_adapter.rs b/crates/runtime/src/turn/context_pipeline_adapter.rs index 691c9fe618..ae6a85059c 100644 --- a/crates/runtime/src/turn/context_pipeline_adapter.rs +++ b/crates/runtime/src/turn/context_pipeline_adapter.rs @@ -632,12 +632,12 @@ pub(crate) fn build_session_context( user_id: Option<&str>, ) -> SessionContext { let provider_policy = - super::prompt_cache::provider_cache_policy_for(cache_capability, provider, model_name); - let provider_strategy = ProviderCacheStrategy::from_explicit_or_provider_model( + super::prompt_cache::provider_cache_policy_for(cache_capability, provider); + let capability = astra_turn_core::cache_placement::CacheCapability::from_explicit_or_provider( cache_capability, - Some(provider), - Some(model_name), + provider, ); + let provider_strategy = ProviderCacheStrategy::from_cache_capability(capability); SessionContext { session_id: session_id.to_string(), run_id: run_id.unwrap_or_default().to_string(), @@ -745,6 +745,12 @@ mod tests { #[test] fn session_context_picks_anthropic_policy_for_bedrock_provider() { let ep = serde_json::Map::new(); + let declared_cache_capability = astra_turn_core::cache_placement::CacheCapability { + protocol: astra_turn_core::cache_placement::CacheProtocol::BedrockCachePoint, + volatile_placement: astra_turn_core::cache_placement::VolatilePlacement::MarkerIsolated, + volatile_delivery: astra_turn_core::cache_placement::VolatileDeliveryPolicy::All, + reuse_scope: None, + }; let ctx = build_session_context( "sid", None, @@ -753,12 +759,13 @@ mod tests { &ep, "bedrock", None, - None, + Some(declared_cache_capability), "2026-05-25", None, ); - // Bedrock Claude translates cache_control → cachePoint downstream, - // so the pipeline still emits Anthropic-style markers. + // Bedrock multiplexes model families, so the provider name alone is + // insufficient. A deployment-declared cachePoint capability selects + // the Anthropic-style pipeline markers translated by the adapter. assert!( ctx.provider_policy.max_markers > 0, "bedrock must use anthropic policy — Bedrock Converse translates cache_control \ @@ -867,6 +874,7 @@ mod tests { protocol: astra_turn_core::cache_placement::CacheProtocol::MarkerExplicit, volatile_placement: astra_turn_core::cache_placement::VolatilePlacement::MarkerIsolated, + volatile_delivery: astra_turn_core::cache_placement::VolatileDeliveryPolicy::All, reuse_scope: Some( astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns, ), @@ -1197,6 +1205,7 @@ mod tests { Some(astra_turn_core::cache_placement::CacheCapability { protocol: astra_turn_core::cache_placement::CacheProtocol::OpenAiAutoPrefix, volatile_placement: astra_turn_core::cache_placement::VolatilePlacement::TailSuffix, + volatile_delivery: astra_turn_core::cache_placement::VolatileDeliveryPolicy::All, reuse_scope: Some( astra_turn_core::cache_placement::CacheReuseScope::IntraTurnRounds, ), @@ -2416,6 +2425,7 @@ mod tests { Some(astra_turn_core::cache_placement::CacheCapability { protocol: astra_turn_core::cache_placement::CacheProtocol::OpenAiAutoPrefix, volatile_placement: astra_turn_core::cache_placement::VolatilePlacement::TailSuffix, + volatile_delivery: astra_turn_core::cache_placement::VolatileDeliveryPolicy::All, reuse_scope: Some( astra_turn_core::cache_placement::CacheReuseScope::IntraTurnRounds, ), @@ -2443,6 +2453,7 @@ mod tests { protocol: astra_turn_core::cache_placement::CacheProtocol::MarkerExplicit, volatile_placement: astra_turn_core::cache_placement::VolatilePlacement::MarkerIsolated, + volatile_delivery: astra_turn_core::cache_placement::VolatileDeliveryPolicy::All, reuse_scope: None, }), ); diff --git a/crates/runtime/src/turn/llm/client.rs b/crates/runtime/src/turn/llm/client.rs index 0606c2de35..53b80a1f66 100644 --- a/crates/runtime/src/turn/llm/client.rs +++ b/crates/runtime/src/turn/llm/client.rs @@ -125,6 +125,14 @@ impl LlmProviderProtocol { Self::BedrockConverse => "bedrock_converse", } } + + /// Whether the concrete request builder preserves each appended provider + /// message as a distinct wire item. Append-only caching is invalid for + /// transports that merge adjacent roles and thereby rewrite the old tail. + #[must_use] + pub(crate) fn preserves_appended_message_boundaries(self) -> bool { + matches!(self, Self::OpenAiCompatible) + } } pub(crate) fn llm_provider_protocol(provider: &str) -> LlmProviderProtocol { @@ -146,6 +154,244 @@ pub(crate) struct ProviderWireRequestIdentity { pub provider_wire_hash: String, pub provider_wire_bytes: u64, pub composition: ProviderWireComposition, + /// Hashes of the provider-final, already-sanitized payload components. + /// These are computed from the same JSON value serialized into `body`; + /// diagnostics therefore never need to approximate the dispatched shape + /// from an earlier logical message/tool projection. + pub fingerprints: ProviderWireFingerprints, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct ProviderWireFingerprints { + pub message_sequence_sha256: String, + pub system_sequence_sha256: String, + pub cache_key_system_sha256: String, + pub conversation_sequence_sha256: String, + pub tool_schema_sequence_sha256: String, + pub cache_key_tool_schema_sequence_sha256: String, + pub cache_capability: Option, + pub cache_key_tool_schema_items: + Vec, +} + +impl ProviderWireFingerprints { + fn from_body( + body: &Value, + protocol: LlmProviderProtocol, + cache_capability: Option, + ) -> Result { + let (messages, system, conversation, tools) = match protocol { + LlmProviderProtocol::OpenAiCompatible => { + let messages = body + .get("messages") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + ( + messages.iter().collect::>(), + messages + .iter() + .filter(|message| { + message.get("role").and_then(Value::as_str) == Some("system") + }) + .collect::>(), + messages + .iter() + .filter(|message| { + message.get("role").and_then(Value::as_str) != Some("system") + }) + .collect::>(), + provider_wire_items(body.get("tools")), + ) + } + LlmProviderProtocol::AnthropicMessages => ( + provider_wire_items(body.get("messages")), + provider_wire_items(body.get("system")), + provider_wire_items(body.get("messages")), + provider_wire_items(body.get("tools")), + ), + LlmProviderProtocol::BedrockConverse => ( + provider_wire_items(body.get("messages")), + provider_wire_items(body.get("system")), + provider_wire_items(body.get("messages")), + provider_wire_items(body.pointer("/toolConfig/tools")), + ), + }; + let cache_key_tools = provider_cache_key_tool_items(body, protocol, cache_capability); + let cache_key_tool_schema_items = cache_key_tools + .iter() + .map(|tool| { + Ok( + astra_turn_core::cache_diagnostics::ProviderFinalToolFingerprint { + name: provider_wire_tool_name(protocol, tool).map(str::to_string), + sha256: serialized_item_sha256(tool)?, + }, + ) + }) + .collect::, astra_core::ClassifiedError>>()?; + let cache_key_system = provider_cache_key_system_items(body, protocol, cache_capability); + Ok(Self { + message_sequence_sha256: serialized_sequence_sha256(&messages)?, + system_sequence_sha256: serialized_sequence_sha256(&system)?, + cache_key_system_sha256: serialized_sequence_sha256(&cache_key_system)?, + conversation_sequence_sha256: serialized_sequence_sha256(&conversation)?, + tool_schema_sequence_sha256: serialized_sequence_sha256(&tools)?, + cache_key_tool_schema_sequence_sha256: serialized_sequence_sha256(&cache_key_tools)?, + cache_capability, + cache_key_tool_schema_items, + }) + } + + #[must_use] + pub(crate) fn cache_diagnostic_fingerprint( + &self, + ) -> Option { + let cache_capability = self.cache_capability?; + Some( + astra_turn_core::cache_diagnostics::ProviderFinalPromptFingerprint { + message_sequence_sha256: self.message_sequence_sha256.clone(), + system_sequence_sha256: self.system_sequence_sha256.clone(), + cache_key_system_sha256: self.cache_key_system_sha256.clone(), + conversation_sequence_sha256: self.conversation_sequence_sha256.clone(), + tool_schema_sequence_sha256: self.tool_schema_sequence_sha256.clone(), + cache_key_tool_schema_sequence_sha256: self + .cache_key_tool_schema_sequence_sha256 + .clone(), + cache_capability, + cache_key_tool_schema_items: self.cache_key_tool_schema_items.clone(), + }, + ) + } +} + +fn provider_cache_key_system_items( + body: &Value, + protocol: LlmProviderProtocol, + cache_capability: Option, +) -> Vec<&Value> { + use astra_turn_core::cache_placement::{CacheProtocol, VolatilePlacement}; + + let mut system = match protocol { + LlmProviderProtocol::OpenAiCompatible => { + let messages = body + .get("messages") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + match cache_capability.map(|capability| capability.volatile_placement) { + Some(VolatilePlacement::Free) => Vec::new(), + Some(VolatilePlacement::TailSuffix | VolatilePlacement::AppendOnlyUserTail) => { + messages + .iter() + .take_while(|message| { + message.get("role").and_then(Value::as_str) == Some("system") + }) + .collect() + } + _ => messages + .iter() + .filter(|message| message.get("role").and_then(Value::as_str) == Some("system")) + .collect(), + } + } + LlmProviderProtocol::AnthropicMessages => provider_wire_items(body.get("system")), + LlmProviderProtocol::BedrockConverse => provider_wire_items(body.get("system")), + }; + + let marker_protocol = cache_capability + .map(|capability| capability.protocol) + .filter(|protocol| { + matches!( + protocol, + CacheProtocol::MarkerExplicit | CacheProtocol::BedrockCachePoint + ) + }); + if let Some(marker_protocol) = marker_protocol { + let last_marker = system.iter().rposition(|item| match marker_protocol { + CacheProtocol::MarkerExplicit => item.get("cache_control").is_some(), + CacheProtocol::BedrockCachePoint => item.get("cachePoint").is_some(), + _ => false, + }); + system.truncate(last_marker.map_or(0, |index| index.saturating_add(1))); + } + system +} + +fn provider_cache_key_tool_items( + body: &Value, + protocol: LlmProviderProtocol, + cache_capability: Option, +) -> Vec<&Value> { + use astra_turn_core::cache_placement::CacheProtocol; + + let mut tools = match protocol { + LlmProviderProtocol::OpenAiCompatible | LlmProviderProtocol::AnthropicMessages => { + provider_wire_items(body.get("tools")) + } + LlmProviderProtocol::BedrockConverse => { + provider_wire_items(body.pointer("/toolConfig/tools")) + } + }; + let Some(cache_capability) = cache_capability else { + return tools; + }; + match cache_capability.protocol { + CacheProtocol::None => Vec::new(), + CacheProtocol::MarkerExplicit => { + let last_marker = tools + .iter() + .rposition(|tool| tool.get("cache_control").is_some()); + tools.truncate(last_marker.map_or(0, |index| index.saturating_add(1))); + tools + } + CacheProtocol::BedrockCachePoint => { + let last_marker = tools + .iter() + .rposition(|tool| tool.get("cachePoint").is_some()); + tools.truncate(last_marker.map_or(0, |index| index.saturating_add(1))); + tools + } + CacheProtocol::OpenAiAutoPrefix | CacheProtocol::StrictHistoryMatch => tools, + } +} + +fn provider_wire_tool_name(protocol: LlmProviderProtocol, tool: &Value) -> Option<&str> { + let pointer = match protocol { + LlmProviderProtocol::OpenAiCompatible => "/function/name", + LlmProviderProtocol::AnthropicMessages => "/name", + LlmProviderProtocol::BedrockConverse => "/toolSpec/name", + }; + tool.pointer(pointer).and_then(Value::as_str) +} + +fn provider_wire_items(value: Option<&Value>) -> Vec<&Value> { + match value { + Some(Value::Array(values)) => values.iter().collect(), + Some(value) => vec![value], + None => Vec::new(), + } +} + +fn serialized_sequence_sha256(values: &[&Value]) -> Result { + serde_json::to_vec(values) + .map(|encoded| format!("{:x}", Sha256::digest(encoded))) + .map_err(|error| { + astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + format!("serialize provider wire fingerprint sequence: {error}"), + ) + }) +} + +fn serialized_item_sha256(value: &Value) -> Result { + serde_json::to_vec(value) + .map(|encoded| format!("{:x}", Sha256::digest(encoded))) + .map_err(|error| { + astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + format!("serialize provider wire fingerprint item: {error}"), + ) + }) } /// Mutually exclusive byte zones from the exact serialized provider body. @@ -310,9 +556,18 @@ pub(crate) struct PreparedProviderRequest { } impl PreparedProviderRequest { + #[cfg(test)] pub(crate) fn from_json( body: &Value, protocol: LlmProviderProtocol, + ) -> Result { + Self::from_json_with_cache_capability(body, protocol, None) + } + + pub(crate) fn from_json_with_cache_capability( + body: &Value, + protocol: LlmProviderProtocol, + cache_capability: Option, ) -> Result { let encoded = serde_json::to_vec(body).map_err(|error| { astra_core::history_work::record_serialization_failure( @@ -333,6 +588,7 @@ impl PreparedProviderRequest { } let provider_wire_hash = format!("{:x}", Sha256::digest(&encoded)); let composition = ProviderWireComposition::from_body(body, protocol, provider_wire_bytes)?; + let fingerprints = ProviderWireFingerprints::from_body(body, protocol, cache_capability)?; Ok(Self { body: Bytes::from(encoded), identity: ProviderWireRequestIdentity { @@ -340,6 +596,7 @@ impl PreparedProviderRequest { provider_wire_hash, provider_wire_bytes, composition, + fingerprints, }, }) } @@ -738,6 +995,11 @@ pub(crate) trait ProviderAttemptObserver: Send + Sync { attempt_index: u32, terminal: &astra_services::InferenceInvocationTerminal, ) -> Result<(), astra_core::ClassifiedError>; + + /// Synchronous boundary when the HTTP send future is first polled. + /// Durable diagnostics use it to distinguish an admitted plan from a + /// request that actually crossed into transport execution. + fn note_dispatch_started(&self, _attempt_index: u32) {} } struct ControlledProviderAttemptObserver<'a> { @@ -848,6 +1110,10 @@ impl ProviderAttemptObserver for ControlledProviderAttemptObserver<'_> { }, } } + + fn note_dispatch_started(&self, attempt_index: u32) { + self.inner.note_dispatch_started(attempt_index); + } } pub(crate) fn provider_attempt_terminal_from_result( @@ -2009,6 +2275,129 @@ pub(crate) fn repair_openai_tool_pairing(messages: &[Value]) -> Vec { repaired } +fn append_only_history_contract_error(message: &'static str) -> astra_core::ClassifiedError { + astra_core::ClassifiedError::new(astra_core::ErrorKind::ContractViolation, message) +} + +/// Validate the exact OpenAI-compatible conversation shape used by an +/// append-only cache deployment. +/// +/// Ordinary transports may repair interrupted tool groups. An append-only +/// transport cannot: a later suffix must never cause a previously sent +/// assistant or synthetic tool message to be replaced. Malformed/incomplete +/// groups therefore fail before provider I/O and remain recoverable through +/// the canonical lifecycle instead of silently changing the cache prefix. +fn validate_append_only_openai_history( + messages: &[Value], +) -> Result<(), astra_core::ClassifiedError> { + let mut pending_tool_ids = HashSet::<&str>::new(); + for message in messages { + match message.get("role").and_then(Value::as_str) { + Some("assistant") => { + if !pending_tool_ids.is_empty() { + return Err(append_only_history_contract_error( + "append-only history contains an incomplete assistant tool group", + )); + } + let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) else { + continue; + }; + for tool_call in tool_calls { + let Some(id) = tool_call + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + else { + return Err(append_only_history_contract_error( + "append-only history contains a tool call without a stable id", + )); + }; + if !pending_tool_ids.insert(id) { + return Err(append_only_history_contract_error( + "append-only history contains duplicate tool call ids", + )); + } + let Some(function) = tool_call.get("function") else { + return Err(append_only_history_contract_error( + "append-only history contains a tool call without a function", + )); + }; + let Some(name) = function.get("name").and_then(Value::as_str) else { + return Err(append_only_history_contract_error( + "append-only history contains a tool call without a function name", + )); + }; + if canonical_valid_tool_name(name) != Some(name) + || !function.get("arguments").is_some_and(Value::is_string) + { + return Err(append_only_history_contract_error( + "append-only history contains a non-canonical tool call", + )); + } + } + } + Some("tool") => { + let Some(id) = message + .get("tool_call_id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + else { + return Err(append_only_history_contract_error( + "append-only history contains a tool result without a stable id", + )); + }; + if !pending_tool_ids.remove(id) { + return Err(append_only_history_contract_error( + "append-only history contains an orphaned or duplicate tool result", + )); + } + if matches!( + message.get("content"), + None | Some(Value::Null | Value::Object(_)) + ) { + return Err(append_only_history_contract_error( + "append-only history contains non-canonical tool result content", + )); + } + } + _ if !pending_tool_ids.is_empty() => { + return Err(append_only_history_contract_error( + "append-only history interrupts an assistant tool group", + )); + } + _ => {} + } + } + if !pending_tool_ids.is_empty() { + return Err(append_only_history_contract_error( + "append-only history ends with an incomplete assistant tool group", + )); + } + Ok(()) +} + +fn validate_append_only_transport_history( + messages: &[Value], + provider: &str, + cache_capability: Option, +) -> Result<(), astra_core::ClassifiedError> { + let capability = CacheCapability::from_explicit_or_provider(cache_capability, provider); + if !matches!( + capability.volatile_placement, + VolatilePlacement::AppendOnlyUserTail + ) { + return Ok(()); + } + if !capability.is_valid() + || !llm_provider_protocol(provider).preserves_appended_message_boundaries() + { + return Err(append_only_history_contract_error( + "append-only cache capability is incompatible with the selected transport", + )); + } + validate_append_only_openai_history(messages) +} + fn anthropic_tool_use_ids(msg: &Value) -> Vec { msg.get("content") .map(anthropic_content_as_blocks) @@ -2683,6 +3072,32 @@ pub(crate) fn build_provider_request_body_with_overrides( streaming: bool, thinking: &astra_turn_core::thinking_config::ThinkingConfig, request_body_overrides: Option<&Map>, +) -> Value { + build_provider_request_body_with_cache_capability( + messages, + tools, + model_name, + provider, + max_output_tokens, + temperature, + streaming, + thinking, + request_body_overrides, + None, + ) +} + +fn build_provider_request_body_with_cache_capability( + messages: &[Value], + tools: &[Value], + model_name: &str, + provider: &str, + max_output_tokens: Option, + temperature: Option, + streaming: bool, + thinking: &astra_turn_core::thinking_config::ThinkingConfig, + request_body_overrides: Option<&Map>, + cache_capability: Option, ) -> Value { let sanitized_overrides = sanitize_request_body_overrides_for_thinking(thinking, request_body_overrides); @@ -2690,6 +3105,8 @@ pub(crate) fn build_provider_request_body_with_overrides( let messages = if messages.iter().any(|message| { crate::turn::wire_assembly::is_required_runtime_preamble(message) || crate::turn::wire_assembly::is_runtime_system_context(message) + || astra_turn_types::is_runtime_owned_message(message) + || astra_turn_types::has_append_only_runtime_authority_policy(message) }) { marker_stripped_messages = { astra_core::history_work::record_serialized_value( @@ -2713,10 +3130,27 @@ pub(crate) fn build_provider_request_body_with_overrides( // thinking, no prior reasoning) yields a no-op policy. We skip the // `messages.to_vec()` clone in that case using `Cow::Borrowed`, falling // back to an owned clone only when the policy may actually mutate. - let policy = astra_turn_core::edge_ledger::ReasoningReplayPolicy::infer( - messages, thinking, provider, model_name, - ); - let reasoning_repaired: std::borrow::Cow<'_, [Value]> = if policy.is_no_op() { + let preserve_exact_history = cache_capability.is_some_and(|capability| { + matches!( + capability.volatile_placement, + VolatilePlacement::AppendOnlyUserTail + ) + }); + let policy = (!preserve_exact_history).then(|| { + astra_turn_core::edge_ledger::ReasoningReplayPolicy::infer( + messages, thinking, provider, model_name, + ) + }); + let reasoning_repaired: std::borrow::Cow<'_, [Value]> = if policy + .as_ref() + .is_none_or(astra_turn_core::edge_ledger::ReasoningReplayPolicy::is_no_op) + { + // Cache placement is not a reasoning-wire capability. Append-only + // deployments therefore preserve assistant fields exactly as + // captured: no pruning, placeholder inference, or field backfill. + // A deployment which requires synthetic replay fields must declare a + // separate typed reasoning capability before such normalization can + // be admitted here. std::borrow::Cow::Borrowed(messages) } else { astra_core::history_work::record_serialized_value( @@ -2724,7 +3158,10 @@ pub(crate) fn build_provider_request_body_with_overrides( messages, ); let mut owned = messages.to_vec(); - astra_turn_core::edge_ledger::strip_stale_reasoning_with_policy(&mut owned, &policy); + astra_turn_core::edge_ledger::strip_stale_reasoning_with_policy( + &mut owned, + policy.as_ref().expect("non-no-op reasoning policy"), + ); std::borrow::Cow::Owned(owned) }; match llm_provider_protocol(provider) { @@ -2821,8 +3258,15 @@ pub(crate) fn build_provider_request_body_with_overrides( ); return body; } - let repaired = repair_openai_tool_pairing(&reasoning_repaired); - let normalized_messages = normalize_openai_tool_message_content(&repaired); + let normalized_messages = if preserve_exact_history { + // Append-only transport admits only already-valid canonical + // tool groups. Suffix-dependent recovery would let a later + // tool result rewrite a message that has already been sent. + reasoning_repaired.to_vec() + } else { + let repaired = repair_openai_tool_pairing(&reasoning_repaired); + normalize_openai_tool_message_content(&repaired) + }; let mut body = json!({ "model": model_name, "messages": normalized_messages, @@ -2925,18 +3369,24 @@ fn apply_no_tool_choice( provider: &str, tools: &[Value], ) -> Result<(), astra_core::ClassifiedError> { - if tools.is_empty() { - return Ok(()); - } match llm_provider_protocol(provider) { LlmProviderProtocol::OpenAiCompatible => { + // Keep the explicit terminal instruction even when the repair + // request physically removed every schema. OpenAI-compatible + // models can otherwise infer the tool protocol from conversation + // history and emit a degraded text call despite an empty `tools` + // array. body["tool_choice"] = Value::String("none".to_string()); Ok(()) } LlmProviderProtocol::AnthropicMessages => { + if tools.is_empty() { + return Ok(()); + } body["tool_choice"] = json!({"type": "none"}); Ok(()) } + LlmProviderProtocol::BedrockConverse if tools.is_empty() => Ok(()), LlmProviderProtocol::BedrockConverse => Err(astra_core::ClassifiedError::new( astra_core::ErrorKind::ContractViolation, "Bedrock Converse cannot preserve a non-empty tool surface at a no-tool settlement boundary", @@ -3007,6 +3457,40 @@ fn apply_request_body_overrides( merge_json_object(body, overrides); } +fn validate_request_body_overrides( + request_body_overrides: Option<&Map>, +) -> Result<(), astra_core::ClassifiedError> { + const RUNTIME_OWNED_FIELDS: &[&str] = &[ + "model", + "messages", + "system", + "tools", + "toolConfig", + "tool_choice", + "stream", + "stream_options", + ]; + let Some(overrides) = request_body_overrides else { + return Ok(()); + }; + let mut conflicts = RUNTIME_OWNED_FIELDS + .iter() + .copied() + .filter(|field| overrides.contains_key(*field)) + .collect::>(); + conflicts.sort_unstable(); + if conflicts.is_empty() { + return Ok(()); + } + Err(astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + format!( + "request_body_overrides cannot replace runtime-owned provider fields: {}", + conflicts.join(", ") + ), + )) +} + #[derive(Clone, Copy)] enum TemperatureField { TopLevel, @@ -3163,28 +3647,31 @@ pub(crate) fn strip_empty_assistant_tool_calls(messages: &mut [Value]) { #[cfg(test)] pub(crate) fn consolidate_system_messages(messages: &[Value]) -> Vec { - consolidate_system_messages_inner(messages, false) + consolidate_system_messages_inner(messages, false, true) } pub(crate) fn consolidate_system_messages_for_provider( messages: &[Value], provider: &str, - model_name: &str, explicit_cache_capability: Option, ) -> Vec { let protocol = llm_provider_protocol(provider); - let cache_cap = CacheCapability::from_explicit_or_provider_model( - explicit_cache_capability, - provider, - model_name, - ); + let cache_cap = CacheCapability::from_explicit_or_provider(explicit_cache_capability, provider); let preserve_runtime_system_tail = matches!(protocol, LlmProviderProtocol::AnthropicMessages) || (matches!(protocol, LlmProviderProtocol::OpenAiCompatible) && !matches!( cache_cap.volatile_placement, VolatilePlacement::CurrentUserOnly )); - consolidate_system_messages_inner(messages, preserve_runtime_system_tail) + let allow_suffix_dependent_history_repair = !matches!( + cache_cap.volatile_placement, + VolatilePlacement::AppendOnlyUserTail + ); + consolidate_system_messages_inner( + messages, + preserve_runtime_system_tail, + allow_suffix_dependent_history_repair, + ) } fn strip_internal_runtime_markers(messages: &mut [Value]) { @@ -3192,6 +3679,7 @@ fn strip_internal_runtime_markers(messages: &mut [Value]) { crate::turn::wire_assembly::strip_required_runtime_preamble_marker(message); if let Some(object) = message.as_object_mut() { object.remove(astra_turn_types::RUNTIME_MESSAGE_PROVENANCE_FIELD); + object.remove(astra_turn_types::APPEND_ONLY_RUNTIME_AUTHORITY_POLICY_FIELD); object.remove(astra_turn_types::USER_TURN_SEMANTICS_FIELD); object.remove(astra_turn_types::TURN_MESSAGE_PROVENANCE_FIELD); object.remove("_compact_boundary"); @@ -3206,14 +3694,58 @@ fn strip_internal_runtime_markers(messages: &mut [Value]) { } } +/// Project canonical messages through the metadata-only portion of the +/// provider boundary. +/// +/// Canonical history retains typed provenance so intent, recovery, and +/// append-only authority consumers can distinguish runtime-owned messages. +/// Provider requests deliberately remove that metadata. Any equality check +/// across those two representations must therefore compare this projection, +/// while preserving roles, content, ordering, and every provider-visible +/// field exactly. +fn project_provider_message_metadata(messages: &[Value]) -> Vec { + let mut projected = messages.to_vec(); + for message in &mut projected { + crate::turn::wire_assembly::strip_required_runtime_preamble_marker(message); + } + strip_internal_runtime_markers(&mut projected); + projected +} + +/// Return whether the request ends in the exact provider-visible projection +/// of a staged canonical append. +/// +/// This is intentionally a shape check, not a content classifier: the only +/// differences ignored are the same typed internal metadata fields removed +/// at the provider boundary. +pub(crate) fn provider_request_preserves_projected_canonical_suffix( + provider_messages: &[Value], + canonical_appended: &[Value], +) -> bool { + if canonical_appended.is_empty() { + return true; + } + let Some(suffix_start) = provider_messages + .len() + .checked_sub(canonical_appended.len()) + else { + return false; + }; + let provider_suffix = &provider_messages[suffix_start..]; + project_provider_message_metadata(provider_suffix) + == project_provider_message_metadata(canonical_appended) +} + fn consolidate_system_messages_inner( messages: &[Value], preserve_runtime_system_tail: bool, + allow_suffix_dependent_history_repair: bool, ) -> Vec { let mut system_parts: Vec = Vec::new(); let mut system_blocks: Vec = Vec::new(); let mut structured_system = false; let mut rest: Vec = Vec::new(); + let mut primary_system_seen = false; let flush_string_parts_into_blocks = |blocks: &mut Vec, parts: &mut Vec| { for part in parts.drain(..) { @@ -3228,8 +3760,9 @@ fn consolidate_system_messages_inner( let is_system = msg.get("role").and_then(|r| r.as_str()) == Some("system"); let preserve_runtime_control = preserve_runtime_system_tail && is_system - && crate::turn::wire_assembly::is_runtime_system_context(msg); + && (primary_system_seen || crate::turn::wire_assembly::is_runtime_system_context(msg)); if is_system && !preserve_runtime_control { + primary_system_seen = true; match msg.get("content") { Some(Value::String(text)) => { if text.is_empty() { @@ -3283,6 +3816,10 @@ fn consolidate_system_messages_inner( out.extend(rest); strip_internal_runtime_markers(&mut out); + if !allow_suffix_dependent_history_repair { + return out; + } + // Sanitize assistant messages: remove empty tool_calls arrays and fix // tool_calls with empty function names. // Some providers (e.g. MiniMax) reject messages containing tool_calls @@ -4100,6 +4637,7 @@ async fn call_llm_and_collect_with_total_budget( let model_key = model_name; // `upstream_name` is what goes in the outbound request body + URL. let upstream_name = wire_model_name.unwrap_or(model_name); + validate_request_body_overrides(request_body_overrides)?; let started = Instant::now(); let controlled_attempt_observer = @@ -4116,16 +4654,16 @@ async fn call_llm_and_collect_with_total_budget( .map(|observer| observer as &dyn ProviderAttemptObserver); let client = global_llm_client(); - // Consolidate system messages: merge all system-role messages into the first - // one, converting extras to a single leading system message. Some providers - // (e.g. MiniMax) reject system messages after the first position. - let messages = - consolidate_system_messages_for_provider(messages, provider, model_name, cache_capability); + // Project system messages according to the declared transport/cache shape. + // A current-user-only capability consolidates them at the head; protocols + // that admit a runtime system suffix preserve that boundary. + let messages = consolidate_system_messages_for_provider(messages, provider, cache_capability); + validate_append_only_transport_history(&messages, provider, cache_capability)?; // All providers stream — including Bedrock (via converse-stream + // AWS vnd.amazon.eventstream). The body builder and URL builder flip // to the streaming variant for every supported provider. - let mut body = build_provider_request_body_with_overrides( + let mut body = build_provider_request_body_with_cache_capability( &messages, tools, upstream_name, @@ -4135,6 +4673,7 @@ async fn call_llm_and_collect_with_total_budget( true, thinking, request_body_overrides, + cache_capability, ); // `ThinkingConfig::Off` is provider-agnostic; native OpenAI-compatible // endpoints still need their typed suppression field to honor it. Apply @@ -4155,8 +4694,11 @@ async fn call_llm_and_collect_with_total_budget( .collect::>(), RuntimeToolChoice::None => HashSet::new(), }; - let prepared_request = - PreparedProviderRequest::from_json(&body, llm_provider_protocol(provider))?; + let prepared_request = PreparedProviderRequest::from_json_with_cache_capability( + &body, + llm_provider_protocol(provider), + cache_capability, + )?; let url = llm_request_url( base_url, @@ -4302,6 +4844,17 @@ async fn call_llm_and_collect_with_total_budget( model_name, "LLM request sending" ); + // Keep the dispatch marker inside the future selected below. If a + // cancellation branch wins before the send future is ever polled, + // diagnostics must not claim that transport execution started. + let send_request = async { + if let Some(attempt_index) = observed_attempt { + attempt_observer + .expect("observed attempt requires observer") + .note_dispatch_started(attempt_index); + } + req.body(prepared_request.body()).send().await + }; let send_result = tokio::select! { biased; _ = wait_llm_cancel(cancel) => { @@ -4317,10 +4870,7 @@ async fn call_llm_and_collect_with_total_budget( .await?; return Err(error); } - result = tokio::time::timeout( - request_deadline, - req.body(prepared_request.body()).send(), - ) => result, + result = tokio::time::timeout(request_deadline, send_request) => result, }; let response = match send_result { Err(_) => { @@ -5135,6 +5685,10 @@ async fn collect_llm_stream_with_semantic_progress_deadline_and_surface( let mut made_progress = false; let mut yield_state = StreamYieldState::new(TokioInstant::now()); let mut hidden_reasoning_state = HiddenReasoningStreamState::default(); + let mut visible_text_filter = + astra_turn_core::xml_tool_call_fallback::DsmlToolCallStreamFilter::default(); + let mut visible_reasoning_filter = + astra_turn_core::xml_tool_call_fallback::DsmlToolCallStreamFilter::default(); let partial_result = |response_id: &Option, full_text: &String, reasoning: &String, @@ -5149,8 +5703,14 @@ async fn collect_llm_stream_with_semantic_progress_deadline_and_surface( .collect(); LlmCallResult { response_id: response_id.clone(), - full_text: full_text.clone(), - reasoning: reasoning.clone(), + full_text: + astra_turn_core::xml_tool_call_fallback::filter_dsml_tool_call_markup_for_display( + full_text, + ), + reasoning: + astra_turn_core::xml_tool_call_fallback::filter_dsml_tool_call_markup_for_display( + reasoning, + ), reasoning_signature: String::new(), tool_calls, usage: usage.clone(), @@ -5383,14 +5943,20 @@ async fn collect_llm_stream_with_semantic_progress_deadline_and_surface( if is_reasoning { reasoning.push_str(&chunk); yield_state.observe_reasoning_activity(&chunk, TokioInstant::now()); - if let Some(callback) = stream_callback.as_deref_mut() { - callback(LlmStreamUpdate::Reasoning(chunk)); + let visible = visible_reasoning_filter.push(&chunk); + if !visible.is_empty() + && let Some(callback) = stream_callback.as_deref_mut() + { + callback(LlmStreamUpdate::Reasoning(visible)); } } else { full_text.push_str(&chunk); yield_state.observe_text(&chunk, TokioInstant::now()); - if let Some(callback) = stream_callback.as_deref_mut() { - callback(LlmStreamUpdate::Text(chunk)); + let visible = visible_text_filter.push(&chunk); + if !visible.is_empty() + && let Some(callback) = stream_callback.as_deref_mut() + { + callback(LlmStreamUpdate::Text(visible)); } } } @@ -5419,8 +5985,11 @@ async fn collect_llm_stream_with_semantic_progress_deadline_and_surface( } reasoning.push_str(r); yield_state.observe_reasoning_activity(r, TokioInstant::now()); - if let Some(callback) = stream_callback.as_deref_mut() { - callback(LlmStreamUpdate::Reasoning(r.to_string())); + let visible = visible_reasoning_filter.push(r); + if !visible.is_empty() + && let Some(callback) = stream_callback.as_deref_mut() + { + callback(LlmStreamUpdate::Reasoning(visible)); } made_progress = true; } @@ -5536,17 +6105,36 @@ async fn collect_llm_stream_with_semantic_progress_deadline_and_surface( for (chunk, is_reasoning) in finish_hidden_reasoning_chunks(&mut hidden_reasoning_state) { if is_reasoning { reasoning.push_str(&chunk); - if let Some(callback) = stream_callback.as_deref_mut() { - callback(LlmStreamUpdate::Reasoning(chunk)); + let visible = visible_reasoning_filter.push(&chunk); + if !visible.is_empty() + && let Some(callback) = stream_callback.as_deref_mut() + { + callback(LlmStreamUpdate::Reasoning(visible)); } } else { full_text.push_str(&chunk); - if let Some(callback) = stream_callback.as_deref_mut() { - callback(LlmStreamUpdate::Text(chunk)); + let visible = visible_text_filter.push(&chunk); + if !visible.is_empty() + && let Some(callback) = stream_callback.as_deref_mut() + { + callback(LlmStreamUpdate::Text(visible)); } } } + let trailing_visible_text = visible_text_filter.finish(); + if !trailing_visible_text.is_empty() + && let Some(callback) = stream_callback.as_deref_mut() + { + callback(LlmStreamUpdate::Text(trailing_visible_text)); + } + let trailing_visible_reasoning = visible_reasoning_filter.finish(); + if !trailing_visible_reasoning.is_empty() + && let Some(callback) = stream_callback + { + callback(LlmStreamUpdate::Reasoning(trailing_visible_reasoning)); + } + if !yield_state.is_terminal() { return Err(StreamCollectError::Transport { error: "provider SSE ended without a terminal marker".to_string(), @@ -5570,20 +6158,22 @@ async fn collect_llm_stream_with_semantic_progress_deadline_and_surface( // Degraded tool-call fallback: some models emit XML or // tags in content instead of structured tool_calls. Recover them. - if tool_calls.is_empty() { - if let Some(parsed) = - astra_turn_core::xml_tool_call_fallback::parse_degraded_tool_calls(&full_text) - { + if let Some(parsed) = + astra_turn_core::xml_tool_call_fallback::parse_degraded_tool_calls(&full_text) + { + if tool_calls.is_empty() { astra_core::agent_warn!( "llm", "recovered {} tool call(s) from degraded text in content (stream)", parsed.len() ); - full_text = - astra_turn_core::xml_tool_call_fallback::strip_degraded_tool_calls(&full_text); tool_calls = parsed; } } + full_text = astra_turn_core::xml_tool_call_fallback::strip_degraded_tool_calls(&full_text); + reasoning = astra_turn_core::xml_tool_call_fallback::filter_dsml_tool_call_markup_for_display( + &reasoning, + ); canonicalize_provider_tool_calls(&mut tool_calls); // Extract ... blocks from content into reasoning. @@ -6235,7 +6825,30 @@ pub(crate) async fn call_llm_nonstream( call: LlmCall<'_>, timeout: std::time::Duration, ) -> Result { - call_llm_nonstream_with_attempt_observer(client, call, timeout, None).await + call_llm_nonstream_with_attempt_observer_and_tool_choice( + client, + call, + timeout, + None, + RuntimeToolChoice::Auto, + ) + .await +} + +#[cfg(test)] +pub(crate) async fn call_llm_nonstream_no_tool_choice( + client: &reqwest::Client, + call: LlmCall<'_>, + timeout: std::time::Duration, +) -> Result { + call_llm_nonstream_with_attempt_observer_and_tool_choice( + client, + call, + timeout, + None, + RuntimeToolChoice::None, + ) + .await } pub(crate) async fn call_llm_nonstream_with_attempt_observer( @@ -6243,6 +6856,23 @@ pub(crate) async fn call_llm_nonstream_with_attempt_observer( call: LlmCall<'_>, timeout: std::time::Duration, attempt_observer: Option<&dyn ProviderAttemptObserver>, +) -> Result { + call_llm_nonstream_with_attempt_observer_and_tool_choice( + client, + call, + timeout, + attempt_observer, + RuntimeToolChoice::Auto, + ) + .await +} + +async fn call_llm_nonstream_with_attempt_observer_and_tool_choice( + client: &reqwest::Client, + call: LlmCall<'_>, + timeout: std::time::Duration, + attempt_observer: Option<&dyn ProviderAttemptObserver>, + tool_choice: RuntimeToolChoice, ) -> Result { let logical_timeout = timeout; let timeout = logical_timeout.saturating_sub(llm_mandatory_settlement_reserve(logical_timeout)); @@ -6282,11 +6912,12 @@ pub(crate) async fn call_llm_nonstream_with_attempt_observer( .as_ref() .map(|observer| observer as &dyn ProviderAttemptObserver); let upstream_name = wire_model_name.unwrap_or(model_name); + validate_request_body_overrides(request_body_overrides)?; - let messages = - consolidate_system_messages_for_provider(messages, provider, model_name, cache_capability); + let messages = consolidate_system_messages_for_provider(messages, provider, cache_capability); + validate_append_only_transport_history(&messages, provider, cache_capability)?; - let mut body = build_provider_request_body_with_overrides( + let mut body = build_provider_request_body_with_cache_capability( &messages, tools, upstream_name, @@ -6296,11 +6927,18 @@ pub(crate) async fn call_llm_nonstream_with_attempt_observer( false, thinking, request_body_overrides, + cache_capability, ); thinking.apply_openai_suppression(&mut body, provider, base_url); + if matches!(tool_choice, RuntimeToolChoice::None) { + apply_no_tool_choice(&mut body, provider, tools)?; + } let wire_output_limit = provider_request_output_limit(&body); - let prepared_request = - PreparedProviderRequest::from_json(&body, llm_provider_protocol(provider))?; + let prepared_request = PreparedProviderRequest::from_json_with_cache_capability( + &body, + llm_provider_protocol(provider), + cache_capability, + )?; let url = llm_request_url( base_url, @@ -6341,12 +6979,18 @@ pub(crate) async fn call_llm_nonstream_with_attempt_observer( model_name, "LLM non-stream request sending" ); - let resp = match req - .timeout(effective_timeout) - .body(prepared_request.body()) - .send() - .await - { + let send_request = async { + if let Some(attempt_index) = observed_attempt { + attempt_observer + .expect("observed attempt requires observer") + .note_dispatch_started(attempt_index); + } + req.timeout(effective_timeout) + .body(prepared_request.body()) + .send() + .await + }; + let resp = match send_request.await { Ok(response) => response, Err(e) => { let elapsed = started.elapsed(); @@ -6659,20 +7303,22 @@ fn parse_openai_compatible_nonstream_response( .map(String::from); // Degraded tool-call fallback: same recovery for non-stream responses. - if tool_calls.is_empty() { - if let Some(parsed) = - astra_turn_core::xml_tool_call_fallback::parse_degraded_tool_calls(&full_text) - { + if let Some(parsed) = + astra_turn_core::xml_tool_call_fallback::parse_degraded_tool_calls(&full_text) + { + if tool_calls.is_empty() { astra_core::agent_warn!( "llm", "recovered {} tool call(s) from degraded text in content (non-stream)", parsed.len() ); - full_text = - astra_turn_core::xml_tool_call_fallback::strip_degraded_tool_calls(&full_text); tool_calls = parsed; } } + full_text = astra_turn_core::xml_tool_call_fallback::strip_degraded_tool_calls(&full_text); + reasoning = astra_turn_core::xml_tool_call_fallback::filter_dsml_tool_call_markup_for_display( + &reasoning, + ); canonicalize_provider_tool_calls(&mut tool_calls); if reasoning.is_empty() { @@ -9430,6 +10076,50 @@ mod tests { ); } + #[tokio::test] + async fn collect_llm_stream_never_publishes_degraded_dsml_markup() { + let d1 = json!({"choices":[{"delta":{"content":"visible before\n<||DS"}}]}); + let d2 = json!({"choices":[{"delta":{"content":"ML||tool_calls><||DSML||invoke name=\"bash\">"}}]}); + let d3 = json!({"choices":[{"delta":{"content":"<||DSML||parameter name=\"command\" string=\"true\">echo ok"}}]}); + let d4 = json!({"choices":[{"delta":{"content":"<||DSML||tool_calls><||DSML||invoke name=\"bash\">"}}]}); + let d5 = json!({"choices":[{"delta":{"content":"<||DSML||parameter name=\"command\" string=\"true\">pwd\nvisible after"}}]}); + let body = format!( + "data: {d1}\n\ndata: {d2}\n\ndata: {d3}\n\ndata: {d4}\n\ndata: {d5}\n\ndata: [DONE]\n\n" + ); + let stream = stream::iter(vec![Ok(Bytes::from(body))]); + let mut updates = Vec::new(); + let mut callback = |update| updates.push(update); + + let result = collect_llm_stream( + stream, + "deepseek-test", + Instant::now(), + LlmCancel::None, + stream_idle_timeout(), + stream_idle_timeout_after_progress(), + Some(&mut callback), + ) + .await + .expect("collect"); + + assert_eq!(result.full_text, "visible before\n\nvisible after"); + assert_eq!(result.tool_calls.len(), 2); + assert_eq!(result.tool_calls[0]["function"]["name"], "bash"); + assert_eq!(result.tool_calls[1]["function"]["name"], "bash"); + let published = updates + .iter() + .filter_map(|update| match update { + LlmStreamUpdate::Text(text) | LlmStreamUpdate::Reasoning(text) => Some(text), + LlmStreamUpdate::ToolCall { .. } => None, + }) + .cloned() + .collect::(); + assert_eq!(published, "visible before\n\nvisible after"); + assert!(!published.contains("DSML")); + assert!(!published.contains("echo ok")); + assert!(!published.contains("pwd")); + } + #[tokio::test] async fn collect_llm_stream_extracts_finish_reason_stop() { let d1 = json!({"choices":[{"delta":{"content":"Hello"}}]}); @@ -9856,6 +10546,54 @@ mod tests { assert_eq!(details["deadline"]["scope"], "inference_ledger"); assert_eq!(details["deadline"]["phase"], "provider_attempt_admission"); assert_eq!(inner.began.load(Ordering::SeqCst), 1); + assert!(!inner.dispatched.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn durable_admission_timeout_never_starts_provider_transport() { + reset_rate_limit_cooldown_for_tests(); + let hits = Arc::new(AtomicU32::new(0)); + let app = Router::new() + .route("/chat/completions", post(mock_500_once)) + .with_state(Hit(hits.clone())); + let base = spawn_local_http_server(app).await; + let messages = vec![json!({"role":"user","content":"x"})]; + let observer = PendingAttemptObserver::default(); + + let error = call_llm_and_collect_with_total_budget( + LlmCall { + purpose: astra_turn_types::InferencePurpose::PrimaryAgent, + messages: &messages, + tools: &[], + cache_capability: None, + route: LlmExecutionRoute { + model_name: "m", + wire_model_name: None, + api_key: "k", + base_url: &base, + provider: "openai", + header_overrides: None, + request_body_overrides: None, + completions_url_override: None, + request_timeout: None, + }, + max_output_tokens: None, + temperature: None, + has_fallback: false, + thinking: &ThinkingConfig::Off, + }, + LlmCancel::None, + None, + Some(&observer), + RuntimeToolChoice::Auto, + std::time::Duration::from_millis(30), + ) + .await + .expect_err("durable admission timeout must stop before provider transport"); + + assert_eq!(error.kind, astra_core::ErrorKind::DatabaseError); + assert_eq!(hits.load(Ordering::SeqCst), 0); + assert!(!observer.dispatched.load(Ordering::SeqCst)); } #[tokio::test] @@ -9997,6 +10735,7 @@ mod tests { #[derive(Default)] struct PendingAttemptObserver { began: AtomicU32, + dispatched: AtomicBool, } struct PendingFinishAttemptObserver; @@ -10018,6 +10757,10 @@ mod tests { ) -> Result<(), astra_core::ClassifiedError> { std::future::pending().await } + + fn note_dispatch_started(&self, _attempt_index: u32) { + self.dispatched.store(true, Ordering::SeqCst); + } } #[async_trait] @@ -10136,6 +10879,424 @@ mod tests { } } + #[test] + fn ordered_message_fingerprint_preserves_system_conversation_interleaving() { + let first = json!({ + "messages": [ + {"role": "system", "content": "s1"}, + {"role": "user", "content": "u1"}, + {"role": "system", "content": "s2"}, + {"role": "assistant", "content": "a1"} + ] + }); + let second = json!({ + "messages": [ + {"role": "system", "content": "s1"}, + {"role": "user", "content": "u1"}, + {"role": "assistant", "content": "a1"}, + {"role": "system", "content": "s2"} + ] + }); + let first = + PreparedProviderRequest::from_json(&first, LlmProviderProtocol::OpenAiCompatible) + .expect("first request"); + let second = + PreparedProviderRequest::from_json(&second, LlmProviderProtocol::OpenAiCompatible) + .expect("second request"); + + assert_eq!( + first.identity().fingerprints.system_sequence_sha256, + second.identity().fingerprints.system_sequence_sha256 + ); + assert_eq!( + first.identity().fingerprints.conversation_sequence_sha256, + second.identity().fingerprints.conversation_sequence_sha256 + ); + assert_ne!( + first.identity().fingerprints.message_sequence_sha256, + second.identity().fingerprints.message_sequence_sha256, + "provider-final diagnostics must detect changes in role interleaving" + ); + } + + #[test] + fn provider_final_cache_key_system_identity_obeys_typed_capability() { + use astra_turn_core::cache_placement::{ + CacheProtocol, CacheReuseScope, VolatileDeliveryPolicy, VolatilePlacement, + }; + + let tail = CacheCapability { + protocol: CacheProtocol::OpenAiAutoPrefix, + volatile_placement: VolatilePlacement::TailSuffix, + volatile_delivery: VolatileDeliveryPolicy::All, + reuse_scope: Some(CacheReuseScope::ConversationTurns), + }; + let tail_body = |stable: &str, volatile: &str| { + json!({ + "messages": [ + {"role": "system", "content": stable}, + {"role": "user", "content": "task"}, + {"role": "system", "content": volatile} + ] + }) + }; + let first = PreparedProviderRequest::from_json_with_cache_capability( + &tail_body("stable", "round 1"), + LlmProviderProtocol::OpenAiCompatible, + Some(tail), + ) + .expect("first tail request"); + let second = PreparedProviderRequest::from_json_with_cache_capability( + &tail_body("stable", "round 2"), + LlmProviderProtocol::OpenAiCompatible, + Some(tail), + ) + .expect("second tail request"); + assert_ne!( + first.identity().fingerprints.system_sequence_sha256, + second.identity().fingerprints.system_sequence_sha256, + "the raw provider receipt must retain the changed suffix" + ); + assert_eq!( + first.identity().fingerprints.cache_key_system_sha256, + second.identity().fingerprints.cache_key_system_sha256, + "a typed TailSuffix excludes system messages after conversation" + ); + let changed_leading = PreparedProviderRequest::from_json_with_cache_capability( + &tail_body("changed", "round 2"), + LlmProviderProtocol::OpenAiCompatible, + Some(tail), + ) + .expect("changed leading request"); + assert_ne!( + second.identity().fingerprints.cache_key_system_sha256, + changed_leading + .identity() + .fingerprints + .cache_key_system_sha256 + ); + + let marker = CacheCapability { + protocol: CacheProtocol::MarkerExplicit, + volatile_placement: VolatilePlacement::MarkerIsolated, + volatile_delivery: VolatileDeliveryPolicy::All, + reuse_scope: Some(CacheReuseScope::ConversationTurns), + }; + let marker_body = |stable: &str, volatile: &str| { + json!({ + "system": [ + {"type": "text", "text": stable, "cache_control": {"type": "ephemeral"}}, + {"type": "text", "text": volatile} + ], + "messages": [{"role": "user", "content": "task"}] + }) + }; + let marker_first = PreparedProviderRequest::from_json_with_cache_capability( + &marker_body("stable", "round 1"), + LlmProviderProtocol::AnthropicMessages, + Some(marker), + ) + .expect("first marker request"); + let marker_second = PreparedProviderRequest::from_json_with_cache_capability( + &marker_body("stable", "round 2"), + LlmProviderProtocol::AnthropicMessages, + Some(marker), + ) + .expect("second marker request"); + assert_eq!( + marker_first.identity().fingerprints.cache_key_system_sha256, + marker_second + .identity() + .fingerprints + .cache_key_system_sha256 + ); + let marker_changed = PreparedProviderRequest::from_json_with_cache_capability( + &marker_body("changed", "round 2"), + LlmProviderProtocol::AnthropicMessages, + Some(marker), + ) + .expect("changed marker request"); + assert_ne!( + marker_second + .identity() + .fingerprints + .cache_key_system_sha256, + marker_changed + .identity() + .fingerprints + .cache_key_system_sha256 + ); + + let marker_tool_body = |stable_description: &str, dynamic_name: &str| { + json!({ + "system": [ + {"type": "text", "text": "stable", "cache_control": {"type": "ephemeral"}} + ], + "messages": [{"role": "user", "content": "task"}], + "tools": [ + { + "name": "stable_tool", + "description": stable_description, + "input_schema": {"type": "object"}, + "cache_control": {"type": "ephemeral"} + }, + { + "name": dynamic_name, + "input_schema": {"type": "object"} + } + ] + }) + }; + let marker_tools_first = PreparedProviderRequest::from_json_with_cache_capability( + &marker_tool_body("stable", "dynamic_one"), + LlmProviderProtocol::AnthropicMessages, + Some(marker), + ) + .expect("first marker tool request"); + let marker_tools_second = PreparedProviderRequest::from_json_with_cache_capability( + &marker_tool_body("stable", "dynamic_two"), + LlmProviderProtocol::AnthropicMessages, + Some(marker), + ) + .expect("second marker tool request"); + assert_ne!( + marker_tools_first + .identity() + .fingerprints + .tool_schema_sequence_sha256, + marker_tools_second + .identity() + .fingerprints + .tool_schema_sequence_sha256 + ); + assert_eq!( + marker_tools_first + .identity() + .fingerprints + .cache_key_tool_schema_sequence_sha256, + marker_tools_second + .identity() + .fingerprints + .cache_key_tool_schema_sequence_sha256, + "typed marker capability excludes dynamic tools after the last marker" + ); + let marker_tools_changed = PreparedProviderRequest::from_json_with_cache_capability( + &marker_tool_body("changed", "dynamic_two"), + LlmProviderProtocol::AnthropicMessages, + Some(marker), + ) + .expect("changed marker tool request"); + assert_ne!( + marker_tools_second + .identity() + .fingerprints + .cache_key_tool_schema_sequence_sha256, + marker_tools_changed + .identity() + .fingerprints + .cache_key_tool_schema_sequence_sha256 + ); + + let append = CacheCapability { + protocol: CacheProtocol::OpenAiAutoPrefix, + volatile_placement: VolatilePlacement::AppendOnlyUserTail, + volatile_delivery: VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: Some(CacheReuseScope::ConversationTurns), + }; + let append_first_body = json!({ + "messages": [ + {"role": "system", "content": "stable"}, + {"role": "user", "content": "task"}, + {"role": "user", "content": "runtime authority 1"} + ] + }); + let append_second_body = json!({ + "messages": [ + {"role": "system", "content": "stable"}, + {"role": "user", "content": "task"}, + {"role": "user", "content": "runtime authority 1"}, + {"role": "assistant", "content": "progress"}, + {"role": "user", "content": "runtime authority 2"} + ] + }); + let prefix = append_first_body["messages"] + .as_array() + .expect("first messages"); + let extension = append_second_body["messages"] + .as_array() + .expect("second messages"); + assert!( + extension.starts_with(prefix), + "append-only provider body must preserve the exact ordered message prefix" + ); + let append_first = PreparedProviderRequest::from_json_with_cache_capability( + &append_first_body, + LlmProviderProtocol::OpenAiCompatible, + Some(append), + ) + .expect("first append request"); + let append_second = PreparedProviderRequest::from_json_with_cache_capability( + &append_second_body, + LlmProviderProtocol::OpenAiCompatible, + Some(append), + ) + .expect("second append request"); + assert_eq!( + append_first.identity().fingerprints.cache_key_system_sha256, + append_second + .identity() + .fingerprints + .cache_key_system_sha256 + ); + } + + #[tokio::test] + async fn provider_attempt_receipt_matches_sanitized_http_body() { + #[derive(Clone, Default)] + struct CapturedBody(Arc>>); + + async fn handler( + State(captured): State, + axum::Json(body): axum::Json, + ) -> Response { + captured.0.lock().expect("capture lock").push(body); + let payload = json!({"choices":[{"delta":{"content":"ok"}}]}); + Response::builder() + .status(200) + .header("content-type", "text/event-stream") + .body(Body::from(format!("data: {payload}\n\ndata: [DONE]\n\n"))) + .expect("response") + } + + fn has_internal_schema_extension(value: &Value) -> bool { + match value { + Value::Array(values) => values.iter().any(has_internal_schema_extension), + Value::Object(values) => { + values.keys().any(|key| key.starts_with("x-astra-")) + || values.values().any(has_internal_schema_extension) + } + _ => false, + } + } + + reset_rate_limit_cooldown_for_tests(); + let captured = CapturedBody::default(); + let app = Router::new() + .route("/chat/completions", post(handler)) + .with_state(captured.clone()); + let base = spawn_local_http_server(app).await; + let observer = RecordingAttemptObserver::default(); + let messages = [json!({"role": "user", "content": "run"})]; + let tools_first = [json!({ + "type": "function", + "function": { + "name": "read_file", + "description": "read", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "x-astra-discovery-summary": "internal-one" + } + } + })]; + let mut tools_second = tools_first.clone(); + tools_second[0]["function"]["parameters"]["x-astra-discovery-summary"] = + Value::String("internal-two".to_string()); + let cache_capability = CacheCapability { + protocol: astra_turn_core::cache_placement::CacheProtocol::OpenAiAutoPrefix, + volatile_placement: astra_turn_core::cache_placement::VolatilePlacement::TailSuffix, + volatile_delivery: + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: Some(astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns), + }; + + for tools in [&tools_first[..], &tools_second[..]] { + call_llm_and_collect_with_stream_callback( + LlmCall { + purpose: astra_turn_types::InferencePurpose::PrimaryAgent, + messages: &messages, + tools, + cache_capability: Some(cache_capability), + route: LlmExecutionRoute { + model_name: "m", + wire_model_name: None, + api_key: "k", + base_url: &base, + provider: "openai", + header_overrides: None, + request_body_overrides: None, + completions_url_override: None, + request_timeout: None, + }, + max_output_tokens: Some(128), + temperature: None, + has_fallback: false, + thinking: &ThinkingConfig::Off, + }, + LlmCancel::None, + None, + Some(&observer), + ) + .await + .expect("provider call"); + } + + let bodies = captured.0.lock().expect("capture lock").clone(); + assert_eq!(bodies.len(), 2); + assert!( + bodies + .iter() + .all(|body| !has_internal_schema_extension(body)) + ); + assert_eq!( + bodies[0], bodies[1], + "internal schema metadata must not alter the provider-final body" + ); + let wires = observer.wires.lock().expect("wire receipts"); + assert_eq!(wires.len(), 2); + for (wire, body) in wires.iter().zip(&bodies) { + let actual = PreparedProviderRequest::from_json_with_cache_capability( + body, + LlmProviderProtocol::OpenAiCompatible, + Some(cache_capability), + ) + .expect("captured provider request"); + assert_eq!(wire.fingerprints, actual.identity().fingerprints); + } + assert_eq!(wires[0].fingerprints, wires[1].fingerprints); + + let mut detector = astra_turn_core::cache_diagnostics::CacheBreakDetector::new(); + for (index, wire) in wires.iter().enumerate() { + let mut snapshot = astra_turn_core::cache_diagnostics::PromptStateSnapshot::capture( + "planned-only", + &[], + "m", + 0, + ); + snapshot.attach_provider_final_fingerprint( + wire.fingerprints + .cache_diagnostic_fingerprint() + .expect("resolved cache capability"), + ); + let (accepted, event) = detector.record_provider_attempt_for_source( + "main", + &astra_turn_core::cache_diagnostics::ProviderAttemptCacheIdentity { + request_id: format!("request-{index}"), + attempt: u32::try_from(index).expect("bounded test attempt"), + }, + snapshot, + Some(if index == 0 { 0 } else { 1 }), + ); + assert!(accepted); + assert!( + event.is_none(), + "sanitized-equal final receipts cannot produce a structural cache break" + ); + } + assert_eq!(detector.stats.total_turns, 2); + assert_eq!(detector.stats.cache_hits, 1); + } + #[test] fn required_tool_choice_uses_each_provider_native_wire_shape() { let messages = [json!({"role": "user", "content": "run"})]; @@ -10229,6 +11390,16 @@ mod tests { } } + #[test] + fn openai_no_tool_choice_remains_explicit_with_empty_schema_surface() { + let mut body = json!({"model": "test-model", "messages": []}); + apply_no_tool_choice(&mut body, "openai", &[]) + .expect("empty repair surface still supports an explicit no-tool choice"); + + assert_eq!(body["tool_choice"], "none"); + assert!(body.get("tools").is_none()); + } + #[test] fn no_tool_choice_fails_closed_for_nonempty_bedrock_surface() { let tools = [json!({ @@ -12499,7 +13670,7 @@ mod tests { runtime["_timestamp"] = json!(1234); runtime["_synthetic"] = json!(true); - let out = consolidate_system_messages_for_provider(&[runtime], "openai", "gpt-4o", None); + let out = consolidate_system_messages_for_provider(&[runtime], "openai", None); assert_eq!(out[0]["content"], "model-visible required context"); assert!( @@ -12523,10 +13694,78 @@ mod tests { } } + #[test] + fn canonical_suffix_check_compares_the_exact_provider_metadata_projection() { + let mut assistant = json!({"role": "assistant", "content": "tool result accepted"}); + assert!(astra_turn_types::mark_turn_message( + &mut assistant, + "turn-chain-1" + )); + let work_frame = + crate::turn::wire_assembly::required_append_only_runtime_authority_message( + "establish the admitted work graph", + crate::turn::wire_assembly::RuntimeAuthorityKind::CanonicalWorkEstablishmentRetry, + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ) + .unwrap() + .unwrap(); + let budget_frame = + crate::turn::wire_assembly::required_append_only_runtime_authority_message( + "finish before the admitted deadline", + crate::turn::wire_assembly::RuntimeAuthorityKind::ExecutionTimeBudget, + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ) + .unwrap() + .unwrap(); + let canonical_appended = vec![assistant, work_frame, budget_frame.clone()]; + + // The main assembly was already consolidated, while the dispatch-time + // budget frame was appended afterward. This mixed representation is + // the real provider-attempt boundary that exposed the regression. + let mut provider_messages = vec![ + json!({"role": "system", "content": "stable policy"}), + json!({"role": "user", "content": "do the task"}), + ]; + provider_messages.extend(project_provider_message_metadata( + &canonical_appended[..canonical_appended.len() - 1], + )); + provider_messages.push(budget_frame); + + assert!(!provider_messages.ends_with(&canonical_appended)); + assert!(provider_request_preserves_projected_canonical_suffix( + &provider_messages, + &canonical_appended, + )); + + let mut changed_content = canonical_appended.clone(); + changed_content[0]["content"] = Value::String("different response".to_string()); + assert!(!provider_request_preserves_projected_canonical_suffix( + &provider_messages, + &changed_content, + )); + + let mut reordered = canonical_appended.clone(); + reordered.swap(0, 1); + assert!(!provider_request_preserves_projected_canonical_suffix( + &provider_messages, + &reordered, + )); + assert!(!provider_request_preserves_projected_canonical_suffix( + &provider_messages[..2], + &canonical_appended, + )); + assert!(provider_request_preserves_projected_canonical_suffix( + &provider_messages, + &[], + )); + } + #[test] fn consolidate_for_openai_preserves_runtime_system_at_current_turn_boundary() { let runtime = crate::turn::wire_assembly::required_runtime_preamble_message( "required resume context", + crate::turn::wire_assembly::RuntimeAuthorityKind::EdgeRequiredContext, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, ) .expect("runtime message"); let msgs = vec![ @@ -12537,7 +13776,7 @@ mod tests { json!({"role": "user", "content": "hi"}), ]; - let out = consolidate_system_messages_for_provider(&msgs, "openai", "gpt-4o", None); + let out = consolidate_system_messages_for_provider(&msgs, "openai", None); assert_eq!(out.len(), 5); assert_eq!(out[0]["role"], "system"); @@ -12555,9 +13794,45 @@ mod tests { } #[test] - fn consolidate_for_strict_history_openai_moves_required_runtime_to_initial_system() { + fn provider_system_consolidation_is_idempotent_with_runtime_tail() { + let runtime = crate::turn::wire_assembly::required_runtime_preamble_message( + "completion settlement", + crate::turn::wire_assembly::RuntimeAuthorityKind::FinalWorkSynthesis, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, + ) + .expect("runtime message"); + let input = vec![ + json!({"role": "system", "content": "stable"}), + json!({"role": "user", "content": "question"}), + json!({"role": "assistant", "content": "answer"}), + runtime, + ]; + + let once = consolidate_system_messages_for_provider(&input, "openai", None); + let twice = consolidate_system_messages_for_provider(&once, "openai", None); + + assert_eq!(once, twice); + assert_eq!(once[0]["content"], "stable"); + assert_eq!( + once.last() + .and_then(|message| message.get("role")) + .and_then(Value::as_str), + Some("system") + ); + assert_eq!( + once.last() + .and_then(|message| message.get("content")) + .and_then(Value::as_str), + Some("completion settlement") + ); + } + + #[test] + fn declared_strict_history_shape_moves_required_runtime_to_initial_system() { let runtime = crate::turn::wire_assembly::required_runtime_preamble_message( "required resume context", + crate::turn::wire_assembly::RuntimeAuthorityKind::EdgeRequiredContext, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, ) .expect("runtime message"); let msgs = vec![ @@ -12568,7 +13843,14 @@ mod tests { json!({"role": "user", "content": "hi"}), ]; - let out = consolidate_system_messages_for_provider(&msgs, "openai", "MiniMax-M2.7", None); + let capability = CacheCapability { + protocol: astra_turn_core::cache_placement::CacheProtocol::StrictHistoryMatch, + volatile_placement: VolatilePlacement::CurrentUserOnly, + volatile_delivery: + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: None, + }; + let out = consolidate_system_messages_for_provider(&msgs, "openai", Some(capability)); assert_eq!(out.len(), 4); assert_eq!(out[0]["role"], "system"); @@ -12582,9 +13864,11 @@ mod tests { } #[test] - fn explicit_current_user_only_capability_overrides_provider_model_heuristic() { + fn explicit_current_user_only_capability_overrides_provider_baseline() { let runtime = crate::turn::wire_assembly::required_runtime_preamble_message( "required resume context", + crate::turn::wire_assembly::RuntimeAuthorityKind::EdgeRequiredContext, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, ) .expect("runtime message"); let msgs = vec![ @@ -12597,15 +13881,12 @@ mod tests { let explicit = CacheCapability { protocol: astra_turn_core::cache_placement::CacheProtocol::StrictHistoryMatch, volatile_placement: VolatilePlacement::CurrentUserOnly, + volatile_delivery: + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly, reuse_scope: None, }; - let out = consolidate_system_messages_for_provider( - &msgs, - "openai", - "metadata-defined-alias", - Some(explicit), - ); + let out = consolidate_system_messages_for_provider(&msgs, "openai", Some(explicit)); assert_eq!(out.len(), 4); assert_eq!(out[0]["role"], "system"); @@ -12622,6 +13903,8 @@ mod tests { fn consolidate_for_anthropic_preserves_runtime_system_boundary_for_body_builder() { let runtime = crate::turn::wire_assembly::required_runtime_preamble_message( "required resume context", + crate::turn::wire_assembly::RuntimeAuthorityKind::EdgeRequiredContext, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, ) .expect("runtime message"); let msgs = vec![ @@ -12632,8 +13915,7 @@ mod tests { json!({"role": "user", "content": "hi"}), ]; - let out = - consolidate_system_messages_for_provider(&msgs, "anthropic", "claude-sonnet-4", None); + let out = consolidate_system_messages_for_provider(&msgs, "anthropic", None); assert_eq!(out.len(), 5); assert_eq!(out[0]["role"], "system"); @@ -14472,6 +15754,8 @@ mod tests { fn build_anthropic_body_keeps_runtime_system_tail_out_of_cached_prefix_block() { let runtime = crate::turn::wire_assembly::required_runtime_preamble_message( "required resume context", + crate::turn::wire_assembly::RuntimeAuthorityKind::EdgeRequiredContext, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, ) .expect("runtime message"); let messages = vec![ @@ -14486,12 +15770,7 @@ mod tests { json!({"role": "user", "content": "hello"}), runtime, ]; - let messages = consolidate_system_messages_for_provider( - &messages, - "anthropic", - "claude-sonnet-4", - None, - ); + let messages = consolidate_system_messages_for_provider(&messages, "anthropic", None); let body = build_provider_request_body( &messages, &[], @@ -15598,6 +16877,291 @@ mod tests { assert_eq!(body["stream"], json!(true)); } + #[test] + fn request_body_overrides_cannot_replace_runtime_owned_wire_shape() { + for field in [ + "model", + "messages", + "system", + "tools", + "toolConfig", + "tool_choice", + "stream", + "stream_options", + ] { + let overrides = Map::from_iter([(field.to_string(), json!([]))]); + let error = validate_request_body_overrides(Some(&overrides)) + .expect_err("runtime-owned request fields must fail closed"); + assert_eq!(error.kind, astra_core::ErrorKind::ContractViolation); + assert!(error.message.contains(field)); + } + assert!( + validate_request_body_overrides(Some(&Map::from_iter([( + "context_management".to_string(), + json!({"edits": []}), + )]))) + .is_ok() + ); + } + + #[test] + fn append_only_capability_matches_final_transport_message_boundaries() { + let frame = |seconds: u64| { + crate::turn::wire_assembly::required_append_only_runtime_authority_message( + &format!("remaining_seconds={seconds}"), + crate::turn::wire_assembly::RuntimeAuthorityKind::ExecutionTimeBudget, + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ) + .expect("valid typed frame") + .expect("non-empty frame") + }; + let first = vec![ + json!({"role": "system", "content": "stable"}), + json!({"role": "user", "content": "do the work"}), + frame(10), + ]; + // A transport failure has no assistant response. Retrying appends a + // fresher typed authority directly after the prior user-role frame. + let mut second = first.clone(); + second.push(frame(8)); + + let openai_first = build_provider_request_body( + &first, + &[], + "deployment", + "openai", + Some(128), + None, + true, + &ThinkingConfig::Off, + ); + let openai_second = build_provider_request_body( + &second, + &[], + "deployment", + "openai", + Some(128), + None, + true, + &ThinkingConfig::Off, + ); + let first_wire = openai_first["messages"].as_array().unwrap(); + let second_wire = openai_second["messages"].as_array().unwrap(); + assert!(second_wire.starts_with(first_wire)); + assert!(second_wire.iter().all(|message| { + message + .get(astra_turn_types::RUNTIME_MESSAGE_PROVENANCE_FIELD) + .is_none() + })); + assert!(llm_provider_protocol("openai").preserves_appended_message_boundaries()); + + for provider in ["anthropic", "bedrock"] { + let first_body = build_provider_request_body( + &first, + &[], + "deployment", + provider, + Some(128), + None, + true, + &ThinkingConfig::Off, + ); + let second_body = build_provider_request_body( + &second, + &[], + "deployment", + provider, + Some(128), + None, + true, + &ThinkingConfig::Off, + ); + let first_wire = first_body["messages"].as_array().unwrap(); + let second_wire = second_body["messages"].as_array().unwrap(); + assert!( + !second_wire.starts_with(first_wire), + "{provider} merges consecutive roles and must not advertise append-only boundaries" + ); + assert!(!llm_provider_protocol(provider).preserves_appended_message_boundaries()); + } + } + + #[test] + fn append_only_reasoning_replay_never_rewrites_the_sent_prefix() { + use astra_turn_core::cache_placement::{ + CacheProtocol, CacheReuseScope, VolatileDeliveryPolicy, + }; + + let capability = CacheCapability { + protocol: CacheProtocol::OpenAiAutoPrefix, + volatile_placement: VolatilePlacement::AppendOnlyUserTail, + volatile_delivery: VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: Some(CacheReuseScope::IntraTurnRounds), + }; + let thinking = ThinkingConfig::Enabled { + budget_tokens: 1024, + }; + let first = vec![ + json!({"role": "user", "content": "work"}), + json!({ + "role": "assistant", + "content": null, + "reasoning_content": "first decision", + "tool_calls": [{ + "id": "call-1", + "type": "function", + "function": {"name": "shell", "arguments": "{}"}, + }], + }), + json!({"role": "tool", "tool_call_id": "call-1", "content": "ok"}), + ]; + let mut second = first.clone(); + second.push(json!({ + "role": "assistant", + "content": "continue", + "reasoning_content": "second decision", + })); + + let assemble = |history: Vec| { + let mut state = crate::turn::agentic_loop::host::make_test_loop_state(); + state.current_round_index = 1; + state.messages = history.clone(); + crate::turn::llm::context::assemble_wire_messages( + crate::turn::llm::context::LlmWireAssemblyInput { + system_messages: vec![json!({"role": "system", "content": "stable"})], + volatile_preamble: Vec::new(), + compacted_messages: history, + state: &mut state, + compaction_boundary_hit: false, + thinking: &thinking, + session_id: "session", + provider: "openai", + model_name: "deployment", + cache_capability: Some(capability), + cache_cfg: &crate::turn::prompt_cache::PromptCacheConfig::default(), + }, + ) + .expect("production wire assembly") + }; + let first = assemble(first); + let second = assemble(second); + + let first_body = build_provider_request_body_with_cache_capability( + &first, + &[], + "deployment", + "openai", + Some(128), + None, + true, + &thinking, + None, + Some(capability), + ); + let second_body = build_provider_request_body_with_cache_capability( + &second, + &[], + "deployment", + "openai", + Some(128), + None, + true, + &thinking, + None, + Some(capability), + ); + + let first_wire = first_body["messages"].as_array().unwrap(); + let second_wire = second_body["messages"].as_array().unwrap(); + assert!( + second_wire.starts_with(first_wire), + "a later reasoning response must only extend the immutable provider prefix" + ); + assert_eq!( + second_wire[2]["reasoning_content"], "first decision", + "historical reasoning must not be rewritten after a later response" + ); + } + + #[test] + fn append_only_cache_shape_does_not_invent_reasoning_wire_fields() { + use astra_turn_core::cache_placement::{ + CacheProtocol, CacheReuseScope, VolatileDeliveryPolicy, + }; + + let capability = CacheCapability { + protocol: CacheProtocol::OpenAiAutoPrefix, + volatile_placement: VolatilePlacement::AppendOnlyUserTail, + volatile_delivery: VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: Some(CacheReuseScope::IntraTurnRounds), + }; + let messages = vec![ + json!({"role": "user", "content": "work"}), + json!({"role": "assistant", "content": "prior answer"}), + json!({"role": "user", "content": "continue"}), + ]; + + let body = build_provider_request_body_with_cache_capability( + &messages, + &[], + "strict-openai-compatible-deployment", + "openai", + Some(128), + None, + true, + &ThinkingConfig::Enabled { + budget_tokens: 1024, + }, + None, + Some(capability), + ); + + assert!( + body["messages"].as_array().unwrap().iter().all(|message| { + message.get("role").and_then(Value::as_str) != Some("assistant") + || message.get("reasoning_content").is_none() + }), + "cache placement alone must not add a non-standard assistant field" + ); + } + + #[test] + fn append_only_history_rejects_suffix_dependent_tool_repairs() { + let incomplete = vec![json!({ + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call-1", + "type": "function", + "function": {"name": "shell", "arguments": "{}"}, + }], + })]; + let error = validate_append_only_openai_history(&incomplete) + .expect_err("an incomplete group would require a synthetic suffix rewrite"); + assert_eq!(error.kind, astra_core::ErrorKind::ContractViolation); + + let late_name_recovery = vec![ + json!({ + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call-2", + "type": "function", + "function": {"name": "", "arguments": "{}"}, + }], + }), + json!({ + "role": "tool", + "tool_call_id": "call-2", + "name": "shell", + "content": "ok", + }), + ]; + let error = validate_append_only_openai_history(&late_name_recovery) + .expect_err("a later result name must never rewrite a sent assistant message"); + assert_eq!(error.kind, astra_core::ErrorKind::ContractViolation); + } + #[test] fn explicit_temperature_is_authoritative_after_route_overrides() { let overrides = Map::from_iter([("temperature".to_string(), json!(0.7))]); diff --git a/crates/runtime/src/turn/llm/context.rs b/crates/runtime/src/turn/llm/context.rs index d823667935..8ffee7d3ff 100644 --- a/crates/runtime/src/turn/llm/context.rs +++ b/crates/runtime/src/turn/llm/context.rs @@ -43,6 +43,9 @@ pub(crate) fn cache_capability_from_model_metadata( astra_services::PromptCacheVolatilePlacementData::TailSuffix => { astra_turn_core::cache_placement::VolatilePlacement::TailSuffix } + astra_services::PromptCacheVolatilePlacementData::AppendOnlyUserTail => { + astra_turn_core::cache_placement::VolatilePlacement::AppendOnlyUserTail + } astra_services::PromptCacheVolatilePlacementData::CurrentUserOnly => { astra_turn_core::cache_placement::VolatilePlacement::CurrentUserOnly } @@ -50,6 +53,14 @@ pub(crate) fn cache_capability_from_model_metadata( astra_turn_core::cache_placement::VolatilePlacement::Free } }; + let volatile_delivery = match value.volatile_delivery { + astra_services::PromptCacheVolatileDeliveryData::All => { + astra_turn_core::cache_placement::VolatileDeliveryPolicy::All + } + astra_services::PromptCacheVolatileDeliveryData::RequiredOnly => { + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly + } + }; let reuse_scope = value.reuse_scope.map(|scope| match scope { astra_services::PromptCacheReuseScopeData::ConversationTurns => { astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns @@ -61,10 +72,23 @@ pub(crate) fn cache_capability_from_model_metadata( Some(astra_turn_core::cache_placement::CacheCapability { protocol, volatile_placement, + volatile_delivery, reuse_scope, }) } +pub(crate) fn compact_strategy_from_model_metadata( + value: Option, + provider: &str, +) -> astra_turn_core::microcompact::CompactStrategy { + let explicit = cache_capability_from_model_metadata(value); + astra_turn_core::microcompact::ProviderCacheStrategy::from_explicit_or_provider( + explicit, + Some(provider), + ) + .compact_strategy +} + fn estimate_json_tokens(value: &Value) -> u32 { estimate_json_tokens_u64(value).min(u32::MAX as u64) as u32 } @@ -484,6 +508,11 @@ pub(crate) fn augment_manifest_trace_with_provider_attempts( let terminal = attempt.terminal.as_ref(); json!({ "authority": "exact_serialized_provider_body_v1", + "transport_stage": if attempt.dispatch_started { + "dispatch_started" + } else { + "prepared_and_admitted" + }, "request_id": request.request_id, "request_hash": request.request_hash, "round": round, @@ -514,6 +543,15 @@ pub(crate) fn augment_manifest_trace_with_provider_attempts( "conversation": request.composition.conversation_items, "tool_schema": request.composition.tool_schema_items, }, + "provider_final_fingerprints": { + "message_sequence_sha256": request.fingerprints.message_sequence_sha256, + "system_sequence_sha256": request.fingerprints.system_sequence_sha256, + "cache_key_system_sha256": request.fingerprints.cache_key_system_sha256, + "conversation_sequence_sha256": request.fingerprints.conversation_sequence_sha256, + "tool_schema_sequence_sha256": request.fingerprints.tool_schema_sequence_sha256, + "cache_key_tool_schema_sequence_sha256": request.fingerprints.cache_key_tool_schema_sequence_sha256, + "cache_capability": request.fingerprints.cache_capability, + }, }) }) .collect(), @@ -1139,11 +1177,8 @@ pub(crate) fn assemble_context_pipeline( }) .collect(); let tool_names: Vec<&str> = tool_names_owned.iter().map(String::as_str).collect(); - let cache_cap = CacheCapability::from_explicit_or_provider_model( - input.cache_capability, - input.provider, - input.model_name, - ); + let cache_cap = + CacheCapability::from_explicit_or_provider(input.cache_capability, input.provider); if state.pipeline_session.is_none() { return Err(astra_core::ClassifiedError::new( astra_core::ErrorKind::InvalidRequest, @@ -1191,7 +1226,29 @@ pub(crate) fn assemble_context_pipeline( input.model_name, )); external.session_memory_entry = input.runtime_signals.session_memory_entry.clone(); - let turn_state = build_turn_state(state, input.user_content); + let required_runtime_texts = astra_turn_core::chat_turn_edge_profile::edge_profile_texts( + input.runtime_signals.edge_profile, + astra_turn_core::chat_turn_edge_profile::EDGE_PROFILE_KEY_RUNTIME_REQUIRED_TEXTS, + ); + let runtime_volatile_injections = + astra_turn_core::chat_turn_edge_profile::edge_profile_runtime_volatile_injections( + input.runtime_signals.edge_profile, + ); + let mut turn_state = build_turn_state(state, input.user_content); + let rehomed_append_only_authority = if matches!( + cache_cap.volatile_placement, + VolatilePlacement::AppendOnlyUserTail + ) { + Vec::new() + } else { + crate::turn::wire_assembly::rehome_append_only_runtime_authority(&mut turn_state.messages) + .map_err(|error| { + astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + error.to_string(), + ) + })? + }; // `AgenticLoopState::max_turn_input_tokens` is an input-budget/wind-down // cap, and `0` is its legacy "unlimited" sentinel. The pipeline's // `SessionContext::model_limit` is different: it must be the concrete @@ -1318,7 +1375,7 @@ pub(crate) fn assemble_context_pipeline( let round_within_turn = state.current_round_index; let inject_volatile = cache_cap.should_inject_volatile_on_round(round_within_turn); - let (system_messages, mut volatile_preamble) = match cache_cap.volatile_placement { + let (mut system_messages, mut volatile_preamble) = match cache_cap.volatile_placement { VolatilePlacement::MarkerIsolated => { let stable_content: Vec = pipeline_output .serialized @@ -1371,7 +1428,9 @@ pub(crate) fn assemble_context_pipeline( let preamble = volatile_preamble_from_text(volatile_text, inject_volatile); (system, preamble) } - VolatilePlacement::TailSuffix | VolatilePlacement::Free => { + VolatilePlacement::TailSuffix + | VolatilePlacement::AppendOnlyUserTail + | VolatilePlacement::Free => { let mut stable_text = String::new(); let mut volatile_text = String::new(); for block in &pipeline_output.serialized.system_blocks { @@ -1386,42 +1445,35 @@ pub(crate) fn assemble_context_pipeline( (system, preamble) } }; - let mut required_runtime_texts = astra_turn_core::chat_turn_edge_profile::edge_profile_texts( - input.runtime_signals.edge_profile, - astra_turn_core::chat_turn_edge_profile::EDGE_PROFILE_KEY_RUNTIME_REQUIRED_TEXTS, - ); - required_runtime_texts.extend( - astra_turn_core::chat_turn_edge_profile::edge_profile_runtime_volatile_injections( - input.runtime_signals.edge_profile, - ) - .into_iter() - .filter(|injection| { - injection.delivery_class - == astra_turn_core::chat_turn_edge_profile::VolatileDeliveryClass::RequiredContext - }) - .filter_map(|injection| injection.render_for_prompt()), - ); + if matches!( + cache_cap.volatile_placement, + VolatilePlacement::AppendOnlyUserTail + ) { + crate::turn::wire_assembly::ensure_append_only_runtime_authority_policy( + &mut system_messages, + ); + } + volatile_preamble.splice(0..0, rehomed_append_only_authority); if let Some(required_text) = crate::turn::wire_assembly::required_runtime_preamble_message( &required_runtime_texts.join("\n\n"), + crate::turn::wire_assembly::RuntimeAuthorityKind::EdgeRequiredContext, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, ) { volatile_preamble.push(required_text); } - let decision_feedback = - astra_turn_core::chat_turn_edge_profile::edge_profile_runtime_volatile_injections( - input.runtime_signals.edge_profile, - ) - .into_iter() + volatile_preamble.extend( + runtime_volatile_injections.into_iter() .filter(|injection| { - injection.delivery_class - == astra_turn_core::chat_turn_edge_profile::VolatileDeliveryClass::DecisionFeedback + matches!( + injection.delivery_class, + astra_turn_core::chat_turn_edge_profile::VolatileDeliveryClass::RequiredContext + | astra_turn_core::chat_turn_edge_profile::VolatileDeliveryClass::DecisionFeedback + ) }) - .filter_map(|injection| injection.render_for_prompt()) - .collect::>(); - if let Some(feedback) = crate::turn::wire_assembly::decision_feedback_preamble_message( - &decision_feedback.join("\n\n"), - ) { - volatile_preamble.push(feedback); - } + .filter_map(|injection| { + crate::turn::wire_assembly::runtime_volatile_preamble_message(&injection) + }), + ); let stable_system_message_count = system_messages.len(); let volatile_preamble_count = volatile_preamble.len(); let system_prompt_tokens = system_messages @@ -1534,7 +1586,9 @@ fn classify_pipeline_abort( /// This centralizes state-derived post-compaction attachments so CLI and web /// paths can share the same message ordering and cache-sensitive volatile /// placement instead of each host rebuilding this logic. -pub(crate) fn assemble_wire_messages(input: LlmWireAssemblyInput<'_>) -> Vec { +pub(crate) fn assemble_wire_messages( + input: LlmWireAssemblyInput<'_>, +) -> Result, astra_core::ClassifiedError> { // The real user message is already in prompt history after the first // boundary. Repeating the full goal plus a changing round id on every // tool round creates an avoidable uncached suffix. Re-emit it for a @@ -1543,7 +1597,7 @@ pub(crate) fn assemble_wire_messages(input: LlmWireAssemblyInput<'_>) -> Vec = input.state.skills.invoked.values().collect(); skills.sort_by_key(|skill| std::cmp::Reverse(skill.invoked_at_turn)); @@ -1556,19 +1610,37 @@ pub(crate) fn assemble_wire_messages(input: LlmWireAssemblyInput<'_>) -> Vec assembly, + Err(error) => { + input.state.restore_volatile_attempt_lease(); + return Err(astra_core::ClassifiedError::new( + astra_core::ErrorKind::ContractViolation, + error.to_string(), + )); + } + }; + if let Err(error) = input + .state + .extend_append_only_runtime_messages(assembly.new_append_only_runtime_messages) + { + input.state.restore_volatile_attempt_lease(); + return Err(error); + } + Ok(assembly.messages) } fn queue_active_turn_frame(state: &mut AgenticLoopState) { @@ -1597,6 +1669,7 @@ fn immediate_prior_user_request(messages: &[Value], latest_user_message: &str) - let user_messages = messages .iter() .filter(|message| message.get("role").and_then(Value::as_str) == Some("user")) + .filter(|message| !astra_turn_types::is_runtime_owned_message(message)) .filter_map(prompt_message_text) .collect::>(); let prior_index = match user_messages @@ -1822,12 +1895,18 @@ pub(crate) fn augment_manifest_trace_with_wire_detail( let mut conversation_role_counts = BTreeMap::::new(); let mut conversation_message_count = 0_usize; let mut system_messages = Vec::new(); + let mut leading_system_messages = Vec::new(); + let mut leading_system_prefix_open = true; for message in messages { let role = message_role(message); *message_role_counts.entry(role.clone()).or_default() += 1; if role == "system" { system_messages.push(message.clone()); + if leading_system_prefix_open { + leading_system_messages.push(message.clone()); + } } else { + leading_system_prefix_open = false; conversation_message_count = conversation_message_count.saturating_add(1); *conversation_role_counts.entry(role).or_default() += 1; } @@ -1836,7 +1915,7 @@ pub(crate) fn augment_manifest_trace_with_wire_detail( astra_core::history_work::HistoryWorkSite::LlmWireTraceClone, &system_messages, ); - let stable_system_prefix = stable_cache_prefix(&system_messages); + let stable_system_prefix = stable_cache_prefix(&leading_system_messages); let stable_tool_prefix = stable_cache_prefix(tool_schemas); let cache_layout = if message_cache_control_count + tool_cache_control_count > 0 { "explicit-breakpoints-v1" @@ -1852,12 +1931,13 @@ pub(crate) fn augment_manifest_trace_with_wire_detail( if let Some(trace_obj) = trace.as_object_mut() { let mut wire = serde_json::json!({ - "projection_authority": "pre_provider_messages_and_tools_v1", + "projection_authority": "planned_pre_client_projection_v1", "trace_detail": match detail { WireTraceDetail::MetricsOnly => "metrics_only", WireTraceDetail::Debug => "debug", }, "message_count": messages.len(), + "leading_system_message_count": leading_system_messages.len(), "tool_schema_count": tool_schemas.len(), "message_role_counts": message_role_counts, "message_cache_control_count": message_cache_control_count, @@ -2082,6 +2162,54 @@ mod context_cache_contract_tests { } } + #[test] + fn normalized_volatile_delivery_maps_without_behavior_guessing() { + let capability = + cache_capability_from_model_metadata(Some(astra_services::PromptCacheCapabilityData { + protocol: astra_services::PromptCacheProtocolData::StrictHistoryMatch, + volatile_placement: + astra_services::PromptCacheVolatilePlacementData::CurrentUserOnly, + volatile_delivery: astra_services::PromptCacheVolatileDeliveryData::All, + reuse_scope: None, + })) + .expect("declared capability"); + + assert_eq!( + capability.volatile_delivery, + astra_turn_core::cache_placement::VolatileDeliveryPolicy::All, + "runtime must map the total metadata value without inferring a behavior policy" + ); + } + + #[test] + fn declared_volatile_delivery_survives_model_metadata_mapping() { + let capability = + cache_capability_from_model_metadata(Some(astra_services::PromptCacheCapabilityData { + protocol: astra_services::PromptCacheProtocolData::OpenAiAutoPrefix, + volatile_placement: astra_services::PromptCacheVolatilePlacementData::TailSuffix, + volatile_delivery: astra_services::PromptCacheVolatileDeliveryData::RequiredOnly, + reuse_scope: Some(astra_services::PromptCacheReuseScopeData::ConversationTurns), + })) + .expect("declared capability"); + + assert_eq!( + capability.protocol, + astra_turn_core::cache_placement::CacheProtocol::OpenAiAutoPrefix + ); + assert_eq!( + capability.volatile_placement, + astra_turn_core::cache_placement::VolatilePlacement::TailSuffix + ); + assert_eq!( + capability.volatile_delivery, + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly + ); + assert_eq!( + capability.reuse_scope, + Some(astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns) + ); + } + fn tool_with_parameter_insert_order(name: &str, parameter_names: &[&str]) -> Value { let mut properties = Map::new(); for parameter_name in parameter_names { @@ -2114,6 +2242,8 @@ mod context_cache_contract_tests { protocol: astra_turn_core::cache_placement::CacheProtocol::StrictHistoryMatch, volatile_placement: astra_turn_core::cache_placement::VolatilePlacement::CurrentUserOnly, + volatile_delivery: + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly, reuse_scope: Some(astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns), } } @@ -2259,6 +2389,8 @@ mod context_cache_contract_tests { protocol: astra_turn_core::cache_placement::CacheProtocol::StrictHistoryMatch, volatile_placement: astra_turn_core::cache_placement::VolatilePlacement::CurrentUserOnly, + volatile_delivery: + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly, reuse_scope: None, }; @@ -2456,7 +2588,7 @@ mod context_cache_contract_tests { json!({"role": "user", "content": "相关的测试够硬核吗?"}), ]; let thinking = astra_turn_core::thinking_config::ThinkingConfig::Off; - let cache_cfg = PromptCacheConfig::latch("openai", "gpt-4"); + let cache_cfg = PromptCacheConfig::latch("openai"); let messages = assemble_wire_messages(LlmWireAssemblyInput { system_messages: vec![json!({"role": "system", "content": "sys"})], @@ -2470,7 +2602,8 @@ mod context_cache_contract_tests { model_name: "gpt-4", cache_capability: None, cache_cfg: &cache_cfg, - }); + }) + .unwrap(); let user_text = messages .iter() @@ -2489,10 +2622,85 @@ mod context_cache_contract_tests { assert!(runtime_system_text.contains("\"turn_id\":7")); assert!(runtime_system_text.contains("\"round_id\":3")); assert!(!message_text(&messages[0]).contains("")); - assert!( - state.volatile_pending.is_empty(), - "active frame must be one-shot per LLM request" - ); + assert_eq!(state.volatile_pending.len(), 1); + assert!(state.volatile_pending[0].attempt_leased); + state.commit_volatile_attempt_lease(); + assert!(state.volatile_pending.is_empty()); + } + + #[test] + fn failed_wire_assembly_restores_pending_authority_transactionally() { + let mut state = crate::turn::agentic_loop::host::make_test_loop_state(); + state.current_round_index = 1; + state.message = "finish".to_string(); + state.messages = vec![json!({"role": "user", "content": "finish"})]; + state.push_volatile_payload( + crate::turn::agentic_loop::host::VolatileKind::FinalAnswerSettlement, + json!({"reason": "post_mutation_observation_required"}), + ); + let pending_before = state.volatile_pending.clone(); + let canonical_before = state.messages.clone(); + let capability = astra_turn_core::cache_placement::CacheCapability { + protocol: astra_turn_core::cache_placement::CacheProtocol::OpenAiAutoPrefix, + volatile_placement: + astra_turn_core::cache_placement::VolatilePlacement::AppendOnlyUserTail, + volatile_delivery: + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: None, + }; + let cache_cfg = PromptCacheConfig::from_cache_capability(Some(capability), "openai"); + let thinking = astra_turn_core::thinking_config::ThinkingConfig::Off; + let malformed = crate::turn::wire_assembly::runtime_system_context_message( + "missing typed authority kind", + true, + ) + .unwrap(); + + let error = assemble_wire_messages(LlmWireAssemblyInput { + system_messages: vec![json!({"role": "system", "content": "sys"})], + volatile_preamble: vec![malformed], + compacted_messages: state.messages.clone(), + state: &mut state, + compaction_boundary_hit: false, + thinking: &thinking, + session_id: "sid", + provider: "openai", + model_name: "alias", + cache_capability: Some(capability), + cache_cfg: &cache_cfg, + }) + .unwrap_err(); + assert_eq!(error.kind, astra_core::ErrorKind::ContractViolation); + assert_eq!(state.messages, canonical_before); + assert_eq!(state.volatile_pending.len(), pending_before.len()); + assert_eq!(state.volatile_pending[0].kind, pending_before[0].kind); + assert_eq!(state.volatile_pending[0].payload, pending_before[0].payload); + assert!(!state.volatile_pending[0].attempt_leased); + + let wire = assemble_wire_messages(LlmWireAssemblyInput { + system_messages: vec![json!({"role": "system", "content": "sys"})], + volatile_preamble: Vec::new(), + compacted_messages: state.messages.clone(), + state: &mut state, + compaction_boundary_hit: false, + thinking: &thinking, + session_id: "sid", + provider: "openai", + model_name: "alias", + cache_capability: Some(capability), + cache_cfg: &cache_cfg, + }) + .expect("retry must retain and deliver the original authority"); + assert_eq!(state.volatile_pending.len(), 1); + assert!(state.volatile_pending[0].attempt_leased); + assert!(state.messages.iter().any(|message| { + astra_turn_types::runtime_authority_kind(message) == Some("final_answer_settlement") + })); + assert!(wire.iter().any(|message| { + message_text(message).contains("post_mutation_observation_required") + })); + state.commit_volatile_attempt_lease(); + assert!(state.volatile_pending.is_empty()); } #[test] @@ -2508,6 +2716,8 @@ mod context_cache_contract_tests { json!({"role": "user", "content": "问题总结?"}), ]; let thinking = astra_turn_core::thinking_config::ThinkingConfig::Off; + let cache_capability = strict_history_cache_capability(); + let cache_cfg = PromptCacheConfig::from_cache_capability(Some(cache_capability), "openai"); let messages = assemble_wire_messages(LlmWireAssemblyInput { system_messages: vec![json!({"role": "system", "content": "sys"})], volatile_preamble: Vec::new(), @@ -2517,28 +2727,113 @@ mod context_cache_contract_tests { thinking: &thinking, session_id: "sid", provider: "openai", - model_name: "deepseek-v4-flash", - cache_capability: None, - cache_cfg: &PromptCacheConfig::latch("openai", "deepseek-v4-flash"), - }); + model_name: "deployment-alias", + cache_capability: Some(cache_capability), + cache_cfg: &cache_cfg, + }) + .unwrap(); assert_eq!( messages.last(), Some(&json!({"role": "user", "content": "问题总结?"})), "runtime focus context must not be appended to user speech" ); - let frame = messages + let focus_policy = messages .iter() .rev() .find(|message| { message.get("role").and_then(Value::as_str) == Some("system") - && message_text(message).contains("") + && message_text(message).contains("") }) .map(message_text) - .expect("typed active-turn frame"); - assert!(frame.contains("不要修改,只读 review uncommitted changes")); - assert!(!frame.contains("immediate_prior_user_request\":\"分析整个 task 系统")); - assert!(frame.contains("whole session only when the user explicitly asks")); + .expect("stable strict-history focus policy"); + assert!(focus_policy.contains("immediately preceding user-assistant exchange")); + assert!(focus_policy.contains("explicitly broadens the scope")); + assert!(!focus_policy.contains("问题总结?")); + assert!(!focus_policy.contains("不要修改,只读 review uncommitted changes")); + assert!(messages.iter().any(|message| { + message.get("role").and_then(Value::as_str) == Some("user") + && message_text(message) == "不要修改,只读 review uncommitted changes" + })); + } + + #[test] + fn edge_profile_active_turn_frame_cannot_churn_strict_provider_prefix() { + fn provider_messages(frame_value: &str) -> Vec { + let mut state = crate::turn::agentic_loop::host::make_test_loop_state(); + state.messages = vec![ + json!({"role": "user", "content": "review the current change"}), + json!({"role": "assistant", "content": "I found one issue"}), + json!({"role": "user", "content": "summarize it"}), + ]; + let mut edge_profile = serde_json::Map::new(); + edge_profile.insert( + astra_turn_core::chat_turn_edge_profile::EDGE_PROFILE_KEY_RUNTIME_VOLATILE_INJECTIONS + .to_string(), + json!([astra_turn_core::chat_turn_edge_profile::RuntimeVolatileInjection { + kind: "active_turn_frame".to_string(), + delivery_class: astra_turn_core::chat_turn_edge_profile::VolatileDeliveryClass::RequiredContext, + payload: json!({"latest_user_message": frame_value, "turn_id": frame_value}), + round_index: 1, + }]), + ); + let visible_tools = vec![tool("bash")]; + let restricted_tools = HashSet::new(); + let cache_cfg = PromptCacheConfig::latch("openai"); + let strict_history = strict_history_cache_capability(); + let output = assemble_context_pipeline(LlmContextAssemblyInput { + state: &mut state, + session_id: "sid-edge-frame", + tool_surface: ToolSurfacePlan::from_visible_tools( + &visible_tools, + &restricted_tools, + ), + runtime_signals: RuntimeSignals::new(&edge_profile, None), + cache_cfg: &cache_cfg, + provider: "openai", + model_name: "deepseek-v4-flash", + context_window: Some(200_000), + max_completion_tokens: Some(16_384), + cache_capability: Some(strict_history), + user_content: "summarize it", + query_source: "test", + }) + .expect("context pipeline should assemble"); + let wire = crate::turn::wire_assembly::assemble_llm_messages_with_cache_capability( + output.system_messages, + output.volatile_preamble, + Vec::new(), + output.messages, + &crate::turn::wire_assembly::PostCompactAttachments::default(), + "sid-edge-frame", + "openai", + "deepseek-v4-flash", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(strict_history), + &cache_cfg, + ); + crate::turn::llm::client::consolidate_system_messages_for_provider( + &wire, + "openai", + Some(strict_history), + ) + } + + let first = provider_messages("frame-alpha-dynamic-value"); + let second = provider_messages("frame-beta-dynamic-value"); + assert_eq!(first[0]["role"], "system"); + assert_eq!( + first[0], second[0], + "typed edge-profile frames must not alter the consolidated strict-provider prefix" + ); + let system = message_text(&first[0]); + assert!(system.contains("active_turn_focus_policy.v1")); + assert!(!system.contains("frame-alpha-dynamic-value")); + assert!(!message_text(&second[0]).contains("frame-beta-dynamic-value")); + assert!(first.iter().any(|message| { + message.get("role").and_then(Value::as_str) == Some("user") + && message_text(message) == "summarize it" + })); } #[test] @@ -2557,6 +2852,27 @@ mod context_cache_contract_tests { ); } + #[test] + fn immediate_prior_user_request_excludes_append_only_runtime_authority() { + let mut runtime = json!({"role": "user", "content": "runtime settlement"}); + astra_turn_types::mark_append_only_required_context( + &mut runtime, + "final_answer_settlement", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ); + let messages = vec![ + json!({"role": "user", "content": "actual prior goal"}), + runtime, + json!({"role": "assistant", "content": "checked"}), + json!({"role": "user", "content": "continue"}), + ]; + + assert_eq!( + immediate_prior_user_request(&messages, "continue").as_deref(), + Some("actual prior goal") + ); + } + #[test] fn later_tool_round_does_not_repeat_the_current_goal_frame() { let mut state = crate::turn::agentic_loop::host::make_test_loop_state(); @@ -2580,8 +2896,9 @@ mod context_cache_contract_tests { provider: "openai", model_name: "gpt-4", cache_capability: None, - cache_cfg: &PromptCacheConfig::latch("openai", "gpt-4"), - }); + cache_cfg: &PromptCacheConfig::latch("openai"), + }) + .unwrap(); assert!( messages @@ -2598,7 +2915,7 @@ mod context_cache_contract_tests { state.message = "continue".to_string(); state.current_round_index = 1; let thinking = astra_turn_core::thinking_config::ThinkingConfig::Off; - let cache_cfg = PromptCacheConfig::latch("openai", "gpt-4"); + let cache_cfg = PromptCacheConfig::latch("openai"); let without_boundary = assemble_wire_messages(LlmWireAssemblyInput { system_messages: vec![json!({"role": "system", "content": "sys"})], @@ -2612,7 +2929,8 @@ mod context_cache_contract_tests { model_name: "gpt-4", cache_capability: None, cache_cfg: &cache_cfg, - }); + }) + .unwrap(); assert!( !without_boundary .iter() @@ -2631,7 +2949,8 @@ mod context_cache_contract_tests { model_name: "gpt-4", cache_capability: None, cache_cfg: &cache_cfg, - }); + }) + .unwrap(); assert!( with_boundary.iter().any(|message| { message_text(message).contains("\"kind\":\"active_turn_frame\"") @@ -2660,7 +2979,8 @@ mod context_cache_contract_tests { model_name: "gpt-4", cache_capability: None, cache_cfg: &cache_cfg, - }); + }) + .unwrap(); assert!( with_second_boundary.iter().any(|message| { message_text(message).contains("\"kind\":\"active_turn_frame\"") @@ -2687,6 +3007,8 @@ mod context_cache_contract_tests { protocol: astra_turn_core::cache_placement::CacheProtocol::StrictHistoryMatch, volatile_placement: astra_turn_core::cache_placement::VolatilePlacement::CurrentUserOnly, + volatile_delivery: + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly, reuse_scope: Some( astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns, ), @@ -2756,6 +3078,8 @@ mod context_cache_contract_tests { protocol: astra_turn_core::cache_placement::CacheProtocol::StrictHistoryMatch, volatile_placement: astra_turn_core::cache_placement::VolatilePlacement::CurrentUserOnly, + volatile_delivery: + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly, reuse_scope: Some( astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns, ), @@ -2780,6 +3104,8 @@ mod context_cache_contract_tests { protocol: astra_turn_core::cache_placement::CacheProtocol::StrictHistoryMatch, volatile_placement: astra_turn_core::cache_placement::VolatilePlacement::CurrentUserOnly, + volatile_delivery: + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly, reuse_scope: Some( astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns, ), @@ -3170,7 +3496,9 @@ mod context_cache_contract_tests { protocol: wire.protocol, provider_wire_bytes: wire.provider_wire_bytes, composition: wire.composition.clone(), + fingerprints: wire.fingerprints.clone(), }, + dispatch_started: true, terminal: Some(astra_services::InferenceInvocationTerminal { status, usage, @@ -3201,6 +3529,11 @@ mod context_cache_contract_tests { ); assert_eq!(projected["request_id"], attempt.request.request_id); assert_eq!(projected["request_hash"], attempt.request.request_hash); + assert_eq!(projected["transport_stage"], "dispatch_started"); + assert_eq!( + projected["provider_final_fingerprints"]["message_sequence_sha256"], + attempt.request.fingerprints.message_sequence_sha256 + ); } assert_ne!( trace["provider_request_attempts"][0]["request_hash"], @@ -3267,8 +3600,8 @@ mod context_cache_contract_tests { "provider-success" ); assert_eq!( - trace["wire"]["projection_authority"], "pre_provider_messages_and_tools_v1", - "the pre-provider projection remains explicitly distinct from exact body facts" + trace["wire"]["projection_authority"], "planned_pre_client_projection_v1", + "the planned projection remains explicitly distinct from exact body facts" ); clear_manifest_provider_request(&mut trace); @@ -3302,7 +3635,7 @@ mod context_cache_contract_tests { assert_eq!(trace["wire"]["message_count"], 2); assert_eq!( trace["wire"]["projection_authority"], - "pre_provider_messages_and_tools_v1" + "planned_pre_client_projection_v1" ); assert_eq!(trace["wire"]["tool_schema_count"], 1); assert_eq!(trace["wire"]["message_cache_control_count"], 1); @@ -3391,6 +3724,38 @@ mod context_cache_contract_tests { ); } + #[test] + fn prompt_cache_identity_hashes_only_the_contiguous_leading_system_prefix() { + let mut baseline = json!({}); + let mut with_later_runtime_system = json!({}); + augment_manifest_trace_with_wire( + &mut baseline, + &[ + json!({"role": "system", "content": "stable"}), + json!({"role": "user", "content": "work"}), + ], + &[], + ); + augment_manifest_trace_with_wire( + &mut with_later_runtime_system, + &[ + json!({"role": "system", "content": "stable"}), + json!({"role": "user", "content": "work"}), + json!({"role": "system", "content": "volatile settlement"}), + ], + &[], + ); + + assert_eq!( + baseline["wire"]["fingerprint"]["prompt_cache_identity"]["stable_system_prefix_hash"], + with_later_runtime_system["wire"]["fingerprint"]["prompt_cache_identity"]["stable_system_prefix_hash"] + ); + assert_ne!( + baseline["wire"]["fingerprint"]["system_message_sequence_sha256"], + with_later_runtime_system["wire"]["fingerprint"]["system_message_sequence_sha256"] + ); + } + #[test] fn model_request_seed_reads_only_exact_manifest_contract_fields() { let trace = json!({ diff --git a/crates/runtime/src/turn/llm/durable.rs b/crates/runtime/src/turn/llm/durable.rs index 23df822aa8..7b3c6953ea 100644 --- a/crates/runtime/src/turn/llm/durable.rs +++ b/crates/runtime/src/turn/llm/durable.rs @@ -2109,6 +2109,7 @@ struct TestInvocationState { #[cfg(test)] struct TestProviderAttemptState { invocation_id: String, + canonical_transition_hash: Option, terminal: Option, } @@ -2425,6 +2426,9 @@ impl InferenceLedgerPersistence for TestInferenceLedgerPersistence { attempt.attempt_id().to_string(), TestProviderAttemptState { invocation_id: attempt.invocation_id().to_string(), + canonical_transition_hash: attempt + .canonical_transition_hash() + .map(str::to_string), terminal: None, }, ) @@ -3088,7 +3092,7 @@ impl DurableInferenceLedger { /// longer server-side completion window; semantic admission must not /// inherit that tail latency. The durable invocation/attempt lifecycle /// remains identical to [`Self::execute_nonstream`]. - pub(crate) async fn execute_stream( + pub(crate) async fn execute_stream_no_tool_choice( &self, scope: astra_turn_types::InferenceInvocationScope, call: LlmCall<'_>, @@ -3124,13 +3128,15 @@ impl DurableInferenceLedger { Some(flag) => LlmCancel::FlagAndToken(flag, &owner_cancel), None => LlmCancel::Token(&owner_cancel), }; - let result = crate::turn::llm::client::call_llm_and_collect_with_stream_callback( - call, - cancel, - None, - Some(attempt_observer.as_ref()), - ) - .await; + let result = + crate::turn::llm::client::call_llm_and_collect_with_stream_callback_and_no_tool_choice( + call, + cancel, + None, + Some(attempt_observer.as_ref()), + ) + .await + ; match result { Ok(result) => { settlement @@ -3383,12 +3389,17 @@ pub(crate) struct DurableProviderRequestIdentity { pub protocol: crate::turn::llm::client::LlmProviderProtocol, pub provider_wire_bytes: u64, pub composition: crate::turn::llm::client::ProviderWireComposition, + pub fingerprints: crate::turn::llm::client::ProviderWireFingerprints, } /// One admitted physical request and its terminal fact, when observed. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct DurableProviderAttemptFact { pub request: DurableProviderRequestIdentity, + /// Runtime transport observation for this physical attempt. Admission and + /// dispatch are separate facts; one retry starting transport must not make + /// another merely prepared request look sent. + pub dispatch_started: bool, pub terminal: Option, } @@ -3410,8 +3421,70 @@ impl DurableInferenceInvocation { self.observer.clone() } + /// Bind the canonical append WAL before the first physical attempt is + /// admitted. The observer carries it unchanged into the same transaction + /// that fences the exact provider body. + pub(crate) fn bind_provider_canonical_transitions( + &self, + transitions: Vec, + ) -> Result<(), astra_core::ClassifiedError> { + for transition in &transitions { + transition.validate().map_err(|error| { + contract_error( + "canonical transition binding", + format!("invalid transition: {error}"), + ) + })?; + } + if self.observer.next_attempt.load(Ordering::Acquire) != 0 { + return Err(contract_error( + "canonical transition binding", + "provider attempt admission already started", + )); + } + let mut bound = self + .observer + .canonical_transitions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !bound.is_empty() && *bound != transitions { + return Err(contract_error( + "canonical transition binding", + "a different transition set is already bound", + )); + } + *bound = transitions; + Ok(()) + } + pub(crate) async fn provider_attempt_facts(&self) -> Vec { - self.observer.state.lock().await.attempt_facts() + let dispatched_attempts = self + .observer + .dispatched_attempts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + self.observer + .state + .lock() + .await + .attempt_facts(&dispatched_attempts) + } + + /// Transition id acknowledged by the same durable transaction that + /// admitted a physical provider attempt. Prepared/request-observer state is + /// deliberately not sufficient authority to advance the runtime WAL head. + pub(crate) fn admitted_canonical_transition_id(&self) -> Option { + self.observer + .admitted_canonical_transition_id + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + #[must_use] + pub(crate) fn provider_dispatch_started(&self) -> bool { + self.observer.dispatch_started.load(Ordering::Acquire) } async fn take_settlement_reservation( @@ -3688,7 +3761,11 @@ struct DurableProviderAttemptObserver { settlement_reservation: Arc>>, invocation: astra_services::InferenceInvocationPlan, request_context: astra_services::ModelRequestContextSeed, + canonical_transitions: std::sync::Mutex>, + admitted_canonical_transition_id: std::sync::Mutex>, next_attempt: AtomicU32, + dispatch_started: AtomicBool, + dispatched_attempts: std::sync::Mutex>, state: Arc>, operations: ProviderOperationGate, owner_lease: Arc, @@ -3705,11 +3782,15 @@ struct ProviderAttemptState { } impl ProviderAttemptState { - fn attempt_facts(&self) -> Vec { + fn attempt_facts( + &self, + dispatched_attempts: &BTreeSet, + ) -> Vec { self.requests .iter() .map(|(attempt, request)| DurableProviderAttemptFact { request: request.clone(), + dispatch_started: dispatched_attempts.contains(attempt), terminal: self.terminals.get(attempt).cloned(), }) .collect() @@ -3874,7 +3955,11 @@ impl DurableProviderAttemptObserver { settlement_reservation, invocation, request_context, + canonical_transitions: std::sync::Mutex::new(Vec::new()), + admitted_canonical_transition_id: std::sync::Mutex::new(None), next_attempt: AtomicU32::new(0), + dispatch_started: AtomicBool::new(false), + dispatched_attempts: std::sync::Mutex::new(BTreeSet::new()), state: Arc::new(tokio::sync::Mutex::new(ProviderAttemptState::default())), operations: ProviderOperationGate::default(), owner_lease, @@ -4062,12 +4147,22 @@ impl ProviderAttemptObserver for DurableProviderAttemptObserver { conversation_items: wire.composition.conversation_items, tool_schema_items: wire.composition.tool_schema_items, }); + let canonical_transitions = self + .canonical_transitions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + let canonical_transition_id = canonical_transitions + .first() + .map(|transition| transition.transition_id.clone()); let attempt = astra_services::plan_inference_provider_attempt_with_context( &self.invocation, attempt_index, service_wire, self.request_context.clone(), - ); + ) + .with_canonical_transitions(&canonical_transitions) + .map_err(|error| service_error("provider canonical transition", error))?; let request = DurableProviderRequestIdentity { request_id: attempt.request_id().to_string(), request_hash: wire.provider_wire_hash.clone(), @@ -4075,6 +4170,7 @@ impl ProviderAttemptObserver for DurableProviderAttemptObserver { protocol: wire.protocol, provider_wire_bytes: wire.provider_wire_bytes, composition: wire.composition.clone(), + fingerprints: wire.fingerprints.clone(), }; { let mut state = self.state.lock().await; @@ -4091,6 +4187,22 @@ impl ProviderAttemptObserver for DurableProviderAttemptObserver { // pre-delivery cancellation without ever authorizing HTTP. return Err(service_error("provider attempt admission", error)); } + if let Some(canonical_transition_id) = canonical_transition_id { + let mut admitted = self + .admitted_canonical_transition_id + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if admitted + .as_deref() + .is_some_and(|existing| existing != canonical_transition_id) + { + return Err(contract_error( + "provider attempt admission", + "physical retries admitted different canonical transition ids", + )); + } + *admitted = Some(canonical_transition_id); + } self.owner_lease .ensure_live("provider delivery authorization")?; if self.operations.is_closed() { @@ -4145,6 +4257,14 @@ impl ProviderAttemptObserver for DurableProviderAttemptObserver { state.terminals.insert(attempt_index, terminal.clone()); Ok(()) } + + fn note_dispatch_started(&self, attempt_index: u32) { + self.dispatched_attempts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(attempt_index); + self.dispatch_started.store(true, Ordering::Release); + } } fn contract_error( @@ -5536,6 +5656,7 @@ mod tests { provider_envelope_bytes: 2, ..Default::default() }, + fingerprints: Default::default(), }; let attempt = observer .begin_attempt(&wire) @@ -7116,6 +7237,7 @@ mod tests { provider_envelope_bytes: 2, ..Default::default() }, + fingerprints: Default::default(), }; let mut admission = Box::pin(observer.begin_attempt(&wire)); tokio::select! { @@ -7176,6 +7298,7 @@ mod tests { provider_envelope_bytes: 2, ..Default::default() }, + fingerprints: Default::default(), }; let admitting_observer = observer.clone(); @@ -7266,6 +7389,7 @@ mod tests { provider_envelope_bytes: 2, ..Default::default() }, + fingerprints: Default::default(), }; let attempt = observer .begin_attempt(&wire) @@ -7321,6 +7445,7 @@ mod tests { provider_envelope_bytes: 2, ..Default::default() }, + fingerprints: Default::default(), }; let mut admission = Box::pin(observer.begin_attempt(&wire)); tokio::select! { @@ -7374,9 +7499,18 @@ mod tests { persistence: TestInferenceLedgerPersistence, ) -> DurableInferenceInvocation { test_ledger_for_persistence(persistence) + .with_run_authority(DurableInferenceRunAuthority::new( + 0, + "test-inference-owner", + 0, + None, + None, + None, + )) .admit( - astra_turn_types::InferenceInvocationScope::Session { + astra_turn_types::InferenceInvocationScope::Run { session_id: "session-test".to_string(), + run_id: "run-test".to_string(), turn: 1, round: 0, operation_id: "agent_turn".to_string(), @@ -7401,9 +7535,70 @@ mod tests { provider_envelope_bytes: 128, ..Default::default() }, + fingerprints: Default::default(), } } + #[tokio::test] + async fn canonical_transition_commits_with_attempt_before_dispatch() { + let persistence = TestInferenceLedgerPersistence::default(); + let invocation = test_invocation(persistence.clone()).await; + let base = vec![serde_json::json!({"role": "user", "content": "goal"})]; + let content = astra_turn_types::render_append_only_runtime_authority_frame( + "test_authority", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + "opaque test authority", + ) + .unwrap(); + let mut authority = serde_json::json!({"role": "user", "content": content}); + astra_turn_types::mark_append_only_required_context( + &mut authority, + "test_authority", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ); + let transition = + astra_turn_types::ProviderCanonicalTransitionV1::new(None, &base, vec![authority]) + .unwrap(); + let transition_id = transition.transition_id.clone(); + invocation + .bind_provider_canonical_transitions(vec![transition]) + .unwrap(); + + let attempt_index = invocation + .attempt_observer() + .begin_attempt(&test_wire_identity()) + .await + .expect("attempt admission commits its canonical WAL"); + assert_eq!( + invocation.admitted_canonical_transition_id().as_deref(), + Some(transition_id.as_str()) + ); + assert!(!invocation.provider_dispatch_started()); + assert!( + persistence + .lock() + .attempts + .values() + .all(|attempt| attempt.canonical_transition_hash.is_some()), + "the durable attempt must own the transition before transport starts" + ); + assert!( + invocation + .bind_provider_canonical_transitions(Vec::new()) + .is_err(), + "transition identity freezes when attempt admission starts" + ); + + let terminal = pre_provider_cancelled_terminal(); + invocation + .attempt_observer() + .finish_attempt(attempt_index, &terminal) + .await + .unwrap(); + invocation.finish(&terminal).await.unwrap(); + persistence.assert_quiescent(); + } + #[tokio::test] async fn invocation_is_durable_before_its_first_provider_attempt() { let persistence = TestInferenceLedgerPersistence::default(); @@ -7884,6 +8079,7 @@ mod tests { provider_envelope_bytes: 128, ..Default::default() }, + fingerprints: Default::default(), }, ); } @@ -7899,7 +8095,7 @@ mod tests { }, ); - let facts = state.attempt_facts(); + let facts = state.attempt_facts(&BTreeSet::from([1])); assert_eq!( facts .iter() @@ -7915,5 +8111,7 @@ mod tests { Some("provider-429") ); assert!(facts[1].terminal.is_none()); + assert!(!facts[0].dispatch_started); + assert!(facts[1].dispatch_started); } } diff --git a/crates/runtime/src/turn/llm/exchange_capture.rs b/crates/runtime/src/turn/llm/exchange_capture.rs index bf843eee19..f42967d3ae 100644 --- a/crates/runtime/src/turn/llm/exchange_capture.rs +++ b/crates/runtime/src/turn/llm/exchange_capture.rs @@ -12,6 +12,10 @@ pub(crate) struct CaptureTrace<'a> { pub session_turn_source: Option<&'a str>, pub turn_chain_id: Option<&'a str>, pub user_query_event_id: Option<&'a str>, + /// Final resolved deployment capability used to assemble this exact + /// provider request. Cache diagnosis must consume this shape instead of + /// guessing from provider or model names. + pub cache_capability: Option, } fn sanitize_component(raw: &str) -> String { @@ -126,6 +130,7 @@ pub(crate) fn build_capture_payload_json( "response": response, "outcome": outcome, "trace": build_capture_trace_json(turn, round, trace), + "cache_capability": trace.and_then(|trace| trace.cache_capability), }) } @@ -181,6 +186,7 @@ pub(crate) fn build_remote_capture_record( "provider": provider, "outcome": outcome, "trace": build_capture_trace_json(turn, round, trace), + "cache_capability": trace.and_then(|trace| trace.cache_capability), })), references: Vec::new(), } @@ -432,6 +438,7 @@ fn persist_capture_inner( "outcome": outcome, "response": response, "trace": build_capture_trace_json(turn, round, trace), + "cache_capability": trace.and_then(|trace| trace.cache_capability), }); let serialization_site = @@ -650,6 +657,7 @@ mod tests { session_turn_source: Some("header"), turn_chain_id: Some("chain-1"), user_query_event_id: Some("query-1"), + cache_capability: None, }), ) .expect("capture path"); @@ -762,6 +770,7 @@ mod tests { session_turn_source: Some("state"), turn_chain_id: Some("chain-7"), user_query_event_id: Some("query-7"), + cache_capability: None, }), ); assert_eq!(record.artifact_kind, "llm_capture"); @@ -803,6 +812,7 @@ mod tests { session_turn_source: Some("state"), turn_chain_id: Some("chain-local"), user_query_event_id: Some("query-local"), + cache_capability: None, }), ) .await @@ -870,6 +880,7 @@ mod tests { session_turn_source: Some("header"), turn_chain_id: Some("chain-remote"), user_query_event_id: Some("query-remote"), + cache_capability: None, }), ) .await diff --git a/crates/runtime/src/turn/llm/summary_client.rs b/crates/runtime/src/turn/llm/summary_client.rs index 56cda5e0a5..b3c1a53cde 100644 --- a/crates/runtime/src/turn/llm/summary_client.rs +++ b/crates/runtime/src/turn/llm/summary_client.rs @@ -11,7 +11,7 @@ use super::client::{LlmCall, OwnedLlmExecutionRoute}; use super::durable::DurableInferenceLedger; #[cfg(test)] -use super::client::{call_llm_nonstream, global_llm_client, llm_nonstream_timeout}; +use super::client::{global_llm_client, llm_nonstream_timeout}; #[derive(Clone)] struct DurableSummaryExecution { @@ -96,6 +96,8 @@ enum SummaryExecution { pub(crate) struct RuntimeSummaryClient { route: OwnedLlmExecutionRoute, max_output_tokens: usize, + prompt_cache_tools: Vec, + cache_capability: Option, execution: SummaryExecution, } @@ -126,6 +128,8 @@ impl RuntimeSummaryClient { Self { route, max_output_tokens, + prompt_cache_tools: Vec::new(), + cache_capability: None, execution: SummaryExecution::Durable(Box::new(DurableSummaryExecution { ledger, base_scope, @@ -134,6 +138,20 @@ impl RuntimeSummaryClient { } } + /// Reuse the main inference request's stable tool projection and exact + /// deployment cache capability for an inline compaction call. Auxiliary + /// callers that do not share a main-request prefix keep the empty default. + #[must_use] + pub(crate) fn with_prompt_cache_context( + mut self, + tools: Vec, + cache_capability: astra_turn_core::cache_placement::CacheCapability, + ) -> Self { + self.prompt_cache_tools = tools; + self.cache_capability = Some(cache_capability); + self + } + /// Auxiliary semantic decisions need a short, predictable response. Some /// models cannot turn reasoning off but do offer an explicit low-effort /// control. Use only the probe-derived capability contract; generic @@ -182,6 +200,8 @@ impl RuntimeSummaryClient { Self { route, max_output_tokens, + prompt_cache_tools: Vec::new(), + cache_capability: None, execution: SummaryExecution::Direct, } } @@ -224,13 +244,13 @@ impl SummaryLlmClient for RuntimeSummaryClient { let requested_logical_attempt = attempt_allocator .reserve_pair_at_least(&allocator_scope_key, durable_pair_base)?; let outcome = ledger - .execute_stream( + .execute_stream_no_tool_choice( base_scope.with_logical_attempt(requested_logical_attempt), LlmCall { purpose, messages, - tools: &[], - cache_capability: None, + tools: &self.prompt_cache_tools, + cache_capability: self.cache_capability, route: self.route.borrowed(), max_output_tokens: Some(self.max_output_tokens), temperature: Self::temperature_for(purpose, &thinking), @@ -254,13 +274,13 @@ impl SummaryLlmClient for RuntimeSummaryClient { } #[cfg(test)] SummaryExecution::Direct => { - call_llm_nonstream( + crate::turn::llm::client::call_llm_nonstream_no_tool_choice( global_llm_client(), LlmCall { purpose, messages, - tools: &[], - cache_capability: None, + tools: &self.prompt_cache_tools, + cache_capability: self.cache_capability, route: self.route.borrowed(), max_output_tokens: Some(self.max_output_tokens), temperature: Self::temperature_for(purpose, &thinking), @@ -273,6 +293,13 @@ impl SummaryLlmClient for RuntimeSummaryClient { } }; match result { + Ok(result) if !result.tool_calls.is_empty() => Err(format!( + "summary inference returned {} tool call(s) instead of structured text", + result.tool_calls.len() + )), + Ok(result) if result.full_text.trim().is_empty() => { + Err("summary inference returned empty text".to_string()) + } Ok(result) => Ok(SummaryResponse { text: result.full_text, is_ptl_error: false, @@ -626,6 +653,74 @@ mod tests { } } + #[tokio::test] + async fn summary_transport_preserves_cache_tools_but_forbids_tool_selection() { + let captured_body = Arc::new(std::sync::Mutex::new(None::)); + let captured_body_for_handler = captured_body.clone(); + let app = Router::new().route( + "/chat/completions", + post(move |axum::Json(body): axum::Json| { + let captured_body = captured_body_for_handler.clone(); + async move { + *captured_body + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(body); + let response = serde_json::json!({ + "id": "summary-response", + "choices": [{ + "message": {"role": "assistant", "content": "structured summary"}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 2} + }); + Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from(response.to_string())) + .expect("summary provider response") + } + }), + ); + let execution = summary_execution(spawn_summary_test_server(app).await); + let tools = vec![serde_json::json!({ + "type": "function", + "function": { + "name": "bash", + "description": "Run a command", + "parameters": {"type": "object", "properties": {}} + } + })]; + let cache_capability = astra_turn_core::cache_placement::CacheCapability { + protocol: astra_turn_core::cache_placement::CacheProtocol::OpenAiAutoPrefix, + volatile_placement: + astra_turn_core::cache_placement::VolatilePlacement::AppendOnlyUserTail, + volatile_delivery: + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: Some(astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns), + }; + let client = RuntimeSummaryClient::new_direct_for_test(summary_route(&execution), 64) + .with_prompt_cache_context(tools.clone(), cache_capability); + let messages = vec![ + serde_json::json!({"role": "system", "content": "stable prefix"}), + serde_json::json!({"role": "user", "content": "summarize"}), + ]; + + let summary = client + .summarize(InferencePurpose::RequiredCompaction, &messages) + .await + .expect("the no-tool transport must return summary text"); + assert_eq!(summary.text, "structured summary"); + + let body = captured_body + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .expect("captured request body"); + assert_eq!(body.get("messages"), Some(&Value::Array(messages))); + assert_eq!(body.get("tools"), Some(&Value::Array(tools))); + assert_eq!(body.get("tool_choice"), Some(&Value::String("none".into()))); + } + #[test] fn summary_attempt_allocator_is_shared_only_within_the_exact_scope() { let allocator = DurableSummaryAttemptAllocator::default(); diff --git a/crates/runtime/src/turn/loop_dispatcher.rs b/crates/runtime/src/turn/loop_dispatcher.rs index 816edbcf03..195fe6a961 100644 --- a/crates/runtime/src/turn/loop_dispatcher.rs +++ b/crates/runtime/src/turn/loop_dispatcher.rs @@ -361,6 +361,8 @@ mod tests { budget_wrapup_injected: false, context_compression_triggered: false, canonical_rewrite_state: Default::default(), + provider_canonical_wal_base: None, + provider_canonical_wal_head_transition_id: None, budget_wrapup_ignored_rounds: 0, compact_tier_applied: astra_turn_core::compaction_types::CompactionTier::Normal, skill_produced_output: false, diff --git a/crates/runtime/src/turn/prompt_cache.rs b/crates/runtime/src/turn/prompt_cache.rs index b123a255e4..b76121ccb2 100644 --- a/crates/runtime/src/turn/prompt_cache.rs +++ b/crates/runtime/src/turn/prompt_cache.rs @@ -71,9 +71,10 @@ //! [`provider_cache_policy_for`] determines the caching strategy from three sources in //! priority order: //! -//! 1. **Explicit** `CacheCapability` marker (highest priority — overrides everything) -//! 2. **Provider heuristics** (Anthropic direct, Bedrock Claude, other) -//! 3. **Environment override** (`ASTRA_TEST_PROMPT_CACHE_DISABLED`) +//! 1. **Explicit deployment metadata** (`CacheCapability`) +//! 2. **Provider transport baseline** when metadata is absent (never a model-name guess) +//! 3. **Environment enablement** (`ASTRA_TEST_PROMPT_CACHE_DISABLED`) controls whether +//! admitted annotations are emitted; it does not reclassify the protocol //! //! ## Public Interface //! @@ -136,24 +137,25 @@ fn saturating_usize_to_u32(value: usize) -> u32 { } impl PromptCacheConfig { - /// Latch config from environment and provider info. Call once at session start. - pub fn latch(provider: &str, model_name: &str) -> Self { - Self::from_cache_capability(None, provider, model_name) + /// Latch config from environment and provider transport. Call once at + /// session start. + pub fn latch(provider: &str) -> Self { + Self::from_cache_capability(None, provider) } pub fn from_cache_capability( cache_capability: Option, provider: &str, - model_name: &str, ) -> Self { let cache_enabled = !std::env::var("ASTRA_TEST_PROMPT_CACHE_DISABLED") .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true")); - let provider_strategy = - astra_turn_core::microcompact::ProviderCacheStrategy::from_explicit_or_provider_model( + let capability = + astra_turn_core::cache_placement::CacheCapability::from_explicit_or_provider( cache_capability, - Some(provider), - Some(model_name), + provider, ); + let provider_strategy = + astra_turn_core::microcompact::ProviderCacheStrategy::from_cache_capability(capability); let is_anthropic = provider_strategy.prompt_cache_protocol == astra_turn_core::microcompact::PromptCacheProtocol::AnthropicCacheControl; Self { @@ -217,13 +219,12 @@ pub(crate) struct EphemeralPipelineOutcome { pub(crate) fn provider_cache_policy_for( cache_capability: Option, provider: &str, - model_name: &str, ) -> ProviderCachePolicy { - let strategy = ProviderCacheStrategy::from_explicit_or_provider_model( + let capability = astra_turn_core::cache_placement::CacheCapability::from_explicit_or_provider( cache_capability, - Some(provider), - Some(model_name), + provider, ); + let strategy = ProviderCacheStrategy::from_cache_capability(capability); if strategy.prompt_cache_protocol == PromptCacheProtocol::AnthropicCacheControl { ProviderCachePolicy::anthropic() } else { @@ -276,6 +277,7 @@ pub(crate) fn assemble_system_message_via_pipeline( tool_names: &[&str], extra_dynamic_sections: &[prompts::PromptSection], cache_cfg: &PromptCacheConfig, + cache_capability: Option, session_id: &str, model_id: &str, provider: &str, @@ -291,7 +293,7 @@ pub(crate) fn assemble_system_message_via_pipeline( None, None, cache_cfg, - None, + cache_capability, session_id, model_id, None, @@ -513,12 +515,12 @@ pub(crate) fn assemble_ephemeral_pipeline_outcome_with_messages( extra_dynamic_sections: volatile, }; - let provider_policy = provider_cache_policy_for(cache_capability, provider, model_id); - let provider_strategy = ProviderCacheStrategy::from_explicit_or_provider_model( + let provider_policy = provider_cache_policy_for(cache_capability, provider); + let capability = astra_turn_core::cache_placement::CacheCapability::from_explicit_or_provider( cache_capability, - Some(provider), - Some(model_id), + provider, ); + let provider_strategy = ProviderCacheStrategy::from_cache_capability(capability); let session_ctx = SessionContext { session_id: session_id.to_string(), run_id: String::new(), @@ -915,12 +917,21 @@ mod tests { ); } + fn bedrock_cache_capability() -> astra_turn_core::cache_placement::CacheCapability { + astra_turn_core::cache_placement::CacheCapability { + protocol: astra_turn_core::cache_placement::CacheProtocol::BedrockCachePoint, + volatile_placement: astra_turn_core::cache_placement::VolatilePlacement::MarkerIsolated, + volatile_delivery: astra_turn_core::cache_placement::VolatileDeliveryPolicy::All, + reuse_scope: Some(astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns), + } + } + #[test] - fn prompt_cache_latch_prefers_provider_over_claude_named_model() { - let openai_proxy = PromptCacheConfig::latch("openai", "claude-sonnet-4"); + fn prompt_cache_latch_uses_provider_transport_only() { + let openai_proxy = PromptCacheConfig::latch("openai"); assert!(!openai_proxy.is_anthropic); - let anthropic_provider = PromptCacheConfig::latch("anthropic", "gpt-4o"); + let anthropic_provider = PromptCacheConfig::latch("anthropic"); assert!(anthropic_provider.is_anthropic); } @@ -931,12 +942,12 @@ mod tests { protocol: astra_turn_core::cache_placement::CacheProtocol::MarkerExplicit, volatile_placement: astra_turn_core::cache_placement::VolatilePlacement::MarkerIsolated, + volatile_delivery: astra_turn_core::cache_placement::VolatileDeliveryPolicy::All, reuse_scope: Some( astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns, ), }), "openai", - "proxy-claude", ); assert!(cfg.is_anthropic); } @@ -1680,6 +1691,8 @@ mod tests { protocol: astra_turn_core::cache_placement::CacheProtocol::StrictHistoryMatch, volatile_placement: astra_turn_core::cache_placement::VolatilePlacement::CurrentUserOnly, + volatile_delivery: + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly, reuse_scope: None, }; @@ -1763,6 +1776,7 @@ mod tests { &["bash", "read_file"], &[], &cache_cfg, + Some(bedrock_cache_capability()), "test-session", "claude-sonnet-4-6", "bedrock", @@ -1820,6 +1834,7 @@ mod tests { &["bash"], &[], &cache_cfg, + Some(bedrock_cache_capability()), "session", "claude-sonnet-4-6", "bedrock", @@ -1830,6 +1845,7 @@ mod tests { &["bash", "tool_search"], &[], &cache_cfg, + Some(bedrock_cache_capability()), "session", "claude-sonnet-4-6", "bedrock", @@ -1876,6 +1892,7 @@ mod tests { &["bash", "read_file"], &[], &cache_cfg, + None, "sid", "gpt-4o", "openai", @@ -1931,6 +1948,7 @@ mod tests { &["bash"], &extra, &cache_cfg, + Some(bedrock_cache_capability()), "sid", "claude-sonnet-4-6", "bedrock", @@ -1991,6 +2009,7 @@ mod tests { prompts::PromptTokenBucket::Environment, )], &cache_cfg, + Some(bedrock_cache_capability()), "sid", "claude-sonnet-4-6", "bedrock", @@ -2021,6 +2040,7 @@ mod tests { &["bash", "read_file"], &[], &cache_cfg, + Some(bedrock_cache_capability()), "sid", "claude-sonnet-4-6", "bedrock", @@ -2076,6 +2096,7 @@ mod tests { cache_enabled: true, is_anthropic: true, }, + Some(bedrock_cache_capability()), "sid", "claude-sonnet-4-6", "bedrock", @@ -2120,6 +2141,7 @@ mod tests { &["bash"], &[], &PromptCacheConfig::default(), + None, "sid", "gpt-4", "openai", @@ -2134,6 +2156,7 @@ mod tests { &["bash"], &[], &PromptCacheConfig::default(), + None, "sid", "gpt-4", "openai", @@ -2163,6 +2186,7 @@ mod tests { &["bash"], &[], &PromptCacheConfig::default(), + None, "sid", "gpt-4", "openai", @@ -2182,6 +2206,7 @@ mod tests { &["bash"], &[], &PromptCacheConfig::default(), + None, "sid", "gpt-4", "openai", @@ -2252,26 +2277,37 @@ mod tests { } #[test] - fn latch_enables_anthropic_style_cache_for_bedrock_claude() { + fn declared_capability_enables_anthropic_style_cache_for_bedrock() { let _lock = astra_core::sync_poison::recover_mutex_lock(&CACHE_ENV_MUTEX); remove_test_env("ASTRA_TEST_PROMPT_CACHE_DISABLED"); - let cfg = PromptCacheConfig::latch("bedrock", "anthropic.claude-sonnet-4-20250514-v1:0"); + let cfg = PromptCacheConfig::from_cache_capability( + Some(astra_turn_core::cache_placement::CacheCapability { + protocol: astra_turn_core::cache_placement::CacheProtocol::BedrockCachePoint, + volatile_placement: + astra_turn_core::cache_placement::VolatilePlacement::MarkerIsolated, + volatile_delivery: astra_turn_core::cache_placement::VolatileDeliveryPolicy::All, + reuse_scope: Some( + astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns, + ), + }), + "bedrock", + ); assert!(cfg.cache_enabled); assert!(cfg.is_anthropic); } #[test] - fn latch_keeps_non_claude_bedrock_on_openai_style_cache() { + fn undeclared_bedrock_stays_on_unmarked_cache_path() { let _lock = astra_core::sync_poison::recover_mutex_lock(&CACHE_ENV_MUTEX); remove_test_env("ASTRA_TEST_PROMPT_CACHE_DISABLED"); - let cfg = PromptCacheConfig::latch("bedrock", "us.amazon.nova-micro-v1:0"); + let cfg = PromptCacheConfig::latch("bedrock"); assert!(cfg.cache_enabled); assert!(!cfg.is_anthropic); } #[test] fn ephemeral_provider_policy_keeps_non_claude_bedrock_prefix_only() { - let policy = provider_cache_policy_for(None, "bedrock", "us.amazon.nova-micro-v1:0"); + let policy = provider_cache_policy_for(None, "bedrock"); assert_eq!( policy.protocol, @@ -2283,9 +2319,19 @@ mod tests { } #[test] - fn ephemeral_provider_policy_enables_anthropic_for_bedrock_claude() { - let policy = - provider_cache_policy_for(None, "bedrock", "anthropic.claude-sonnet-4-20250514-v1:0"); + fn ephemeral_provider_policy_honors_declared_bedrock_cachepoint() { + let policy = provider_cache_policy_for( + Some(astra_turn_core::cache_placement::CacheCapability { + protocol: astra_turn_core::cache_placement::CacheProtocol::BedrockCachePoint, + volatile_placement: + astra_turn_core::cache_placement::VolatilePlacement::MarkerIsolated, + volatile_delivery: astra_turn_core::cache_placement::VolatileDeliveryPolicy::All, + reuse_scope: Some( + astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns, + ), + }), + "bedrock", + ); assert_eq!( policy.protocol, @@ -2314,6 +2360,7 @@ mod tests { protocol: astra_turn_core::cache_placement::CacheProtocol::MarkerExplicit, volatile_placement: astra_turn_core::cache_placement::VolatilePlacement::MarkerIsolated, + volatile_delivery: astra_turn_core::cache_placement::VolatileDeliveryPolicy::All, reuse_scope: Some( astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns, ), @@ -2350,12 +2397,12 @@ mod tests { protocol: astra_turn_core::cache_placement::CacheProtocol::MarkerExplicit, volatile_placement: astra_turn_core::cache_placement::VolatilePlacement::MarkerIsolated, + volatile_delivery: astra_turn_core::cache_placement::VolatileDeliveryPolicy::All, reuse_scope: Some( astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns, ), }), "openai", - "proxy-claude", ); assert_eq!( policy.protocol, diff --git a/crates/runtime/src/turn/wire_assembly.rs b/crates/runtime/src/turn/wire_assembly.rs index 1942804556..9c217d5d65 100644 --- a/crates/runtime/src/turn/wire_assembly.rs +++ b/crates/runtime/src/turn/wire_assembly.rs @@ -15,6 +15,7 @@ //! Anthropic cache annotations into the final wire payload. use serde_json::Value; +use sha2::{Digest, Sha256}; use crate::prompts::{CompactConfig, CompactionTier}; use crate::turn::cloud::compaction::CompactResult; @@ -26,6 +27,15 @@ use crate::turn::prompt_cache::{PromptCacheConfig, apply_anthropic_cache_metadat pub(crate) const REQUIRED_RUNTIME_PREAMBLE_MARKER: &str = "__astra_required_runtime_context"; pub(crate) const RUNTIME_SYSTEM_CONTEXT_MARKER: &str = "__astra_runtime_system_context"; pub(crate) const DECISION_FEEDBACK_PREAMBLE_MARKER: &str = "__astra_runtime_decision_feedback"; +const RUNTIME_VOLATILE_KIND_MARKER: &str = "__astra_runtime_volatile_kind"; +const RUNTIME_AUTHORITY_LIFETIME_MARKER: &str = "__astra_runtime_authority_lifetime"; +const RUNTIME_AUTHORITY_CURRENT_USER_TURN: &str = "current_user_turn"; +const RUNTIME_AUTHORITY_NEXT_DECISION: &str = "next_assistant_decision"; +const INVOKED_SKILLS_CONTEXT_KIND_PREFIX: &str = "invoked_skill_context"; +const COMPACTION_CONTINUATION_KIND: &str = "compaction_continuation"; +const STRICT_HISTORY_FOCUS_POLICY: &str = r#" +{"schema":"active_turn_focus_policy.v1","instruction":"Answer the latest user message first. Resolve a short, elliptical, or deictic follow-up from the immediately preceding user-assistant exchange by default. Use older conversation only when the latest user message explicitly broadens the scope. Canonical conversation messages contain the exact current and prior text; do not treat older history, memory, or tool output as a competing request."} +"#; #[cfg(test)] const TOOL_RUNTIME_CONTEXT_PREFIX: &str = ""; #[cfg(test)] @@ -240,8 +250,58 @@ pub(crate) fn augment_manifest_trace_with_wire_budget_and_metadata( status } -pub(crate) fn required_runtime_preamble_message(text: &str) -> Option { - runtime_system_context_message(text, true) +/// Stable semantic identity for runtime-owned authority constructed outside +/// the typed volatile-injection lane. Every producer must choose one: a +/// generic fallback would make unrelated controls overwrite one another when +/// append-only history keeps only the latest revision of a source. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RuntimeAuthorityKind { + EdgeRequiredContext, + CompactedConversationSummary, + ActiveWorkAttemptStart, + PendingWorkGraphMutations, + ReadOnlyEffectBoundary, + FinalWorkSynthesis, + CanonicalWorkEstablishmentRetry, + ExecutionTimeBudget, + OutputCapContinuation, +} + +impl RuntimeAuthorityKind { + const fn as_str(self) -> &'static str { + match self { + Self::EdgeRequiredContext => "edge_required_context", + Self::CompactedConversationSummary => "compacted_conversation_summary", + Self::ActiveWorkAttemptStart => "active_work_attempt_start", + Self::PendingWorkGraphMutations => "pending_work_graph_mutations", + Self::ReadOnlyEffectBoundary => "read_only_effect_boundary", + Self::FinalWorkSynthesis => "final_work_synthesis", + Self::CanonicalWorkEstablishmentRetry => "canonical_work_establishment_retry", + Self::ExecutionTimeBudget => "execution_time_budget", + Self::OutputCapContinuation => "output_cap_continuation", + } + } +} + +pub(crate) fn required_runtime_preamble_message( + text: &str, + kind: RuntimeAuthorityKind, + lifetime: astra_turn_types::RuntimeAuthorityLifetime, +) -> Option { + let mut message = runtime_system_context_message(text, true)?; + mark_runtime_authority( + &mut message, + kind.as_str(), + match lifetime { + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn => { + RUNTIME_AUTHORITY_CURRENT_USER_TURN + } + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision => { + RUNTIME_AUTHORITY_NEXT_DECISION + } + }, + ); + Some(message) } pub(crate) fn decision_feedback_preamble_message(text: &str) -> Option { @@ -250,6 +310,53 @@ pub(crate) fn decision_feedback_preamble_message(text: &str) -> Option { Some(message) } +/// Project one typed runtime injection into the shared wire-only system lane. +/// +/// Keep the producer kind attached until provider-specific filtering. Folding +/// typed edge-profile values into an untyped text blob would let a required- +/// class `active_turn_frame` bypass the strict-history cache contract. +pub(crate) fn runtime_volatile_preamble_message( + injection: &astra_turn_core::chat_turn_edge_profile::RuntimeVolatileInjection, +) -> Option { + let text = injection.render_for_prompt()?; + let mut message = match injection.delivery_class { + astra_turn_core::chat_turn_edge_profile::VolatileDeliveryClass::RequiredContext => { + runtime_system_context_message(&text, true) + } + astra_turn_core::chat_turn_edge_profile::VolatileDeliveryClass::DecisionFeedback => { + decision_feedback_preamble_message(&text) + } + astra_turn_core::chat_turn_edge_profile::VolatileDeliveryClass::AdvisoryEvidence => { + runtime_system_context_message(&text, false) + } + astra_turn_core::chat_turn_edge_profile::VolatileDeliveryClass::TelemetryOnly => None, + }?; + let kind = injection.kind.trim(); + let authority_kind = + if crate::turn::agentic_loop::host::VolatileKind::wire_kind_is_singleton(kind) { + kind.to_string() + } else { + // Accumulative categories need one content-addressed instance key per + // fact. Exact retries rebuild the same key and dedupe; distinct + // background notifications/budget facts cannot overwrite each other. + format!("{kind}:sha256:{:x}", Sha256::digest(text.as_bytes())) + }; + message[RUNTIME_VOLATILE_KIND_MARKER] = Value::String(authority_kind); + if matches!( + injection.delivery_class, + astra_turn_core::chat_turn_edge_profile::VolatileDeliveryClass::RequiredContext + ) { + message[RUNTIME_AUTHORITY_LIFETIME_MARKER] = + Value::String(RUNTIME_AUTHORITY_NEXT_DECISION.to_string()); + } + Some(message) +} + +fn mark_runtime_authority(message: &mut Value, kind: &str, lifetime: &str) { + message[RUNTIME_VOLATILE_KIND_MARKER] = Value::String(kind.to_string()); + message[RUNTIME_AUTHORITY_LIFETIME_MARKER] = Value::String(lifetime.to_string()); +} + pub(crate) fn runtime_system_context_message(text: &str, required: bool) -> Option { let text = text.trim(); if text.is_empty() { @@ -309,7 +416,7 @@ fn runtime_system_context_from_message(mut message: Value) -> Option { fn current_turn_boundary(messages: &[Value]) -> usize { messages .iter() - .rposition(|message| message.get("role").and_then(Value::as_str) == Some("user")) + .rposition(astra_turn_types::is_human_user_message) .unwrap_or(messages.len()) } @@ -381,12 +488,17 @@ pub(crate) fn is_required_runtime_preamble(message: &Value) -> bool { .unwrap_or(false) } -fn is_prompt_visible_under_strict_history(message: &Value) -> bool { +fn is_prompt_visible_under_required_only(message: &Value) -> bool { + // Required-only delivery keeps optional, changing evidence off the wire + // while preserving lifecycle authority. ActiveTurnFrame is the one + // required-class exception: its exact current/prior text is already in + // canonical conversation history. One byte-stable leading policy conveys + // the resolution rule without duplicating turn-specific values. is_required_runtime_preamble(message) - || message - .get(DECISION_FEEDBACK_PREAMBLE_MARKER) - .and_then(Value::as_bool) - .unwrap_or(false) + && message + .get(RUNTIME_VOLATILE_KIND_MARKER) + .and_then(Value::as_str) + != Some("active_turn_frame") } pub(crate) fn strip_required_runtime_preamble_marker(message: &mut Value) { @@ -394,9 +506,303 @@ pub(crate) fn strip_required_runtime_preamble_marker(message: &mut Value) { object.remove(REQUIRED_RUNTIME_PREAMBLE_MARKER); object.remove(RUNTIME_SYSTEM_CONTEXT_MARKER); object.remove(DECISION_FEEDBACK_PREAMBLE_MARKER); + object.remove(RUNTIME_VOLATILE_KIND_MARKER); + object.remove(RUNTIME_AUTHORITY_LIFETIME_MARKER); } } +fn append_stable_system_policy(system_messages: &mut Vec, policy: &str) { + // This is stable policy, not a runtime tail. Fold it into the leading + // system value before any runtime-control message is placed. The operation + // is deterministic for both string and structured system content. + let Some(primary) = system_messages.first_mut() else { + system_messages.push(serde_json::json!({ + "role": "system", + "content": policy, + })); + return; + }; + match primary.get_mut("content") { + Some(Value::String(content)) => { + if !content.is_empty() { + content.push_str("\n\n"); + } + content.push_str(policy); + } + Some(Value::Array(blocks)) => { + if !blocks.is_empty() { + blocks.push(serde_json::json!({"type": "text", "text": "\n\n"})); + } + blocks.push(serde_json::json!({ + "type": "text", + "text": policy, + })); + } + _ => { + primary["role"] = Value::String("system".to_string()); + primary["content"] = Value::String(policy.to_string()); + } + } +} + +fn append_required_only_focus_policy(system_messages: &mut Vec) { + append_stable_system_policy(system_messages, STRICT_HISTORY_FOCUS_POLICY); +} + +pub(crate) fn ensure_append_only_runtime_authority_policy(system_messages: &mut Vec) { + let already_present = system_messages + .iter() + .any(astra_turn_types::has_append_only_runtime_authority_policy); + if already_present { + return; + } + append_stable_system_policy( + system_messages, + astra_turn_types::APPEND_ONLY_RUNTIME_AUTHORITY_POLICY, + ); + if let Some(primary) = system_messages.first_mut() { + astra_turn_types::mark_append_only_runtime_authority_policy(primary); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AppendOnlyRuntimeAuthorityError { + InvalidCacheCapability, + MissingOrInvalidDelivery, + InvalidProviderRole, + MissingKind, + MissingOrInvalidLifetime, + MissingTextContent, + MalformedFrameContent, +} + +impl std::fmt::Display for AppendOnlyRuntimeAuthorityError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let detail = match self { + Self::InvalidCacheCapability => { + "append-only placement requires required-only volatile delivery" + } + Self::MissingOrInvalidDelivery => { + "runtime provenance has a missing or unknown delivery" + } + Self::InvalidProviderRole => { + "append-only runtime authority must use the provider user role" + } + Self::MissingKind => "required runtime authority is missing its kind", + Self::MissingOrInvalidLifetime => { + "required runtime authority is missing a valid lifetime" + } + Self::MissingTextContent => "required runtime authority is missing text content", + Self::MalformedFrameContent => { + "required runtime authority frame does not match its typed provenance" + } + }; + write!( + formatter, + "append-only runtime authority contract violated: {detail}" + ) + } +} + +impl std::error::Error for AppendOnlyRuntimeAuthorityError {} + +fn into_append_only_runtime_authority( + mut message: Value, +) -> Result { + let Some(kind) = message + .get(RUNTIME_VOLATILE_KIND_MARKER) + .and_then(Value::as_str) + .filter(|kind| !kind.trim().is_empty()) + .map(str::to_string) + else { + return Err(AppendOnlyRuntimeAuthorityError::MissingKind); + }; + let Some(lifetime) = message + .get(RUNTIME_AUTHORITY_LIFETIME_MARKER) + .and_then(Value::as_str) + .and_then(|lifetime| match lifetime { + RUNTIME_AUTHORITY_CURRENT_USER_TURN => { + Some(astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn) + } + RUNTIME_AUTHORITY_NEXT_DECISION => { + Some(astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision) + } + _ => None, + }) + else { + return Err(AppendOnlyRuntimeAuthorityError::MissingOrInvalidLifetime); + }; + let Some(content) = message + .get("content") + .and_then(Value::as_str) + .map(str::to_string) + else { + return Err(AppendOnlyRuntimeAuthorityError::MissingTextContent); + }; + + let framed_content = + astra_turn_types::render_append_only_runtime_authority_frame(&kind, lifetime, &content) + .map_err(|_| AppendOnlyRuntimeAuthorityError::MalformedFrameContent)?; + if let Some(object) = message.as_object_mut() { + object.insert("role".to_string(), Value::String("user".to_string())); + object.insert("content".to_string(), Value::String(framed_content)); + object.remove(REQUIRED_RUNTIME_PREAMBLE_MARKER); + object.remove(RUNTIME_SYSTEM_CONTEXT_MARKER); + object.remove(DECISION_FEEDBACK_PREAMBLE_MARKER); + object.remove(RUNTIME_VOLATILE_KIND_MARKER); + object.remove(RUNTIME_AUTHORITY_LIFETIME_MARKER); + } + astra_turn_types::mark_append_only_required_context(&mut message, &kind, lifetime); + Ok(message) +} + +pub(crate) fn required_append_only_runtime_authority_message( + text: &str, + kind: RuntimeAuthorityKind, + lifetime: astra_turn_types::RuntimeAuthorityLifetime, +) -> Result, AppendOnlyRuntimeAuthorityError> { + required_runtime_preamble_message(text, kind, lifetime) + .map(into_append_only_runtime_authority) + .transpose() +} + +pub(crate) fn append_only_runtime_authority_is_redundant( + history: &[Value], + candidate: &Value, +) -> bool { + let Some(kind) = astra_turn_types::runtime_authority_kind(candidate) else { + return false; + }; + let Some((index, prior)) = history.iter().enumerate().rev().find(|(_, prior)| { + astra_turn_types::runtime_message_delivery(prior) + == Some(astra_turn_types::RuntimeMessageDelivery::AppendOnlyRequiredContext) + && astra_turn_types::runtime_authority_kind(prior) == Some(kind) + }) else { + return false; + }; + if prior.get("content") != candidate.get("content") { + return false; + } + + append_only_runtime_authority_is_active(history, index, candidate) +} + +fn append_only_runtime_authority_is_active( + history: &[Value], + index: usize, + _authority: &Value, +) -> bool { + astra_turn_types::append_only_runtime_authority_is_active(history, index) +} + +fn unframe_append_only_runtime_authority( + message: &Value, + kind: &str, + lifetime: astra_turn_types::RuntimeAuthorityLifetime, +) -> Result { + let frame = astra_turn_types::parse_append_only_runtime_authority_frame(message) + .map_err(|_| AppendOnlyRuntimeAuthorityError::MalformedFrameContent)?; + if frame.kind != kind || frame.lifetime != lifetime { + return Err(AppendOnlyRuntimeAuthorityError::MalformedFrameContent); + } + Ok(frame.payload) +} + +fn validate_append_only_runtime_authority( + message: &Value, +) -> Result< + (String, astra_turn_types::RuntimeAuthorityLifetime, String), + AppendOnlyRuntimeAuthorityError, +> { + if astra_turn_types::runtime_message_delivery(message) + != Some(astra_turn_types::RuntimeMessageDelivery::AppendOnlyRequiredContext) + { + return Err(AppendOnlyRuntimeAuthorityError::MissingOrInvalidDelivery); + } + if message.get("role").and_then(Value::as_str) != Some("user") { + return Err(AppendOnlyRuntimeAuthorityError::InvalidProviderRole); + } + let kind = astra_turn_types::runtime_authority_kind(message) + .filter(|kind| !kind.trim().is_empty()) + .ok_or(AppendOnlyRuntimeAuthorityError::MissingKind)? + .to_string(); + let lifetime = astra_turn_types::runtime_authority_lifetime(message) + .ok_or(AppendOnlyRuntimeAuthorityError::MissingOrInvalidLifetime)?; + let payload = unframe_append_only_runtime_authority(message, &kind, lifetime)?; + Ok((kind, lifetime, payload)) +} + +/// Remove append-only provider frames from a projection targeting another +/// wire shape. Expired frames stay only in canonical state. A frame whose +/// typed lifetime is still active is re-homed to the ordinary required-system +/// lane so provider switching cannot turn runtime authority into human intent +/// or silently discard an unconsumed control. +pub(crate) fn rehome_append_only_runtime_authority( + messages: &mut Vec, +) -> Result, AppendOnlyRuntimeAuthorityError> { + let original = std::mem::take(messages); + let mut projected = Vec::with_capacity(original.len()); + let mut rehomed = Vec::new(); + for (index, mut message) in original.iter().cloned().enumerate() { + if astra_turn_types::is_runtime_owned_message(&message) + && astra_turn_types::runtime_message_delivery(&message).is_none() + { + return Err(AppendOnlyRuntimeAuthorityError::MissingOrInvalidDelivery); + } + if astra_turn_types::runtime_message_delivery(&message) + != Some(astra_turn_types::RuntimeMessageDelivery::AppendOnlyRequiredContext) + { + projected.push(message); + continue; + } + + let (kind, lifetime, content) = validate_append_only_runtime_authority(&message)?; + if !append_only_runtime_authority_is_active(&original, index, &message) { + continue; + } + message["role"] = Value::String("system".to_string()); + message["content"] = Value::String(content); + message[RUNTIME_SYSTEM_CONTEXT_MARKER] = Value::Bool(true); + message[REQUIRED_RUNTIME_PREAMBLE_MARKER] = Value::Bool(true); + mark_runtime_authority( + &mut message, + &kind, + match lifetime { + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn => { + RUNTIME_AUTHORITY_CURRENT_USER_TURN + } + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision => { + RUNTIME_AUTHORITY_NEXT_DECISION + } + }, + ); + rehomed.push(message); + } + *messages = projected; + Ok(rehomed) +} + +fn retain_latest_runtime_system_authority_by_kind(messages: &mut Vec) { + let mut last_by_kind = std::collections::HashMap::new(); + for (index, message) in messages.iter().enumerate() { + if let Some(kind) = message + .get(RUNTIME_VOLATILE_KIND_MARKER) + .and_then(Value::as_str) + { + last_by_kind.insert(kind.to_string(), index); + } + } + let mut index = 0usize; + messages.retain(|message| { + let keep = message + .get(RUNTIME_VOLATILE_KIND_MARKER) + .and_then(Value::as_str) + .is_none_or(|kind| last_by_kind.get(kind) == Some(&index)); + index += 1; + keep + }); +} + pub(crate) fn session_memory_entry_for_pipeline( content: Option<&str>, snapshot_updated_turn: Option, @@ -773,14 +1179,18 @@ pub(crate) fn maybe_append_continuation_prompt( if already_queued { return; } - let last_is_user = messages + if messages .last() - .and_then(|m| m.get("role").and_then(Value::as_str)) - == Some("user"); - if last_is_user { + .is_some_and(astra_turn_types::is_human_user_message) + { return; } - if let Some(message) = runtime_system_context_message(COMPACTION_CONTEXT_NOTE, true) { + if let Some(mut message) = runtime_system_context_message(COMPACTION_CONTEXT_NOTE, true) { + mark_runtime_authority( + &mut message, + COMPACTION_CONTINUATION_KIND, + RUNTIME_AUTHORITY_NEXT_DECISION, + ); messages.push(message); } } @@ -797,13 +1207,17 @@ pub(crate) fn maybe_append_continuation_prompt( /// so tool pairing stays valid and later rounds can reuse the accumulated /// current-turn prefix. Other non-marker providers keep the current-user /// boundary. Real user/tool messages remain byte-for-byte unchanged. -/// 4. `strip_stale_reasoning` is applied in place. -/// 5. `apply_anthropic_cache_metadata` (Anthropic path only). +/// 4. `apply_anthropic_cache_metadata` (Anthropic path only). +/// +/// Reasoning replay normalization deliberately happens once, in the final +/// provider request projector. Keeping it out of shared history assembly is +/// what lets that projector enforce an immutable append-only wire prefix. +#[cfg(test)] pub(crate) fn assemble_llm_messages_with_cache_capability( system_messages: Vec, volatile_preamble: Vec, drained_volatile: Vec, - mut compacted_messages: Vec, + compacted_messages: Vec, attachments: &PostCompactAttachments<'_>, session_id: &str, provider: &str, @@ -812,38 +1226,106 @@ pub(crate) fn assemble_llm_messages_with_cache_capability( cache_capability: Option, cache_cfg: &PromptCacheConfig, ) -> Vec { - let cache_cap = - astra_turn_core::cache_placement::CacheCapability::from_explicit_or_provider_model( - cache_capability, - provider, - model_name, - ); - let suppress_volatile = matches!( - cache_cap.volatile_placement, - astra_turn_core::cache_placement::VolatilePlacement::CurrentUserOnly + assemble_llm_messages_with_cache_capability_output( + system_messages, + volatile_preamble, + drained_volatile, + compacted_messages, + attachments, + session_id, + provider, + model_name, + thinking, + cache_capability, + cache_cfg, + ) + .expect("test wire assembly must satisfy runtime-authority invariants") + .messages +} + +/// Assembly result used by the stateful host. Append-only runtime controls +/// are returned separately so the caller can persist exactly the new frames +/// in canonical history after using the same values on the provider wire. +#[derive(Debug)] +pub(crate) struct LlmMessageAssembly { + pub messages: Vec, + pub new_append_only_runtime_messages: Vec, +} + +pub(crate) fn assemble_llm_messages_with_cache_capability_output( + mut system_messages: Vec, + volatile_preamble: Vec, + drained_volatile: Vec, + mut compacted_messages: Vec, + attachments: &PostCompactAttachments<'_>, + session_id: &str, + provider: &str, + _model_name: &str, + _thinking: &astra_turn_core::thinking_config::ThinkingConfig, + cache_capability: Option, + cache_cfg: &PromptCacheConfig, +) -> Result { + for message in compacted_messages + .iter() + .filter(|message| astra_turn_types::is_runtime_owned_message(message)) + { + validate_append_only_runtime_authority(message)?; + } + let cache_cap = astra_turn_core::cache_placement::CacheCapability::from_explicit_or_provider( + cache_capability, + provider, ); + if !cache_cap.is_valid() + || (matches!( + cache_cap.volatile_placement, + astra_turn_core::cache_placement::VolatilePlacement::AppendOnlyUserTail + ) && !crate::turn::llm::client::llm_provider_protocol(provider) + .preserves_appended_message_boundaries()) + { + return Err(AppendOnlyRuntimeAuthorityError::InvalidCacheCapability); + } + let suppress_optional_volatile = !cache_cap.should_inject_volatile_on_round(0); // Structured volatile lane (`state.volatile_pending`): drained upstream, // rendered to the provider-specific runtime-system slot. // Producers use `state.push_volatile(Kind, content)` and never touch // `state.messages[]` for volatile content, so `messages[]` stays byte- // stable across rounds. The runtime system message is wire-only and never // becomes canonical user/tool history. - let mut runtime_system_messages = volatile_preamble - .into_iter() - .filter(|message| !suppress_volatile || is_required_runtime_preamble(message)) - .filter_map(runtime_system_context_from_message) - .collect::>(); + // The invariant focus rule belongs to the stable leading system lane. + // Required runtime context remains separate and follows the capability's + // physical placement; this prevents a required tail from dragging stable + // policy out of the cacheable prefix. + if suppress_optional_volatile { + append_required_only_focus_policy(&mut system_messages); + } + if matches!( + cache_cap.volatile_placement, + astra_turn_core::cache_placement::VolatilePlacement::AppendOnlyUserTail + ) { + ensure_append_only_runtime_authority_policy(&mut system_messages); + } + let mut runtime_system_messages = Vec::new(); + runtime_system_messages.extend( + volatile_preamble + .into_iter() + .filter(|message| { + !suppress_optional_volatile || is_prompt_visible_under_required_only(message) + }) + .filter_map(runtime_system_context_from_message), + ); runtime_system_messages.extend( render_drained_volatile_messages(&drained_volatile) .into_iter() .filter(|message| { - !suppress_volatile || is_prompt_visible_under_strict_history(message) + !suppress_optional_volatile || is_prompt_visible_under_required_only(message) }), ); runtime_system_messages.extend( take_runtime_system_context_messages(&mut compacted_messages) .into_iter() - .filter(|message| !suppress_volatile || is_required_runtime_preamble(message)), + .filter(|message| { + !suppress_optional_volatile || is_prompt_visible_under_required_only(message) + }), ); if !attachments.invoked_skills.is_empty() { @@ -856,15 +1338,56 @@ pub(crate) fn assemble_llm_messages_with_cache_capability( } let built = builder.build(); runtime_system_messages.extend(built.to_messages().into_iter().filter_map(|message| { - message + let skill_name = message + .pointer("/attachment_metadata/name") + .and_then(Value::as_str)? + .to_string(); + let mut message = message .get("content") .and_then(Value::as_str) - .and_then(|content| runtime_system_context_message(content, true)) + .and_then(|content| runtime_system_context_message(content, true))?; + let authority_kind = format!("{INVOKED_SKILLS_CONTEXT_KIND_PREFIX}:{skill_name}"); + mark_runtime_authority( + &mut message, + &authority_kind, + RUNTIME_AUTHORITY_CURRENT_USER_TURN, + ); + Some(message) })); } + // Re-homed authority precedes live source projections. If the same kind + // is rebuilt or updated in this round, the latest source-owned value is + // the sole authority sent to the provider. + retain_latest_runtime_system_authority_by_kind(&mut runtime_system_messages); + + let mut new_append_only_runtime_messages = Vec::new(); + if matches!( + cache_cap.volatile_placement, + astra_turn_core::cache_placement::VolatilePlacement::AppendOnlyUserTail + ) { + let mut remaining_runtime_system_messages = Vec::new(); + for message in runtime_system_messages { + if is_required_runtime_preamble(&message) { + let message = into_append_only_runtime_authority(message)?; + if !append_only_runtime_authority_is_redundant(&compacted_messages, &message) { + new_append_only_runtime_messages.push(message); + } + } else { + // Optional delivery is an independent capability dimension. + // When explicitly enabled it remains a non-durable system + // suffix; only required runtime authority may enter the + // append-only provenance lane. + remaining_runtime_system_messages.push(message); + } + } + runtime_system_messages = remaining_runtime_system_messages; + } let mut llm_messages = system_messages; llm_messages.extend(compacted_messages); + let append_only_runtime_start = + (!new_append_only_runtime_messages.is_empty()).then_some(llm_messages.len()); + llm_messages.extend(new_append_only_runtime_messages.iter().cloned()); let runtime_system_start = if runtime_system_messages.is_empty() { None } else if matches!( @@ -878,25 +1401,26 @@ pub(crate) fn assemble_llm_messages_with_cache_capability( insert_runtime_system_context( &mut llm_messages, runtime_system_messages, - cache_cap.volatile_placement, + if matches!( + cache_cap.volatile_placement, + astra_turn_core::cache_placement::VolatilePlacement::AppendOnlyUserTail + ) { + astra_turn_core::cache_placement::VolatilePlacement::TailSuffix + } else { + cache_cap.volatile_placement + }, ) }; - let reasoning_policy = astra_turn_core::edge_ledger::ReasoningReplayPolicy::infer( - &llm_messages, - thinking, - provider, - model_name, - ); - astra_turn_core::edge_ledger::strip_stale_reasoning_with_policy( - &mut llm_messages, - &reasoning_policy, - ); - // Keep Anthropic's existing message-level cache boundary on the last stable // message before runtime context. This preserves the pre-#629 marker logic; // only the runtime message's role and placement change here. if cache_cfg.should_annotate() { - if let Some(prefix_end) = runtime_system_start { + let prefix_end = match (runtime_system_start, append_only_runtime_start) { + (Some(system_start), Some(append_start)) => Some(system_start.min(append_start)), + (Some(start), None) | (None, Some(start)) => Some(start), + (None, None) => None, + }; + if let Some(prefix_end) = prefix_end { apply_anthropic_cache_metadata(&mut llm_messages[..prefix_end], cache_cfg, session_id); } else { apply_anthropic_cache_metadata(&mut llm_messages, cache_cfg, session_id); @@ -906,7 +1430,10 @@ pub(crate) fn assemble_llm_messages_with_cache_capability( astra_core::history_work::HistoryWorkSite::ProviderWireAssembly, &llm_messages, ); - llm_messages + Ok(LlmMessageAssembly { + messages: llm_messages, + new_append_only_runtime_messages, + }) } #[cfg(test)] @@ -963,23 +1490,7 @@ fn render_drained_volatile_messages( payload: inj.payload.clone(), round_index: inj.round_index, }; - let Some(text) = edge_injection.render_for_prompt() else { - continue; - }; - let delivery_class = inj.kind.delivery_class(); - let message = match delivery_class { - astra_turn_core::chat_turn_edge_profile::VolatileDeliveryClass::RequiredContext => { - runtime_system_context_message(&text, true) - } - astra_turn_core::chat_turn_edge_profile::VolatileDeliveryClass::DecisionFeedback => { - decision_feedback_preamble_message(&text) - } - astra_turn_core::chat_turn_edge_profile::VolatileDeliveryClass::AdvisoryEvidence => { - runtime_system_context_message(&text, false) - } - astra_turn_core::chat_turn_edge_profile::VolatileDeliveryClass::TelemetryOnly => None, - }; - if let Some(message) = message { + if let Some(message) = runtime_volatile_preamble_message(&edge_injection) { out.push(message); } } @@ -992,11 +1503,44 @@ mod tests { use serde_json::json; fn cache_cfg() -> PromptCacheConfig { - PromptCacheConfig::latch("openai", "gpt-4") + PromptCacheConfig::latch("openai") } fn anthropic_cache_cfg() -> PromptCacheConfig { - PromptCacheConfig::latch("anthropic", "claude-sonnet-4") + PromptCacheConfig::latch("anthropic") + } + + fn required_only_tail_capability() -> astra_turn_core::cache_placement::CacheCapability { + astra_turn_core::cache_placement::CacheCapability { + protocol: astra_turn_core::cache_placement::CacheProtocol::OpenAiAutoPrefix, + volatile_placement: astra_turn_core::cache_placement::VolatilePlacement::TailSuffix, + volatile_delivery: + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: Some(astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns), + } + } + + fn append_only_required_capability() -> astra_turn_core::cache_placement::CacheCapability { + astra_turn_core::cache_placement::CacheCapability { + protocol: astra_turn_core::cache_placement::CacheProtocol::OpenAiAutoPrefix, + volatile_placement: + astra_turn_core::cache_placement::VolatilePlacement::AppendOnlyUserTail, + volatile_delivery: + astra_turn_core::cache_placement::VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: Some(astra_turn_core::cache_placement::CacheReuseScope::ConversationTurns), + } + } + + fn settlement( + round_index: u32, + signal: &str, + ) -> crate::turn::agentic_loop::host::VolatileInjection { + crate::turn::agentic_loop::host::VolatileInjection { + kind: crate::turn::agentic_loop::host::VolatileKind::FinalAnswerSettlement, + payload: json!({"signal": signal}), + round_index, + attempt_leased: false, + } } fn message_text(message: &Value) -> String { @@ -1011,6 +1555,629 @@ mod tests { } } + #[test] + fn append_only_settlement_extends_the_previous_provider_prefix() { + let system = vec![json!({"role": "system", "content": "stable rules"})]; + let human_user = json!({"role": "user", "content": "finish the implementation"}); + let first = assemble_llm_messages_with_cache_capability_output( + system.clone(), + Vec::new(), + vec![settlement(8, "post_mutation_observation_missing")], + vec![human_user.clone()], + &PostCompactAttachments::default(), + "sid", + "openai", + "model", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(append_only_required_capability()), + &cache_cfg(), + ) + .unwrap(); + assert_eq!(first.new_append_only_runtime_messages.len(), 1); + let frame = &first.new_append_only_runtime_messages[0]; + assert_eq!(frame["role"], "user"); + assert_eq!( + astra_turn_types::runtime_authority_lifetime(frame), + Some(astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision) + ); + let framed_content = message_text(frame); + assert!(framed_content.starts_with("\n")); + assert!(framed_content.ends_with("\n")); + assert_eq!( + framed_content.matches("").count(), + 1 + ); + + let previous_provider_messages = + crate::turn::llm::client::consolidate_system_messages_for_provider( + &first.messages, + "openai", + Some(append_only_required_capability()), + ); + let mut history = vec![human_user]; + history.extend(first.new_append_only_runtime_messages); + history.push(json!({ + "role": "assistant", + "content": "", + "tool_calls": [{"id": "verify-1", "type": "function", "function": {"name": "read_file", "arguments": "{}"}}] + })); + history.push(json!({"role": "tool", "tool_call_id": "verify-1", "content": "verified"})); + let second = assemble_llm_messages_with_cache_capability_output( + system, + Vec::new(), + vec![settlement(9, "completion_action_still_pending")], + history, + &PostCompactAttachments::default(), + "sid", + "openai", + "model", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(append_only_required_capability()), + &cache_cfg(), + ) + .unwrap(); + + let second_provider_messages = + crate::turn::llm::client::consolidate_system_messages_for_provider( + &second.messages, + "openai", + Some(append_only_required_capability()), + ); + assert!(second_provider_messages.starts_with(&previous_provider_messages)); + assert_eq!( + second + .messages + .iter() + .take_while(|message| message["role"] == "system") + .count(), + 1 + ); + assert_eq!( + second_provider_messages[previous_provider_messages.len() - 1], + previous_provider_messages[previous_provider_messages.len() - 1], + "the prior control frame must remain at its original prefix position" + ); + assert!(second_provider_messages.iter().all(|message| { + message + .get(astra_turn_types::RUNTIME_MESSAGE_PROVENANCE_FIELD) + .is_none() + })); + } + + #[test] + fn append_only_lifetimes_dedupe_only_while_the_prior_frame_is_active() { + let system = vec![json!({"role": "system", "content": "stable rules"})]; + let human_user = json!({"role": "user", "content": "finish"}); + let initial = assemble_llm_messages_with_cache_capability_output( + system.clone(), + Vec::new(), + vec![settlement(8, "verify")], + vec![human_user.clone()], + &PostCompactAttachments::default(), + "sid", + "explicit-shape-provider", + "model", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(append_only_required_capability()), + &cache_cfg(), + ) + .unwrap(); + let frame = initial.new_append_only_runtime_messages[0].clone(); + + let transport_retry = assemble_llm_messages_with_cache_capability_output( + system.clone(), + Vec::new(), + vec![settlement(8, "verify")], + vec![human_user.clone(), frame.clone()], + &PostCompactAttachments::default(), + "sid", + "explicit-shape-provider", + "model", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(append_only_required_capability()), + &cache_cfg(), + ) + .unwrap(); + assert!(transport_retry.new_append_only_runtime_messages.is_empty()); + + let consumed_by_assistant = assemble_llm_messages_with_cache_capability_output( + system.clone(), + Vec::new(), + vec![settlement(8, "verify")], + vec![ + human_user.clone(), + frame.clone(), + json!({"role": "assistant", "content": "I checked"}), + ], + &PostCompactAttachments::default(), + "sid", + "explicit-shape-provider", + "model", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(append_only_required_capability()), + &cache_cfg(), + ) + .unwrap(); + assert_eq!( + consumed_by_assistant.new_append_only_runtime_messages.len(), + 1, + "the next-assistant-decision lifetime ends at an assistant frame" + ); + + let consumed = assemble_llm_messages_with_cache_capability_output( + system, + Vec::new(), + vec![settlement(8, "verify")], + vec![ + human_user, + frame.clone(), + json!({"role": "assistant", "content": "I checked"}), + json!({"role": "user", "content": "new human goal"}), + ], + &PostCompactAttachments::default(), + "sid", + "explicit-shape-provider", + "model", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(append_only_required_capability()), + &cache_cfg(), + ) + .unwrap(); + assert_eq!(consumed.new_append_only_runtime_messages.len(), 1); + let old_index = consumed + .messages + .iter() + .position(|message| message == &frame) + .expect("old control frame remains in history"); + let new_user_index = consumed + .messages + .iter() + .position(|message| message["content"] == "new human goal") + .expect("new human goal"); + assert!(old_index < new_user_index); + } + + #[test] + fn malformed_required_authority_is_a_contract_error_not_a_wire_fallback() { + let untyped_required = runtime_system_context_message("must be observed", true).unwrap(); + let error = assemble_llm_messages_with_cache_capability_output( + vec![json!({"role": "system", "content": "stable rules"})], + vec![untyped_required], + Vec::new(), + vec![json!({"role": "user", "content": "finish"})], + &PostCompactAttachments::default(), + "sid", + "openai", + "model", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(append_only_required_capability()), + &cache_cfg(), + ) + .unwrap_err(); + + assert_eq!(error, AppendOnlyRuntimeAuthorityError::MissingKind); + } + + #[test] + fn persisted_unknown_runtime_delivery_is_rejected_before_provider_wire() { + let malformed = json!({ + "role": "user", + "content": "future runtime control", + astra_turn_types::RUNTIME_MESSAGE_PROVENANCE_FIELD: { + "producer": "runtime", + "delivery": "future_delivery", + }, + }); + let error = assemble_llm_messages_with_cache_capability_output( + vec![json!({"role": "system", "content": "stable rules"})], + Vec::new(), + Vec::new(), + vec![json!({"role": "user", "content": "finish"}), malformed], + &PostCompactAttachments::default(), + "sid", + "openai", + "alias", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(append_only_required_capability()), + &cache_cfg(), + ) + .unwrap_err(); + + assert_eq!( + error, + AppendOnlyRuntimeAuthorityError::MissingOrInvalidDelivery + ); + } + + #[test] + fn same_provider_rejects_corrupted_persisted_append_only_frames() { + let mut system = runtime_system_context_message("authority", true).unwrap(); + mark_runtime_authority( + &mut system, + "final_answer_settlement", + RUNTIME_AUTHORITY_NEXT_DECISION, + ); + let valid = into_append_only_runtime_authority(system).unwrap(); + let mut wrong_role = valid.clone(); + wrong_role["role"] = json!("system"); + let mut wrong_lifetime = valid.clone(); + wrong_lifetime[astra_turn_types::RUNTIME_MESSAGE_PROVENANCE_FIELD]["authority_lifetime"] = + json!("future_lifetime"); + let mut mismatched_header = valid; + mismatched_header["content"] = json!( + "\n{\"kind\":\"different\",\"lifetime\":\"next_assistant_decision\",\"schema\":\"runtime_authority_frame.v1\"}\nauthority\n" + ); + + for (frame, expected) in [ + ( + wrong_role, + AppendOnlyRuntimeAuthorityError::InvalidProviderRole, + ), + ( + wrong_lifetime, + AppendOnlyRuntimeAuthorityError::MissingOrInvalidLifetime, + ), + ( + mismatched_header, + AppendOnlyRuntimeAuthorityError::MalformedFrameContent, + ), + ] { + let error = assemble_llm_messages_with_cache_capability_output( + vec![json!({"role": "system", "content": "stable rules"})], + Vec::new(), + Vec::new(), + vec![json!({"role": "user", "content": "finish"}), frame], + &PostCompactAttachments::default(), + "sid", + "openai", + "alias", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(append_only_required_capability()), + &cache_cfg(), + ) + .unwrap_err(); + assert_eq!(error, expected); + } + } + + #[test] + fn append_only_shape_rejects_optional_volatile_delivery() { + let capability = astra_turn_core::cache_placement::CacheCapability { + volatile_delivery: astra_turn_core::cache_placement::VolatileDeliveryPolicy::All, + ..append_only_required_capability() + }; + let error = assemble_llm_messages_with_cache_capability_output( + vec![json!({"role": "system", "content": "stable rules"})], + vec![runtime_system_context_message("changing optional", false).unwrap()], + Vec::new(), + vec![json!({"role": "user", "content": "finish"})], + &PostCompactAttachments::default(), + "sid", + "openai", + "alias", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(capability), + &cache_cfg(), + ) + .unwrap_err(); + + assert_eq!( + error, + AppendOnlyRuntimeAuthorityError::InvalidCacheCapability + ); + } + + #[test] + fn provider_switch_rehomes_only_unconsumed_append_only_authority() { + let mut active_system = runtime_system_context_message("active", true).unwrap(); + mark_runtime_authority( + &mut active_system, + "final_answer_settlement", + RUNTIME_AUTHORITY_NEXT_DECISION, + ); + let active_frame = into_append_only_runtime_authority(active_system).unwrap(); + let human = json!({"role": "user", "content": "finish"}); + + let mut active_history = vec![human.clone(), active_frame.clone()]; + let rehomed = rehome_append_only_runtime_authority(&mut active_history).unwrap(); + assert_eq!(active_history, vec![human.clone()]); + assert_eq!(rehomed.len(), 1); + assert_eq!(rehomed[0]["role"], "system"); + assert_eq!(rehomed[0]["content"], "active"); + assert!(is_required_runtime_preamble(&rehomed[0])); + + let mut consumed_history = vec![ + human, + active_frame, + json!({"role": "assistant", "content": "observed"}), + ]; + let rehomed = rehome_append_only_runtime_authority(&mut consumed_history).unwrap(); + assert!(rehomed.is_empty()); + assert_eq!(consumed_history.len(), 2); + assert_eq!(consumed_history[1]["role"], "assistant"); + } + + #[test] + fn current_turn_required_context_and_skill_attachment_do_not_grow_each_round() { + let required = required_runtime_preamble_message( + "project authority", + RuntimeAuthorityKind::EdgeRequiredContext, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, + ) + .unwrap(); + let attachments = PostCompactAttachments { + invoked_skills: vec![InvokedSkillRef { + name: "review", + content: "stable checklist", + }], + }; + let first = assemble_llm_messages_with_cache_capability_output( + vec![json!({"role": "system", "content": "stable rules"})], + vec![ + required_runtime_preamble_message( + "project authority", + RuntimeAuthorityKind::EdgeRequiredContext, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, + ) + .unwrap(), + ], + Vec::new(), + vec![json!({"role": "user", "content": "review it"})], + &attachments, + "sid", + "explicit-shape-provider", + "model", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(append_only_required_capability()), + &cache_cfg(), + ) + .unwrap(); + assert_eq!(first.new_append_only_runtime_messages.len(), 2); + let mut history = vec![json!({"role": "user", "content": "review it"})]; + history.extend(first.new_append_only_runtime_messages); + history.push(json!({"role": "assistant", "content": "working"})); + + let next = assemble_llm_messages_with_cache_capability_output( + vec![json!({"role": "system", "content": "stable rules"})], + vec![required], + Vec::new(), + history, + &attachments, + "sid", + "explicit-shape-provider", + "model", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(append_only_required_capability()), + &cache_cfg(), + ) + .unwrap(); + assert!(next.new_append_only_runtime_messages.is_empty()); + } + + #[test] + fn append_only_keeps_independent_authorities_and_dedupes_only_same_source() { + let contexts = vec![ + required_runtime_preamble_message( + "edge revision 1", + RuntimeAuthorityKind::EdgeRequiredContext, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, + ) + .unwrap(), + required_runtime_preamble_message( + "attempt contract", + RuntimeAuthorityKind::ActiveWorkAttemptStart, + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ) + .unwrap(), + required_runtime_preamble_message( + "pending graph mutation", + RuntimeAuthorityKind::PendingWorkGraphMutations, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, + ) + .unwrap(), + required_runtime_preamble_message( + "edge revision 2", + RuntimeAuthorityKind::EdgeRequiredContext, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, + ) + .unwrap(), + required_runtime_preamble_message( + "read-only boundary", + RuntimeAuthorityKind::ReadOnlyEffectBoundary, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, + ) + .unwrap(), + required_runtime_preamble_message( + "final synthesis", + RuntimeAuthorityKind::FinalWorkSynthesis, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, + ) + .unwrap(), + ]; + + let output = assemble_llm_messages_with_cache_capability_output( + vec![json!({"role": "system", "content": "stable rules"})], + contexts, + Vec::new(), + vec![json!({"role": "user", "content": "do the work"})], + &PostCompactAttachments::default(), + "sid", + "openai", + "arbitrary-deployment", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(append_only_required_capability()), + &cache_cfg(), + ) + .unwrap(); + + let frames = &output.new_append_only_runtime_messages; + assert_eq!(frames.len(), 5, "only the older edge revision is redundant"); + let kinds = frames + .iter() + .filter_map(astra_turn_types::runtime_authority_kind) + .collect::>(); + assert_eq!( + kinds, + std::collections::HashSet::from([ + "edge_required_context", + "active_work_attempt_start", + "pending_work_graph_mutations", + "read_only_effect_boundary", + "final_work_synthesis", + ]) + ); + assert!( + frames + .iter() + .all(|frame| !message_text(frame).contains("edge revision 1")) + ); + assert!( + frames + .iter() + .any(|frame| message_text(frame).contains("edge revision 2")) + ); + let attempt = frames + .iter() + .find(|frame| { + astra_turn_types::runtime_authority_kind(frame) == Some("active_work_attempt_start") + }) + .expect("attempt authority"); + assert_eq!( + astra_turn_types::runtime_authority_lifetime(attempt), + Some(astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision) + ); + for frame in frames.iter().filter(|frame| !std::ptr::eq(*frame, attempt)) { + assert_eq!( + astra_turn_types::runtime_authority_lifetime(frame), + Some(astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn) + ); + } + } + + #[test] + fn multiple_invoked_skills_survive_tail_and_append_only_projection() { + let attachments = PostCompactAttachments { + invoked_skills: vec![ + InvokedSkillRef { + name: "review", + content: "review contract", + }, + InvokedSkillRef { + name: "benchmark", + content: "benchmark contract", + }, + ], + }; + for capability in [ + required_only_tail_capability(), + append_only_required_capability(), + ] { + let first = assemble_llm_messages_with_cache_capability_output( + vec![json!({"role": "system", "content": "stable"})], + Vec::new(), + Vec::new(), + vec![json!({"role": "user", "content": "run both"})], + &attachments, + "sid", + "openai", + "alias", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(capability), + &cache_cfg(), + ) + .unwrap(); + let all_text = first.messages.iter().map(message_text).collect::(); + assert!(all_text.contains("review contract")); + assert!(all_text.contains("benchmark contract")); + + if matches!( + capability.volatile_placement, + astra_turn_core::cache_placement::VolatilePlacement::AppendOnlyUserTail + ) { + assert_eq!(first.new_append_only_runtime_messages.len(), 2); + let mut history = vec![json!({"role": "user", "content": "run both"})]; + history.extend(first.new_append_only_runtime_messages); + let retry = assemble_llm_messages_with_cache_capability_output( + vec![json!({"role": "system", "content": "stable"})], + Vec::new(), + Vec::new(), + history, + &attachments, + "sid", + "openai", + "alias", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(capability), + &cache_cfg(), + ) + .unwrap(); + assert!(retry.new_append_only_runtime_messages.is_empty()); + } + } + } + + #[test] + fn append_only_accumulative_runtime_facts_have_distinct_retry_stable_identities() { + let facts = vec![ + crate::turn::agentic_loop::host::VolatileInjection { + kind: crate::turn::agentic_loop::host::VolatileKind::BackgroundTaskNotification, + payload: json!({"agent_id": "agent-a", "status": "complete"}), + round_index: 3, + attempt_leased: false, + }, + crate::turn::agentic_loop::host::VolatileInjection { + kind: crate::turn::agentic_loop::host::VolatileKind::BackgroundTaskNotification, + payload: json!({"agent_id": "agent-b", "status": "failed"}), + round_index: 3, + attempt_leased: false, + }, + ]; + let first = assemble_llm_messages_with_cache_capability_output( + vec![json!({"role": "system", "content": "stable"})], + Vec::new(), + facts.clone(), + vec![json!({"role": "user", "content": "continue"})], + &PostCompactAttachments::default(), + "sid", + "openai", + "alias", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(append_only_required_capability()), + &cache_cfg(), + ) + .unwrap(); + assert_eq!(first.new_append_only_runtime_messages.len(), 2); + let kinds = first + .new_append_only_runtime_messages + .iter() + .filter_map(astra_turn_types::runtime_authority_kind) + .collect::>(); + assert_eq!(kinds.len(), 2); + assert!( + kinds + .iter() + .all(|kind| kind.starts_with("background_task_notification:sha256:")) + ); + + let mut history = vec![json!({"role": "user", "content": "continue"})]; + history.extend(first.new_append_only_runtime_messages); + let retry = assemble_llm_messages_with_cache_capability_output( + vec![json!({"role": "system", "content": "stable"})], + Vec::new(), + facts, + history, + &PostCompactAttachments::default(), + "sid", + "openai", + "alias", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(append_only_required_capability()), + &cache_cfg(), + ) + .unwrap(); + assert!(retry.new_append_only_runtime_messages.is_empty()); + } + #[test] fn context_compaction_observation_preserves_typed_wire_facts() { use crate::turn::cloud::compaction::{CompactBoundary, CompactResult, CompactTrigger}; @@ -1711,6 +2878,30 @@ mod tests { ); } + #[test] + fn continuation_prompt_appends_after_runtime_owned_user_tail() { + let mut runtime_tail = json!({ + "role": "user", + "content": "control" + }); + astra_turn_types::mark_append_only_required_context( + &mut runtime_tail, + "completion_settlement", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ); + let mut msgs = vec![ + json!({"role": "user", "content": "goal"}), + json!({"role": "assistant", "content": "partial"}), + runtime_tail, + ]; + + maybe_append_continuation_prompt(&mut msgs, true); + + assert_eq!(msgs.len(), 4); + assert_eq!(msgs[3]["role"], "system"); + assert_eq!(msgs[3]["content"], COMPACTION_CONTEXT_NOTE); + } + #[test] fn continuation_prompt_does_not_classify_assistant_completion_prose() { let mut msgs = vec![ @@ -1929,7 +3120,7 @@ mod tests { "claude-sonnet-4", &astra_turn_core::thinking_config::ThinkingConfig::Off, None, - &PromptCacheConfig::latch("anthropic", "claude-sonnet-4"), + &PromptCacheConfig::latch("anthropic"), ); let server_out = assemble_llm_messages_with_cache_capability( system, @@ -1947,7 +3138,7 @@ mod tests { "claude-sonnet-4", &astra_turn_core::thinking_config::ThinkingConfig::Off, None, - &PromptCacheConfig::latch("anthropic", "claude-sonnet-4"), + &PromptCacheConfig::latch("anthropic"), ); // Both paths must emit well-formed message arrays; the last message @@ -1972,7 +3163,7 @@ mod tests { "gpt-4o", &astra_turn_core::thinking_config::ThinkingConfig::Off, None, - &PromptCacheConfig::latch("openai", "gpt-4o"), + &PromptCacheConfig::latch("openai"), ); assert!( @@ -2091,8 +3282,12 @@ mod tests { #[test] fn required_runtime_context_keeps_system_authority() { let system = vec![json!({"role": "system", "content": "sys"})]; - let required = - required_runtime_preamble_message("required resume context").expect("required message"); + let required = required_runtime_preamble_message( + "required resume context", + RuntimeAuthorityKind::EdgeRequiredContext, + astra_turn_types::RuntimeAuthorityLifetime::CurrentUserTurn, + ) + .expect("required message"); let compacted = vec![json!({"role": "user", "content": "hi"})]; let msgs = assemble_llm_messages_with_cache_capability( @@ -2122,6 +3317,7 @@ mod tests { kind: crate::turn::agentic_loop::host::VolatileKind::SelfStatus, payload: json!("## ⚡ Self-Status\nTurn 9/299 | Cache: 86%"), round_index: 9, + attempt_leased: false, }]; let compacted = vec![json!({"role": "user", "content": "相关的测试够硬核吗?"})]; let msgs = assemble_llm_messages_with_cache_capability( @@ -2158,6 +3354,7 @@ mod tests { }] }), round_index: 2, + attempt_leased: false, }]; let compacted = vec![json!({"role": "user", "content": "fix the failing tests"})]; let msgs = assemble_llm_messages_with_cache_capability( @@ -2202,6 +3399,7 @@ mod tests { "active_goal": "相关的测试够硬核吗?" }), round_index: 3, + attempt_leased: false, }]; let compacted = vec![ json!({"role": "user", "content": "一共多少 changes?"}), @@ -2438,13 +3636,14 @@ mod tests { } #[test] - fn current_user_only_models_keep_typed_decision_feedback_only() { + fn required_only_delivery_suppresses_round_specific_decision_feedback() { let system = vec![json!({"role": "system", "content": "sys"})]; let preamble = vec![json!({"role": "system", "content": "volatile"})]; let drained = vec![crate::turn::agentic_loop::host::VolatileInjection { kind: crate::turn::agentic_loop::host::VolatileKind::PolicyAdvisory, payload: json!("optional policy advisory"), round_index: 1, + attempt_leased: false, }]; let compacted = vec![ json!({"role": "user", "content": "hi"}), @@ -2459,20 +3658,18 @@ mod tests { &PostCompactAttachments::default(), "sid", "openai", - "deepseek-v4-flash", + "deployment-alias", &astra_turn_core::thinking_config::ThinkingConfig::Off, - None, + Some(required_only_tail_capability()), &cache_cfg(), ); - assert_eq!(msgs.len(), 5, "typed decision feedback must remain visible"); + assert_eq!(msgs.len(), 4, "advisory feedback must not churn the prefix"); assert_eq!(msgs[0]["role"], "system"); - assert_eq!(msgs[1]["role"], "system"); - assert!(message_text(&msgs[1]).contains("optional policy advisory")); - assert!(message_text(&msgs[1]).contains("")); - assert_eq!(msgs[2]["role"], "user"); - assert_eq!(msgs[2]["content"], "hi"); - assert_eq!(msgs[3]["role"], "assistant"); - assert_eq!(msgs[4]["role"], "tool"); + assert!(message_text(&msgs[0]).contains("active_turn_focus_policy.v1")); + assert_eq!(msgs[1]["role"], "user"); + assert_eq!(msgs[1]["content"], "hi"); + assert_eq!(msgs[2]["role"], "assistant"); + assert_eq!(msgs[3]["role"], "tool"); assert!( msgs.iter().all(|message| { !message @@ -2485,19 +3682,25 @@ mod tests { .and_then(Value::as_str) .unwrap_or_default() .contains("volatile") + && !message + .get("content") + .and_then(Value::as_str) + .unwrap_or_default() + .contains("optional policy advisory") }), - "CurrentUserOnly providers must drop untyped optional volatile content" + "RequiredOnly delivery must drop all optional volatile content" ); } #[test] - fn current_user_only_models_keep_required_typed_runtime_as_system() { + fn required_only_delivery_keeps_required_typed_runtime_as_system() { let system = vec![json!({"role": "system", "content": "sys"})]; let preamble = vec![json!({"role": "system", "content": "volatile"})]; let drained = vec![crate::turn::agentic_loop::host::VolatileInjection { - kind: crate::turn::agentic_loop::host::VolatileKind::ActiveTurnFrame, - payload: json!({"latest_user_goal": "latest user goal"}), + kind: crate::turn::agentic_loop::host::VolatileKind::BudgetAdvisory, + payload: json!({"instruction": "finish with verified evidence"}), round_index: 1, + attempt_leased: false, }]; let compacted = vec![json!({"role": "user", "content": "hi"})]; @@ -2509,19 +3712,126 @@ mod tests { &PostCompactAttachments::default(), "sid", "openai", - "deepseek-v4-flash", + "deployment-alias", &astra_turn_core::thinking_config::ThinkingConfig::Off, - None, + Some(required_only_tail_capability()), &cache_cfg(), ); assert_eq!(msgs.len(), 3); + assert!(message_text(&msgs[0]).contains("active_turn_focus_policy.v1")); assert_eq!(msgs[1]["role"], "system"); let runtime_text = message_text(&msgs[1]); assert!(runtime_text.contains("")); - assert!(runtime_text.contains("\"kind\":\"active_turn_frame\"")); - assert!(runtime_text.contains("latest user goal")); + assert!(runtime_text.contains("\"kind\":\"budget_advisory\"")); + assert!(runtime_text.contains("finish with verified evidence")); assert!(!runtime_text.contains("volatile")); assert_eq!(msgs[2], json!({"role": "user", "content": "hi"})); } + + #[test] + fn declared_required_only_prefix_keeps_completion_authority_out_of_leading_system() { + let compacted = vec![ + json!({"role": "user", "content": "finish the change"}), + json!({ + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "c1", + "type": "function", + "function": {"name": "bash", "arguments": "{}"} + }] + }), + json!({"role": "tool", "tool_call_id": "c1", "content": "ok"}), + ]; + let assemble = |drained| { + let internal = assemble_llm_messages_with_cache_capability( + vec![json!({"role": "system", "content": "stable contract"})], + Vec::new(), + drained, + compacted.clone(), + &PostCompactAttachments::default(), + "sid", + "openai", + "deployment-alias", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(required_only_tail_capability()), + &cache_cfg(), + ); + crate::turn::llm::client::consolidate_system_messages_for_provider( + &internal, + "openai", + Some(required_only_tail_capability()), + ) + }; + + let baseline = assemble(Vec::new()); + let settlement = assemble(vec![crate::turn::agentic_loop::host::VolatileInjection { + kind: crate::turn::agentic_loop::host::VolatileKind::FinalAnswerSettlement, + payload: json!({ + "schema": "completion_settlement.v2", + "mode": "text_only", + "instruction": "answer now" + }), + round_index: 4, + attempt_leased: false, + }]); + + assert_eq!(baseline[0], settlement[0]); + assert!(message_text(&baseline[0]).contains("active_turn_focus_policy.v1")); + let settlement_index = settlement + .iter() + .position(|message| { + message.get("role").and_then(Value::as_str) == Some("system") + && message_text(message).contains("completion_settlement.v2") + }) + .expect("required completion settlement remains provider-visible"); + assert_eq!(settlement_index, settlement.len() - 1); + assert_eq!(settlement[settlement_index - 1]["role"], "tool"); + } + + #[test] + fn required_only_delivery_projects_dynamic_frames_to_one_stable_focus_policy() { + let assemble = |latest: &str, prior: &str, turn_id: u64| { + let drained = vec![crate::turn::agentic_loop::host::VolatileInjection { + kind: crate::turn::agentic_loop::host::VolatileKind::ActiveTurnFrame, + payload: json!({ + "latest_user_message": latest, + "active_goal": latest, + "immediate_prior_user_request": prior, + "turn_id": turn_id, + "round_id": turn_id + 10 + }), + round_index: turn_id as u32, + attempt_leased: false, + }]; + assemble_llm_messages_with_cache_capability( + vec![json!({"role": "system", "content": "stable"})], + Vec::new(), + drained, + vec![json!({"role": "user", "content": latest})], + &PostCompactAttachments::default(), + "sid", + "openai", + "deployment-alias", + &astra_turn_core::thinking_config::ThinkingConfig::Off, + Some(required_only_tail_capability()), + &cache_cfg(), + ) + }; + + let first = assemble("Reply ACK", "first request", 2); + let second = assemble("问题总结?", "只读 review", 9); + assert_eq!(first.len(), 2); + assert_eq!(second.len(), 2); + assert_eq!(message_text(&first[0]), message_text(&second[0])); + assert!(message_text(&first[0]).starts_with("stable\n\n")); + let focus_policy = message_text(&first[0]); + assert!(focus_policy.contains("active_turn_focus_policy.v1")); + for dynamic in ["Reply ACK", "first request", "问题总结?", "只读 review"] { + assert!(!focus_policy.contains(dynamic)); + } + assert_eq!(first[1], json!({"role": "user", "content": "Reply ACK"})); + assert_eq!(second[1], json!({"role": "user", "content": "问题总结?"})); + } } diff --git a/crates/runtime/tests/cache_provider_matrix_e2e.rs b/crates/runtime/tests/cache_provider_matrix_e2e.rs index ce4c67e2b4..ab734f43a8 100644 --- a/crates/runtime/tests/cache_provider_matrix_e2e.rs +++ b/crates/runtime/tests/cache_provider_matrix_e2e.rs @@ -49,7 +49,9 @@ use std::sync::{Arc, Mutex}; use astra_runtime::server::server_loop_host::{CapturedLlmRequest, ServerAgenticLoopHostBuilder}; use astra_runtime::turn::agentic_loop::host::make_test_loop_state; use astra_runtime::{FernetTokenEncryptor, MatrixOneSettings}; -use astra_turn_core::cache_placement::{CacheCapability, VolatilePlacement}; +use astra_turn_core::cache_placement::{ + CacheCapability, CacheProtocol, CacheReuseScope, VolatileDeliveryPolicy, VolatilePlacement, +}; use serde_json::{Value, json}; const VALID_FERNET_KEY: &str = "cJ8pxr3t6iJmSYqe6wD7vu2rN_C3ovGUxkC5H3NXFNY="; @@ -155,6 +157,7 @@ struct ProviderCase { provider: &'static str, model: &'static str, is_marker_isolated: bool, + cache_capability: Option, } /// The five provider shapes we need to keep honest. @@ -168,42 +171,64 @@ const PROVIDER_MATRIX: &[ProviderCase] = &[ provider: "anthropic", model: "claude-sonnet-4", is_marker_isolated: true, + cache_capability: None, }, ProviderCase { label: "bedrock-claude", provider: "bedrock", model: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", is_marker_isolated: true, + cache_capability: Some(CacheCapability { + protocol: CacheProtocol::BedrockCachePoint, + volatile_placement: VolatilePlacement::MarkerIsolated, + volatile_delivery: VolatileDeliveryPolicy::All, + reuse_scope: None, + }), }, ProviderCase { label: "deepseek-anthropic", provider: "anthropic", model: "deepseek-v4-pro-anthropic", is_marker_isolated: true, + cache_capability: None, }, ProviderCase { label: "openai-gpt", provider: "openai", model: "gpt-4o", is_marker_isolated: false, + cache_capability: None, }, ProviderCase { label: "qwen-openai-compatible", provider: "openai", model: "qwen-max", is_marker_isolated: false, + cache_capability: None, }, ProviderCase { label: "deepseek-v4-openai-compatible", provider: "openai", model: "deepseek-v4-pro", is_marker_isolated: false, + cache_capability: Some(CacheCapability { + protocol: CacheProtocol::OpenAiAutoPrefix, + volatile_placement: VolatilePlacement::TailSuffix, + volatile_delivery: VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: Some(CacheReuseScope::ConversationTurns), + }), }, ProviderCase { label: "minimax", provider: "openai", model: "MiniMax-M2.7", is_marker_isolated: false, + cache_capability: Some(CacheCapability { + protocol: CacheProtocol::StrictHistoryMatch, + volatile_placement: VolatilePlacement::CurrentUserOnly, + volatile_delivery: VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: Some(CacheReuseScope::ConversationTurns), + }), }, ]; @@ -212,7 +237,7 @@ fn build_host_for( rounds: Vec, capture: Arc>>, ) -> astra_runtime::server::server_loop_host::ServerAgenticLoopHost { - ServerAgenticLoopHostBuilder::new( + let builder = ServerAgenticLoopHostBuilder::new( mock_matrixone(), mock_encryptor(), "test-user".to_string(), @@ -222,12 +247,15 @@ fn build_host_for( .with_edge_tools(sample_edge_tools()) .with_test_llm_rounds(rounds) .with_llm_request_capture(capture) - .with_mock_provider(case.provider, case.model) - .build() + .with_mock_provider(case.provider, case.model); + match case.cache_capability { + Some(capability) => builder.with_mock_cache_capability(capability).build(), + None => builder.build(), + } } fn cache_capability_for(case: ProviderCase) -> CacheCapability { - CacheCapability::for_provider_and_model(case.provider, case.model) + CacheCapability::from_explicit_or_provider(case.cache_capability, case.provider) } /// Run a single "user → reply" turn through the mock host. Leaves @@ -670,10 +698,7 @@ async fn matrix_tool_loop_growth_preserves_prefix_bytes() { assert!(!text.contains("")); } - let suppresses_volatile = matches!( - cache_capability_for(case).volatile_placement, - VolatilePlacement::CurrentUserOnly - ); + let suppresses_volatile = !cache_capability_for(case).should_inject_volatile_on_round(1); let r1_runtime_systems = messages_with_role(&r1.messages, "system"); let r2_runtime_systems = messages_with_role(&r2.messages, "system"); let r3_runtime_systems = messages_with_role(&r3.messages, "system"); @@ -743,7 +768,8 @@ async fn matrix_tool_loop_growth_preserves_prefix_bytes() { if matches!( cache_capability_for(case).volatile_placement, VolatilePlacement::TailSuffix - ) { + ) && !suppresses_volatile + { let r1_runtime = first_runtime_system_index(&r1.messages) .expect("TailSuffix round 1 must contain runtime system context"); let r2_runtime = first_runtime_system_index(&r2.messages) @@ -764,6 +790,237 @@ async fn matrix_tool_loop_growth_preserves_prefix_bytes() { } } +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial(prompt_cache_env)] +async fn deepseek_required_settlement_stays_in_provider_final_tail() { + let case = PROVIDER_MATRIX + .iter() + .copied() + .find(|case| case.label == "deepseek-v4-openai-compatible") + .expect("deepseek matrix row"); + let capture = Arc::new(Mutex::new(Vec::new())); + let mut host = build_host_for( + case, + vec![scripted_round("r1"), scripted_round("r2")], + capture.clone(), + ); + let mut state = make_test_loop_state(); + state.max_turn_input_tokens = 200_000; + state.messages.extend([ + json!({"role": "user", "content": "finish"}), + json!({ + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": {"name": "bash", "arguments": "{}"} + }] + }), + json!({"role": "tool", "tool_call_id": "call_1", "content": "done"}), + ]); + + for revision in 1..=2 { + state.push_volatile_payload( + astra_runtime::turn::agentic_loop::host::VolatileKind::FinalAnswerSettlement, + json!({ + "schema": "completion_settlement.v2", + "revision": revision, + "mode": "text_only" + }), + ); + host.run_one_mock_turn_for_test(&mut state).await.unwrap(); + if revision == 1 { + state + .messages + .push(json!({"role": "assistant", "content": "bounded reply"})); + } + } + + let guard = capture.lock().unwrap(); + assert_eq!(guard.len(), 2); + let first = &guard[0].provider_messages; + let second = &guard[1].provider_messages; + assert_eq!( + first[0], second[0], + "leading system prefix must stay byte-stable" + ); + assert!(flatten_content(&first[0]).contains("active_turn_focus_policy.v1")); + for (revision, messages) in [(1, first), (2, second)] { + let settlement_index = messages + .iter() + .position(|message| flatten_content(message).contains("completion_settlement.v2")) + .expect("required settlement remains provider-visible"); + let settlement = &messages[settlement_index]; + assert_eq!( + settlement.get("role").and_then(Value::as_str), + Some("system") + ); + assert!( + messages[..settlement_index] + .iter() + .any(|message| message.get("role").and_then(Value::as_str) == Some("tool")), + "required tail must follow the complete assistant/tool group" + ); + let text = flatten_content(settlement); + assert!(text.contains("completion_settlement.v2")); + assert!(text.contains(&format!("\"revision\":{revision}"))); + assert!( + settlement.get("__astra_runtime_system_context").is_none(), + "internal ownership marker must not reach the provider body" + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial(prompt_cache_env)] +async fn append_only_metadata_drives_two_round_provider_prefix_and_canonical_ownership() { + let case = ProviderCase { + label: "declared-append-only", + provider: "openai", + model: "deployment-alias", + is_marker_isolated: false, + cache_capability: Some(CacheCapability { + protocol: CacheProtocol::OpenAiAutoPrefix, + volatile_placement: VolatilePlacement::AppendOnlyUserTail, + volatile_delivery: VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: Some(CacheReuseScope::ConversationTurns), + }), + }; + let capture = Arc::new(Mutex::new(Vec::new())); + let mut host = build_host_for( + case, + vec![scripted_round("r1"), scripted_round("r2")], + capture.clone(), + ); + let mut state = make_test_loop_state(); + state.max_turn_input_tokens = 200_000; + state + .messages + .push(json!({"role": "user", "content": "finish"})); + + state.push_volatile_payload( + astra_runtime::turn::agentic_loop::host::VolatileKind::FinalAnswerSettlement, + json!({"schema": "completion_settlement.v2", "revision": 1}), + ); + host.run_one_mock_turn_for_test(&mut state).await.unwrap(); + let first_frame = state + .messages + .iter() + .find(|message| { + astra_turn_types::runtime_authority_kind(message) == Some("final_answer_settlement") + }) + .cloned() + .expect("first request must commit its append-only authority frame"); + state.messages.extend([ + json!({ + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "observe-1", + "type": "function", + "function": {"name": "read_file", "arguments": "{}"} + }] + }), + json!({"role": "tool", "tool_call_id": "observe-1", "content": "observed"}), + ]); + state.push_volatile_payload( + astra_runtime::turn::agentic_loop::host::VolatileKind::FinalAnswerSettlement, + json!({"schema": "completion_settlement.v2", "revision": 2}), + ); + host.run_one_mock_turn_for_test(&mut state).await.unwrap(); + + let guard = capture.lock().unwrap(); + assert_eq!(guard.len(), 2); + assert_eq!(guard[0].tools, guard[1].tools); + assert!( + guard[1] + .provider_messages + .starts_with(&guard[0].provider_messages), + "round 2 must strictly extend the exact round-1 provider messages" + ); + assert!(guard[0].provider_messages.iter().all(|message| { + message + .get(astra_turn_types::RUNTIME_MESSAGE_PROVENANCE_FIELD) + .is_none() + })); + assert_eq!( + state + .messages + .iter() + .filter(|message| astra_turn_types::is_human_user_message(message)) + .count(), + 1 + ); + assert!(state.messages.contains(&first_frame)); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial(prompt_cache_env)] +async fn provider_switch_replaces_live_authority_kind_without_leaking_frame_syntax() { + let append_case = ProviderCase { + label: "append-source", + provider: "openai", + model: "append-alias", + is_marker_isolated: false, + cache_capability: Some(CacheCapability { + protocol: CacheProtocol::OpenAiAutoPrefix, + volatile_placement: VolatilePlacement::AppendOnlyUserTail, + volatile_delivery: VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: None, + }), + }; + let append_capture = Arc::new(Mutex::new(Vec::new())); + let mut append_host = build_host_for(append_case, vec![scripted_round("r1")], append_capture); + let mut state = make_test_loop_state(); + state.max_turn_input_tokens = 200_000; + state + .messages + .push(json!({"role": "user", "content": "finish"})); + state.push_volatile_payload( + astra_runtime::turn::agentic_loop::host::VolatileKind::FinalAnswerSettlement, + json!({"schema": "completion_settlement.v2", "revision": 1}), + ); + append_host + .run_one_mock_turn_for_test(&mut state) + .await + .unwrap(); + + let tail_case = ProviderCase { + label: "tail-destination", + provider: "openai", + model: "tail-alias", + is_marker_isolated: false, + cache_capability: Some(CacheCapability { + protocol: CacheProtocol::OpenAiAutoPrefix, + volatile_placement: VolatilePlacement::TailSuffix, + volatile_delivery: VolatileDeliveryPolicy::RequiredOnly, + reuse_scope: None, + }), + }; + let tail_capture = Arc::new(Mutex::new(Vec::new())); + let mut tail_host = build_host_for(tail_case, vec![scripted_round("r2")], tail_capture.clone()); + state.push_volatile_payload( + astra_runtime::turn::agentic_loop::host::VolatileKind::FinalAnswerSettlement, + json!({"schema": "completion_settlement.v2", "revision": 2}), + ); + tail_host + .run_one_mock_turn_for_test(&mut state) + .await + .unwrap(); + + let guard = tail_capture.lock().unwrap(); + let provider_messages = &guard[0].provider_messages; + let joined = provider_messages + .iter() + .map(flatten_content) + .collect::>() + .join("\n"); + assert!(!joined.contains("")); + assert!(!joined.contains("\"revision\":1")); + assert_eq!(joined.matches("\"revision\":2").count(), 1); +} + // ── Invariant 5: trailing role=system messages don't claim the cache marker // // 5c0b9693 regression: when the runtime appends a trailing `role=system` @@ -851,10 +1108,7 @@ async fn matrix_volatile_lane_keeps_history_clean() { use astra_runtime::turn::agentic_loop::host::VolatileKind; for case in PROVIDER_MATRIX.iter().copied() { - let suppresses_volatile = matches!( - cache_capability_for(case).volatile_placement, - VolatilePlacement::CurrentUserOnly - ); + let suppresses_volatile = !cache_capability_for(case).should_inject_volatile_on_round(1); let capture = Arc::new(Mutex::new(Vec::new())); let mut host = build_host_for(case, vec![scripted_round("r1")], capture.clone()); let mut state = make_test_loop_state(); @@ -907,8 +1161,8 @@ async fn matrix_volatile_lane_keeps_history_clean() { assert!( !runtime_text.contains("⚠ REFLECTION") && !runtime_text.contains("✓ 2 tools executed") - && runtime_text.contains("runtime behavior evidence"), - "[{label}] strict-history providers must retain typed decision feedback while suppressing lower-authority optional evidence; got {runtime_text:?}", + && !runtime_text.contains("runtime behavior evidence"), + "[{label}] required-only delivery must suppress every non-authoritative volatile class; got {runtime_text:?}", label = case.label, ); } else { @@ -976,11 +1230,19 @@ async fn matrix_runtime_injections_do_not_rewrite_history() { .map(flatten_content) .collect::>() .join("\n"); - assert!( - runtime_text.contains("runtime evidence: duplicate read"), - "[{label}] typed decision feedback must be delivered with system authority; got {runtime_text:?}", - label = case.label, - ); + if cache_capability_for(case).should_inject_volatile_on_round(1) { + assert!( + runtime_text.contains("runtime evidence: duplicate read"), + "[{label}] enabled decision feedback must be delivered with system authority; got {runtime_text:?}", + label = case.label, + ); + } else { + assert!( + !runtime_text.contains("runtime evidence: duplicate read"), + "[{label}] required-only delivery must not leak optional decision feedback; got {runtime_text:?}", + label = case.label, + ); + } } } diff --git a/crates/services/src/inference_execution.rs b/crates/services/src/inference_execution.rs index 8fb03766e5..820b03e6e8 100644 --- a/crates/services/src/inference_execution.rs +++ b/crates/services/src/inference_execution.rs @@ -67,10 +67,25 @@ pub struct InferenceProviderAttemptPlan { owner_token: String, owner_generation: u64, wire: InferenceProviderWireIdentity, + canonical_transition_id: Option, + canonical_parent_transition_id: Option, + canonical_transition_json: Option, + canonical_transition_hash: Option, invocation_input: InferenceInvocationInput, request_context: ModelRequestContextSeed, } +/// Ordered write-ahead transitions committed with one physical provider +/// attempt. Coordinates come from the invocation identity, never timestamps. +#[derive(Clone, Debug, PartialEq)] +pub struct InferenceCanonicalTransitionReceipt { + pub turn: u32, + pub round: u32, + pub logical_attempt: u32, + pub physical_attempt: u32, + pub transitions: Vec, +} + /// Immutable identity of the exact serialized provider request body. /// /// The runtime constructs this only after provider-specific request assembly. @@ -151,6 +166,80 @@ impl InferenceProviderAttemptPlan { pub fn request_context(&self) -> &ModelRequestContextSeed { &self.request_context } + + #[must_use] + pub fn canonical_transition_hash(&self) -> Option<&str> { + self.canonical_transition_hash.as_deref() + } + + #[must_use] + pub fn canonical_transition_id(&self) -> Option<&str> { + self.canonical_transition_id.as_deref() + } + + /// Bind canonical append WAL entries to the same immutable admission as + /// the exact provider body. Empty means this request owns no canonical + /// append transition. + pub fn with_canonical_transitions( + mut self, + transitions: &[astra_turn_types::ProviderCanonicalTransitionV1], + ) -> ServiceResult { + if transitions.is_empty() { + self.canonical_transition_id = None; + self.canonical_parent_transition_id = None; + self.canonical_transition_json = None; + self.canonical_transition_hash = None; + return Ok(self); + } + if self.invocation_input.purpose != InferencePurpose::PrimaryAgent + || !matches!( + self.invocation_input.scope, + InferenceInvocationScope::Run { .. } + ) + { + return Err(ServiceError::invalid( + "canonical append transitions require a run-scoped primary-agent owner", + )); + } + if transitions.len() != 1 { + return Err(ServiceError::invalid( + "one provider attempt must bind exactly one canonical transition snapshot", + )); + } + let transition = &transitions[0]; + transition.validate().map_err(|error| { + ServiceError::invalid(format!( + "invalid provider canonical append transition: {error}" + )) + })?; + let encoded = serde_json::to_vec(transitions).map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Internal, + "serialize provider canonical append transitions", + error, + ) + })?; + let max_encoded = + usize::try_from(astra_turn_types::MAX_PROVIDER_CANONICAL_TRANSITION_DURABLE_BYTES) + .unwrap_or(usize::MAX) + .saturating_add(2); + if encoded.len() > max_encoded { + return Err(ServiceError::invalid( + "provider canonical append transitions exceed the durable byte bound", + )); + } + self.canonical_transition_id = Some(transition.transition_id.clone()); + self.canonical_parent_transition_id = transition.parent_transition_id.clone(); + self.canonical_transition_hash = Some(format!("{:x}", Sha256::digest(&encoded))); + self.canonical_transition_json = Some(String::from_utf8(encoded).map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Internal, + "encode provider canonical append transitions as UTF-8 JSON", + error, + ) + })?); + Ok(self) + } } impl InferenceProviderWireIdentity { @@ -569,6 +658,10 @@ pub fn plan_inference_provider_attempt_with_context( owner_token: invocation.owner_token.clone(), owner_generation: invocation.owner_generation, wire, + canonical_transition_id: None, + canonical_parent_transition_id: None, + canonical_transition_json: None, + canonical_transition_hash: None, invocation_input: invocation.input.clone(), request_context, } @@ -1816,6 +1909,9 @@ struct PersistedProviderAttemptFact { provider_protocol: String, provider_wire_hash: String, provider_wire_bytes: i64, + canonical_transition_id: Option, + canonical_parent_transition_id: Option, + canonical_transition_hash: Option, status: String, terminal_fingerprint: Option, } @@ -1826,7 +1922,9 @@ async fn load_provider_attempt_fact( ) -> ServiceResult> { sqlx::query( "SELECT invocation_id, attempt_index, provider, admission_token, provider_protocol, - provider_wire_hash, provider_wire_bytes, status, terminal_fingerprint + provider_wire_hash, provider_wire_bytes, + canonical_transition_id, canonical_parent_transition_id, + canonical_transition_hash, status, terminal_fingerprint FROM inference_provider_attempts WHERE user_id = ? AND attempt_id = ? LIMIT 1", ) @@ -1850,6 +1948,9 @@ async fn load_provider_attempt_fact( provider_protocol: row.try_get("provider_protocol")?, provider_wire_hash: row.try_get("provider_wire_hash")?, provider_wire_bytes: row.try_get("provider_wire_bytes")?, + canonical_transition_id: row.try_get("canonical_transition_id")?, + canonical_parent_transition_id: row.try_get("canonical_parent_transition_id")?, + canonical_transition_hash: row.try_get("canonical_transition_hash")?, status: row.try_get("status")?, terminal_fingerprint: row.try_get("terminal_fingerprint")?, }) @@ -1891,6 +1992,15 @@ fn validate_persisted_provider_attempt_identity( if persisted.provider_wire_bytes != provider_wire_bytes { mismatches.push("provider_wire_bytes"); } + if persisted.canonical_transition_id != attempt.canonical_transition_id { + mismatches.push("canonical_transition_id"); + } + if persisted.canonical_parent_transition_id != attempt.canonical_parent_transition_id { + mismatches.push("canonical_parent_transition_id"); + } + if persisted.canonical_transition_hash != attempt.canonical_transition_hash { + mismatches.push("canonical_transition_hash"); + } if mismatches.is_empty() { return Ok(()); } @@ -1922,6 +2032,140 @@ fn validate_ambiguous_provider_attempt_admission( ))) } +fn ambiguous_canonical_admission_proof_sql(parent_present: bool) -> String { + matrixone_statement_with_null_shape( + "SELECT attempt.canonical_transition_json AS canonical_payload + FROM inference_canonical_transition_heads AS head + INNER JOIN inference_provider_attempts AS attempt + ON attempt.user_id = head.user_id + AND attempt.session_id = head.session_id + AND attempt.attempt_id = head.head_attempt_id + AND attempt.canonical_transition_id = head.head_transition_id + INNER JOIN inference_invocations AS invocation + ON invocation.user_id = attempt.user_id + AND invocation.invocation_id = attempt.invocation_id + AND invocation.session_id = attempt.session_id + AND invocation.turn_index = head.turn_index + WHERE head.user_id = ? AND head.session_id = ? AND head.turn_index = ? + AND head.head_transition_id = ? AND head.head_attempt_id = ? + AND attempt.invocation_id = ? + AND attempt.canonical_parent_transition_id <=> ? + AND attempt.canonical_transition_hash = ? + AND attempt.canonical_transition_json IS NOT NULL + AND OCTET_LENGTH(attempt.canonical_transition_json) <= ? + AND attempt.status = 'started' AND attempt.terminal_fingerprint IS NULL + AND invocation.scope_kind = 'run' + AND invocation.purpose = 'primary_agent' + LIMIT 1", + [parent_present], + ) +} + +async fn validate_ambiguous_canonical_head_admission( + db: &sqlx::Pool, + attempt: &InferenceProviderAttemptPlan, +) -> ServiceResult<()> { + let Some(transition_id) = attempt.canonical_transition_id.as_deref() else { + let exact: i64 = sqlx::query_scalar( + "SELECT COUNT(*) + FROM inference_provider_attempts + WHERE user_id = ? AND attempt_id = ? + AND status = 'started' AND terminal_fingerprint IS NULL + AND canonical_transition_id IS NULL + AND canonical_parent_transition_id IS NULL + AND canonical_transition_hash IS NULL + AND canonical_transition_json IS NULL", + ) + .bind(&attempt.user_id) + .bind(&attempt.attempt_id) + .fetch_one(db) + .await + .map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "resolve ambiguous inference attempt without canonical transition", + error, + ) + })?; + return if exact == 1 { + Ok(()) + } else { + Err(ServiceError::conflict( + "provider attempt admission has unexpected canonical recovery state", + )) + }; + }; + let (session_id, turn) = match &attempt.invocation_input.scope { + InferenceInvocationScope::Run { + session_id, turn, .. + } if attempt.invocation_input.purpose == InferencePurpose::PrimaryAgent => { + (session_id.as_str(), *turn) + } + _ => { + return Err(ServiceError::conflict( + "ambiguous canonical head admission has no run-scoped primary owner", + )); + } + }; + let expected_hash = attempt + .canonical_transition_hash + .as_deref() + .ok_or_else(|| { + ServiceError::conflict( + "ambiguous canonical head admission has no immutable payload hash", + ) + })?; + let max_payload_bytes = checked_i64( + astra_turn_types::MAX_PROVIDER_CANONICAL_TRANSITION_DURABLE_BYTES.saturating_add(2), + "canonical_transition_json byte bound", + )?; + // This is the rare commit-ambiguous path, so prove the exact payload from + // bounded raw LONGTEXT bytes. Do not replace this with CAST(... AS CHAR) + // or database SHA2: MatrixOne can evaluate both through a 65,535-byte + // character width (matrixorigin/matrixone#28103). + let proof_sql = + ambiguous_canonical_admission_proof_sql(attempt.canonical_parent_transition_id.is_some()); + let proof = sqlx::query(&proof_sql) + .bind(&attempt.user_id) + .bind(session_id) + .bind(i64::from(turn)) + .bind(transition_id) + .bind(&attempt.attempt_id) + .bind(&attempt.invocation_id) + .bind(&attempt.canonical_parent_transition_id) + .bind(expected_hash) + .bind(max_payload_bytes) + .fetch_optional(db) + .await + .map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "resolve ambiguous inference canonical head admission", + error, + ) + })?; + let Some(proof) = proof else { + return Err(ServiceError::conflict( + "provider attempt admission has no exact recoverable canonical head", + )); + }; + let canonical_payload: Vec = proof.try_get("canonical_payload").map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "decode ambiguous canonical head payload bytes", + error, + ) + })?; + let actual_payload_hash = format!("{:x}", Sha256::digest(&canonical_payload)); + if expected_hash == actual_payload_hash { + Ok(()) + } else { + Err(ServiceError::conflict( + "provider attempt admission canonical payload hash does not match its immutable identity", + )) + } +} + fn validate_first_provider_attempt_binding( invocation: &InferenceInvocationPlan, attempt: &InferenceProviderAttemptPlan, @@ -1949,18 +2193,180 @@ fn validate_first_provider_attempt_binding( ))) } +async fn advance_inference_canonical_transition_head( + connection: &mut sqlx::MySqlConnection, + attempt: &InferenceProviderAttemptPlan, +) -> ServiceResult<()> { + let Some(transition_id) = attempt.canonical_transition_id.as_deref() else { + if attempt.canonical_parent_transition_id.is_some() + || attempt.canonical_transition_json.is_some() + || attempt.canonical_transition_hash.is_some() + { + return Err(ServiceError::invalid( + "canonical transition payload is missing its immutable transition id", + )); + } + return Ok(()); + }; + let (session_id, turn) = match &attempt.invocation_input.scope { + InferenceInvocationScope::Run { + session_id, turn, .. + } if attempt.invocation_input.purpose == InferencePurpose::PrimaryAgent => { + (session_id.as_str(), *turn) + } + _ => { + return Err(ServiceError::invalid( + "canonical transition head requires a run-scoped primary-agent owner", + )); + } + }; + let current = sqlx::query( + "SELECT head_transition_id, head_attempt_id + FROM inference_canonical_transition_heads + WHERE user_id = ? AND session_id = ? AND turn_index = ? + FOR UPDATE", + ) + .bind(&attempt.user_id) + .bind(session_id) + .bind(i64::from(turn)) + .fetch_optional(&mut *connection) + .await + .map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "lock inference canonical transition head", + error, + ) + })?; + + if let Some(current) = current { + let current_transition_id: String = + current.try_get("head_transition_id").map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "decode inference canonical transition head id", + error, + ) + })?; + let current_attempt_id: String = current.try_get("head_attempt_id").map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "decode inference canonical transition head attempt", + error, + ) + })?; + let exact_retry = current_transition_id == transition_id; + if !exact_retry + && attempt.canonical_parent_transition_id.as_deref() + != Some(current_transition_id.as_str()) + { + return Err(ServiceError::conflict(format!( + "canonical transition {} does not extend current head {}", + transition_id, current_transition_id + ))); + } + if current_attempt_id != attempt.attempt_id { + let retired = sqlx::query( + "UPDATE inference_provider_attempts + SET canonical_transition_json = NULL + WHERE user_id = ? AND attempt_id = ? + AND canonical_transition_id = ? + AND canonical_transition_json IS NOT NULL", + ) + .bind(&attempt.user_id) + .bind(¤t_attempt_id) + .bind(¤t_transition_id) + .execute(&mut *connection) + .await + .map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "retire previous inference canonical transition payload", + error, + ) + })?; + if retired.rows_affected() != 1 { + return Err(ServiceError::conflict( + "current canonical transition head has no unique recoverable payload", + )); + } + } + let updated = sqlx::query( + "UPDATE inference_canonical_transition_heads + SET head_transition_id = ?, head_attempt_id = ?, updated_at = NOW(6) + WHERE user_id = ? AND session_id = ? AND turn_index = ? + AND head_transition_id = ? AND head_attempt_id = ?", + ) + .bind(transition_id) + .bind(&attempt.attempt_id) + .bind(&attempt.user_id) + .bind(session_id) + .bind(i64::from(turn)) + .bind(¤t_transition_id) + .bind(¤t_attempt_id) + .execute(&mut *connection) + .await + .map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "advance inference canonical transition head", + error, + ) + })?; + if updated.rows_affected() != 1 { + return Err(ServiceError::conflict( + "canonical transition head changed during provider-attempt admission", + )); + } + return Ok(()); + } + + if attempt.canonical_parent_transition_id.is_some() { + return Err(ServiceError::conflict(format!( + "canonical transition {transition_id} names a parent but no durable head exists" + ))); + } + let inserted = sqlx::query( + "INSERT INTO inference_canonical_transition_heads + (user_id, session_id, turn_index, head_transition_id, head_attempt_id, updated_at) + VALUES (?, ?, ?, ?, ?, NOW(6))", + ) + .bind(&attempt.user_id) + .bind(session_id) + .bind(i64::from(turn)) + .bind(transition_id) + .bind(&attempt.attempt_id) + .execute(&mut *connection) + .await + .map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "create inference canonical transition head", + error, + ) + })?; + if inserted.rows_affected() != 1 { + return Err(ServiceError::conflict( + "canonical transition head was not created exactly once", + )); + } + Ok(()) +} + async fn insert_inference_provider_attempt_admission( connection: &mut sqlx::MySqlConnection, attempt: &InferenceProviderAttemptPlan, provider_wire_bytes: i64, ) -> ServiceResult<()> { - let result = sqlx::query( + let insert_sql = matrixone_statement_with_null_shape( "INSERT INTO inference_provider_attempts (attempt_id, invocation_id, user_id, session_id, run_id, harness_run_id, attempt_index, provider, admission_token, provider_protocol, provider_wire_hash, provider_wire_bytes, + canonical_transition_id, canonical_parent_transition_id, + canonical_transition_json, canonical_transition_hash, status, usage_status, started_at, terminal_at) SELECT ?, invocation_id, user_id, session_id, run_id, harness_run_id, - ?, ?, ?, ?, ?, ?, 'started', 'unavailable', NOW(6), NULL + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'started', 'unavailable', NOW(6), NULL FROM inference_invocations WHERE user_id = ? AND invocation_id = ? AND status = 'admitted' AND NOT EXISTS ( @@ -1969,31 +2375,43 @@ async fn insert_inference_provider_attempt_admission( WHERE settlement_debt.user_id = inference_invocations.user_id AND settlement_debt.invocation_id = inference_invocations.invocation_id )", - ) - .bind(&attempt.attempt_id) - .bind(i64::from(attempt.attempt_index)) - .bind(&attempt.provider) - .bind(&attempt.admission_token) - .bind(&attempt.wire.protocol) - .bind(&attempt.wire.provider_wire_hash) - .bind(provider_wire_bytes) - .bind(&attempt.user_id) - .bind(&attempt.invocation_id) - .execute(&mut *connection) - .await - .map_err(|error| { - ServiceError::with_source( - ServiceErrorKind::Persistence, - "insert inference provider attempt", - error, - ) - })?; + [ + attempt.canonical_transition_id.is_some(), + attempt.canonical_parent_transition_id.is_some(), + attempt.canonical_transition_json.is_some(), + attempt.canonical_transition_hash.is_some(), + ], + ); + let result = sqlx::query(&insert_sql) + .bind(&attempt.attempt_id) + .bind(i64::from(attempt.attempt_index)) + .bind(&attempt.provider) + .bind(&attempt.admission_token) + .bind(&attempt.wire.protocol) + .bind(&attempt.wire.provider_wire_hash) + .bind(provider_wire_bytes) + .bind(&attempt.canonical_transition_id) + .bind(&attempt.canonical_parent_transition_id) + .bind(&attempt.canonical_transition_json) + .bind(&attempt.canonical_transition_hash) + .bind(&attempt.user_id) + .bind(&attempt.invocation_id) + .execute(&mut *connection) + .await + .map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "insert inference provider attempt", + error, + ) + })?; if result.rows_affected() != 1 { return Err(ServiceError::conflict(format!( "inference invocation {} is not admitted for provider attempt {}", attempt.invocation_id, attempt.attempt_id ))); } + advance_inference_canonical_transition_head(connection, attempt).await?; // This row was inserted immediately above in the same transaction, so its // context-expiry fact is exactly NULL. Re-reading and locking it would add // a database round trip without adding any concurrency protection. @@ -2030,7 +2448,8 @@ async fn validate_ambiguous_invocation_with_first_attempt_admission( attempt.attempt_id )) })?; - validate_ambiguous_provider_attempt_admission(&attempt_fact, attempt, provider_wire_bytes) + validate_ambiguous_provider_attempt_admission(&attempt_fact, attempt, provider_wire_bytes)?; + validate_ambiguous_canonical_head_admission(db, attempt).await } /// Atomically admit a logical invocation and its first physical provider @@ -2168,6 +2587,9 @@ async fn lock_admitted_inference_invocation( let owner_generation = i64::try_from(owner_generation).map_err(|_| { ServiceError::invalid("inference owner generation exceeds the durable BIGINT range") })?; + // Keep recovery byte-exact. MatrixOne CAST(... AS CHAR) truncates large + // LONGTEXT values, so the SQL predicate bounds the raw transfer and Rust + // owns the complete-payload digest and JSON validation. let row = sqlx::query( "SELECT status, owner_token, owner_generation, IF(owner_lease_expires_at > NOW(6), 1, 0) AS lease_live @@ -2385,13 +2807,15 @@ pub async fn begin_inference_provider_attempt( attempt.invocation_id ))); } - let result = sqlx::query( + let insert_sql = matrixone_statement_with_null_shape( "INSERT INTO inference_provider_attempts (attempt_id, invocation_id, user_id, session_id, run_id, harness_run_id, attempt_index, provider, admission_token, provider_protocol, provider_wire_hash, provider_wire_bytes, + canonical_transition_id, canonical_parent_transition_id, + canonical_transition_json, canonical_transition_hash, status, usage_status, started_at, terminal_at) SELECT ?, invocation_id, user_id, session_id, run_id, harness_run_id, - ?, ?, ?, ?, ?, ?, 'started', 'unavailable', NOW(6), NULL + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'started', 'unavailable', NOW(6), NULL FROM inference_invocations WHERE user_id = ? AND invocation_id = ? AND status = 'admitted' AND owner_token = ? AND owner_generation = ? @@ -2409,24 +2833,40 @@ pub async fn begin_inference_provider_attempt( AND open_attempt.invocation_id = inference_invocations.invocation_id AND open_attempt.status = 'started' )", - ) - .bind(&attempt.attempt_id) - .bind(i64::from(attempt.attempt_index)) - .bind(&attempt.provider) - .bind(&attempt.admission_token) - .bind(&attempt.wire.protocol) - .bind(&attempt.wire.provider_wire_hash) - .bind(provider_wire_bytes) - .bind(&attempt.user_id) - .bind(&attempt.invocation_id) - .bind(&attempt.owner_token) - .bind(i64::try_from(attempt.owner_generation).map_err(|_| { - ServiceError::invalid("inference owner generation exceeds the durable BIGINT range") - })?) - .execute(&mut *tx) - .await; + [ + attempt.canonical_transition_id.is_some(), + attempt.canonical_parent_transition_id.is_some(), + attempt.canonical_transition_json.is_some(), + attempt.canonical_transition_hash.is_some(), + ], + ); + let result = sqlx::query(&insert_sql) + .bind(&attempt.attempt_id) + .bind(i64::from(attempt.attempt_index)) + .bind(&attempt.provider) + .bind(&attempt.admission_token) + .bind(&attempt.wire.protocol) + .bind(&attempt.wire.provider_wire_hash) + .bind(provider_wire_bytes) + .bind(&attempt.canonical_transition_id) + .bind(&attempt.canonical_parent_transition_id) + .bind(&attempt.canonical_transition_json) + .bind(&attempt.canonical_transition_hash) + .bind(&attempt.user_id) + .bind(&attempt.invocation_id) + .bind(&attempt.owner_token) + .bind(i64::try_from(attempt.owner_generation).map_err(|_| { + ServiceError::invalid("inference owner generation exceeds the durable BIGINT range") + })?) + .execute(&mut *tx) + .await; match result { Ok(result) if result.rows_affected() == 1 => { + if let Err(error) = advance_inference_canonical_transition_head(&mut tx, attempt).await + { + rollback_inference_tx(tx, "advance inference canonical transition head").await; + return Err(error); + } if let Err(error) = insert_model_request_context_event( &mut tx, attempt, @@ -2447,11 +2887,14 @@ pub async fn begin_inference_provider_attempt( error, ); match load_provider_attempt_fact(db, attempt).await { - Ok(Some(persisted)) => validate_ambiguous_provider_attempt_admission( - &persisted, - attempt, - provider_wire_bytes, - ), + Ok(Some(persisted)) => { + validate_ambiguous_provider_attempt_admission( + &persisted, + attempt, + provider_wire_bytes, + )?; + validate_ambiguous_canonical_head_admission(db, attempt).await + } Ok(None) => Err(commit_error), Err(read_error) => { tracing::warn!( @@ -2490,6 +2933,381 @@ pub async fn begin_inference_provider_attempt( } } +/// Load the single database-authoritative WAL head for one unfinished turn. +/// Parent validation and head advancement happen in provider-attempt admission; +/// recovery therefore reads and materializes one snapshot, independent of the +/// number or size of earlier physical attempts. +pub async fn load_inference_canonical_transitions_for_session( + pool: &SharedPool, + user_id: &str, + session_id: &str, + first_turn: u32, +) -> ServiceResult> { + let max_payload_bytes = checked_i64( + astra_turn_types::MAX_PROVIDER_CANONICAL_TRANSITION_DURABLE_BYTES.saturating_add(2), + "canonical_transition_json byte bound", + )?; + validate_identity(user_id, "user_id", 128)?; + validate_identity(session_id, "session_id", 64)?; + if let Some(previous_turn) = first_turn.checked_sub(1) + && let Err(error) = retire_inference_canonical_transitions_through_turn( + pool, + user_id, + session_id, + previous_turn, + ) + .await + { + tracing::warn!( + target: "astra_services::inference_execution", + user_id, + session_id, + previous_turn, + %error, + "failed to retire already-absorbed provider canonical WAL; recovery continues" + ); + } + reconcile_provider_canonical_transition_boundary(pool.get(), user_id, session_id, first_turn) + .await?; + let head = sqlx::query( + "SELECT head_transition_id, head_attempt_id + FROM inference_canonical_transition_heads + WHERE user_id = ? AND session_id = ? AND turn_index = ?", + ) + .bind(user_id) + .bind(session_id) + .bind(i64::from(first_turn)) + .fetch_optional(pool.get()) + .await + .map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "load inference canonical transition head", + error, + ) + })?; + let Some(head) = head else { + return Ok(Vec::new()); + }; + let head_transition_id: String = head.try_get("head_transition_id").map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "decode inference canonical transition head id", + error, + ) + })?; + let head_attempt_id: String = head.try_get("head_attempt_id").map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "decode inference canonical transition head attempt", + error, + ) + })?; + let row = sqlx::query( + "SELECT invocation.turn_index, invocation.round_index, + invocation.logical_attempt, attempt.attempt_index, + attempt.canonical_transition_id, attempt.canonical_parent_transition_id, + attempt.canonical_transition_json, + attempt.canonical_transition_hash + FROM inference_provider_attempts AS attempt + INNER JOIN inference_invocations AS invocation + ON invocation.user_id = attempt.user_id + AND invocation.invocation_id = attempt.invocation_id + WHERE attempt.user_id = ? AND attempt.session_id = ? + AND attempt.attempt_id = ? AND attempt.canonical_transition_id = ? + AND invocation.turn_index = ? + AND invocation.scope_kind = 'run' + AND invocation.purpose = 'primary_agent' + AND attempt.canonical_transition_json IS NOT NULL + AND OCTET_LENGTH(attempt.canonical_transition_json) <= ? + LIMIT 1", + ) + .bind(user_id) + .bind(session_id) + .bind(&head_attempt_id) + .bind(&head_transition_id) + .bind(i64::from(first_turn)) + .bind(max_payload_bytes) + .fetch_optional(pool.get()) + .await + .map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "load inference canonical transition head payload", + error, + ) + })? + .ok_or_else(|| { + ServiceError::conflict( + "inference canonical transition head has no owner-matched attempt payload", + ) + })?; + let turn = decode_non_negative_u32(&row, "turn_index")?; + let round = decode_non_negative_u32(&row, "round_index")?; + let logical_attempt = decode_non_negative_u32(&row, "logical_attempt")?; + let physical_attempt = decode_non_negative_u32(&row, "attempt_index")?; + let persisted_transition_id: Option = + row.try_get("canonical_transition_id").map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "decode canonical transition id", + error, + ) + })?; + let persisted_parent_transition_id: Option = row + .try_get("canonical_parent_transition_id") + .map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "decode canonical parent transition id", + error, + ) + })?; + let encoded: Option> = row.try_get("canonical_transition_json").map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "decode canonical transition JSON bytes", + error, + ) + })?; + let encoded = encoded.ok_or_else(|| { + ServiceError::conflict("inference canonical transition head payload was retired early") + })?; + let persisted_hash: Option = + row.try_get("canonical_transition_hash").map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "decode canonical transition hash", + error, + ) + })?; + let actual_hash = format!("{:x}", Sha256::digest(&encoded)); + if persisted_hash.as_deref() != Some(actual_hash.as_str()) { + return Err(ServiceError::conflict( + "provider canonical transition WAL hash does not match its payload", + )); + } + let transitions: Vec = + serde_json::from_slice(&encoded).map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "parse canonical transition WAL payload", + error, + ) + })?; + if transitions.len() != 1 { + return Err(ServiceError::conflict( + "provider canonical WAL head payload must contain exactly one transition", + )); + } + let transition = &transitions[0]; + transition.validate().map_err(|error| { + ServiceError::conflict(format!( + "provider canonical transition WAL is invalid: {error}" + )) + })?; + if persisted_transition_id.as_deref() != Some(transition.transition_id.as_str()) + || transition.transition_id != head_transition_id + || persisted_parent_transition_id != transition.parent_transition_id + { + return Err(ServiceError::conflict( + "provider canonical WAL head metadata does not match its payload", + )); + } + Ok(vec![InferenceCanonicalTransitionReceipt { + turn, + round, + logical_attempt, + physical_attempt, + transitions, + }]) +} + +/// Remove recoverable message payloads after the canonical coordinator has +/// absorbed them. The content hash remains as immutable audit evidence; owner, +/// session, turn, run-scope, and primary-purpose predicates prevent one +/// conversation surface from retiring another's WAL. +pub async fn retire_inference_canonical_transitions_through_turn( + pool: &SharedPool, + user_id: &str, + session_id: &str, + through_turn: u32, +) -> ServiceResult { + validate_identity(user_id, "user_id", 128)?; + validate_identity(session_id, "session_id", 64)?; + let mut tx = pool.get().begin().await.map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "begin inference canonical transition retirement", + error, + ) + })?; + let result = sqlx::query( + "UPDATE inference_provider_attempts AS attempt + INNER JOIN inference_invocations AS invocation + ON invocation.user_id = attempt.user_id + AND invocation.invocation_id = attempt.invocation_id + SET attempt.canonical_transition_json = NULL + WHERE attempt.user_id = ? AND attempt.session_id = ? + AND invocation.turn_index <= ? + AND invocation.scope_kind = 'run' + AND invocation.purpose = 'primary_agent' + AND attempt.canonical_transition_json IS NOT NULL", + ) + .bind(user_id) + .bind(session_id) + .bind(i64::from(through_turn)) + .execute(&mut *tx) + .await + .map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "retire absorbed inference canonical transition payloads", + error, + ) + })?; + let retired = result.rows_affected(); + sqlx::query( + "DELETE FROM inference_canonical_transition_heads + WHERE user_id = ? AND session_id = ? AND turn_index <= ?", + ) + .bind(user_id) + .bind(session_id) + .bind(i64::from(through_turn)) + .execute(&mut *tx) + .await + .map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "retire inference canonical transition heads", + error, + ) + })?; + tx.commit().await.map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "commit inference canonical transition retirement", + error, + ) + })?; + Ok(retired) +} + +/// Establish a fail-closed recovery boundary before a restored host can use +/// attempt-owned canonical state and admit new provider I/O. +/// +/// An expired pre-delivery invocation is safe to cancel. An expired started +/// attempt becomes `delivery_unknown` through the ordinary recovery path. A +/// live old owner blocks the restored turn. A delivery-unknown terminal does +/// not: at-most-once forbids reusing that exact invocation/attempt identity, +/// while a later identity may extend its committed canonical transition. The +/// unknown response cannot execute tools because it was never observed by the +/// runtime. +async fn reconcile_provider_canonical_transition_boundary( + db: &sqlx::Pool, + user_id: &str, + session_id: &str, + first_turn: u32, +) -> ServiceResult<()> { + let expired = sqlx::query( + "SELECT invocation_id + FROM inference_invocations + WHERE user_id = ? AND session_id = ? AND turn_index >= ? + AND scope_kind = 'run' AND purpose = 'primary_agent' + AND status = 'admitted' AND owner_lease_expires_at <= NOW(6) + ORDER BY turn_index ASC, round_index ASC, logical_attempt ASC, + invocation_id ASC", + ) + .bind(user_id) + .bind(session_id) + .bind(i64::from(first_turn)) + .fetch_all(db) + .await + .map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "load expired inference owners at canonical recovery boundary", + error, + ) + })?; + for row in expired { + let invocation_id: String = row.try_get("invocation_id").map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "decode expired inference owner at canonical recovery boundary", + error, + ) + })?; + recover_expired_inference_invocation(db, user_id, &invocation_id) + .await + .map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "recover expired inference owner at canonical recovery boundary", + error, + ) + })?; + } + + let blocker = sqlx::query( + "SELECT invocation_id, status + FROM inference_invocations + WHERE user_id = ? AND session_id = ? AND turn_index >= ? + AND scope_kind = 'run' AND purpose = 'primary_agent' + AND status = 'admitted' + ORDER BY turn_index ASC, round_index ASC, logical_attempt ASC, + invocation_id ASC + LIMIT 1", + ) + .bind(user_id) + .bind(session_id) + .bind(i64::from(first_turn)) + .fetch_optional(db) + .await + .map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "verify canonical recovery inference delivery boundary", + error, + ) + })?; + if let Some(blocker) = blocker { + let invocation_id: String = blocker.try_get("invocation_id").map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "decode canonical recovery inference blocker identity", + error, + ) + })?; + let status: String = blocker.try_get("status").map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + "decode canonical recovery inference blocker status", + error, + ) + })?; + return Err(ServiceError::conflict(format!( + "inference invocation {invocation_id} is {status} at the canonical recovery boundary; new provider delivery is forbidden" + ))); + } + Ok(()) +} + +fn decode_non_negative_u32(row: &sqlx::mysql::MySqlRow, column: &str) -> ServiceResult { + let value: i64 = row.try_get(column).map_err(|error| { + ServiceError::with_source( + ServiceErrorKind::Persistence, + format!("decode inference canonical transition coordinate {column}"), + error, + ) + })?; + u32::try_from(value).map_err(|_| { + ServiceError::conflict(format!( + "inference canonical transition coordinate {column} is outside u32 range" + )) + }) +} + async fn record_successful_attempt_debt_if_needed( db: &sqlx::Pool, attempt: &InferenceProviderAttemptPlan, @@ -4791,6 +5609,7 @@ pub async fn finish_inference_invocation( #[cfg(test)] mod tests { use super::*; + use serde_json::json; fn input() -> InferenceInvocationInput { InferenceInvocationInput { @@ -5037,6 +5856,9 @@ mod tests { provider_protocol: attempt.wire.protocol.clone(), provider_wire_hash: attempt.wire.provider_wire_hash.clone(), provider_wire_bytes: i64::try_from(attempt.wire.provider_wire_bytes).unwrap(), + canonical_transition_id: attempt.canonical_transition_id.clone(), + canonical_parent_transition_id: attempt.canonical_parent_transition_id.clone(), + canonical_transition_hash: attempt.canonical_transition_hash.clone(), status: status.to_string(), terminal_fingerprint: terminal_fingerprint.map(str::to_string), } @@ -5056,6 +5878,114 @@ mod tests { ) } + fn exact_provider_attempt_with_transition() -> InferenceProviderAttemptPlan { + let content = astra_turn_types::render_append_only_runtime_authority_frame( + "test_authority", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + "opaque test authority", + ) + .unwrap(); + let mut authority = json!({"role": "user", "content": content}); + astra_turn_types::mark_append_only_required_context( + &mut authority, + "test_authority", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ); + let transition = astra_turn_types::ProviderCanonicalTransitionV1::new( + None, + &[json!({"role": "user", "content": "goal"})], + vec![authority], + ) + .unwrap(); + exact_provider_attempt() + .with_canonical_transitions(&[transition]) + .unwrap() + } + + #[test] + fn ambiguous_attempt_reread_requires_exact_canonical_transition_identity() { + let attempt = exact_provider_attempt_with_transition(); + let exact = provider_attempt_fact(&attempt, "started", None); + validate_ambiguous_provider_attempt_admission( + &exact, + &attempt, + i64::try_from(attempt.wire.provider_wire_bytes).unwrap(), + ) + .expect("exact body and transition identity authorize the ambiguous commit"); + + let mut changed_hash = exact.clone(); + changed_hash.canonical_transition_hash = Some("f".repeat(64)); + assert_eq!( + validate_ambiguous_provider_attempt_admission( + &changed_hash, + &attempt, + i64::try_from(attempt.wire.provider_wire_bytes).unwrap(), + ) + .expect_err("a different WAL hash must not authorize provider delivery") + .kind, + ServiceErrorKind::Conflict + ); + + let transitions: Vec = + serde_json::from_str( + attempt + .canonical_transition_json + .as_deref() + .expect("transition JSON"), + ) + .unwrap(); + let mut subagent_input = input(); + subagent_input.purpose = InferencePurpose::SubAgent; + let subagent = plan_inference_invocation(subagent_input).unwrap(); + let subagent_attempt = plan_inference_provider_attempt( + &subagent, + 0, + InferenceProviderWireIdentity::new( + "openai_compatible", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + 4_096, + ) + .unwrap(), + ); + assert_eq!( + subagent_attempt + .with_canonical_transitions(&transitions) + .expect_err("subagent prompt history cannot own root canonical WAL") + .kind, + ServiceErrorKind::Invalid + ); + } + + #[test] + fn ambiguous_canonical_proof_separates_root_and_child_statement_identities() { + let root = ambiguous_canonical_admission_proof_sql(false); + let child = ambiguous_canonical_admission_proof_sql(true); + assert!(root.ends_with("/* astra-null-shape:0 */")); + assert!(child.ends_with("/* astra-null-shape:1 */")); + assert_ne!(root, child); + } + + #[test] + fn provider_attempt_owns_one_self_contained_canonical_snapshot() { + let attempt = exact_provider_attempt_with_transition(); + let transitions: Vec = + serde_json::from_str( + attempt + .canonical_transition_json + .as_deref() + .expect("transition JSON"), + ) + .unwrap(); + assert_eq!(transitions.len(), 1); + assert_eq!( + exact_provider_attempt() + .with_canonical_transitions(&[transitions[0].clone(), transitions[0].clone()]) + .expect_err("one physical request cannot own competing snapshots") + .kind, + ServiceErrorKind::Invalid + ); + } + #[test] fn ambiguous_attempt_admission_accepts_only_the_exact_started_wire_fact() { let attempt = exact_provider_attempt(); diff --git a/crates/services/src/lib.rs b/crates/services/src/lib.rs index 44a79f08f1..2b9f3f1724 100644 --- a/crates/services/src/lib.rs +++ b/crates/services/src/lib.rs @@ -198,18 +198,19 @@ pub use harness::{ SkillifyRunRequest, SkillifySourceFile, SkillifySourcePacket, UnconfiguredHarnessService, }; pub use inference_execution::{ - InferenceInvocationAdmissionResolution, InferenceInvocationInput, InferenceInvocationPlan, - InferenceInvocationTerminal, InferenceProviderAttemptPlan, InferenceProviderDeliveryState, - InferenceProviderWireIdentity, InferenceRunAdmissionAuthority, - InferenceSettlementReconcileOutcome, InferenceTerminalStatus, InferenceUsage, - InferenceUsageStatus, admit_inference_invocation, + InferenceCanonicalTransitionReceipt, InferenceInvocationAdmissionResolution, + InferenceInvocationInput, InferenceInvocationPlan, InferenceInvocationTerminal, + InferenceProviderAttemptPlan, InferenceProviderDeliveryState, InferenceProviderWireIdentity, + InferenceRunAdmissionAuthority, InferenceSettlementReconcileOutcome, InferenceTerminalStatus, + InferenceUsage, InferenceUsageStatus, admit_inference_invocation, admit_inference_invocation_with_first_provider_attempt, begin_inference_provider_attempt, declare_inference_attempt_settlement, declare_inference_settlement, finish_inference_invocation, finish_inference_provider_attempt, - next_inference_logical_attempt_pair_base, plan_inference_invocation, - plan_inference_provider_attempt, plan_inference_provider_attempt_with_context, - reconcile_inference_settlement, reconcile_inference_settlements, - renew_inference_invocation_owner, settle_uncertain_inference_admission, + load_inference_canonical_transitions_for_session, next_inference_logical_attempt_pair_base, + plan_inference_invocation, plan_inference_provider_attempt, + plan_inference_provider_attempt_with_context, reconcile_inference_settlement, + reconcile_inference_settlements, renew_inference_invocation_owner, + retire_inference_canonical_transitions_through_turn, settle_uncertain_inference_admission, }; pub use interaction_contract::{ InteractionContract, InteractionDurableStore, InteractionIdentity, InteractionKind, @@ -257,8 +258,8 @@ pub use models::{ ModelExecutionPlacement, ModelListCursor, ModelListItem, ModelListItemResponse, ModelListPage, ModelListPageResponse, ModelOfferingResolutionError, ModelRecord, ModelService, ModelUpdateRequestData, PricingData, PromptCacheCapabilityData, PromptCacheProtocolData, - PromptCacheReuseScopeData, PromptCacheVolatilePlacementData, QuirksData, - ResolvedActiveLlmModel, ResolvedModelOffering, UnconfiguredModelService, + PromptCacheReuseScopeData, PromptCacheVolatileDeliveryData, PromptCacheVolatilePlacementData, + QuirksData, ResolvedActiveLlmModel, ResolvedModelOffering, UnconfiguredModelService, model_catalog_revision, project_model_access, project_model_access_page, project_model_access_page_with_default_catalog, project_model_access_with_default, prompt_cache_capability_from_models_yaml, resolve_active_llm_model, diff --git a/crates/services/src/models.rs b/crates/services/src/models.rs index 7200c73618..e14de33e23 100644 --- a/crates/services/src/models.rs +++ b/crates/services/src/models.rs @@ -1,6 +1,6 @@ use async_trait::async_trait; use axum::{Json, http::StatusCode}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, ser::SerializeStruct}; use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use sqlx::{Row, query}; @@ -181,10 +181,19 @@ pub enum PromptCacheProtocolData { pub enum PromptCacheVolatilePlacementData { MarkerIsolated, TailSuffix, + AppendOnlyUserTail, CurrentUserOnly, Free, } +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PromptCacheVolatileDeliveryData { + #[default] + All, + RequiredOnly, +} + #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum PromptCacheReuseScopeData { @@ -211,14 +220,109 @@ impl PromptCacheReuseScopeData { } } -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +const PROMPT_CACHE_CAPABILITY_SCHEMA_VERSION: u8 = 2; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct PromptCacheCapabilityData { pub protocol: PromptCacheProtocolData, pub volatile_placement: PromptCacheVolatilePlacementData, - #[serde(default, skip_serializing_if = "Option::is_none")] + /// Total delivery policy after deserialization. New serializations always + /// write this field. Legacy payload migration happens only in the custom + /// deserializer below, never in runtime placement decisions. + pub volatile_delivery: PromptCacheVolatileDeliveryData, pub reuse_scope: Option, } +impl Serialize for PromptCacheCapabilityData { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if matches!( + self.volatile_placement, + PromptCacheVolatilePlacementData::AppendOnlyUserTail + ) && (!matches!( + self.volatile_delivery, + PromptCacheVolatileDeliveryData::RequiredOnly + ) || !matches!(self.protocol, PromptCacheProtocolData::OpenAiAutoPrefix)) + { + return Err(serde::ser::Error::custom( + "append_only_user_tail requires open_ai_auto_prefix with volatile_delivery=required_only", + )); + } + let mut state = serializer.serialize_struct( + "PromptCacheCapabilityData", + 4 + usize::from(self.reuse_scope.is_some()), + )?; + state.serialize_field("schema_version", &PROMPT_CACHE_CAPABILITY_SCHEMA_VERSION)?; + state.serialize_field("protocol", &self.protocol)?; + state.serialize_field("volatile_placement", &self.volatile_placement)?; + state.serialize_field("volatile_delivery", &self.volatile_delivery)?; + if let Some(reuse_scope) = self.reuse_scope { + state.serialize_field("reuse_scope", &reuse_scope)?; + } + state.end() + } +} + +impl<'de> Deserialize<'de> for PromptCacheCapabilityData { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct WireCapability { + #[serde(default)] + schema_version: Option, + protocol: PromptCacheProtocolData, + volatile_placement: PromptCacheVolatilePlacementData, + #[serde(default)] + volatile_delivery: Option, + #[serde(default)] + reuse_scope: Option, + } + + let wire = WireCapability::deserialize(deserializer)?; + if let Some(schema_version) = wire.schema_version + && schema_version != PROMPT_CACHE_CAPABILITY_SCHEMA_VERSION + { + return Err(serde::de::Error::custom(format!( + "unsupported prompt-cache capability schema version {schema_version}; expected {PROMPT_CACHE_CAPABILITY_SCHEMA_VERSION}" + ))); + } + let volatile_delivery = match (wire.schema_version, wire.volatile_delivery) { + (_, Some(delivery)) => delivery, + (Some(_), None) => { + return Err(serde::de::Error::missing_field("volatile_delivery")); + } + // Before the delivery axis existed, every admitted volatile block + // was delivered. The neutral legacy meaning is therefore `all`. + // Placement cannot determine delivery: a65 also serialized an + // explicit `all` by omitting this field, so guessing from shape + // would irreversibly rewrite valid data. + (None, None) => PromptCacheVolatileDeliveryData::All, + }; + if matches!( + wire.volatile_placement, + PromptCacheVolatilePlacementData::AppendOnlyUserTail + ) && (!matches!( + volatile_delivery, + PromptCacheVolatileDeliveryData::RequiredOnly + ) || !matches!(wire.protocol, PromptCacheProtocolData::OpenAiAutoPrefix)) + { + return Err(serde::de::Error::custom( + "append_only_user_tail requires open_ai_auto_prefix with volatile_delivery=required_only", + )); + } + Ok(Self { + protocol: wire.protocol, + volatile_placement: wire.volatile_placement, + volatile_delivery, + reuse_scope: wire.reuse_scope, + }) + } +} + #[derive(Debug, Deserialize)] struct ModelsYamlPromptCacheEntry { name: String, @@ -5124,6 +5228,7 @@ mod tests { prompt_cache_capability: Some(PromptCacheCapabilityData { protocol: PromptCacheProtocolData::StrictHistoryMatch, volatile_placement: PromptCacheVolatilePlacementData::CurrentUserOnly, + volatile_delivery: PromptCacheVolatileDeliveryData::RequiredOnly, reuse_scope: Some(PromptCacheReuseScopeData::ConversationTurns), }), ..QuirksData::default() @@ -5221,6 +5326,7 @@ mod tests { prompt_cache_capability: Some(PromptCacheCapabilityData { protocol: PromptCacheProtocolData::StrictHistoryMatch, volatile_placement: PromptCacheVolatilePlacementData::CurrentUserOnly, + volatile_delivery: PromptCacheVolatileDeliveryData::RequiredOnly, reuse_scope: Some(PromptCacheReuseScopeData::IntraTurnRounds), }), request_body_overrides: Some(Map::from_iter([( @@ -5261,6 +5367,7 @@ mod tests { prompt_cache_capability: protocol: openai_auto_prefix volatile_placement: tail_suffix + volatile_delivery: required_only reuse_scope: intra_turn_rounds - name: deepseek-v4-flash quirks: @@ -5276,6 +5383,7 @@ mod tests { Some(PromptCacheCapabilityData { protocol: PromptCacheProtocolData::OpenAiAutoPrefix, volatile_placement: PromptCacheVolatilePlacementData::TailSuffix, + volatile_delivery: PromptCacheVolatileDeliveryData::RequiredOnly, reuse_scope: Some(PromptCacheReuseScopeData::IntraTurnRounds), }) ); @@ -5284,6 +5392,10 @@ mod tests { Some(PromptCacheCapabilityData { protocol: PromptCacheProtocolData::StrictHistoryMatch, volatile_placement: PromptCacheVolatilePlacementData::CurrentUserOnly, + // This fixture intentionally omits the delivery axis. The + // pre-axis schema delivered all volatile context; placement + // must never be used to guess a different behavior. + volatile_delivery: PromptCacheVolatileDeliveryData::All, reuse_scope: None, }) ); @@ -5293,6 +5405,87 @@ mod tests { ); } + #[test] + fn prompt_cache_legacy_delivery_defaults_to_pre_axis_all_without_shape_inference() { + let strict: PromptCacheCapabilityData = serde_json::from_value(serde_json::json!({ + "protocol": "strict_history_match", + "volatile_placement": "current_user_only", + })) + .unwrap(); + assert_eq!( + strict.volatile_delivery, + PromptCacheVolatileDeliveryData::All + ); + + let prefix: PromptCacheCapabilityData = serde_json::from_value(serde_json::json!({ + "protocol": "openai_auto_prefix", + "volatile_placement": "tail_suffix", + })) + .unwrap(); + assert_eq!( + prefix.volatile_delivery, + PromptCacheVolatileDeliveryData::All + ); + } + + #[test] + fn prompt_cache_serialization_never_recreates_legacy_delivery_ambiguity() { + let capability = PromptCacheCapabilityData { + protocol: PromptCacheProtocolData::StrictHistoryMatch, + volatile_placement: PromptCacheVolatilePlacementData::CurrentUserOnly, + volatile_delivery: PromptCacheVolatileDeliveryData::All, + reuse_scope: None, + }; + + let encoded = serde_json::to_value(capability).unwrap(); + assert_eq!( + encoded["schema_version"], + PROMPT_CACHE_CAPABILITY_SCHEMA_VERSION + ); + assert_eq!(encoded["volatile_delivery"], "all"); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + capability + ); + } + + #[test] + fn versioned_prompt_cache_capability_requires_explicit_delivery() { + let error = serde_json::from_value::(serde_json::json!({ + "schema_version": PROMPT_CACHE_CAPABILITY_SCHEMA_VERSION, + "protocol": "strict_history_match", + "volatile_placement": "current_user_only", + })) + .unwrap_err(); + + assert!(error.to_string().contains("volatile_delivery")); + } + + #[test] + fn append_only_prompt_cache_capability_requires_prefix_protocol_and_required_delivery() { + for value in [ + serde_json::json!({ + "protocol": "openai_auto_prefix", + "volatile_placement": "append_only_user_tail", + }), + serde_json::json!({ + "schema_version": PROMPT_CACHE_CAPABILITY_SCHEMA_VERSION, + "protocol": "openai_auto_prefix", + "volatile_placement": "append_only_user_tail", + "volatile_delivery": "all", + }), + serde_json::json!({ + "schema_version": PROMPT_CACHE_CAPABILITY_SCHEMA_VERSION, + "protocol": "marker_explicit", + "volatile_placement": "append_only_user_tail", + "volatile_delivery": "required_only", + }), + ] { + let error = serde_json::from_value::(value).unwrap_err(); + assert!(error.to_string().contains("append_only_user_tail")); + } + } + // -- ModelListItemResponse / conversions -- #[test] diff --git a/crates/services/src/session_lifecycle.rs b/crates/services/src/session_lifecycle.rs index 4fd6aa4a28..0abd613d37 100644 --- a/crates/services/src/session_lifecycle.rs +++ b/crates/services/src/session_lifecycle.rs @@ -433,6 +433,13 @@ const SESSION_DELETE_DIRECT_BATCH_TABLES: &[SessionBatchDeleteStatement] = &[ label: "inference_invocation_settlement_debts", sql: SESSION_DELETE_INFERENCE_SETTLEMENT_DEBTS_SQL, }, + SessionBatchDeleteStatement { + label: "inference_canonical_transition_heads", + sql: "DELETE FROM inference_canonical_transition_heads + WHERE session_id = ? AND user_id = ? + ORDER BY turn_index ASC + LIMIT ?", + }, SessionBatchDeleteStatement { label: "inference_provider_attempts", sql: "DELETE FROM inference_provider_attempts @@ -554,6 +561,7 @@ const SESSION_DELETE_CORE_RESIDUAL_TABLES: &[(&str, &str)] = &[ ("agent_events", "user_id"), ("agent_event_edges", "user_id"), ("agent_runs", "user_id"), + ("inference_canonical_transition_heads", "user_id"), ("inference_invocation_settlement_debts", "user_id"), ]; @@ -1517,6 +1525,7 @@ mod tests { "agent_events", "agent_run_events", "conversation_log", + "inference_canonical_transition_heads", "inference_invocation_settlement_debts", "inference_invocations", "inference_provider_attempts", diff --git a/crates/services/src/storage.rs b/crates/services/src/storage.rs index d5c64afa5a..32fcadc4bd 100644 --- a/crates/services/src/storage.rs +++ b/crates/services/src/storage.rs @@ -121,7 +121,7 @@ pub const AGENT_ID_LEN: usize = 255; pub const AGENT_EVENT_ID_LEN: usize = 128; static CORE_SCHEMA_INIT_LOCK: OnceLock> = OnceLock::new(); const CORE_SCHEMA_CONTRACT_COMPONENT: &str = "astra-core"; -pub const CORE_SCHEMA_CONTRACT_VERSION: &str = "2026-08-25-v67"; +pub const CORE_SCHEMA_CONTRACT_VERSION: &str = "2026-09-04-v69"; const CORE_SCHEMA_CONTRACT_TABLE_SQL: &str = "CREATE TABLE IF NOT EXISTS astra_schema_contracts ( component VARCHAR(64) NOT NULL PRIMARY KEY, contract_version VARCHAR(64) NOT NULL, @@ -2440,6 +2440,48 @@ fn inference_provider_attempt_schema_mismatches( None => reasons.push("missing nullable column context_expired_at".to_string()), } + match columns.get("canonical_transition_json") { + Some(column) + if column.nullable + && (column.data_type.eq_ignore_ascii_case("text") + || column.data_type.eq_ignore_ascii_case("longtext")) => {} + Some(column) if !column.nullable => { + reasons.push("non-nullable column canonical_transition_json".to_string()); + } + Some(column) => reasons.push(format!( + "column canonical_transition_json has type {}, expected text-compatible storage", + column.data_type + )), + None => reasons.push("missing nullable column canonical_transition_json".to_string()), + } + for (name, expected_type, expected_width) in [ + ("canonical_transition_id", "char", Some(64_i64)), + ("canonical_parent_transition_id", "char", Some(64_i64)), + ("canonical_transition_hash", "char", Some(64_i64)), + ] { + let Some(column) = columns.get(name) else { + reasons.push(format!("missing nullable column {name}")); + continue; + }; + if !column.nullable { + reasons.push(format!("non-nullable column {name}")); + } + if !column.data_type.eq_ignore_ascii_case(expected_type) { + reasons.push(format!( + "column {name} has type {}, expected {expected_type}", + column.data_type + )); + } + if let Some(expected_width) = expected_width + && column.character_maximum_length != Some(expected_width) + { + reasons.push(format!( + "column {name} has width {:?}, expected {expected_width}", + column.character_maximum_length + )); + } + } + for (name, expected_columns) in [ ("PRIMARY", &["user_id", "attempt_id"][..]), ( @@ -2470,6 +2512,71 @@ fn inference_provider_attempt_schema_mismatches( reasons } +fn inference_canonical_transition_head_schema_mismatches( + columns: &BTreeMap, + indexes: &BTreeMap, +) -> Vec { + let mut reasons = Vec::new(); + for (name, expected_type, expected_width) in [ + ("user_id", "varchar", Some(128_i64)), + ("session_id", "varchar", Some(64_i64)), + ("turn_index", "bigint", None), + ("head_transition_id", "char", Some(64_i64)), + ("head_attempt_id", "varchar", Some(64_i64)), + ("updated_at", "datetime", None), + ] { + let Some(column) = columns.get(name) else { + reasons.push(format!("missing NOT NULL column {name}")); + continue; + }; + if column.nullable { + reasons.push(format!("nullable column {name}")); + } + if !column.data_type.eq_ignore_ascii_case(expected_type) { + reasons.push(format!( + "column {name} has type {}, expected {expected_type}", + column.data_type + )); + } + if let Some(expected_width) = expected_width + && column.character_maximum_length != Some(expected_width) + { + reasons.push(format!( + "column {name} has width {:?}, expected {expected_width}", + column.character_maximum_length + )); + } + } + for (name, expected_columns) in [ + ("PRIMARY", &["user_id", "session_id", "turn_index"][..]), + ( + "uq_inference_canonical_head_attempt", + &["user_id", "head_attempt_id"][..], + ), + ] { + let Some(index) = indexes.get(name) else { + reasons.push(format!("missing unique constraint {name}")); + continue; + }; + if index.non_unique { + reasons.push(format!("constraint {name} is not unique")); + } + if !index + .columns + .iter() + .map(String::as_str) + .eq(expected_columns.iter().copied()) + { + reasons.push(format!( + "constraint {name} has columns ({}), expected ({})", + index.columns.join(", "), + expected_columns.join(", ") + )); + } + } + reasons +} + fn inference_invocation_schema_mismatches( columns: &BTreeMap, ) -> Vec { @@ -2641,6 +2748,89 @@ async fn verify_inference_provider_attempt_schema_contract( ))) } +async fn verify_inference_canonical_transition_head_schema_contract( + pool: &sqlx::Pool, + database: &str, +) -> Result<(), sqlx::Error> { + validate_schema_identifier(database, "matrixone database")?; + let table = "inference_canonical_transition_heads"; + let column_rows = query( + "SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?", + ) + .bind(database) + .bind(table) + .fetch_all(pool) + .await?; + let mut columns = BTreeMap::new(); + for row in column_rows { + let name: String = row.try_get("COLUMN_NAME")?; + let nullable = match row.try_get::("IS_NULLABLE")?.as_str() { + "YES" => true, + "NO" => false, + value => { + return Err(sqlx::Error::Protocol(format!( + "schema column {table}.{name} has invalid IS_NULLABLE value {value}" + ))); + } + }; + columns.insert( + name, + ObservedColumnShape { + data_type: row.try_get("DATA_TYPE")?, + character_maximum_length: row.try_get("CHARACTER_MAXIMUM_LENGTH")?, + nullable, + }, + ); + } + let index_rows = query( + "SELECT INDEX_NAME, NON_UNIQUE, COLUMN_NAME + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + AND INDEX_NAME IN ('PRIMARY', 'uq_inference_canonical_head_attempt') + ORDER BY INDEX_NAME, SEQ_IN_INDEX", + ) + .bind(database) + .bind(table) + .fetch_all(pool) + .await?; + let mut indexes = BTreeMap::::new(); + for row in index_rows { + let name: String = row.try_get("INDEX_NAME")?; + let non_unique = match row.try_get::("NON_UNIQUE")? { + 0 => false, + 1 => true, + value => { + return Err(sqlx::Error::Protocol(format!( + "schema constraint {table}.{name} has invalid NON_UNIQUE value {value}" + ))); + } + }; + let column: String = row.try_get("COLUMN_NAME")?; + let index = indexes + .entry(name.clone()) + .or_insert_with(|| ObservedIndexShape { + columns: Vec::new(), + non_unique, + }); + if index.non_unique != non_unique { + return Err(sqlx::Error::Protocol(format!( + "schema constraint {table}.{name} reports inconsistent uniqueness" + ))); + } + index.columns.push(column); + } + let reasons = inference_canonical_transition_head_schema_mismatches(&columns, &indexes); + if reasons.is_empty() { + return Ok(()); + } + Err(sqlx::Error::Protocol(format!( + "obsolete core schema table {table} requires manual migration before startup: {}", + reasons.join(", ") + ))) +} + async fn fail_if_required_columns_missing_or_not_nullable( pool: &sqlx::Pool, database: &str, @@ -6450,6 +6640,10 @@ async fn ensure_core_schema_while_leased( provider_protocol VARCHAR(32) NOT NULL, provider_wire_hash CHAR(64) NOT NULL, provider_wire_bytes BIGINT NOT NULL, + canonical_transition_id CHAR(64) NULL, + canonical_parent_transition_id CHAR(64) NULL, + canonical_transition_json LONGTEXT NULL, + canonical_transition_hash CHAR(64) NULL, status VARCHAR(32) NOT NULL, terminal_fingerprint CHAR(64) NULL, usage_status VARCHAR(32) NOT NULL, @@ -6483,6 +6677,23 @@ async fn ensure_core_schema_while_leased( ) .execute(&pool) .await?; + + core_schema_create!( + pool, + "inference_canonical_transition_heads", + "CREATE TABLE IF NOT EXISTS inference_canonical_transition_heads ( + user_id VARCHAR(128) NOT NULL, + session_id VARCHAR(64) NOT NULL, + turn_index BIGINT NOT NULL, + head_transition_id CHAR(64) NOT NULL, + head_attempt_id VARCHAR(64) NOT NULL, + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (user_id, session_id, turn_index), + UNIQUE KEY uq_inference_canonical_head_attempt (user_id, head_attempt_id) + )", + ) + .execute(&pool) + .await?; add_column_if_missing( &pool, &settings.database, @@ -6761,6 +6972,7 @@ async fn ensure_core_schema_while_leased( .await?; verify_inference_invocation_schema_contract(&pool, &settings.database).await?; verify_inference_provider_attempt_schema_contract(&pool, &settings.database).await?; + verify_inference_canonical_transition_head_schema_contract(&pool, &settings.database).await?; for (table, nullable_columns) in [ ( "inference_routes", @@ -9052,6 +9264,38 @@ mod tests { nullable: true, }, ), + ( + "canonical_transition_id", + ObservedColumnShape { + data_type: "char".to_string(), + character_maximum_length: Some(64), + nullable: true, + }, + ), + ( + "canonical_parent_transition_id", + ObservedColumnShape { + data_type: "char".to_string(), + character_maximum_length: Some(64), + nullable: true, + }, + ), + ( + "canonical_transition_json", + ObservedColumnShape { + data_type: "text".to_string(), + character_maximum_length: None, + nullable: true, + }, + ), + ( + "canonical_transition_hash", + ObservedColumnShape { + data_type: "char".to_string(), + character_maximum_length: Some(64), + nullable: true, + }, + ), ( "usage_status", ObservedColumnShape { @@ -9092,6 +9336,50 @@ mod tests { .collect() } + fn canonical_transition_head_columns() -> BTreeMap { + [ + ("user_id", "varchar", Some(128), false), + ("session_id", "varchar", Some(64), false), + ("turn_index", "bigint", None, false), + ("head_transition_id", "char", Some(64), false), + ("head_attempt_id", "varchar", Some(64), false), + ("updated_at", "datetime", None, false), + ] + .into_iter() + .map(|(name, data_type, character_maximum_length, nullable)| { + ( + name.to_string(), + ObservedColumnShape { + data_type: data_type.to_string(), + character_maximum_length, + nullable, + }, + ) + }) + .collect() + } + + fn canonical_transition_head_indexes() -> BTreeMap { + [ + ("PRIMARY", vec!["user_id", "session_id", "turn_index"]), + ( + "uq_inference_canonical_head_attempt", + vec!["user_id", "head_attempt_id"], + ), + ] + .into_iter() + .map(|(name, columns)| { + ( + name.to_string(), + ObservedIndexShape { + columns: columns.into_iter().map(str::to_string).collect(), + non_unique: false, + }, + ) + }) + .collect() + } + #[test] fn provider_attempt_schema_contract_accepts_the_exact_wire_shape() { assert!( @@ -9103,6 +9391,43 @@ mod tests { ); } + #[test] + fn canonical_transition_head_schema_contract_is_exact_and_fail_closed() { + let exact_columns = canonical_transition_head_columns(); + let exact_indexes = canonical_transition_head_indexes(); + assert!( + inference_canonical_transition_head_schema_mismatches(&exact_columns, &exact_indexes,) + .is_empty() + ); + + let mut missing_id = exact_columns.clone(); + missing_id.remove("head_transition_id"); + assert!( + inference_canonical_transition_head_schema_mismatches(&missing_id, &exact_indexes) + .iter() + .any(|reason| reason.contains("missing NOT NULL column head_transition_id")) + ); + + let mut wrong_width = exact_columns.clone(); + wrong_width + .get_mut("head_transition_id") + .unwrap() + .character_maximum_length = Some(32); + assert!( + inference_canonical_transition_head_schema_mismatches(&wrong_width, &exact_indexes) + .iter() + .any(|reason| reason.contains("head_transition_id has width Some(32)")) + ); + + let mut wrong_primary = exact_indexes; + wrong_primary.get_mut("PRIMARY").unwrap().columns.swap(1, 2); + assert!( + inference_canonical_transition_head_schema_mismatches(&exact_columns, &wrong_primary) + .iter() + .any(|reason| reason.contains("constraint PRIMARY has columns")) + ); + } + #[test] fn invocation_schema_contract_requires_an_exact_admission_fence() { let exact = [ @@ -9228,6 +9553,88 @@ mod tests { exact_indexes.clone(), )); + let mut columns = exact_columns.clone(); + columns.remove("canonical_transition_id"); + cases.push(( + "missing nullable column canonical_transition_id", + columns, + exact_indexes.clone(), + )); + + let mut columns = exact_columns.clone(); + columns + .get_mut("canonical_parent_transition_id") + .unwrap() + .nullable = false; + cases.push(( + "non-nullable column canonical_parent_transition_id", + columns, + exact_indexes.clone(), + )); + + let mut columns = exact_columns.clone(); + columns + .get_mut("canonical_transition_id") + .unwrap() + .character_maximum_length = Some(32); + cases.push(( + "canonical_transition_id has width Some(32)", + columns, + exact_indexes.clone(), + )); + + let mut columns = exact_columns.clone(); + columns.remove("canonical_transition_json"); + cases.push(( + "missing nullable column canonical_transition_json", + columns, + exact_indexes.clone(), + )); + + let mut columns = exact_columns.clone(); + columns + .get_mut("canonical_transition_json") + .unwrap() + .nullable = false; + cases.push(( + "non-nullable column canonical_transition_json", + columns, + exact_indexes.clone(), + )); + + let mut columns = exact_columns.clone(); + columns + .get_mut("canonical_transition_json") + .unwrap() + .data_type = "varchar".to_string(); + cases.push(( + "canonical_transition_json has type varchar", + columns, + exact_indexes.clone(), + )); + + let mut columns = exact_columns.clone(); + columns + .get_mut("canonical_transition_hash") + .unwrap() + .nullable = false; + cases.push(( + "non-nullable column canonical_transition_hash", + columns, + exact_indexes.clone(), + )); + + let mut columns = exact_columns.clone(); + columns + .get_mut("canonical_transition_hash") + .unwrap() + .character_maximum_length = Some(32); + cases.push(( + "canonical_transition_hash has width Some(32)", + columns, + exact_indexes.clone(), + )); + let mut indexes = exact_indexes.clone(); indexes.remove("PRIMARY"); cases.push(( diff --git a/crates/services/tests/inference_execution_db_it.rs b/crates/services/tests/inference_execution_db_it.rs index 040ea6908d..4229d12b52 100644 --- a/crates/services/tests/inference_execution_db_it.rs +++ b/crates/services/tests/inference_execution_db_it.rs @@ -16,12 +16,14 @@ use astra_services::{ admit_inference_invocation_with_first_provider_attempt, begin_inference_provider_attempt, declare_inference_attempt_settlement, declare_inference_settlement, finish_inference_invocation, finish_inference_provider_attempt, - next_inference_logical_attempt_pair_base, plan_inference_invocation, - plan_inference_provider_attempt, reconcile_inference_settlements, - renew_inference_invocation_owner, settle_uncertain_inference_admission, + load_inference_canonical_transitions_for_session, next_inference_logical_attempt_pair_base, + plan_inference_invocation, plan_inference_provider_attempt, reconcile_inference_settlements, + renew_inference_invocation_owner, retire_inference_canonical_transitions_through_turn, + settle_uncertain_inference_admission, }; use astra_turn_types::{InferenceInvocationScope, InferencePurpose}; use serial_test::serial; +use sha2::Digest; use sqlx::Row; use uuid::Uuid; @@ -366,6 +368,410 @@ async fn uncertain_admission_recovery_is_scope_fenced_and_atomic() { cleanup(pool, &user_id, &session_id, &run_id).await; } +#[tokio::test] +#[ignore = "requires live DB: run with ASTRA_TEST_DB_IT=1"] +#[serial] +async fn large_canonical_payload_does_not_change_provider_terminal_identity() { + let (shared_pool, _) = common::setup_pool_and_settings().await; + let pool = shared_pool.get(); + let suffix = Uuid::new_v4().simple().to_string(); + let user_id = format!("canonical-large-user-{suffix}"); + let session_id = format!("canonical-large-session-{suffix}"); + let run_id = format!("canonical-large-run-{suffix}"); + seed_run(pool, &user_id, &session_id, &run_id).await; + + let durable_base = astra_turn_types::CanonicalPrefixIdentityV1::from_messages(&[]) + .expect("empty durable base"); + let history = vec![serde_json::json!({ + "role": "user", + "content": "x".repeat(128 * 1024), + })]; + let transition = astra_turn_types::ProviderCanonicalTransitionV1::new_from_durable_base( + None, + durable_base, + &history, + Vec::new(), + ) + .expect("construct a large canonical transition"); + let plan = plan_inference_invocation(run_input( + &user_id, + &session_id, + &run_id, + 0, + "large_canonical_terminal", + )) + .expect("plan large canonical invocation"); + admit_inference_invocation(&shared_pool, &plan) + .await + .expect("admit large canonical invocation"); + let attempt = provider_attempt(&plan, 0) + .with_canonical_transitions(std::slice::from_ref(&transition)) + .expect("bind one large canonical transition"); + begin_inference_provider_attempt(&shared_pool, &attempt) + .await + .expect("admit large canonical provider attempt"); + let stored_bytes: i64 = sqlx::query_scalar( + "SELECT OCTET_LENGTH(canonical_transition_json) + FROM inference_provider_attempts + WHERE user_id = ? AND attempt_id = ?", + ) + .bind(&user_id) + .bind(attempt.attempt_id()) + .fetch_one(pool) + .await + .expect("measure persisted canonical payload"); + assert!( + stored_bytes > 65_535, + "the regression must cross MatrixOne's CAST AS CHAR truncation boundary" + ); + let stored_payload: Vec = sqlx::query_scalar( + "SELECT canonical_transition_json + FROM inference_provider_attempts + WHERE user_id = ? AND attempt_id = ?", + ) + .bind(&user_id) + .bind(attempt.attempt_id()) + .fetch_one(pool) + .await + .expect("read complete persisted canonical payload bytes"); + assert_eq!( + i64::try_from(stored_payload.len()).expect("stored payload length"), + stored_bytes + ); + let locally_rehashed_payload = format!("{:x}", sha2::Sha256::digest(&stored_payload)); + assert_eq!( + Some(locally_rehashed_payload.as_str()), + attempt.canonical_transition_hash() + ); + let terminal = InferenceInvocationTerminal { + status: InferenceTerminalStatus::Cancelled, + usage: InferenceUsage::default(), + usage_status: InferenceUsageStatus::Unavailable, + provider_response_id: None, + error_kind: Some("large_payload_complete".to_string()), + error_message: Some("close large canonical payload attempt".to_string()), + }; + finish_inference_provider_attempt(&shared_pool, &attempt, &terminal) + .await + .expect("terminal identity must not depend on reloading mutable WAL payload bytes"); + finish_inference_invocation(&shared_pool, &plan, &terminal) + .await + .expect("finish large canonical invocation"); + let receipts = + load_inference_canonical_transitions_for_session(&shared_pool, &user_id, &session_id, 1) + .await + .expect("recover the complete large canonical payload"); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].transitions.len(), 1); + let mut recovered = Vec::new(); + receipts[0].transitions[0] + .apply_to(&mut recovered) + .expect("materialize the large canonical transition"); + assert_eq!(recovered, history); + let mut corrupted_payload = stored_payload; + corrupted_payload.push(b' '); + sqlx::query( + "UPDATE inference_provider_attempts + SET canonical_transition_json = ? + WHERE user_id = ? AND attempt_id = ?", + ) + .bind(corrupted_payload) + .bind(&user_id) + .bind(attempt.attempt_id()) + .execute(pool) + .await + .expect("corrupt the head payload without changing immutable metadata"); + assert_eq!( + load_inference_canonical_transitions_for_session(&shared_pool, &user_id, &session_id, 1,) + .await + .expect_err("recovery must fail closed on payload/hash drift") + .kind, + ServiceErrorKind::Conflict + ); + cleanup(pool, &user_id, &session_id, &run_id).await; +} + +#[tokio::test] +#[ignore = "requires live DB: run with ASTRA_TEST_DB_IT=1"] +#[serial] +async fn superseded_payload_owner_can_terminalize_after_its_child_becomes_head() { + let (shared_pool, _) = common::setup_pool_and_settings().await; + let pool = shared_pool.get(); + let suffix = Uuid::new_v4().simple().to_string(); + let user_id = format!("canonical-late-terminal-user-{suffix}"); + let session_id = format!("canonical-late-terminal-session-{suffix}"); + let run_id = format!("canonical-late-terminal-run-{suffix}"); + seed_run(pool, &user_id, &session_id, &run_id).await; + + let durable_base = astra_turn_types::CanonicalPrefixIdentityV1::from_messages(&[]) + .expect("empty durable base"); + let parent_history = vec![serde_json::json!({"role": "user", "content": "parent"})]; + let parent_transition = astra_turn_types::ProviderCanonicalTransitionV1::new_from_durable_base( + None, + durable_base.clone(), + &parent_history, + Vec::new(), + ) + .expect("construct parent transition"); + let parent_plan = plan_inference_invocation(run_input( + &user_id, + &session_id, + &run_id, + 0, + "late_terminal_parent", + )) + .expect("plan parent invocation"); + admit_inference_invocation(&shared_pool, &parent_plan) + .await + .expect("admit parent invocation"); + let parent_attempt = provider_attempt(&parent_plan, 0) + .with_canonical_transitions(std::slice::from_ref(&parent_transition)) + .expect("bind parent transition"); + begin_inference_provider_attempt(&shared_pool, &parent_attempt) + .await + .expect("admit parent provider attempt"); + + let child_history = vec![ + serde_json::json!({"role": "user", "content": "parent"}), + serde_json::json!({"role": "assistant", "content": "child"}), + ]; + let child_transition = astra_turn_types::ProviderCanonicalTransitionV1::new_from_durable_base( + Some(parent_transition.transition_id.clone()), + durable_base, + &child_history, + Vec::new(), + ) + .expect("construct child transition"); + let child_plan = plan_inference_invocation(run_input( + &user_id, + &session_id, + &run_id, + 1, + "late_terminal_child", + )) + .expect("plan child invocation"); + admit_inference_invocation(&shared_pool, &child_plan) + .await + .expect("admit child invocation"); + let child_attempt = provider_attempt(&child_plan, 0) + .with_canonical_transitions(std::slice::from_ref(&child_transition)) + .expect("bind child transition"); + begin_inference_provider_attempt(&shared_pool, &child_attempt) + .await + .expect("atomically make child the canonical head"); + + let terminal = InferenceInvocationTerminal { + status: InferenceTerminalStatus::Cancelled, + usage: InferenceUsage::default(), + usage_status: InferenceUsageStatus::Unavailable, + provider_response_id: None, + error_kind: Some("late_terminal_complete".to_string()), + error_message: Some("terminalize after successor admission".to_string()), + }; + finish_inference_provider_attempt(&shared_pool, &parent_attempt, &terminal) + .await + .expect("late parent terminal must depend only on immutable attempt identity"); + let payload_owners: Vec = sqlx::query_scalar( + "SELECT attempt_id FROM inference_provider_attempts + WHERE user_id = ? AND session_id = ? + AND canonical_transition_json IS NOT NULL", + ) + .bind(&user_id) + .bind(&session_id) + .fetch_all(pool) + .await + .expect("load unique canonical payload owner"); + assert_eq!(payload_owners, vec![child_attempt.attempt_id().to_string()]); + + finish_inference_provider_attempt(&shared_pool, &child_attempt, &terminal) + .await + .expect("finish child provider attempt"); + finish_inference_invocation(&shared_pool, &parent_plan, &terminal) + .await + .expect("finish parent invocation"); + finish_inference_invocation(&shared_pool, &child_plan, &terminal) + .await + .expect("finish child invocation"); + cleanup(pool, &user_id, &session_id, &run_id).await; +} + +#[tokio::test] +#[ignore = "requires live DB: run with ASTRA_TEST_DB_IT=1"] +#[serial] +async fn canonical_transition_head_keeps_one_payload_across_three_hundred_rounds() { + let (shared_pool, _) = common::setup_pool_and_settings().await; + let pool = shared_pool.get(); + let suffix = Uuid::new_v4().simple().to_string(); + let user_id = format!("canonical-head-user-{suffix}"); + let session_id = format!("canonical-head-session-{suffix}"); + let run_id = format!("canonical-head-run-{suffix}"); + seed_run(pool, &user_id, &session_id, &run_id).await; + + let durable_base = astra_turn_types::CanonicalPrefixIdentityV1::from_messages(&[]) + .expect("empty durable base"); + let mut history = Vec::new(); + let mut parent_transition_id = None; + for round in 0..300_u32 { + history.push(serde_json::json!({ + "role": "user", + "content": format!("durable request {round}") + })); + let transition = astra_turn_types::ProviderCanonicalTransitionV1::new_from_durable_base( + parent_transition_id.clone(), + durable_base.clone(), + &history, + Vec::new(), + ) + .expect("construct an explicitly linked canonical transition"); + let plan = plan_inference_invocation(run_input( + &user_id, + &session_id, + &run_id, + round, + &format!("canonical_head_{round}"), + )) + .expect("plan canonical head invocation"); + admit_inference_invocation(&shared_pool, &plan) + .await + .expect("admit canonical head invocation"); + let attempt = provider_attempt(&plan, 0) + .with_canonical_transitions(std::slice::from_ref(&transition)) + .expect("bind one canonical transition"); + begin_inference_provider_attempt(&shared_pool, &attempt) + .await + .expect("atomically advance canonical head"); + let terminal = InferenceInvocationTerminal { + status: InferenceTerminalStatus::Cancelled, + usage: InferenceUsage::default(), + usage_status: InferenceUsageStatus::Unavailable, + provider_response_id: None, + error_kind: Some("test_round_complete".to_string()), + error_message: Some("close scale-test provider attempt".to_string()), + }; + finish_inference_provider_attempt(&shared_pool, &attempt, &terminal) + .await + .expect("finish canonical head attempt"); + if round == 0 { + let retry = provider_attempt(&plan, 1) + .with_canonical_transitions(std::slice::from_ref(&transition)) + .expect("bind the same transition to its physical retry"); + begin_inference_provider_attempt(&shared_pool, &retry) + .await + .expect("same-id physical retry moves the unique payload owner"); + finish_inference_provider_attempt(&shared_pool, &retry, &terminal) + .await + .expect("finish same-id physical retry"); + } + finish_inference_invocation(&shared_pool, &plan, &terminal) + .await + .expect("finish canonical head invocation"); + parent_transition_id = Some(transition.transition_id); + } + + let stale = astra_turn_types::ProviderCanonicalTransitionV1::new_from_durable_base( + Some("0".repeat(64)), + durable_base.clone(), + &history, + Vec::new(), + ) + .expect("construct a structurally valid but causally stale transition"); + let stale_plan = plan_inference_invocation(run_input( + &user_id, + &session_id, + &run_id, + 300, + "canonical_head_stale_parent", + )) + .expect("plan stale-parent invocation"); + admit_inference_invocation(&shared_pool, &stale_plan) + .await + .expect("admit stale-parent logical invocation"); + let stale_attempt = provider_attempt(&stale_plan, 0) + .with_canonical_transitions(std::slice::from_ref(&stale)) + .expect("bind stale-parent transition"); + assert_eq!( + begin_inference_provider_attempt(&shared_pool, &stale_attempt) + .await + .expect_err("a stale parent must fail before provider delivery") + .kind, + ServiceErrorKind::Conflict + ); + let stale_attempts: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM inference_provider_attempts + WHERE user_id = ? AND attempt_id = ?", + ) + .bind(&user_id) + .bind(stale_attempt.attempt_id()) + .fetch_one(pool) + .await + .expect("count rolled-back stale attempt"); + assert_eq!(stale_attempts, 0); + let stale_terminal = InferenceInvocationTerminal { + status: InferenceTerminalStatus::Cancelled, + usage: InferenceUsage::default(), + usage_status: InferenceUsageStatus::Unavailable, + provider_response_id: None, + error_kind: Some("stale_parent_rejected".to_string()), + error_message: Some("canonical head CAS rejected stale parent".to_string()), + }; + finish_inference_invocation(&shared_pool, &stale_plan, &stale_terminal) + .await + .expect("close pre-delivery stale-parent invocation"); + + let active_payloads: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM inference_provider_attempts + WHERE user_id = ? AND session_id = ? + AND canonical_transition_json IS NOT NULL", + ) + .bind(&user_id) + .bind(&session_id) + .fetch_one(pool) + .await + .expect("count active canonical payloads"); + assert_eq!(active_payloads, 1); + let attempts: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM inference_provider_attempts + WHERE user_id = ? AND session_id = ? AND canonical_transition_id IS NOT NULL", + ) + .bind(&user_id) + .bind(&session_id) + .fetch_one(pool) + .await + .expect("count canonical audit rows"); + assert_eq!(attempts, 301); + + let receipts = + load_inference_canonical_transitions_for_session(&shared_pool, &user_id, &session_id, 1) + .await + .expect("load the unique canonical head"); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].transitions.len(), 1); + assert_eq!( + receipts[0].transitions[0].transition_id, + parent_transition_id.expect("final transition id") + ); + let mut recovered = Vec::new(); + receipts[0].transitions[0] + .apply_to(&mut recovered) + .expect("materialize only the unique leaf snapshot"); + assert_eq!(recovered, history); + + retire_inference_canonical_transitions_through_turn(&shared_pool, &user_id, &session_id, 1) + .await + .expect("retire canonical head and payload together"); + let heads: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM inference_canonical_transition_heads + WHERE user_id = ? AND session_id = ?", + ) + .bind(&user_id) + .bind(&session_id) + .fetch_one(pool) + .await + .expect("count retired canonical heads"); + assert_eq!(heads, 0); + cleanup(pool, &user_id, &session_id, &run_id).await; +} + #[tokio::test] #[ignore = "requires live DB: run with ASTRA_TEST_DB_IT=1"] #[serial] @@ -1461,6 +1867,10 @@ async fn cleanup(pool: &sqlx::Pool, user_id: &str, session_id: &str "DELETE FROM inference_invocation_settlement_debts WHERE user_id = ? AND session_id = ?", session_id, ), + ( + "DELETE FROM inference_canonical_transition_heads WHERE user_id = ? AND session_id = ?", + session_id, + ), ( "DELETE FROM inference_provider_attempts WHERE user_id = ? AND session_id = ?", session_id, @@ -3591,6 +4001,16 @@ async fn expired_inference_owner_recovers_every_sigkill_shape_without_old_owner_ "pre_delivery" ); assert_eq!(pre_delivery_fact.get::("owner_generation"), 2); + let pre_delivery_attempts: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM inference_provider_attempts + WHERE user_id = ? AND invocation_id = ?", + ) + .bind(&user_id) + .bind(pre_delivery.invocation_id()) + .fetch_one(pool) + .await + .expect("count pre-delivery attempts"); + assert_eq!(pre_delivery_attempts, 0, "pre-dispatch failure owns no WAL"); assert_eq!( renew_inference_invocation_owner(&shared_pool, &pre_delivery) .await @@ -3610,7 +4030,31 @@ async fn expired_inference_owner_recovers_every_sigkill_shape_without_old_owner_ admit_inference_invocation(&shared_pool, &delivery_unknown) .await .expect("admit delivered orphan"); - let open_attempt = provider_attempt(&delivery_unknown, 0); + let durable_base = astra_turn_types::CanonicalPrefixIdentityV1::from_messages(&[]) + .expect("empty durable base"); + let old_user = serde_json::json!({"role": "user", "content": "old request"}); + let frame_content = astra_turn_types::render_append_only_runtime_authority_frame( + "test_authority", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + "preserve this request boundary", + ) + .expect("authority frame"); + let mut authority = serde_json::json!({"role": "user", "content": frame_content}); + astra_turn_types::mark_append_only_required_context( + &mut authority, + "test_authority", + astra_turn_types::RuntimeAuthorityLifetime::NextAssistantDecision, + ); + let transition = astra_turn_types::ProviderCanonicalTransitionV1::new_from_durable_base( + None, + durable_base, + std::slice::from_ref(&old_user), + vec![authority.clone()], + ) + .expect("self-contained attempt transition"); + let open_attempt = provider_attempt(&delivery_unknown, 0) + .with_canonical_transitions(&[transition]) + .expect("root attempt may own canonical WAL"); begin_inference_provider_attempt(&shared_pool, &open_attempt) .await .expect("authorize provider delivery"); @@ -3663,6 +4107,88 @@ async fn expired_inference_owner_recovers_every_sigkill_shape_without_old_owner_ "delivery_unknown" ); assert_eq!(delivered_fact.get::("terminal_contexts"), 1); + let receipts = + load_inference_canonical_transitions_for_session(&shared_pool, &user_id, &session_id, 1) + .await + .expect("delivery-unknown terminal does not brick canonical recovery"); + assert_eq!(receipts.len(), 1); + let fresh_user = serde_json::json!({"role": "user", "content": "hi"}); + let mut restored = Vec::new(); + receipts[0].transitions[0] + .apply_to(&mut restored) + .expect("recover old request on its durable base"); + restored.push(fresh_user.clone()); + assert_eq!(restored, vec![old_user, authority, fresh_user]); + assert_eq!( + retire_inference_canonical_transitions_through_turn( + &shared_pool, + &user_id, + &session_id, + 1, + ) + .await + .expect("retire canonically absorbed WAL"), + 1 + ); + let retired = sqlx::query( + "SELECT canonical_transition_json, canonical_transition_hash + FROM inference_provider_attempts + WHERE user_id = ? AND attempt_id = ?", + ) + .bind(&user_id) + .bind(open_attempt.attempt_id()) + .fetch_one(pool) + .await + .expect("load retired WAL audit row"); + assert!( + retired + .try_get::, _>("canonical_transition_json") + .unwrap() + .is_none() + ); + assert!( + retired + .try_get::, _>("canonical_transition_hash") + .unwrap() + .is_some() + ); + + let successor = plan_inference_invocation(run_input( + &user_id, + &session_id, + &run_id, + 11, + "after_delivery_unknown", + )) + .expect("plan successor invocation"); + admit_inference_invocation(&shared_pool, &successor) + .await + .expect("new invocation identity remains admissible"); + let successor_attempt = provider_attempt(&successor, 0); + begin_inference_provider_attempt(&shared_pool, &successor_attempt) + .await + .expect("delivery-unknown old identity cannot block new provider delivery"); + let successor_terminal = InferenceInvocationTerminal { + status: InferenceTerminalStatus::Cancelled, + usage: InferenceUsage::default(), + usage_status: InferenceUsageStatus::Unavailable, + provider_response_id: None, + error_kind: Some("test_cleanup".to_string()), + error_message: Some("close successor attempt".to_string()), + }; + finish_inference_provider_attempt(&shared_pool, &successor_attempt, &successor_terminal) + .await + .expect("finish successor attempt"); + finish_inference_invocation(&shared_pool, &successor, &successor_terminal) + .await + .expect("finish successor invocation"); + assert_eq!( + begin_inference_provider_attempt(&shared_pool, &open_attempt) + .await + .expect_err("old attempt identity can never be delivered twice") + .kind, + ServiceErrorKind::Conflict + ); let late_terminal = InferenceInvocationTerminal { status: InferenceTerminalStatus::Failed, usage: InferenceUsage::default(), diff --git a/docs/design/prompt-lifecycle.md b/docs/design/prompt-lifecycle.md index baf1e3a78e..4d4264dff3 100644 --- a/docs/design/prompt-lifecycle.md +++ b/docs/design/prompt-lifecycle.md @@ -1,7 +1,7 @@ # Prompt lifecycle > Status: target design contract. -> Last updated: 2026-07-07. +> Last updated: 2026-09-03. Prompt lifecycle defines how Astra builds, versions, caches, inspects, and evolves prompts. It is distinct from context selection and tool routing, though it consumes both. @@ -85,6 +85,111 @@ Prompt cache goals: ForkPrefix is a cache/diagnostic optimization, not restore correctness. +Provider cache behavior is capability-driven along independent axes: cache +protocol, physical volatile placement, optional-volatile delivery, and reuse +scope. Concrete offerings declare these facts; runtime code never infers them +from a model name. Missing legacy delivery metadata retains the pre-axis `all` +behavior, while newly serialized metadata is versioned and writes every +behavioral field explicitly. + +`append_only_user_tail` is a distinct provider wire shape, not a compatibility +fallback. It is valid only with `required_only` delivery. A required runtime +control is appended as a typed, runtime-owned `role=user` frame so the next +request strictly extends the prior provider history. Provider role is not +semantic authorship: intent, memory, observer, display, turn-boundary, and +ordinary summary projections must use typed provenance and must never count +that frame as human speech. Unknown runtime provenance fails closed and cannot +be promoted to a user request. + +Every append-only authority frame carries a kind and one explicit lifetime: +`next_assistant_decision` or `current_user_turn`. A later frame of the same kind +supersedes an earlier one; assistant or human-turn boundaries consume the +applicable lifetime. Retries are transactional: a failed wire assembly neither +commits a partial frame nor consumes pending authority. Cache-reusing inline +compaction may retain the exact frame bytes only under the same stable semantic +policy used by main inference; all other summary/learning projections exclude +runtime-owned frames. + +When an active session changes to a non-append wire shape, expired frames are +removed from the provider projection. Active authority is unframed and re-homed +to the required system lane; kinds rebuilt by a current authoritative source +replace their historical frame instead of being duplicated. Canonical history +retains the typed record needed for deterministic resume, while the chosen +provider projection contains each effective authority kind once. Correctness is +never weakened to preserve cache reuse. + +Turn focus is represented by one invariant leading-system policy: the exact +current and immediately prior text remains in canonical conversation messages +and is never recopied into the cached system prefix. Required runtime context +remains model-visible through the role required by the selected wire shape, +without changing its typed runtime ownership. For automatic-prefix protocols a +changed system message after the conversation boundary is a volatile suffix, +not a changed leading-system identity. Planned diagnostics are explicitly +labelled as a pre-client projection. Provider-final diagnostics come only from +the immutable prepared-body receipt and fingerprint the ordered message, +system, conversation, and tool-schema sequences after every provider +transformation and internal-schema sanitization. Both read the exact resolved +capability captured with the request and never guess a shape from provider or +model labels. The receipt also derives cache-key system and tool identities +from that typed capability: automatic-prefix layouts exclude post-history +system tails, while explicit-marker layouts stop at their protocol-native +system/tool marker. Every dispatched physical attempt advances this structural +baseline exactly once by durable request identity; only attempts with provider +usage contribute hit/miss counts. Pre-dispatch attempts and missing-usage +terminals never fabricate cache statistics. + +Append-only canonical state uses the provider-attempt ledger as a write-ahead +boundary. Before HTTP is authorized, the same transaction that admits the +exact provider body stores every newly provider-owned canonical append as a +versioned transition with predecessor/result message counts and canonical root +hashes. The transition is self-contained from the canonical coordinator's +admitted durable base: it carries either the lossless uncommitted suffix or an +explicit replacement after an authorized compaction rewrite, followed by the +attempt-owned append. A request with no new runtime frame still stores a +recovery-only snapshot: provider delivery, rather than the presence of a +particular authority shape, creates the durability obligation. Initial +authority frames are authority-only appends; an internal continuation is one +atomic assistant-plus-authority append. + +A resumed host uses the admitted durable message count as an ownership +boundary. It detaches the fresh request suffix, loads the database-authoritative +per-turn head, restores that one self-contained leaf on the durable base, and +finally reattaches the fresh suffix. Every transition names its immutable parent +transition id. Provider-attempt admission validates parent-to-current-head and +advances the head in the same transaction as the exact provider body; a +same-transition physical retry is idempotent. It never compares message values +to infer lineage or guess whether repeated input such as `continue` belongs +before or after the crash. A missing parent, fork, stale head, payload/hash +conflict, or ambiguous commit without the exact head attempt fails before HTTP +or canonical mutation. +This covers a crash after HTTP authorization but before any step or canonical +checkpoint. Provider body roles, model names, prompt text, transport errors, +timestamps, run-local counters, and attempt ids are not recovery evidence. + +WAL snapshots cross the same durable credential-redaction boundary as runtime +checkpoints. They preserve the already-durable canonical base byte-for-byte and +redact only newly retained message data. Head advancement clears the prior +attempt's large JSON payload in the admission transaction, leaving exactly one +recoverable snapshot regardless of provider-round count; transition id, parent, +and content hash remain as audit evidence. Once a canonical commit absorbs a +turn, its head and final payload are retired atomically; the next session +boundary retries retirement for an earlier commit that crashed before cleanup. +Hard session deletion removes both owner-scoped heads and attempt rows. + +Prefix mismatch is not replacement authority. An append transition must prove +that the admitted durable base remains an exact prefix. A replacement +transition can be created only from the canonical rewrite proof after its +pre-mutation permit has been validated and bound to the exact resulting +predecessor identity and compaction generation; otherwise provider admission +fails before HTTP. + +At a text-only completion boundary, a provider may receive one request with a +stable schema declaration plus its native no-tool choice. If it nevertheless +requests a tool, the bounded repair request removes the schema declaration +physically while retaining a provider-native no-tool choice where the protocol +supports one. Cache reuse never takes precedence over terminal execution +authority. + ## Prompt introspection The system should be able to explain: @@ -95,6 +200,11 @@ The system should be able to explain: - which memories/artifacts were included; - whether cache should have hit or missed. +Provider control syntax recovered from a degraded text response is runtime +protocol, not assistant prose. Streaming clients must withhold it across chunk +boundaries, while the canonical parser retains the original bytes long enough +to recover and validate the structured action. + ## Evolution Prompt changes go through tuning/evaluation gates when they affect behavior. Emergency safety prompt updates may bypass normal rollout only under explicit policy and must be auditable. diff --git a/scripts/harness/test_verifier_readiness.py b/scripts/harness/test_verifier_readiness.py index a4b92528b9..e0b7c0c2d9 100755 --- a/scripts/harness/test_verifier_readiness.py +++ b/scripts/harness/test_verifier_readiness.py @@ -1375,6 +1375,20 @@ def test_static_binding_rejects_dynamic_or_ambiguous_forms(self): "export DEBIAN_FRONTEND=noninteractive", ) + def test_post_scoring_exit_status_assignment_is_not_a_static_binding(self): + """Verifier bookkeeping after scoring is outside the no-score plan.""" + script = ( + "pytest /tests/test_outputs.py\n" + "EXIT_STATUS=$?\n" + "if [ $EXIT_STATUS -eq 0 ]; then\n" + " echo 1 > /logs/verifier/reward.txt\n" + "fi\n" + ) + plan = readiness.build_dependency_setup_plan(script, Path("test.sh")) + + self.assertEqual(plan.runner_family, "pytest") + self.assertEqual(plan.steps, ()) + def test_fixture_stage_rejects_non_tests_source_and_unsafe_options(self): rejected = ( "cp /tmp/input.csv .\npytest /tests/x.py\n", diff --git a/scripts/harness/verifier_readiness.py b/scripts/harness/verifier_readiness.py index 6fb6f4e06f..715365ff1d 100755 --- a/scripts/harness/verifier_readiness.py +++ b/scripts/harness/verifier_readiness.py @@ -1781,19 +1781,22 @@ def build_dependency_setup_plan( used_bindings: set[str] = set() authoritative_tests = tests_source_dir or test_path.parent for sequence, unit in enumerate(units): - binding = _parse_static_binding_declaration(unit) - fixture = _classify_fixture_stage( - unit, authoritative_tests, sequence, len(steps) - ) + # The readiness plan deliberately stops at the official scorer. Shell + # bookkeeping after that boundary (for example ``status=$?`` before + # writing reward.txt) must not be interpreted as a pre-scoring static + # dependency binding. We still reject a post-boundary dependency + # action, but do not parse or execute any other scorer-following unit. if scoring_boundary: - if binding is not None or fixture is not None: - raise ReadinessContractError("plan_static_binding_disallowed") if _contains_dependency_intent(unit): raise ReadinessError( "dependency setup appears after the scoring boundary" ) continue + binding = _parse_static_binding_declaration(unit) + fixture = _classify_fixture_stage( + unit, authoritative_tests, sequence, len(steps) + ) if binding is not None: if binding.name in bindings: raise ReadinessContractError("plan_static_binding_disallowed") diff --git a/scripts/schema/schema_inventory.py b/scripts/schema/schema_inventory.py index 128cf216fe..6cd7aac472 100755 --- a/scripts/schema/schema_inventory.py +++ b/scripts/schema/schema_inventory.py @@ -270,6 +270,16 @@ class AutoIncrementMetadata: migration_owner="astra_services::storage / inference_execution", product_owner="inference durability, usage attribution, billing, and recovery", ), + "inference_canonical_transition_heads": TableMetadata( + semantic_owner="astra_services::inference_execution", + state_class="durable per-turn canonical provider transition lineage head", + primary_query="lock or load the single current head by user_id, session_id, and turn_index, then join head_attempt_id to its exact provider-attempt payload", + retention_policy="retain until the canonical coordinator absorbs the turn; retirement removes the head and provider payload through the absorbed turn, and session hard delete removes any remainder", + rebuildability="not safely rebuildable while an unabsorbed provider delivery exists because message values and provider responses do not identify the sole recoverable lineage leaf", + merge_guidance="keep separate from immutable provider attempts; the composite primary key serializes one mutable head per session turn while the unique attempt key prevents ambiguous lineage", + migration_owner="astra_services::storage / inference_execution", + product_owner="provider canonical context durability, crash recovery, and fork prevention", + ), "inference_provider_attempts": TableMetadata( semantic_owner="astra_services::inference_execution", state_class="durable upstream inference delivery attempt fact",