diff --git a/Cargo.lock b/Cargo.lock index c181266079..15ef9de468 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7001,6 +7001,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "shell-words", "tracedecay-domain", "tracing", ] diff --git a/crates/tracedecay-agent-hosts/src/agents/context_scout.rs b/crates/tracedecay-agent-hosts/src/agents/context_scout.rs index c38ec3863c..56237e0544 100644 --- a/crates/tracedecay-agent-hosts/src/agents/context_scout.rs +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout.rs @@ -15,9 +15,9 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use thiserror::Error; use tracedecay_contracts::context_scout::{ - ContextScoutAddressV1, ContextScoutCandidateV1, ContextScoutCategoryV1, - ContextScoutDeliveryOutcomeV1, ContextScoutDeliveryReceiptV1, ContextScoutDeliveryWindowV1, - ContextScoutDurableClaimV1, ContextScoutDurableQueueEntryV1, + ContextScoutAddressV1, ContextScoutCandidateEvidenceV1, ContextScoutCandidateV1, + ContextScoutCategoryV1, ContextScoutDeliveryOutcomeV1, ContextScoutDeliveryReceiptV1, + ContextScoutDeliveryWindowV1, ContextScoutDurableClaimV1, ContextScoutDurableQueueEntryV1, ContextScoutEvidenceAvailabilityV1, ContextScoutFeedbackKindV1, ContextScoutFeedbackV1, ContextScoutLeaseV1, ContextScoutModelBackendV1, ContextScoutModelOutcomeV1, ContextScoutModelReceiptV1, ContextScoutRouteV1, ContextScoutSuggestionEnvelopeV1, @@ -31,8 +31,11 @@ use tracedecay_runtime_core::cancellation::{CancellationToken, MonotonicDeadline use tracedecay_tokenizer::o200k_base; pub mod address_registry; +pub mod decision_engine; +pub mod intent_pass; pub mod model; pub mod owner; +pub mod session_window; const MAX_SCOUT_TEXT_BYTES: usize = 4 * 1024; const MAX_SCOUT_CANDIDATES: usize = 32; @@ -47,7 +50,10 @@ mod store; #[cfg(test)] mod store_tests; -pub use evidence::ContextScoutEvidenceEnvelopeExt; +pub use evidence::{ + ContextScoutCandidateEvidenceExt, ContextScoutEvidenceEnvelopeExt, + ContextScoutSessionClaimInputV1, ContextScoutSessionEvidenceExt, +}; pub use store::ProjectContextScoutDurableStoreV1; pub(crate) trait ContextScoutAddressExt { @@ -78,11 +84,18 @@ pub(crate) trait ContextScoutCandidateExt { impl ContextScoutCandidateExt for ContextScoutCandidateV1 { fn validate(&self, limits: ContextScoutLimitsV1) -> Result<(), ContextScoutErrorV1> { + // Code evidence cites at least one anchor; session evidence cites + // none, its window digest is the citation. + let anchors_in_bounds = match &self.evidence { + ContextScoutCandidateEvidenceV1::Code(_) => { + (1..=limits.max_evidence).contains(&self.evidence.anchor_count()) + } + ContextScoutCandidateEvidenceV1::Session(_) => self.evidence.anchor_count() == 0, + }; if self.dedupe_key == [0; 32] || !safe_suggestion_text(&self.suggestion_text) || self.suggestion_text.len() > limits.max_text_bytes - || self.evidence.anchor_count() == 0 - || self.evidence.anchor_count() > limits.max_evidence + || !anchors_in_bounds || self.expires_at.0 <= 0 { return Err(ContextScoutErrorV1::InvalidCandidate); @@ -148,6 +161,8 @@ pub enum ContextScoutTriggerV1 { SavedEdit, StopBoundary, ExplicitRequest, + /// A session intent pass at a prompt, completed-tool, or stop boundary. + IntentBoundary, } /// Content-free daemon receptivity state. The selector owns delivery timing; @@ -183,7 +198,9 @@ pub const fn select_context_scout_delivery_window( } match input.trigger { ContextScoutTriggerV1::SavedEdit => ContextScoutDeliveryWindowV1::NextBoundary, - ContextScoutTriggerV1::StopBoundary => ContextScoutDeliveryWindowV1::Immediate, + ContextScoutTriggerV1::StopBoundary | ContextScoutTriggerV1::IntentBoundary => { + ContextScoutDeliveryWindowV1::Immediate + } ContextScoutTriggerV1::ExplicitRequest => ContextScoutDeliveryWindowV1::OnRequest, } } @@ -203,6 +220,8 @@ pub enum ContextScoutSuppressionV1 { EvidencePartial, EvidenceStale, EvidenceUnavailable, + /// A suggestion for the same session is already pending delivery. + PendingSibling, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -391,7 +410,7 @@ pub fn select_deterministic_context_scout( saw_unavailable = true; continue; } - match candidate.evidence.availability { + match candidate.evidence.availability() { ContextScoutEvidenceAvailabilityV1::Complete => {} ContextScoutEvidenceAvailabilityV1::Partial if input.delivery_window == ContextScoutDeliveryWindowV1::OnRequest => {} @@ -1224,6 +1243,9 @@ fn validate_durable_envelope( || envelope.configuration_revision == [0; 32] || envelope.delivery_window == ContextScoutDeliveryWindowV1::Suppressed || !envelope.candidate.durable() + // The public projection pins a content generation, so session + // evidence must carry the generation it was judged against. + || envelope.candidate.evidence.code_generation_id().is_none() { return Err(ContextScoutErrorV1::DirtyOverlayDurabilityViolation); } @@ -1260,8 +1282,23 @@ impl ContextScoutDurableQueueEntryExt for ContextScoutDurableQueueEntryV1 { | ContextScoutModelOutcomeV1::InvalidOutput, None, ) => {} + // The intent pass records its engine outcome in the session + // evidence's decision receipt, never in a Codex model receipt. + (ContextScoutRouteV1::DecisionEngine, outcome, None) + if self + .envelope + .candidate + .evidence + .as_session() + .is_some_and(|evidence| evidence.decision.outcome == outcome) => {} _ => return Err(ContextScoutErrorV1::InvalidCandidate), } + // Session evidence is produced only by the intent pass. + if self.envelope.candidate.evidence.as_session().is_some() + != (self.route == ContextScoutRouteV1::DecisionEngine) + { + return Err(ContextScoutErrorV1::InvalidCandidate); + } validate_durable_envelope(&self.envelope)?; if self.work.address != self.envelope.address || self.work.input_watermark != self.envelope.input_watermark @@ -1343,6 +1380,18 @@ pub struct ContextScoutControlV1 { pub mode: ContextScoutRuntimeModeV1, pub model_path: Option, pub limits: ContextScoutLimitsV1, + /// Pinned session intent pass, absent when none is configured. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intent: Option, +} + +/// Behavior pin of the configured intent pass: whether it runs and the digest +/// of its whole settings block (engine, window, questions, lifecycle bounds). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ContextScoutIntentControlV1 { + pub enabled: bool, + pub settings_digest: [u8; 32], } impl ContextScoutControlV1 { @@ -1354,6 +1403,9 @@ impl ContextScoutControlV1 { | (ContextScoutRuntimeModeV1::ConfiguredModel, None) ) || self.model_path == Some(ContextScoutModelBackendV1::Disabled) + || self + .intent + .is_some_and(|intent| intent.settings_digest == [0; 32]) { return Err(ContextScoutErrorV1::InvalidLimits); } @@ -1551,21 +1603,35 @@ where mode: ContextScoutRuntimeModeV1, model_execution: ContextScoutModelExecutionV1, ) -> Result { - let selection = match mode { - ContextScoutRuntimeModeV1::Deterministic => ContextScoutSelectionV1 { - route: ContextScoutRouteV1::Deterministic, + let selection = if let Some(model_outcome) = intent_pass_model_outcome(input)? { + // Intent-pass candidates were judged by the decision engine when + // they were built; selection among them is deterministic, and the + // route records the engine outcome their receipts carry. + ContextScoutSelectionV1 { + route: ContextScoutRouteV1::DecisionEngine, decision: select_deterministic_context_scout(input, limits)?, - model_outcome: ContextScoutModelOutcomeV1::NotRequested, + model_outcome, model_receipt: None, - }, - ContextScoutRuntimeModeV1::ConfiguredModel => { - select_model_assisted_context_scout(input, limits, &self.model, model_execution) - .await? + } + } else { + match mode { + ContextScoutRuntimeModeV1::Deterministic => ContextScoutSelectionV1 { + route: ContextScoutRouteV1::Deterministic, + decision: select_deterministic_context_scout(input, limits)?, + model_outcome: ContextScoutModelOutcomeV1::NotRequested, + model_receipt: None, + }, + ContextScoutRuntimeModeV1::ConfiguredModel => { + select_model_assisted_context_scout(input, limits, &self.model, model_execution) + .await? + } } }; self.last_route = Some(selection.route); - self.last_model_outcome = Some(selection.model_outcome); - self.last_model_receipt.clone_from(&selection.model_receipt); + if selection.route != ContextScoutRouteV1::DecisionEngine { + self.last_model_outcome = Some(selection.model_outcome); + self.last_model_receipt.clone_from(&selection.model_receipt); + } if selection.model_outcome == ContextScoutModelOutcomeV1::Cancelled { self.last_suppression = Some(ContextScoutSuppressionV1::Cancelled); return Ok(ContextScoutRuntimeOutcomeV1::Suppressed { @@ -1660,6 +1726,27 @@ where } } +/// The engine outcome of an intent-pass selection, `None` for code-evidence +/// selections. One intent pass offers session evidence only, all of it from +/// one engine exchange; mixing evidence kinds is a malformed input. +fn intent_pass_model_outcome( + input: &ContextScoutSelectionInputV1, +) -> Result, ContextScoutErrorV1> { + let mut outcomes = input.candidates.iter().map(|candidate| { + candidate + .evidence + .as_session() + .map(|evidence| evidence.decision.outcome) + }); + let Some(first) = outcomes.next() else { + return Ok(None); + }; + if outcomes.any(|outcome| outcome != first) { + return Err(ContextScoutErrorV1::InvalidCandidate); + } + Ok(first) +} + #[cfg(test)] mod tests { use super::*; @@ -1667,7 +1754,7 @@ mod tests { use std::sync::Mutex; use std::time::Duration; use tempfile::TempDir; - use tracedecay_contracts::context_scout::ContextScoutEvidenceEnvelopeV1; + use tracedecay_contracts::context_scout::ContextScoutCandidateEvidenceV1; fn address() -> ContextScoutAddressV1 { ContextScoutAddressV1 { @@ -1737,8 +1824,8 @@ mod tests { ); } - fn evidence() -> ContextScoutEvidenceEnvelopeV1 { - super::evidence::fixture_context_scout_evidence() + fn evidence() -> ContextScoutCandidateEvidenceV1 { + super::evidence::fixture_context_scout_evidence().into() } fn candidate(key: u8, score: u16) -> ContextScoutCandidateV1 { @@ -1940,7 +2027,7 @@ mod tests { #[test] fn dirty_overlay_never_crosses_durable_checkpoint_or_receipt_boundary() { let mut overlay = candidate(1, 10); - overlay.evidence.content = + overlay.evidence.as_code_mut().unwrap().content = tracedecay_domain::feedback::FeedbackContentIdentityV1::EphemeralOverlay { session_id: tracedecay_domain::SessionId::new("session.overlay").unwrap(), owner_client_id: tracedecay_domain::HostInstanceId::new("client.overlay").unwrap(), @@ -2313,11 +2400,11 @@ mod tests { #[cfg(feature = "token-counting")] fn stale_candidate(key: u8, score: u16) -> ContextScoutCandidateV1 { - let fresh = evidence(); + let fresh = super::evidence::fixture_context_scout_evidence(); let mut sources = fresh.sources.clone(); sources[0].contribution_state = tracedecay_contracts::RetrieverContributionState::Stale; sources[0].temporal.freshness = tracedecay_contracts::FreshnessState::Stale; - let evidence = ContextScoutEvidenceEnvelopeV1::claim( + let evidence = tracedecay_contracts::context_scout::ContextScoutEvidenceEnvelopeV1::claim( fresh.scope, fresh.authorized_scope, fresh.content, @@ -2333,7 +2420,7 @@ mod tests { ContextScoutEvidenceAvailabilityV1::Stale ); ContextScoutCandidateV1 { - evidence, + evidence: evidence.into(), ..candidate(key, score) } } @@ -2731,6 +2818,7 @@ mod tests { mode: ContextScoutRuntimeModeV1::ConfiguredModel, model_path: Some(ContextScoutModelBackendV1::CodexAppServer), limits: ContextScoutLimitsV1::bounded_defaults(), + intent: None, }) .unwrap(); assert_eq!( @@ -2851,6 +2939,7 @@ mod tests { mode: ContextScoutRuntimeModeV1::ConfiguredModel, model_path: Some(ContextScoutModelBackendV1::CodexAppServer), limits, + intent: None, }; let widened_execution = ContextScoutModelExecutionV1 { deadline: MonotonicDeadline::at(Instant::now() + Duration::from_secs(5)), @@ -2901,6 +2990,7 @@ mod tests { mode: ContextScoutRuntimeModeV1::ConfiguredModel, model_path: Some(ContextScoutModelBackendV1::CodexAppServer), limits: ContextScoutLimitsV1::bounded_defaults(), + intent: None, }) .unwrap(); assert_eq!( @@ -2980,6 +3070,7 @@ mod tests { mode: ContextScoutRuntimeModeV1::Deterministic, model_path: None, limits: ContextScoutLimitsV1::bounded_defaults(), + intent: None, }) .unwrap(); assert_eq!( @@ -3006,7 +3097,7 @@ mod tests { }, ); let mut dirty = candidate(2, 10); - dirty.evidence.content = + dirty.evidence.as_code_mut().unwrap().content = tracedecay_domain::feedback::FeedbackContentIdentityV1::EphemeralOverlay { session_id: tracedecay_domain::SessionId::new("session.overlay").unwrap(), owner_client_id: tracedecay_domain::HostInstanceId::new("client.overlay").unwrap(), diff --git a/crates/tracedecay-agent-hosts/src/agents/context_scout/address_registry.rs b/crates/tracedecay-agent-hosts/src/agents/context_scout/address_registry.rs index c062b8955f..38c26d5699 100644 --- a/crates/tracedecay-agent-hosts/src/agents/context_scout/address_registry.rs +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout/address_registry.rs @@ -22,7 +22,7 @@ use tracedecay_contracts::{ use tracedecay_domain::configuration::{ CONTEXT_SCOUT_SETTINGS_SETTING_KEY, ConfigurationRevisionId, ConfigurationValueV1, ContextScoutConfigurationModeV1, ContextScoutConfigurationStateV1, - ContextScoutConfiguredModelPathV1, SettingKey, + ContextScoutConfiguredModelPathV1, ContextScoutIntentSettingsV1, SettingKey, }; use tracedecay_domain::feedback::{ FeedbackContentIdentityV1, FeedbackDiagnosticProducerV1, FeedbackFindingLifecycleV1, @@ -37,8 +37,8 @@ use tracedecay_hooks::{HookEventEnvelopeV2, HookScopeBindingV1}; use super::{ ContextScoutControlV1, ContextScoutDeliverySelectionInputV1, ContextScoutEvidenceEnvelopeExt, - ContextScoutLimitsV1, ContextScoutRuntimeModeV1, ContextScoutSelectionInputV1, - ContextScoutServiceStateV1, select_context_scout_delivery_window, + ContextScoutIntentControlV1, ContextScoutLimitsV1, ContextScoutRuntimeModeV1, + ContextScoutSelectionInputV1, ContextScoutServiceStateV1, select_context_scout_delivery_window, }; use tracedecay_runtime_core::db::Database; use tracedecay_runtime_core::db::engine::params; @@ -80,8 +80,36 @@ impl ContextScoutLifecycleAddressV1 { fn session_key(&self) -> (&ProjectId, &WorktreeId, &SessionId) { (&self.project_id, &self.worktree_id, &self.session_id) } + + /// The session-scoped address every intent-pass suggestion of this + /// routed session shares. It keeps the session key (so the registry binds + /// it beside the session's native lifecycles without conflict) and + /// replaces the per-turn identities with fixed intent identities, so a + /// rebind returns the same address and a newer pass supersedes the older + /// one by generation instead of accumulating one address per turn. + pub fn intent_scoped(&self) -> Option { + Some(Self { + profile_id: self.profile_id.clone(), + provider_id: self.provider_id.clone(), + project_id: self.project_id.clone(), + worktree_id: self.worktree_id.clone(), + session_id: self.session_id.clone(), + thread_id: ThreadId::new(INTENT_LIFECYCLE_IDENTITY).ok()?, + turn_id: TurnId::new(INTENT_LIFECYCLE_IDENTITY).ok()?, + agent_id: AgentInstanceId::new(INTENT_LIFECYCLE_IDENTITY).ok()?, + logical_message_id: MessageId::new(INTENT_LIFECYCLE_IDENTITY).ok()?, + }) + } + + /// Whether this is a session's intent-scoped lifecycle. + pub fn is_intent_scoped(&self) -> bool { + self.intent_scoped().is_some_and(|scoped| scoped == *self) + } } +/// Per-turn identity of every intent-scoped lifecycle. +const INTENT_LIFECYCLE_IDENTITY: &str = "tracedecay.context-scout.intent"; + /// Hook envelope whose exact daemon-issued binding has already been checked. #[derive(Clone, Debug)] pub struct AdmittedContextScoutHookV1 { @@ -106,6 +134,8 @@ pub struct ContextScoutConfigurationPinV1 { revision_id: ConfigurationRevisionId, configuration_digest: ManifestDigest, control: ContextScoutControlV1, + /// The intent-pass settings `control.intent` digests. + intent: Option, } impl ContextScoutConfigurationPinV1 { @@ -145,6 +175,13 @@ impl ContextScoutConfigurationPinV1 { ContextScoutModelBackendV1::CodexAppServer } }); + let intent = match &settings.intent { + Some(intent) => Some(ContextScoutIntentControlV1 { + enabled: intent.enabled, + settings_digest: digest_bytes(&intent.settings_digest().ok()?)?, + }), + None => None, + }; let configuration_digest = current.snapshot.effective_behavior_digest.clone(); let control = ContextScoutControlV1 { configuration_revision: digest_bytes(&configuration_digest)?, @@ -152,11 +189,13 @@ impl ContextScoutConfigurationPinV1 { mode, model_path, limits, + intent, }; Some(Self { revision_id: current.revision_id.clone(), configuration_digest, control, + intent: settings.intent.clone(), }) } @@ -172,6 +211,11 @@ impl ContextScoutConfigurationPinV1 { self.control } + /// The pinned intent-pass settings, absent when none are configured. + pub fn intent_settings(&self) -> Option<&ContextScoutIntentSettingsV1> { + self.intent.as_ref() + } + /// Revalidates this pin against the authoritative current configuration. /// A revision or effective-behavior change invalidates in-flight Scout /// work even when the host/session identity remains unchanged. @@ -1009,7 +1053,7 @@ pub fn context_scout_candidates_from_publication( category, relevance_score: u16::MAX, suggestion_text: text, - evidence, + evidence: evidence.into(), expires_at: UtcMicros(observed_at.0.saturating_add(CANDIDATE_TTL_MICROS_V1)), }) }) @@ -1255,6 +1299,33 @@ mod tests { model_path: Some(ContextScoutConfiguredModelPathV1::CodexAppServer), model_id: Some("gpt-5.6-mini".to_owned()), model_timeout_secs: Some(60), + intent: None, + }; + let with_intent = ContextScoutSettingsV1 { + intent: Some(tracedecay_domain::configuration::ContextScoutIntentSettingsV1 { + enabled: true, + engine: tracedecay_domain::configuration::DecisionEngineSettingsV1 { + base_url: "http://127.0.0.1:8088".to_owned(), + model: None, + timeout_ms: 250, + max_concurrent: 2, + max_state_bytes: 64 * 1024, + }, + window: tracedecay_domain::configuration::IntentWindowSettingsV1 { + max_user_turns: 3, + max_messages: 12, + max_bytes: 16 * 1024, + max_age_secs: 1_800, + freshen_budget_ms: 150, + }, + questions: + tracedecay_domain::configuration::ContextScoutIntentSettingsV1::reference_questions(), + action_fact_score_permille: 700, + ttl_secs: 600, + min_delivery_gap_secs: 30, + prompt_lookup_deadline_ms: 200, + }), + ..configured.clone() }; let configured = ContextScoutConfigurationPinV1::from_current(&configuration( "revision.scout.model", @@ -1265,6 +1336,30 @@ mod tests { configured.control().model_path, Some(ContextScoutModelBackendV1::CodexAppServer) ); + assert_eq!(configured.control().intent, None); + + let expected_digest = with_intent + .intent + .as_ref() + .unwrap() + .settings_digest() + .unwrap(); + let with_intent = ContextScoutConfigurationPinV1::from_current(&configuration( + "revision.scout.intent", + with_intent, + )) + .unwrap(); + assert_eq!( + with_intent.control().intent, + Some(ContextScoutIntentControlV1 { + enabled: true, + settings_digest: digest_bytes(&expected_digest).unwrap(), + }) + ); + assert_ne!( + with_intent.control().configuration_revision, + configured.control().configuration_revision + ); } #[test] diff --git a/crates/tracedecay-agent-hosts/src/agents/context_scout/decision_engine.rs b/crates/tracedecay-agent-hosts/src/agents/context_scout/decision_engine.rs new file mode 100644 index 0000000000..9675113355 --- /dev/null +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout/decision_engine.rs @@ -0,0 +1,1139 @@ +//! Pluggable typed-question client for the shared decision-model API. +//! +//! One `POST {base_url}/v1/systemone` carries a transient `state` string and a +//! set of typed questions (`noul` yes/no probability, `choice` over named +//! options, `score` over an ordered rubric) and returns one calibrated answer +//! per question key. The wire contract is served by `laya-serve`, Kev's +//! server, Ollama (0.35+) and llama.cpp, so response parsing is deliberately +//! tolerant about key names (`probs` / `probabilities` / `options`) and +//! strict about values: anything missing or out of range is +//! [`DecisionEngineOutcomeV1::Malformed`], never a guess. +//! +//! This module owns its request, answer, limit and identity types so that +//! configuration wiring only has to map operator settings onto +//! [`DecisionEngineConfigV1`]. It never persists the `state`: callers hand it +//! transient text, it truncates that text to a byte budget (keeping the most +//! recent tail) and sends it once. +//! +//! Failure is always a typed outcome, never a panic and never an error the +//! caller must interpret: an unreachable server, a blown timeout, a non-200, +//! an unparseable body and local back-pressure each leave the caller free to +//! fall back to its non-model path. + +use std::collections::BTreeMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; +use tracedecay_application::http_agent::http_agent; +use tracedecay_runtime_core::native_runtime; + +/// Path of the typed-question endpoint under the configured base URL. +const SYSTEMONE_PATH: &str = "/v1/systemone"; + +/// Tolerance when checking a `score` against the rubric's last level index. +const SCORE_EPSILON: f64 = 1e-6; + +/// What one question asks. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DecisionQuestionKindV1 { + /// A calibrated yes/no probability. + Noul, + /// One of `options`, with a probability per option. + Choice { options: Vec }, + /// A position on an ordered rubric; `levels[0]` is the lowest level. + Score { levels: Vec }, +} + +/// One typed question, addressed by `key` in the answer map. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecisionQuestionV1 { + pub key: String, + pub kind: DecisionQuestionKindV1, + pub instructions: String, +} + +/// One request: a transient `state` and the questions asked about it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecisionRequestV1 { + pub state: String, + pub questions: Vec, +} + +/// One parsed answer, matching the kind of the question it answers. +#[derive(Debug, Clone, PartialEq)] +pub enum DecisionAnswerV1 { + /// Probability in `[0, 1]`. + Noul(f64), + /// `chosen` is the server's pick when it named one; `probabilities` holds + /// every probability the server reported for a requested option. + Choice { + chosen: Option, + probabilities: BTreeMap, + }, + /// Expected level index on the rubric, in `[0, levels - 1]`. + Score(f64), +} + +/// Every answer of one successful exchange. +#[derive(Debug, Clone, PartialEq)] +pub struct DecisionAnswersV1 { + pub answers: BTreeMap, + /// The model name the server reported, when it reported one. + pub model: Option, + pub latency_micros: u64, +} + +/// Result of one ask. Every non-`Answered` variant means "no model opinion". +#[derive(Debug, Clone, PartialEq)] +pub enum DecisionEngineOutcomeV1 { + Answered(DecisionAnswersV1), + /// Connection, DNS, TLS or transport failure. + Unreachable, + /// The whole-request budget elapsed. + TimedOut, + /// The server answered with a non-200 status. + Refused { + status: u16, + }, + /// The body was not the expected shape, a requested answer was missing or + /// out of range, or the request itself was not well formed (no questions, + /// duplicate keys, an empty option list); invalid requests never reach + /// the network. + Malformed, + /// Every concurrency permit was in use; nothing was sent. + Backpressured, +} + +/// Client settings. Construct through [`DecisionEngineClientV1::new`], which +/// validates them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecisionEngineConfigV1 { + /// Base URL of the server, e.g. `http://127.0.0.1:8000`. + pub base_url: String, + /// Sent as `"model"` only when set. + pub model: Option, + /// Whole-request budget. + pub timeout: Duration, + /// Simultaneous in-flight requests this client allows. + pub max_concurrent: usize, + /// Byte budget for `state`; longer text keeps its newest tail. + pub max_state_bytes: usize, +} + +/// Why a [`DecisionEngineConfigV1`] was rejected. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum DecisionEngineConfigErrorV1 { + #[error("decision engine base URL is empty")] + EmptyBaseUrl, + #[error("decision engine base URL must start with http:// or https://")] + InvalidBaseUrl, + #[error("decision engine timeout must be positive")] + ZeroTimeout, + #[error("decision engine needs at least one concurrent request")] + ZeroConcurrency, + #[error("decision engine state budget must be positive")] + ZeroStateBytes, +} + +/// Stable identity of the configured engine: which server and which model a +/// decision came from. The URL is digested so receipts never carry it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecisionEngineIdentityV1 { + pub base_url_digest: [u8; 32], + pub model: Option, +} + +/// Blocking entry point of an engine, so callers and tests can substitute it. +pub trait DecisionEnginePortV1: Send + Sync { + fn ask_blocking(&self, request: &DecisionRequestV1) -> DecisionEngineOutcomeV1; + fn identity(&self) -> DecisionEngineIdentityV1; +} + +/// HTTP client for the decision-model API. +#[derive(Clone)] +pub struct DecisionEngineClientV1 { + endpoint: String, + identity: DecisionEngineIdentityV1, + agent: ureq::Agent, + in_flight: Arc, + max_concurrent: usize, + timeout: Duration, + max_state_bytes: usize, +} + +impl std::fmt::Debug for DecisionEngineClientV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DecisionEngineClientV1") + .field("model", &self.identity.model) + .field("timeout", &self.timeout) + .field("max_concurrent", &self.max_concurrent) + .finish_non_exhaustive() + } +} + +/// Releases one concurrency permit when dropped. +struct Permit(Arc); + +impl Drop for Permit { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::AcqRel); + } +} + +impl DecisionEngineClientV1 { + pub fn new(config: DecisionEngineConfigV1) -> Result { + let base = config.base_url.trim().trim_end_matches('/'); + if base.is_empty() { + return Err(DecisionEngineConfigErrorV1::EmptyBaseUrl); + } + if !(base.starts_with("http://") || base.starts_with("https://")) { + return Err(DecisionEngineConfigErrorV1::InvalidBaseUrl); + } + if config.timeout.is_zero() { + return Err(DecisionEngineConfigErrorV1::ZeroTimeout); + } + if config.max_concurrent == 0 { + return Err(DecisionEngineConfigErrorV1::ZeroConcurrency); + } + if config.max_state_bytes == 0 { + return Err(DecisionEngineConfigErrorV1::ZeroStateBytes); + } + let model = config + .model + .map(|model| model.trim().to_owned()) + .filter(|model| !model.is_empty()); + // The decision server is a sidecar the operator named explicitly: + // environment proxies would only add a hop, and redirects would let + // it send the transcript window somewhere it did not name. + let agent = http_agent( + ureq::Agent::config_builder() + .timeout_global(Some(config.timeout)) + .http_status_as_error(false) + .proxy(None) + .max_redirects(0) + .build(), + ); + Ok(Self { + endpoint: format!("{base}{SYSTEMONE_PATH}"), + identity: DecisionEngineIdentityV1 { + base_url_digest: Sha256::digest(base.as_bytes()).into(), + model, + }, + agent, + in_flight: Arc::new(AtomicUsize::new(0)), + max_concurrent: config.max_concurrent, + timeout: config.timeout, + max_state_bytes: config.max_state_bytes, + }) + } + + pub fn identity(&self) -> DecisionEngineIdentityV1 { + self.identity.clone() + } + + fn try_acquire(&self) -> Option { + let mut current = self.in_flight.load(Ordering::Acquire); + loop { + if current >= self.max_concurrent { + return None; + } + match self.in_flight.compare_exchange_weak( + current, + current + 1, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return Some(Permit(Arc::clone(&self.in_flight))), + Err(observed) => current = observed, + } + } + } + + /// Asks every question of `request` in one exchange. + /// + /// Blocks the calling thread for at most the configured timeout; call it + /// off an async executor (see [`Self::ask`]). + pub fn ask_blocking(&self, request: &DecisionRequestV1) -> DecisionEngineOutcomeV1 { + if !request_is_well_formed(request) { + tracing::debug!("decision engine request is not well formed"); + return DecisionEngineOutcomeV1::Malformed; + } + let Some(_permit) = self.try_acquire() else { + tracing::debug!("decision engine back-pressured"); + return DecisionEngineOutcomeV1::Backpressured; + }; + let started = Instant::now(); + let body = request_body( + request, + self.identity.model.as_deref(), + self.max_state_bytes, + ); + let mut response = match self.agent.post(&self.endpoint).send_json(&body) { + Ok(response) => response, + Err(error) => return transport_failure(&error), + }; + let status = response.status().as_u16(); + if status != 200 { + tracing::debug!(status, "decision engine refused"); + return DecisionEngineOutcomeV1::Refused { status }; + } + let value = match response.body_mut().read_json::() { + Ok(value) => value, + Err(error) => { + return if is_timeout(&error) { + tracing::debug!(%error, "decision engine timed out reading its answer"); + DecisionEngineOutcomeV1::TimedOut + } else { + tracing::debug!(%error, "decision engine answer unreadable"); + DecisionEngineOutcomeV1::Malformed + }; + } + }; + let latency_micros = u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX); + match parse_answers(&value, request) { + Some((answers, model)) => DecisionEngineOutcomeV1::Answered(DecisionAnswersV1 { + answers, + model, + latency_micros, + }), + None => { + tracing::debug!("decision engine answer malformed"); + DecisionEngineOutcomeV1::Malformed + } + } + } + + /// [`Self::ask_blocking`] on the runtime's blocking pool. A lost worker + /// reads as an unreachable engine. + pub async fn ask(&self, request: DecisionRequestV1) -> DecisionEngineOutcomeV1 { + let client = self.clone(); + match native_runtime::spawn_blocking(move || client.ask_blocking(&request)).await { + Ok(outcome) => outcome, + Err(error) => { + tracing::debug!(%error, "decision engine worker lost"); + DecisionEngineOutcomeV1::Unreachable + } + } + } +} + +impl DecisionEnginePortV1 for DecisionEngineClientV1 { + fn ask_blocking(&self, request: &DecisionRequestV1) -> DecisionEngineOutcomeV1 { + Self::ask_blocking(self, request) + } + + fn identity(&self) -> DecisionEngineIdentityV1 { + Self::identity(self) + } +} + +fn request_is_well_formed(request: &DecisionRequestV1) -> bool { + if request.questions.is_empty() { + return false; + } + let mut keys = std::collections::BTreeSet::new(); + request.questions.iter().all(|question| { + let kind_ok = match &question.kind { + DecisionQuestionKindV1::Noul => true, + DecisionQuestionKindV1::Choice { options } => { + let unique = options.iter().collect::>(); + !options.is_empty() && unique.len() == options.len() + } + DecisionQuestionKindV1::Score { levels } => !levels.is_empty(), + }; + kind_ok && !question.key.is_empty() && keys.insert(question.key.as_str()) + }) +} + +/// The newest `max_bytes` of `state`, cut on a character boundary. +pub(crate) fn tail_on_char_boundary(state: &str, max_bytes: usize) -> &str { + if state.len() <= max_bytes { + return state; + } + let mut start = state.len() - max_bytes; + while !state.is_char_boundary(start) { + start += 1; + } + &state[start..] +} + +fn request_body(request: &DecisionRequestV1, model: Option<&str>, max_state_bytes: usize) -> Value { + let mut questions = Map::new(); + for question in &request.questions { + let mut entry = Map::new(); + match &question.kind { + DecisionQuestionKindV1::Noul => { + entry.insert("type".to_owned(), json!("noul")); + } + DecisionQuestionKindV1::Choice { options } => { + entry.insert("type".to_owned(), json!("choice")); + entry.insert("criteria".to_owned(), json!(options)); + } + DecisionQuestionKindV1::Score { levels } => { + entry.insert("type".to_owned(), json!("score")); + entry.insert("criteria".to_owned(), json!(levels)); + } + } + entry.insert("instructions".to_owned(), json!(question.instructions)); + questions.insert(question.key.clone(), Value::Object(entry)); + } + let mut body = Map::new(); + body.insert( + "state".to_owned(), + json!(tail_on_char_boundary(&request.state, max_state_bytes)), + ); + body.insert("questions".to_owned(), Value::Object(questions)); + if let Some(model) = model { + body.insert("model".to_owned(), json!(model)); + } + Value::Object(body) +} + +fn transport_failure(error: &ureq::Error) -> DecisionEngineOutcomeV1 { + if is_timeout(error) { + tracing::debug!(%error, "decision engine timed out"); + DecisionEngineOutcomeV1::TimedOut + } else { + tracing::debug!(%error, "decision engine unreachable"); + DecisionEngineOutcomeV1::Unreachable + } +} + +fn is_timeout(error: &ureq::Error) -> bool { + match error { + ureq::Error::Timeout(_) => true, + ureq::Error::Io(io) => { + matches!( + io.kind(), + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + ) || io.to_string().to_ascii_lowercase().contains("timed out") + } + other => other.to_string().to_ascii_lowercase().contains("timeout"), + } +} + +type ParsedAnswers = (BTreeMap, Option); + +/// Every requested question must be answered with the requested kind; extra +/// answers are ignored. +fn parse_answers(value: &Value, request: &DecisionRequestV1) -> Option { + let answers = value.get("answers")?.as_object()?; + let mut parsed = BTreeMap::new(); + for question in &request.questions { + let answer = answers.get(&question.key)?.as_object()?; + parsed.insert(question.key.clone(), parse_answer(answer, &question.kind)?); + } + let model = value + .get("model") + .and_then(Value::as_str) + .map(str::to_owned) + .filter(|model| !model.is_empty()); + Some((parsed, model)) +} + +fn parse_answer( + answer: &Map, + kind: &DecisionQuestionKindV1, +) -> Option { + let declared = answer.get("type").and_then(Value::as_str); + match kind { + DecisionQuestionKindV1::Noul => { + if declared.is_some_and(|declared| declared != "noul") { + return None; + } + unit_interval(answer.get("noul")?).map(DecisionAnswerV1::Noul) + } + DecisionQuestionKindV1::Choice { options } => { + if declared.is_some_and(|declared| declared != "choice") { + return None; + } + let chosen = match answer.get("choice") { + None | Some(Value::Null) => None, + Some(value) => Some(label_text(value)?), + }; + if chosen + .as_ref() + .is_some_and(|chosen| !options.contains(chosen)) + { + return None; + } + let reported = ["probs", "probabilities", "options"] + .iter() + .find_map(|key| answer.get(*key)) + .map(|value| value.as_object()) + .map(|map| map.map(|map| choice_probabilities(map, options))); + let probabilities = match reported { + Some(Some(Some(probabilities))) => probabilities, + // A reported map that is not an object, or that carries an + // out-of-range value, poisons the whole answer. + Some(_) => return None, + None => BTreeMap::new(), + }; + if chosen.is_none() && probabilities.is_empty() { + return None; + } + Some(DecisionAnswerV1::Choice { + chosen, + probabilities, + }) + } + DecisionQuestionKindV1::Score { levels } => { + if declared.is_some_and(|declared| declared != "score") { + return None; + } + let score = answer.get("score")?.as_f64()?; + let highest = (levels.len() - 1) as f64; + (score.is_finite() && score >= -SCORE_EPSILON && score <= highest + SCORE_EPSILON) + .then(|| DecisionAnswerV1::Score(score.clamp(0.0, highest))) + } + } +} + +/// Probabilities for the requested options; keys the request did not name are +/// ignored, a requested option with a bad value voids the map. +fn choice_probabilities( + reported: &Map, + options: &[String], +) -> Option> { + let mut probabilities = BTreeMap::new(); + for (label, value) in reported { + if !options.contains(label) { + continue; + } + probabilities.insert(label.clone(), unit_interval(value)?); + } + Some(probabilities) +} + +fn unit_interval(value: &Value) -> Option { + value + .as_f64() + .filter(|probability| (0.0..=1.0).contains(probability)) +} + +fn label_text(value: &Value) -> Option { + match value { + Value::String(text) => Some(text.clone()), + Value::Number(number) => Some(number.to_string()), + Value::Bool(flag) => Some(flag.to_string()), + _ => None, + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +pub mod test_server { + use std::io::{BufRead, BufReader, Read, Write}; + use std::net::{Ipv4Addr, TcpListener}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + /// A loopback server answering every request with `status` and `body` + /// after `delay`, counting the requests it saw and keeping the last body. + pub struct FakeDecisionEngine { + pub url: String, + pub requests: Arc, + pub last_body: Arc>, + } + + pub fn fake_decision_engine(status: u16, body: &str, delay: Duration) -> FakeDecisionEngine { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(AtomicUsize::new(0)); + let last_body = Arc::new(Mutex::new(String::new())); + let body = body.to_string(); + let (seen, captured) = (Arc::clone(&requests), Arc::clone(&last_body)); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { return }; + let body = body.clone(); + let (seen, captured) = (Arc::clone(&seen), Arc::clone(&captured)); + std::thread::spawn(move || { + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut content_length = 0; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + break; + } + if let Some(value) = + line.to_ascii_lowercase().strip_prefix("content-length:") + { + content_length = value.trim().parse().unwrap_or(0); + } + } + let mut request_body = vec![0; content_length]; + let _ = reader.read_exact(&mut request_body); + *captured.lock().unwrap() = String::from_utf8_lossy(&request_body).into_owned(); + seen.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(delay); + let _ = write!( + stream, + "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + }); + } + }); + FakeDecisionEngine { + url, + requests, + last_body, + } + } + + /// A `noul` answer object. + pub fn noul_answer(probability: f64) -> String { + format!(r#"{{"type":"noul","noul":{probability}}}"#) + } + + /// A `choice` answer object with the server's pick and per-option probabilities + /// under `probabilities_key` (`probs`, `probabilities` or `options`). + pub fn choice_answer( + chosen: Option<&str>, + probabilities_key: &str, + probabilities: &[(&str, f64)], + ) -> String { + let map = probabilities + .iter() + .map(|(label, probability)| format!(r#""{label}":{probability}"#)) + .collect::>() + .join(","); + let chosen = chosen.map_or_else(String::new, |chosen| format!(r#""choice":"{chosen}","#)); + format!(r#"{{"type":"choice",{chosen}"{probabilities_key}":{{{map}}}}}"#) + } + + /// A `score` answer object. + pub fn score_answer(score: f64) -> String { + format!(r#"{{"type":"score","score":{score},"legend":{{"0":"low","1":"high"}}}}"#) + } + + /// A full response body from `(key, answer object)` pairs. + pub fn answers_body(model: Option<&str>, answers: &[(&str, String)]) -> String { + let answers = answers + .iter() + .map(|(key, answer)| format!(r#""{key}":{answer}"#)) + .collect::>() + .join(","); + let model = model.map_or_else(String::new, |model| format!(r#""model":"{model}","#)); + format!(r#"{{{model}"answers":{{{answers}}}}}"#) + } + + /// The single-question body of the session-recall style `noul` probe. + pub fn single_noul_body(key: &str, probability: f64) -> String { + answers_body(None, &[(key, noul_answer(probability))]) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use std::time::{Duration, Instant}; + + use super::test_server::{ + answers_body, choice_answer, fake_decision_engine, noul_answer, score_answer, + single_noul_body, + }; + use super::*; + + fn config(url: &str) -> DecisionEngineConfigV1 { + DecisionEngineConfigV1 { + base_url: url.to_owned(), + model: None, + timeout: Duration::from_secs(2), + max_concurrent: 2, + max_state_bytes: 4096, + } + } + + fn client(url: &str) -> DecisionEngineClientV1 { + DecisionEngineClientV1::new(config(url)).unwrap() + } + + fn noul(key: &str) -> DecisionQuestionV1 { + DecisionQuestionV1 { + key: key.to_owned(), + kind: DecisionQuestionKindV1::Noul, + instructions: format!("is {key} true?"), + } + } + + fn choice(key: &str, options: &[&str]) -> DecisionQuestionV1 { + DecisionQuestionV1 { + key: key.to_owned(), + kind: DecisionQuestionKindV1::Choice { + options: options.iter().map(|option| (*option).to_owned()).collect(), + }, + instructions: format!("which {key}?"), + } + } + + fn score(key: &str, levels: &[&str]) -> DecisionQuestionV1 { + DecisionQuestionV1 { + key: key.to_owned(), + kind: DecisionQuestionKindV1::Score { + levels: levels.iter().map(|level| (*level).to_owned()).collect(), + }, + instructions: format!("how much {key}?"), + } + } + + fn request(state: &str, questions: Vec) -> DecisionRequestV1 { + DecisionRequestV1 { + state: state.to_owned(), + questions, + } + } + + fn answered(outcome: DecisionEngineOutcomeV1) -> DecisionAnswersV1 { + match outcome { + DecisionEngineOutcomeV1::Answered(answers) => answers, + other => panic!("expected an answer, got {other:?}"), + } + } + + #[test] + fn invalid_configuration_is_rejected() { + let invalid = |edit: fn(&mut DecisionEngineConfigV1)| { + let mut config = config("http://127.0.0.1:1"); + edit(&mut config); + DecisionEngineClientV1::new(config).unwrap_err() + }; + assert_eq!( + invalid(|config| config.base_url = " ".to_owned()), + DecisionEngineConfigErrorV1::EmptyBaseUrl + ); + assert_eq!( + invalid(|config| config.base_url = "127.0.0.1:8000".to_owned()), + DecisionEngineConfigErrorV1::InvalidBaseUrl + ); + assert_eq!( + invalid(|config| config.timeout = Duration::ZERO), + DecisionEngineConfigErrorV1::ZeroTimeout + ); + assert_eq!( + invalid(|config| config.max_concurrent = 0), + DecisionEngineConfigErrorV1::ZeroConcurrency + ); + assert_eq!( + invalid(|config| config.max_state_bytes = 0), + DecisionEngineConfigErrorV1::ZeroStateBytes + ); + } + + #[test] + fn the_endpoint_joins_the_base_url_once() { + let client = client(" http://127.0.0.1:8000/ "); + assert_eq!(client.endpoint, "http://127.0.0.1:8000/v1/systemone"); + let same = DecisionEngineClientV1::new(config("http://127.0.0.1:8000")).unwrap(); + assert_eq!(client.identity(), same.identity()); + assert_eq!( + client.identity().base_url_digest, + <[u8; 32]>::from(Sha256::digest(b"http://127.0.0.1:8000")) + ); + } + + #[test] + fn identity_carries_the_trimmed_model_only_when_set() { + let mut with_model = config("http://127.0.0.1:8000"); + with_model.model = Some(" laya-1 ".to_owned()); + assert_eq!( + DecisionEngineClientV1::new(with_model) + .unwrap() + .identity() + .model + .as_deref(), + Some("laya-1") + ); + let mut blank = config("http://127.0.0.1:8000"); + blank.model = Some(" ".to_owned()); + assert_eq!( + DecisionEngineClientV1::new(blank).unwrap().identity().model, + None + ); + } + + #[test] + fn a_noul_answer_is_read_as_a_probability() { + let server = fake_decision_engine(200, &single_noul_body("recall", 0.73), Duration::ZERO); + let answers = + answered(client(&server.url).ask_blocking(&request("state", vec![noul("recall")]))); + assert_eq!(answers.answers["recall"], DecisionAnswerV1::Noul(0.73),); + assert_eq!(answers.model, None); + } + + #[test] + fn a_choice_answer_reads_the_pick_and_every_probability_spelling() { + for key in ["probs", "probabilities", "options"] { + let body = answers_body( + Some("laya-test"), + &[( + "route", + choice_answer(Some("code"), key, &[("code", 0.7), ("memory", 0.3)]), + )], + ); + let server = fake_decision_engine(200, &body, Duration::ZERO); + let answers = answered(client(&server.url).ask_blocking(&request( + "state", + vec![choice("route", &["code", "memory"])], + ))); + assert_eq!(answers.model.as_deref(), Some("laya-test"), "{key}"); + assert_eq!( + answers.answers["route"], + DecisionAnswerV1::Choice { + chosen: Some("code".to_owned()), + probabilities: BTreeMap::from([ + ("code".to_owned(), 0.7), + ("memory".to_owned(), 0.3) + ]), + }, + "{key}" + ); + } + } + + #[test] + fn a_choice_without_a_pick_still_answers_from_its_probabilities() { + let body = answers_body( + None, + &[( + "route", + choice_answer(None, "probabilities", &[("code", 0.4), ("memory", 0.6)]), + )], + ); + let server = fake_decision_engine(200, &body, Duration::ZERO); + let answers = answered(client(&server.url).ask_blocking(&request( + "state", + vec![choice("route", &["code", "memory"])], + ))); + let DecisionAnswerV1::Choice { + chosen, + probabilities, + } = &answers.answers["route"] + else { + panic!("choice expected"); + }; + assert_eq!(*chosen, None); + assert_eq!(probabilities.len(), 2); + } + + #[test] + fn a_score_answer_is_read_as_a_level_position() { + let body = answers_body(None, &[("urgency", score_answer(1.4))]); + let server = fake_decision_engine(200, &body, Duration::ZERO); + let answers = answered(client(&server.url).ask_blocking(&request( + "state", + vec![score("urgency", &["low", "mid", "high"])], + ))); + assert_eq!(answers.answers["urgency"], DecisionAnswerV1::Score(1.4)); + } + + #[test] + fn several_questions_are_answered_in_one_exchange() { + let body = answers_body( + Some("m"), + &[ + ("recall", noul_answer(0.9)), + ( + "route", + choice_answer(Some("memory"), "probs", &[("code", 0.1), ("memory", 0.9)]), + ), + ("urgency", score_answer(0.5)), + ("unrequested", noul_answer(0.1)), + ], + ); + let server = fake_decision_engine(200, &body, Duration::ZERO); + let answers = answered(client(&server.url).ask_blocking(&request( + "state", + vec![ + noul("recall"), + choice("route", &["code", "memory"]), + score("urgency", &["low", "high"]), + ], + ))); + assert_eq!(answers.answers.len(), 3, "unrequested answers are dropped"); + assert_eq!(server.requests.load(Ordering::SeqCst), 1); + } + + #[test] + fn the_request_asks_each_question_by_key_and_sends_model_only_when_set() { + let body = answers_body( + None, + &[ + ("recall", noul_answer(0.9)), + ( + "route", + choice_answer(Some("code"), "probs", &[("code", 1.0), ("memory", 0.0)]), + ), + ("urgency", score_answer(0.0)), + ], + ); + let questions = || { + vec![ + noul("recall"), + choice("route", &["code", "memory"]), + score("urgency", &["low", "high"]), + ] + }; + + let server = fake_decision_engine(200, &body, Duration::ZERO); + client(&server.url).ask_blocking(&request("the prompt", questions())); + let sent: Value = serde_json::from_str(&server.last_body.lock().unwrap()).unwrap(); + assert_eq!(sent["state"], "the prompt"); + assert!(sent.get("model").is_none()); + let asked = sent["questions"].as_object().unwrap(); + assert_eq!(asked.len(), 3); + assert_eq!(asked["recall"]["type"], "noul"); + assert_eq!(asked["recall"]["instructions"], "is recall true?"); + assert!(asked["recall"].get("criteria").is_none()); + assert_eq!(asked["route"]["type"], "choice"); + assert_eq!(asked["route"]["criteria"], json!(["code", "memory"])); + assert_eq!(asked["urgency"]["type"], "score"); + assert_eq!(asked["urgency"]["criteria"], json!(["low", "high"])); + + let with_model = fake_decision_engine(200, &body, Duration::ZERO); + let mut configured = config(&with_model.url); + configured.model = Some("laya-1".to_owned()); + DecisionEngineClientV1::new(configured) + .unwrap() + .ask_blocking(&request("the prompt", questions())); + let sent: Value = serde_json::from_str(&with_model.last_body.lock().unwrap()).unwrap(); + assert_eq!(sent["model"], "laya-1"); + } + + #[test] + fn an_oversized_state_keeps_its_newest_tail_on_a_char_boundary() { + assert_eq!(tail_on_char_boundary("abcdef", 4), "cdef"); + assert_eq!(tail_on_char_boundary("abc", 10), "abc"); + // Cutting mid-character advances to the next boundary. + assert_eq!(tail_on_char_boundary("aéz", 2), "z"); + assert_eq!(tail_on_char_boundary("aéz", 3), "éz"); + + let server = fake_decision_engine(200, &single_noul_body("q", 0.5), Duration::ZERO); + let mut configured = config(&server.url); + configured.max_state_bytes = 8; + let client = DecisionEngineClientV1::new(configured).unwrap(); + client.ask_blocking(&request("0123456789 newest", vec![noul("q")])); + let sent: Value = serde_json::from_str(&server.last_body.lock().unwrap()).unwrap(); + assert_eq!(sent["state"], "9 newest"); + } + + #[test] + fn unusable_answers_are_malformed_or_refused() { + let cases: Vec<(u16, String, DecisionEngineOutcomeV1)> = vec![ + ( + 500, + r#"{"detail":"inference failed"}"#.to_owned(), + DecisionEngineOutcomeV1::Refused { status: 500 }, + ), + ( + 404, + String::new(), + DecisionEngineOutcomeV1::Refused { status: 404 }, + ), + ( + 200, + "not json".to_owned(), + DecisionEngineOutcomeV1::Malformed, + ), + ( + 200, + r#"{"answers":{}}"#.to_owned(), + DecisionEngineOutcomeV1::Malformed, + ), + (200, r#"{}"#.to_owned(), DecisionEngineOutcomeV1::Malformed), + ( + 200, + answers_body(None, &[("q", noul_answer(1.5))]), + DecisionEngineOutcomeV1::Malformed, + ), + ( + 200, + answers_body(None, &[("q", noul_answer(-0.1))]), + DecisionEngineOutcomeV1::Malformed, + ), + ( + 200, + answers_body(None, &[("q", r#"{"type":"noul"}"#.to_owned())]), + DecisionEngineOutcomeV1::Malformed, + ), + ( + 200, + answers_body(None, &[("q", r#"{"type":"noul","noul":"0.5"}"#.to_owned())]), + DecisionEngineOutcomeV1::Malformed, + ), + ( + 200, + answers_body(None, &[("q", score_answer(0.5))]), + DecisionEngineOutcomeV1::Malformed, + ), + ]; + for (status, body, expected) in cases { + let server = fake_decision_engine(status, &body, Duration::ZERO); + assert_eq!( + client(&server.url).ask_blocking(&request("anything", vec![noul("q")])), + expected, + "{status} {body}" + ); + } + } + + #[test] + fn choice_and_score_answers_out_of_range_are_malformed() { + let choice_cases = [ + // Chosen option the request never offered. + choice_answer(Some("other"), "probs", &[("code", 0.5), ("memory", 0.5)]), + // Probability outside [0, 1]. + choice_answer(Some("code"), "probs", &[("code", 1.2), ("memory", 0.0)]), + // Neither a pick nor probabilities. + r#"{"type":"choice"}"#.to_owned(), + // Wrong answer kind for the question. + noul_answer(0.5), + ]; + for answer in choice_cases { + let server = fake_decision_engine( + 200, + &answers_body(None, &[("q", answer.clone())]), + Duration::ZERO, + ); + assert_eq!( + client(&server.url) + .ask_blocking(&request("s", vec![choice("q", &["code", "memory"])])), + DecisionEngineOutcomeV1::Malformed, + "{answer}" + ); + } + for value in [-0.5, 2.5, 99.0] { + let server = fake_decision_engine( + 200, + &answers_body(None, &[("q", score_answer(value))]), + Duration::ZERO, + ); + assert_eq!( + client(&server.url).ask_blocking(&request("s", vec![score("q", &["a", "b"])])), + DecisionEngineOutcomeV1::Malformed, + "{value}" + ); + } + } + + #[test] + fn invalid_requests_never_reach_the_network() { + let server = fake_decision_engine(200, &single_noul_body("q", 0.5), Duration::ZERO); + let client = client(&server.url); + for bad in [ + request("s", vec![]), + request("s", vec![noul("q"), noul("q")]), + request("s", vec![noul("")]), + request("s", vec![choice("q", &[])]), + request("s", vec![choice("q", &["a", "a"])]), + request("s", vec![score("q", &[])]), + ] { + assert_eq!( + client.ask_blocking(&bad), + DecisionEngineOutcomeV1::Malformed + ); + } + assert_eq!(server.requests.load(Ordering::SeqCst), 0); + } + + #[test] + fn a_slow_engine_is_abandoned_at_the_timeout() { + let server = + fake_decision_engine(200, &single_noul_body("q", 0.99), Duration::from_secs(5)); + let mut configured = config(&server.url); + configured.timeout = Duration::from_millis(150); + let client = DecisionEngineClientV1::new(configured).unwrap(); + let started = Instant::now(); + assert_eq!( + client.ask_blocking(&request("s", vec![noul("q")])), + DecisionEngineOutcomeV1::TimedOut + ); + assert!(started.elapsed() < Duration::from_secs(2)); + } + + #[test] + fn an_unreachable_engine_is_unreachable() { + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let mut configured = config(&format!("http://127.0.0.1:{port}")); + configured.timeout = Duration::from_millis(500); + assert_eq!( + DecisionEngineClientV1::new(configured) + .unwrap() + .ask_blocking(&request("s", vec![noul("q")])), + DecisionEngineOutcomeV1::Unreachable + ); + } + + #[test] + fn exhausted_permits_back_pressure_instead_of_queueing() { + let server = + fake_decision_engine(200, &single_noul_body("q", 0.5), Duration::from_millis(700)); + let mut configured = config(&server.url); + configured.max_concurrent = 1; + configured.timeout = Duration::from_secs(5); + let client = DecisionEngineClientV1::new(configured).unwrap(); + + let holder = { + let client = client.clone(); + std::thread::spawn(move || client.ask_blocking(&request("s", vec![noul("q")]))) + }; + let deadline = Instant::now() + Duration::from_secs(3); + while server.requests.load(Ordering::SeqCst) == 0 { + assert!(Instant::now() < deadline, "the first request never arrived"); + std::thread::sleep(Duration::from_millis(5)); + } + + assert_eq!( + client.ask_blocking(&request("s", vec![noul("q")])), + DecisionEngineOutcomeV1::Backpressured + ); + assert_eq!(server.requests.load(Ordering::SeqCst), 1); + + assert!(matches!( + holder.join().unwrap(), + DecisionEngineOutcomeV1::Answered(_) + )); + // The permit is returned once the holder finishes. + assert!(matches!( + client.ask_blocking(&request("s", vec![noul("q")])), + DecisionEngineOutcomeV1::Answered(_) + )); + } + + #[asupersync::test] + async fn the_async_entry_point_answers_off_the_executor() { + let server = fake_decision_engine(200, &single_noul_body("q", 0.25), Duration::ZERO); + let outcome = client(&server.url).ask(request("s", vec![noul("q")])).await; + assert_eq!(answered(outcome).answers["q"], DecisionAnswerV1::Noul(0.25)); + } + + #[test] + fn the_client_is_usable_as_a_port() { + let server = fake_decision_engine(200, &single_noul_body("q", 0.5), Duration::ZERO); + let port: Box = Box::new(client(&server.url)); + assert!(matches!( + port.ask_blocking(&request("s", vec![noul("q")])), + DecisionEngineOutcomeV1::Answered(_) + )); + assert_eq!(port.identity().model, None); + } + + #[test] + fn answer_arithmetic_helpers_reject_non_numbers() { + assert_eq!(unit_interval(&json!(0.0)), Some(0.0)); + assert_eq!(unit_interval(&json!(1)), Some(1.0)); + assert_eq!(unit_interval(&json!("0.5")), None); + assert_eq!(unit_interval(&Value::Null), None); + } +} diff --git a/crates/tracedecay-agent-hosts/src/agents/context_scout/evidence.rs b/crates/tracedecay-agent-hosts/src/agents/context_scout/evidence.rs index 737dff0c50..4a16f5a39a 100644 --- a/crates/tracedecay-agent-hosts/src/agents/context_scout/evidence.rs +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout/evidence.rs @@ -1,19 +1,27 @@ #[cfg(test)] use tracedecay_contracts::context_scout::ContextScoutEvidenceSourceKindV1; use tracedecay_contracts::context_scout::{ - ContextScoutEvidenceAvailabilityV1, ContextScoutEvidenceEnvelopeV1, - ContextScoutEvidenceSourceReceiptV1, ContextScoutRedactionReceiptV1, + ContextScoutCandidateEvidenceV1, ContextScoutEvidenceAvailabilityV1, + ContextScoutEvidenceEnvelopeV1, ContextScoutEvidenceSourceReceiptV1, ContextScoutIntentKindV1, + ContextScoutIntentReceiptV1, ContextScoutModelOutcomeV1, ContextScoutRedactionReceiptV1, + ContextScoutSessionEvidenceV1, ContextScoutSessionWindowV1, IntentDecisionSourceV1, }; use tracedecay_contracts::{ AuthorityReceipt, CoverageCompleteness, OmissionReason, ResolvedScope, RetrieverContributionState, }; use tracedecay_domain::feedback::{FeedbackContentIdentityV1, FeedbackScopeV1}; -use tracedecay_domain::{CodeGenerationId, RetrievalAnchorId, UtcMicros, canonical_sha256}; +use tracedecay_domain::{ + CodeGenerationId, ManifestDigest, ProviderId, RetrievalAnchorId, SessionId, UtcMicros, + canonical_sha256, +}; use super::{ContextScoutErrorV1, MAX_SCOUT_EVIDENCE}; const EVIDENCE_CLAIM_DIGEST_DOMAIN: &str = "tracedecay.context-scout.evidence-claim.v1"; +const SESSION_EVIDENCE_CLAIM_DIGEST_DOMAIN: &str = + "tracedecay.context-scout.session-evidence-claim.v1"; +pub(super) const MAX_DECISION_ENGINE_MODEL_BYTES: usize = 256; trait ContextScoutRedactionReceiptExt { fn validate(&self, authority: &AuthorityReceipt) -> Result<(), ContextScoutErrorV1>; @@ -169,15 +177,7 @@ impl ContextScoutEvidenceEnvelopeExt for ContextScoutEvidenceEnvelopeV1 { mut sources: Vec, claimed_at: UtcMicros, ) -> Result { - match &mut redaction { - ContextScoutRedactionReceiptV1::MetadataOnly { .. } => {} - ContextScoutRedactionReceiptV1::Sanitized { receipts, .. } => { - receipts.sort_by(|left, right| left.as_str().cmp(right.as_str())); - } - ContextScoutRedactionReceiptV1::Redacted { omissions, .. } => { - omissions.sort_by_key(|omission| (omission.domain, omission.reason)); - } - } + canonicalize_redaction(&mut redaction); for source in &mut sources { source .anchors @@ -293,6 +293,232 @@ impl ContextScoutEvidenceEnvelopeExt for ContextScoutEvidenceEnvelopeV1 { } } +/// Validation and anchors for either candidate evidence variant. Session +/// evidence cites no code anchors. +pub trait ContextScoutCandidateEvidenceExt { + fn validate(&self) -> Result<(), ContextScoutErrorV1>; + fn anchor_count(&self) -> usize; + fn anchors(&self) -> impl Iterator; +} + +impl ContextScoutCandidateEvidenceExt for ContextScoutCandidateEvidenceV1 { + fn validate(&self) -> Result<(), ContextScoutErrorV1> { + match self { + Self::Code(evidence) => ContextScoutEvidenceEnvelopeExt::validate(evidence), + Self::Session(evidence) => ContextScoutSessionEvidenceExt::validate(evidence), + } + } + + fn anchor_count(&self) -> usize { + self.as_code() + .map_or(0, ContextScoutEvidenceEnvelopeExt::anchor_count) + } + + fn anchors(&self) -> impl Iterator { + self.as_code() + .into_iter() + .flat_map(|evidence| evidence.sources.iter()) + .flat_map(|source| source.anchors.iter()) + } +} + +/// Everything a session-evidence claim binds, before its digest exists. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContextScoutSessionClaimInputV1 { + pub authorized_scope: ResolvedScope, + pub provider_id: ProviderId, + pub session_id: SessionId, + pub protected_session_id: [u8; 32], + pub window: ContextScoutSessionWindowV1, + pub prompt_excerpt_digest: Option, + pub code_generation_id: Option, + pub authority: AuthorityReceipt, + pub redaction: ContextScoutRedactionReceiptV1, + pub availability: ContextScoutEvidenceAvailabilityV1, + pub decision: ContextScoutIntentReceiptV1, + pub claimed_at: UtcMicros, +} + +pub trait ContextScoutSessionEvidenceExt: Sized { + /// Canonicalizes (sorted receipts, answers, and facts), digests, and + /// validates one session-evidence claim. + fn claim(input: ContextScoutSessionClaimInputV1) -> Result; + fn validate(&self) -> Result<(), ContextScoutErrorV1>; +} + +impl ContextScoutSessionEvidenceExt for ContextScoutSessionEvidenceV1 { + fn claim(input: ContextScoutSessionClaimInputV1) -> Result { + let ContextScoutSessionClaimInputV1 { + authorized_scope, + provider_id, + session_id, + protected_session_id, + window, + prompt_excerpt_digest, + code_generation_id, + authority, + mut redaction, + availability, + mut decision, + claimed_at, + } = input; + canonicalize_redaction(&mut redaction); + decision.answers.sort_by_key(|answer| answer.intent); + decision.facts.sort(); + let mut evidence = Self { + authorized_scope, + provider_id, + session_id, + protected_session_id, + window, + prompt_excerpt_digest, + code_generation_id, + authority, + redaction, + availability, + decision, + claimed_at, + claim_digest: ManifestDigest::zero() + .map_err(|_| ContextScoutErrorV1::InvalidEvidence)?, + }; + evidence.claim_digest = session_claim_digest(&evidence)?; + ContextScoutSessionEvidenceExt::validate(&evidence)?; + Ok(evidence) + } + + fn validate(&self) -> Result<(), ContextScoutErrorV1> { + self.authorized_scope + .validate() + .map_err(|_| ContextScoutErrorV1::InvalidEvidence)?; + self.provider_id + .validate() + .map_err(|_| ContextScoutErrorV1::InvalidEvidence)?; + self.session_id + .validate() + .map_err(|_| ContextScoutErrorV1::InvalidEvidence)?; + self.window + .window_digest + .validate() + .map_err(|_| ContextScoutErrorV1::InvalidEvidence)?; + if let Some(digest) = &self.prompt_excerpt_digest { + digest + .validate() + .map_err(|_| ContextScoutErrorV1::InvalidEvidence)?; + } + if let Some(generation) = &self.code_generation_id { + generation + .validate() + .map_err(|_| ContextScoutErrorV1::InvalidEvidence)?; + } + if self.protected_session_id == [0; 32] + || self.window.message_count == 0 + || self.window.first_store_id > self.window.last_store_id + || self.claimed_at.0 <= 0 + || self.authority.revalidated_at > self.claimed_at + { + return Err(ContextScoutErrorV1::InvalidEvidence); + } + self.authority + .validate_for(&self.authorized_scope) + .map_err(|_| ContextScoutErrorV1::InvalidEvidence)?; + self.redaction.validate(&self.authority)?; + validate_intent_receipt(&self.decision)?; + if session_claim_digest(self)? != self.claim_digest { + return Err(ContextScoutErrorV1::InvalidEvidence); + } + Ok(()) + } +} + +fn session_claim_digest( + evidence: &ContextScoutSessionEvidenceV1, +) -> Result { + canonical_sha256(&( + SESSION_EVIDENCE_CLAIM_DIGEST_DOMAIN, + &evidence.authorized_scope, + &evidence.provider_id, + &evidence.session_id, + evidence.protected_session_id, + &evidence.window, + &evidence.prompt_excerpt_digest, + &evidence.code_generation_id, + &evidence.authority, + &evidence.redaction, + evidence.availability, + &evidence.decision, + evidence.claimed_at, + )) + .map_err(|_| ContextScoutErrorV1::InvalidEvidence) +} + +/// A receipt is canonical (answers and facts strictly ordered by intent), +/// bounded, and names its selected intent through the source it claims: a +/// `Model` selection was answered at or above its threshold, an `ActionFact` +/// selection is an observed fact, and `Both` is both. Engine answers exist +/// only from an identified engine that succeeded. +fn validate_intent_receipt( + receipt: &ContextScoutIntentReceiptV1, +) -> Result<(), ContextScoutErrorV1> { + let max_intents = ContextScoutIntentKindV1::ALL.len(); + if receipt.answers.len() > max_intents + || receipt.facts.len() > max_intents + || receipt + .answers + .windows(2) + .any(|pair| pair[0].intent >= pair[1].intent) + || receipt.facts.windows(2).any(|pair| pair[0] >= pair[1]) + || receipt + .answers + .iter() + .any(|answer| answer.probability_permille > 1_000 || answer.threshold_permille > 1_000) + { + return Err(ContextScoutErrorV1::InvalidEvidence); + } + if let Some(engine) = &receipt.engine { + engine + .base_url_digest + .validate() + .map_err(|_| ContextScoutErrorV1::InvalidEvidence)?; + if engine.model.as_deref().is_some_and(|model| { + model.trim().is_empty() + || model.len() > MAX_DECISION_ENGINE_MODEL_BYTES + || model.chars().any(char::is_control) + }) { + return Err(ContextScoutErrorV1::InvalidEvidence); + } + } + let succeeded = receipt.outcome == ContextScoutModelOutcomeV1::Succeeded; + if (succeeded || !receipt.answers.is_empty()) && !(succeeded && receipt.engine.is_some()) { + return Err(ContextScoutErrorV1::InvalidEvidence); + } + let model_selected = receipt.answers.iter().any(|answer| { + answer.intent == receipt.selected + && answer.probability_permille >= answer.threshold_permille + }); + let fact_selected = receipt.facts.contains(&receipt.selected); + let consistent = match receipt.source { + IntentDecisionSourceV1::Model => model_selected, + IntentDecisionSourceV1::ActionFact => fact_selected, + IntentDecisionSourceV1::Both => model_selected && fact_selected, + }; + if !consistent { + return Err(ContextScoutErrorV1::InvalidEvidence); + } + Ok(()) +} + +fn canonicalize_redaction(redaction: &mut ContextScoutRedactionReceiptV1) { + match redaction { + ContextScoutRedactionReceiptV1::MetadataOnly { .. } => {} + ContextScoutRedactionReceiptV1::Sanitized { receipts, .. } => { + receipts.sort_by(|left, right| left.as_str().cmp(right.as_str())); + } + ContextScoutRedactionReceiptV1::Redacted { omissions, .. } => { + omissions.sort_by_key(|omission| (omission.domain, omission.reason)); + } + } +} + fn fold_availability( sources: &[ContextScoutEvidenceSourceReceiptV1], ) -> Result { @@ -412,3 +638,231 @@ pub(super) fn fixture_context_scout_evidence() -> ContextScoutEvidenceEnvelopeV1 ) .unwrap() } + +#[cfg(test)] +pub(super) fn fixture_context_scout_session_claim() -> ContextScoutSessionClaimInputV1 { + use tracedecay_contracts::context_scout::{ + ContextScoutIntentAnswerV1, DecisionEngineIdentityV1, + }; + + let code = fixture_context_scout_evidence(); + ContextScoutSessionClaimInputV1 { + authorized_scope: code.authorized_scope, + provider_id: tracedecay_domain::test_fixtures::id("provider.claude"), + session_id: tracedecay_domain::test_fixtures::id("session.scout"), + protected_session_id: [3; 32], + window: ContextScoutSessionWindowV1 { + first_store_id: 40, + last_store_id: 52, + message_count: 6, + window_digest: tracedecay_domain::test_fixtures::digest('f'), + newest_timestamp: Some(1_700_000_000_000), + }, + prompt_excerpt_digest: Some(tracedecay_domain::test_fixtures::digest('9')), + code_generation_id: Some(code.code_generation_id), + authority: code.authority, + redaction: code.redaction, + availability: ContextScoutEvidenceAvailabilityV1::Complete, + decision: ContextScoutIntentReceiptV1 { + engine: Some(DecisionEngineIdentityV1 { + base_url_digest: tracedecay_domain::test_fixtures::digest('8'), + model: Some("intent-small".to_owned()), + }), + outcome: ContextScoutModelOutcomeV1::Succeeded, + answers: vec![ + ContextScoutIntentAnswerV1 { + intent: ContextScoutIntentKindV1::SessionRecall, + probability_permille: 910, + threshold_permille: 600, + }, + ContextScoutIntentAnswerV1 { + intent: ContextScoutIntentKindV1::CallGraph, + probability_permille: 120, + threshold_permille: 600, + }, + ], + facts: vec![ + ContextScoutIntentKindV1::Search, + ContextScoutIntentKindV1::SessionRecall, + ], + selected: ContextScoutIntentKindV1::SessionRecall, + source: IntentDecisionSourceV1::Both, + }, + claimed_at: UtcMicros(2), + } +} + +#[cfg(test)] +pub(super) fn fixture_context_scout_session_evidence() -> ContextScoutSessionEvidenceV1 { + ContextScoutSessionEvidenceV1::claim(fixture_context_scout_session_claim()).unwrap() +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use tracedecay_contracts::context_scout::ContextScoutIntentAnswerV1; + + use super::*; + + fn rejected(mutate: impl FnOnce(&mut ContextScoutSessionClaimInputV1)) { + let mut claim = fixture_context_scout_session_claim(); + mutate(&mut claim); + assert_eq!( + ContextScoutSessionEvidenceV1::claim(claim), + Err(ContextScoutErrorV1::InvalidEvidence) + ); + } + + #[test] + fn session_claim_canonicalizes_and_validates() { + let evidence = fixture_context_scout_session_evidence(); + assert_eq!( + evidence + .decision + .answers + .iter() + .map(|answer| answer.intent) + .collect::>(), + vec![ + ContextScoutIntentKindV1::CallGraph, + ContextScoutIntentKindV1::SessionRecall + ], + "claim sorts answers by intent" + ); + assert_eq!( + evidence.decision.facts, + vec![ + ContextScoutIntentKindV1::Search, + ContextScoutIntentKindV1::SessionRecall + ] + ); + ContextScoutSessionEvidenceExt::validate(&evidence).unwrap(); + assert_eq!( + ContextScoutSessionEvidenceV1::claim(fixture_context_scout_session_claim()).unwrap(), + evidence, + "claims are deterministic" + ); + } + + #[test] + fn session_claim_digest_binds_every_field() { + let evidence = fixture_context_scout_session_evidence(); + let mut tampered = evidence.clone(); + tampered.window.last_store_id += 1; + assert_eq!( + ContextScoutSessionEvidenceExt::validate(&tampered), + Err(ContextScoutErrorV1::InvalidEvidence) + ); + let mut tampered = evidence.clone(); + tampered.decision.answers[1].probability_permille = 950; + assert_eq!( + ContextScoutSessionEvidenceExt::validate(&tampered), + Err(ContextScoutErrorV1::InvalidEvidence) + ); + let mut tampered = evidence; + tampered.claim_digest = tracedecay_domain::test_fixtures::digest('7'); + assert_eq!( + ContextScoutSessionEvidenceExt::validate(&tampered), + Err(ContextScoutErrorV1::InvalidEvidence) + ); + } + + #[test] + fn session_claim_requires_matching_scope_and_ordered_authority() { + rejected(|claim| { + claim.authority.authorized_scope_digest = tracedecay_domain::test_fixtures::digest('6'); + }); + rejected(|claim| claim.claimed_at = UtcMicros(0)); + rejected(|claim| claim.authority.revalidated_at = UtcMicros(3)); + rejected(|claim| claim.protected_session_id = [0; 32]); + rejected(|claim| { + claim.redaction = ContextScoutRedactionReceiptV1::MetadataOnly { + disclosure: tracedecay_contracts::DisclosureClass::Metadata, + }; + }); + } + + #[test] + fn session_window_must_be_non_empty_and_ordered() { + rejected(|claim| claim.window.message_count = 0); + rejected(|claim| { + claim.window.first_store_id = 53; + }); + let mut single = fixture_context_scout_session_claim(); + single.window.first_store_id = 52; + single.window.message_count = 1; + ContextScoutSessionEvidenceV1::claim(single).unwrap(); + } + + #[test] + fn intent_receipt_source_must_name_its_evidence() { + // Model selection needs a qualifying answer. + rejected(|claim| { + claim.decision.selected = ContextScoutIntentKindV1::CallGraph; + claim.decision.source = IntentDecisionSourceV1::Model; + }); + // ActionFact selection needs the fact. + rejected(|claim| { + claim.decision.selected = ContextScoutIntentKindV1::Impact; + claim.decision.source = IntentDecisionSourceV1::ActionFact; + }); + // Both needs both. + rejected(|claim| { + claim.decision.selected = ContextScoutIntentKindV1::Search; + }); + // Answers exist only from an identified, successful engine. + rejected(|claim| claim.decision.engine = None); + rejected(|claim| claim.decision.outcome = ContextScoutModelOutcomeV1::DeadlineExceeded); + // Repeated answers or facts are not canonical. + rejected(|claim| { + claim.decision.answers.push(ContextScoutIntentAnswerV1 { + intent: ContextScoutIntentKindV1::CallGraph, + probability_permille: 100, + threshold_permille: 600, + }); + }); + rejected(|claim| { + claim.decision.facts.push(ContextScoutIntentKindV1::Search); + }); + rejected(|claim| claim.decision.answers[0].probability_permille = 1_001); + rejected(|claim| { + claim.decision.engine.as_mut().unwrap().model = Some(" ".to_owned()); + }); + // A fact-only pass with a backpressured engine is valid. + let mut fact_only = fixture_context_scout_session_claim(); + fact_only.decision.engine = None; + fact_only.decision.outcome = ContextScoutModelOutcomeV1::Backpressured; + fact_only.decision.answers.clear(); + fact_only.decision.selected = ContextScoutIntentKindV1::Search; + fact_only.decision.source = IntentDecisionSourceV1::ActionFact; + ContextScoutSessionEvidenceV1::claim(fact_only).unwrap(); + } + + #[test] + fn candidate_evidence_union_is_tagged_and_dispatches() { + let session: ContextScoutCandidateEvidenceV1 = + fixture_context_scout_session_evidence().into(); + let code: ContextScoutCandidateEvidenceV1 = fixture_context_scout_evidence().into(); + ContextScoutCandidateEvidenceExt::validate(&session).unwrap(); + ContextScoutCandidateEvidenceExt::validate(&code).unwrap(); + assert_eq!(session.anchor_count(), 0); + assert_eq!(session.anchors().count(), 0); + assert_eq!(code.anchor_count(), 1); + + let encoded = serde_json::to_value(&session).unwrap(); + assert_eq!(encoded["kind"], "session"); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + session + ); + let encoded = serde_json::to_value(&code).unwrap(); + assert_eq!(encoded["kind"], "code"); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + code + ); + // The pre-union shape (a bare code envelope) does not decode. + let bare = serde_json::to_value(fixture_context_scout_evidence()).unwrap(); + assert!(serde_json::from_value::(bare).is_err()); + } +} diff --git a/crates/tracedecay-agent-hosts/src/agents/context_scout/intent_pass.rs b/crates/tracedecay-agent-hosts/src/agents/context_scout/intent_pass.rs new file mode 100644 index 0000000000..aec433daac --- /dev/null +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout/intent_pass.rs @@ -0,0 +1,910 @@ +//! The Context Scout session intent pass. +//! +//! One admitted prompt, tool, or stop boundary reads a bounded window of the +//! session's own ingested transcript ([`super::session_window`]), asks the +//! configured decision engine one batched set of yes/no intent questions +//! ([`super::decision_engine`]), derives deterministic action facts from the +//! window's newest turn, and turns the policy decision into at most one +//! session-evidence suggestion at the session's intent-scoped address. The +//! window body is transient: only its digest and store-id bounds enter the +//! durable claim, and the engine receives it once. +//! +//! Every early return is a typed outcome. A model never chooses receptivity: +//! quiet mode, spacing, duplicates, and the one-pending-per-session rule are +//! decided here and in the owner, after the engine answered. + +use std::collections::BTreeSet; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tracedecay_contracts::context_scout::{ + ContextScoutCandidateV1, ContextScoutCategoryV1, ContextScoutDeliveryReceiptV1, + ContextScoutDeliveryWindowV1, ContextScoutDurableQueueEntryV1, + ContextScoutEvidenceAvailabilityV1, ContextScoutIntentAnswerV1, ContextScoutIntentReceiptV1, + ContextScoutModelOutcomeV1, ContextScoutRedactionReceiptV1, ContextScoutSessionEvidenceV1, + ContextScoutSessionWindowV1, DecisionEngineIdentityV1 as ReceiptEngineIdentityV1, +}; +use tracedecay_contracts::{AuthorityReceipt, PolicyDecisionRef, RequestContext}; +use tracedecay_domain::configuration::{ + ContextScoutIntentKindV1, ContextScoutIntentSettingsV1, INTENT_MAX_PERMILLE, +}; +use tracedecay_domain::{ + CodeGenerationId, ComponentVersion, ManifestDigest, NativeHostIdentityV1, ProviderId, + UtcMicros, canonical_sha256, +}; +use tracedecay_global_db::{AnalyticsEventInsert, RegisteredGlobalDb}; +use tracedecay_policy::intent_action_facts::{ActionFactInputV1, action_facts}; +use tracedecay_policy::intent_catalog::suggestion_text; +use tracedecay_policy::intent_decision::{ + IntentCandidateV1, IntentDecisionInputV1, IntentDecisionV1, decide_intents, +}; +use tracedecay_runtime_core::cancellation::{CancellationToken, MonotonicDeadline}; +use tracedecay_runtime_core::native_runtime; +use tracedecay_sessions::SessionProvider; + +use super::address_registry::{ + AdmittedContextScoutHookV1, ContextScoutAddressBindOutcomeV1, ContextScoutAuthorityPinV1, + ContextScoutLifecycleAddressV1, ProjectContextScoutAddressRegistryV1, +}; +use super::decision_engine::{ + DecisionAnswerV1, DecisionEngineOutcomeV1, DecisionEnginePortV1, DecisionQuestionKindV1, + DecisionQuestionV1, DecisionRequestV1, +}; +use super::evidence::MAX_DECISION_ENGINE_MODEL_BYTES; +use super::owner::{ContextScoutIntentSessionStateV1, ProjectContextScoutOwnerV1}; +use super::session_window::{ + SessionWindowFreshnessV1, SessionWindowLimitsV1, SessionWindowOutcomeV1, + SessionWindowRequestV1, SessionWindowRoleV1, SessionWindowV1, read_session_window, + render_window_state, +}; +use super::{ + ContextScoutDeliverySelectionInputV1, ContextScoutDurableStoreOutcomeV1, + ContextScoutRuntimeOutcomeV1, ContextScoutSelectionInputV1, ContextScoutServiceStateV1, + ContextScoutSessionClaimInputV1, ContextScoutSessionEvidenceExt, ContextScoutSuppressionV1, + ContextScoutTriggerV1, select_context_scout_delivery_window, +}; + +const INTENT_CANDIDATE_DEDUPE_DOMAIN: &str = "tracedecay.context-scout.intent-candidate.v1"; +const INTENT_ENVELOPE_DOMAIN: &str = "tracedecay.context-scout.intent-envelope.v1"; +const INTENT_WATERMARK_DOMAIN: &str = "tracedecay.context-scout.intent-watermark.v1"; +const INTENT_POLICY_ID: &str = "policy.context-scout.intent-pass.v1"; +const INTENT_POLICY_VERSION: &str = "tracedecay.context-scout.intent-pass.v1"; +/// Interval between window reads while the pass waits for the boundary's +/// turn to be ingested. +const FRESHEN_POLL: Duration = Duration::from_millis(50); +/// Hosts record transcript times in whole seconds or milliseconds, so a turn +/// written in the same second as the boundary may read up to a second early. +const BOUNDARY_TIMESTAMP_SLACK_MICROS: i64 = 1_000_000; + +/// Where the pass records the suggestions it withheld, beside the hook hint +/// analytics hint settlement reads. +pub struct ContextScoutIntentAnalyticsV1<'a> { + pub analytics: &'a RegisteredGlobalDb, + /// `RegisteredGlobalDb::canonical_project_key` of the project root. + pub project_key: &'a str, +} + +/// Every input of one intent pass. The caller has admitted the hook, pinned +/// configuration and scope into `pin`, and authorized `context`. +pub struct ContextScoutIntentPassV1<'a> { + pub owner: &'a ProjectContextScoutOwnerV1, + pub registry: &'a ProjectContextScoutAddressRegistryV1, + /// The project session store the hosts ingest transcripts into. + pub sessions: &'a RegisteredGlobalDb, + pub analytics: Option>, + pub hook: &'a AdmittedContextScoutHookV1, + pub pin: &'a ContextScoutAuthorityPinV1, + pub context: &'a RequestContext, + /// The session's native lifecycle; the pass binds its intent scope. + pub lifecycle: &'a ContextScoutLifecycleAddressV1, + /// The project's current ready code generation. Session evidence is + /// pinned to it, so a project without one refuses the pass. + pub code_generation_id: Option, + /// The engine of the installed configuration; `None` when the configured + /// engine was refused. + pub engine: Option>, + pub observed_at: UtcMicros, + pub deadline: MonotonicDeadline, + pub cancellation: CancellationToken, +} + +/// Typed end of one intent pass. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ContextScoutIntentPassOutcomeV1 { + /// No enabled intent pass is configured; nothing was read or asked. + Disabled, + /// The project has no ready code generation to pin evidence to. + CodeGenerationUnavailable, + /// The host has no ingested transcript provider. + ProviderUnsupported, + /// The session's intent-scoped address could not be bound. + AddressUnavailable, + WindowUnavailable, + WindowEmpty, + WindowStale, + Cancelled, + /// The engine and action facts named nothing to suggest. + NoCandidate { + model_outcome: ContextScoutModelOutcomeV1, + }, + Suppressed { + reason: ContextScoutSuppressionV1, + model_outcome: ContextScoutModelOutcomeV1, + }, + Enqueued { + entry: Box, + /// The intent-scoped lifecycle the entry's address is bound to. + lifecycle: Box, + }, + Unavailable, +} + +impl ContextScoutIntentPassOutcomeV1 { + /// Stable operator-log name of the outcome. + pub const fn as_str(&self) -> &'static str { + match self { + Self::Disabled => "disabled", + Self::CodeGenerationUnavailable => "code_generation_unavailable", + Self::ProviderUnsupported => "provider_unsupported", + Self::AddressUnavailable => "address_unavailable", + Self::WindowUnavailable => "window_unavailable", + Self::WindowEmpty => "window_empty", + Self::WindowStale => "window_stale", + Self::Cancelled => "cancelled", + Self::NoCandidate { .. } => "no_candidate", + Self::Suppressed { .. } => "suppressed", + Self::Enqueued { .. } => "enqueued", + Self::Unavailable => "unavailable", + } + } +} + +/// The LCM transcript provider a hook host ingests under. Cursor's desktop +/// and cloud surfaces share one transcript provider; Droid ingests none. +pub fn intent_session_provider(host: NativeHostIdentityV1) -> Option { + match host { + NativeHostIdentityV1::ClaudeCode => Some(SessionProvider::Claude), + NativeHostIdentityV1::CursorDesktop | NativeHostIdentityV1::CursorCloud => { + Some(SessionProvider::Cursor) + } + NativeHostIdentityV1::Codex => Some(SessionProvider::Codex), + NativeHostIdentityV1::Hermes => Some(SessionProvider::Hermes), + NativeHostIdentityV1::Kiro => Some(SessionProvider::Kiro), + NativeHostIdentityV1::Cline => Some(SessionProvider::Cline), + NativeHostIdentityV1::RooCode => Some(SessionProvider::RooCode), + NativeHostIdentityV1::Kilo => Some(SessionProvider::Kilo), + NativeHostIdentityV1::KimiCode => Some(SessionProvider::Kimi), + NativeHostIdentityV1::OpenCode => Some(SessionProvider::OpenCode), + NativeHostIdentityV1::Pi => Some(SessionProvider::Pi), + NativeHostIdentityV1::FactoryDroid => None, + } +} + +/// Analytics provider of an intent suggestion: hint settlement strips the +/// `hook_` prefix to find the session's ingested activity. +pub fn intent_analytics_provider(provider_id: &ProviderId) -> String { + format!("hook_{}", provider_id.as_str()) +} + +/// The `hint_emitted` analytics row of one delivered intent-pass suggestion. +/// Hint settlement resolves it into `hint_outcome` acted/ignored from the +/// session's later ingested tool activity, exactly as for hook hints; the +/// metadata carries the decision receipt so the dashboard and fine-tuning +/// exports can explain it without the window body. `None` for code evidence. +pub fn intent_hint_emitted_event( + project_key: &str, + entry: &ContextScoutDurableQueueEntryV1, + receipt: &ContextScoutDeliveryReceiptV1, +) -> Option { + let evidence = entry.envelope.candidate.evidence.as_session()?; + let decision = &evidence.decision; + let metadata = serde_json::json!({ + "source": "context_scout_intent_pass", + "route": entry.route, + "engine": decision.engine, + "model_outcome": decision.outcome, + "decision_source": decision.source, + "answers": decision.answers, + "facts": decision.facts, + "window_digest": evidence.window.window_digest, + "window_message_count": evidence.window.message_count, + "relevance_score": entry.envelope.candidate.relevance_score, + "delivery_outcome": receipt.outcome, + }); + Some(AnalyticsEventInsert { + provider: intent_analytics_provider(&evidence.provider_id), + project_id: project_key.to_owned(), + session_id: Some(evidence.session_id.as_str().to_owned()), + timestamp: receipt.delivered_at.0.div_euclid(1_000_000), + event_kind: "hint_emitted".to_owned(), + hook_name: None, + tool_name: None, + tool_category: None, + skill_name: None, + hint_category: Some(decision.selected.as_key().to_owned()), + hint_id: Some(hex::encode(entry.envelope.envelope_id)), + outcome: None, + metadata_json: Some(metadata.to_string()), + }) +} + +/// Runs one intent pass. The engine is asked at most once. +#[tracing::instrument( + name = "hosts.agent.context_scout.intent_pass", + level = "trace", + skip_all +)] +pub async fn run_context_scout_intent_pass( + pass: ContextScoutIntentPassV1<'_>, +) -> ContextScoutIntentPassOutcomeV1 { + let configuration = pass.pin.configuration(); + let control = configuration.control(); + let Some(settings) = configuration + .intent_settings() + .filter(|settings| settings.enabled) + else { + return ContextScoutIntentPassOutcomeV1::Disabled; + }; + match control.state { + ContextScoutServiceStateV1::Disabled => return ContextScoutIntentPassOutcomeV1::Disabled, + ContextScoutServiceStateV1::Paused => { + pass.owner + .record_intent_outcome(ContextScoutSuppressionV1::Paused) + .await; + return ContextScoutIntentPassOutcomeV1::Suppressed { + reason: ContextScoutSuppressionV1::Paused, + model_outcome: ContextScoutModelOutcomeV1::NotRequested, + }; + } + ContextScoutServiceStateV1::Active => {} + } + let Some(code_generation_id) = pass.code_generation_id.clone() else { + return ContextScoutIntentPassOutcomeV1::CodeGenerationUnavailable; + }; + let envelope = pass.hook.envelope(); + let Some(provider) = intent_session_provider(envelope.producer) else { + return ContextScoutIntentPassOutcomeV1::ProviderUnsupported; + }; + let Ok(provider_id) = ProviderId::new(provider.id()) else { + return ContextScoutIntentPassOutcomeV1::ProviderUnsupported; + }; + let Some(intent_lifecycle) = pass.lifecycle.intent_scoped() else { + return ContextScoutIntentPassOutcomeV1::AddressUnavailable; + }; + let address = match pass + .registry + .bind(pass.hook, pass.pin, intent_lifecycle.clone()) + .await + { + ContextScoutAddressBindOutcomeV1::Bound(address) + | ContextScoutAddressBindOutcomeV1::Existing(address) => address, + ContextScoutAddressBindOutcomeV1::Conflict + | ContextScoutAddressBindOutcomeV1::Unavailable => { + return ContextScoutIntentPassOutcomeV1::AddressUnavailable; + } + }; + let Ok(session) = pass + .owner + .intent_session_state( + address, + pass.observed_at, + i64::from(settings.min_delivery_gap_secs).saturating_mul(1_000_000), + ) + .await + else { + return ContextScoutIntentPassOutcomeV1::Unavailable; + }; + + let window = match read_boundary_window(&pass, settings, provider.id()).await { + SessionWindowOutcomeV1::Ready(window) => window, + SessionWindowOutcomeV1::Empty => return ContextScoutIntentPassOutcomeV1::WindowEmpty, + SessionWindowOutcomeV1::Unavailable(reason) => { + tracing::debug!(%reason, "Context Scout intent window unavailable"); + return ContextScoutIntentPassOutcomeV1::WindowUnavailable; + } + }; + if matches!(window.freshness, SessionWindowFreshnessV1::Stale { .. }) { + return ContextScoutIntentPassOutcomeV1::WindowStale; + } + if pass.cancellation.is_cancelled() { + return ContextScoutIntentPassOutcomeV1::Cancelled; + } + + let engine_identity = pass.engine.as_ref().map(|engine| engine.identity()); + // Facts are pure string inspection over the window, independent of the + // engine; the engine exchange is the only bounded wait of the pass. + let facts = window_action_facts(&window); + let engine_outcome = match ask_engine(&pass, settings, &window).await { + EngineCallV1::Cancelled => return ContextScoutIntentPassOutcomeV1::Cancelled, + EngineCallV1::Finished(outcome) => outcome, + }; + let model_outcome = engine_outcome.model_outcome(); + let answers = engine_outcome.answers(); + + let decision = decide_intents(&IntentDecisionInputV1 { + answers: answers.as_deref(), + facts: &facts, + questions: &settings.questions, + action_fact_score_permille: settings.action_fact_score_permille, + delivered: &session.delivered_intents, + }); + let selected = match decision { + IntentDecisionV1::Candidates(candidates) if !candidates.is_empty() => candidates[0], + IntentDecisionV1::Candidates(_) + | IntentDecisionV1::BelowThreshold { .. } + | IntentDecisionV1::NoSignal => { + let duplicate = delivered_duplicate(answers.as_deref(), &facts, settings, &session); + let suppression = duplicate.map(|_| ContextScoutSuppressionV1::Duplicate); + pass.owner + .record_intent_outcome( + suppression.unwrap_or(ContextScoutSuppressionV1::NoEligibleCandidate), + ) + .await; + if let Some(intent) = duplicate { + record_withheld(&pass, "suppressed_duplicate", intent, &provider_id, None).await; + return ContextScoutIntentPassOutcomeV1::Suppressed { + reason: ContextScoutSuppressionV1::Duplicate, + model_outcome, + }; + } + return ContextScoutIntentPassOutcomeV1::NoCandidate { model_outcome }; + } + }; + + let delivery_window = + select_context_scout_delivery_window(&ContextScoutDeliverySelectionInputV1 { + trigger: ContextScoutTriggerV1::IntentBoundary, + quiet_mode: false, + has_recent_delivery: session.delivered_within_gap, + // A sibling still pending is refused as `PendingSibling`, and spacing + // after a displayed suggestion is the delivery gap above. + has_unresolved_interaction: false, + critical_safety_evidence: false, + delivered_dedupe_keys: session.delivered_dedupe_keys.clone(), + }); + let Some(envelope_id) = intent_envelope_id(pass.hook) else { + return ContextScoutIntentPassOutcomeV1::Unavailable; + }; + if !matches!( + delivery_window, + ContextScoutDeliveryWindowV1::Immediate | ContextScoutDeliveryWindowV1::NextBoundary + ) { + // An entry only an idle-window claim could take would also make the + // session's hook claim ambiguous; spacing withholds it instead. + pass.owner + .record_intent_outcome(ContextScoutSuppressionV1::QuietOrUnreceptive) + .await; + record_withheld( + &pass, + "suppressed_budget", + selected.intent, + &provider_id, + Some(envelope_id), + ) + .await; + return ContextScoutIntentPassOutcomeV1::Suppressed { + reason: ContextScoutSuppressionV1::QuietOrUnreceptive, + model_outcome, + }; + } + + let Some(candidate) = intent_candidate( + &pass, + settings, + IntentEvidenceV1 { + provider_id: provider_id.clone(), + window: &window, + code_generation_id, + engine_identity: engine_identity.as_ref(), + engine_outcome: &engine_outcome, + facts: &facts, + selected, + }, + ) else { + return ContextScoutIntentPassOutcomeV1::Unavailable; + }; + let Some(input_watermark) = digest_bytes(canonical_sha256(&( + INTENT_WATERMARK_DOMAIN, + window.window_digest, + control.configuration_revision, + selected.intent, + ))) else { + return ContextScoutIntentPassOutcomeV1::Unavailable; + }; + let input = ContextScoutSelectionInputV1 { + address, + input_watermark, + configuration_revision: control.configuration_revision, + envelope_id, + now: pass.observed_at, + delivery_window, + delivered_dedupe_keys: session.delivered_dedupe_keys, + candidates: vec![candidate], + }; + let prepared = pass + .owner + .prepare_intent(&input, pass.deadline, pass.cancellation.clone()) + .await + .map(|outcome| match outcome { + // The store kept the copy of this suggestion it already claimed + // or delivered to the session. + ContextScoutRuntimeOutcomeV1::Enqueued { + store_outcome: ContextScoutDurableStoreOutcomeV1::Duplicate, + .. + } => ContextScoutRuntimeOutcomeV1::Suppressed { + reason: ContextScoutSuppressionV1::Duplicate, + }, + outcome => outcome, + }); + match prepared { + Ok(ContextScoutRuntimeOutcomeV1::Enqueued { + entry, + store_outcome: ContextScoutDurableStoreOutcomeV1::Stored, + }) => ContextScoutIntentPassOutcomeV1::Enqueued { + entry, + lifecycle: Box::new(intent_lifecycle), + }, + Ok(ContextScoutRuntimeOutcomeV1::Suppressed { reason }) => { + let withheld = match reason { + ContextScoutSuppressionV1::Duplicate => Some("suppressed_duplicate"), + ContextScoutSuppressionV1::PendingSibling => Some("suppressed_budget"), + _ => None, + }; + if let Some(event_kind) = withheld { + record_withheld( + &pass, + event_kind, + selected.intent, + &provider_id, + Some(envelope_id), + ) + .await; + } + ContextScoutIntentPassOutcomeV1::Suppressed { + reason, + model_outcome, + } + } + Ok( + ContextScoutRuntimeOutcomeV1::Enqueued { .. } + | ContextScoutRuntimeOutcomeV1::Unavailable, + ) + | Err(_) => ContextScoutIntentPassOutcomeV1::Unavailable, + } +} + +/// Reads the window, waiting up to the configured freshen budget for the +/// boundary's own turn to land in the transcript store (hosts and the +/// historical ingestor write it; the pass never ingests on its own). +async fn read_boundary_window( + pass: &ContextScoutIntentPassV1<'_>, + settings: &ContextScoutIntentSettingsV1, + provider: &str, +) -> SessionWindowOutcomeV1 { + let now_micros = u64::try_from(pass.observed_at.0).unwrap_or(0); + let request = SessionWindowRequestV1 { + provider, + session_id: pass.lifecycle.session_id.as_str(), + limits: SessionWindowLimitsV1 { + max_user_turns: usize::from(settings.window.max_user_turns), + max_messages: usize::from(settings.window.max_messages), + max_bytes: usize::try_from(settings.window.max_bytes).unwrap_or(usize::MAX), + max_age_micros: Some(u64::from(settings.window.max_age_secs).saturating_mul(1_000_000)), + }, + now_micros, + }; + let freshen_until = + Instant::now() + Duration::from_millis(u64::from(settings.window.freshen_budget_ms)); + let boundary_at = pass.hook.envelope().observed_at.0; + loop { + let outcome = read_session_window(pass.sessions, &request).await; + let caught_up = match &outcome { + SessionWindowOutcomeV1::Ready(window) => { + window.newest_timestamp_micros.is_some_and(|newest| { + newest.saturating_add(BOUNDARY_TIMESTAMP_SLACK_MICROS) >= boundary_at + }) + } + SessionWindowOutcomeV1::Empty => false, + SessionWindowOutcomeV1::Unavailable(_) => true, + }; + let next = Instant::now() + FRESHEN_POLL; + if caught_up + || next > freshen_until + || pass.deadline.is_elapsed_at(next) + || pass.cancellation.is_cancelled() + || native_runtime::sleep_until(next).await.is_err() + { + return outcome; + } + } +} + +enum EngineCallV1 { + Finished(EngineOutcomeV1), + Cancelled, +} + +/// What the pass knows about the engine exchange. +enum EngineOutcomeV1 { + /// No question is configured, so the engine was not asked. + NotRequested, + /// The configured engine was refused when the configuration installed. + Unavailable, + /// The pass deadline elapsed before the engine answered. + DeadlineExceeded, + Asked(DecisionEngineOutcomeV1), +} + +impl EngineOutcomeV1 { + fn model_outcome(&self) -> ContextScoutModelOutcomeV1 { + match self { + Self::NotRequested => ContextScoutModelOutcomeV1::NotRequested, + Self::Unavailable => ContextScoutModelOutcomeV1::Unavailable, + Self::DeadlineExceeded => ContextScoutModelOutcomeV1::DeadlineExceeded, + Self::Asked(outcome) => match outcome { + DecisionEngineOutcomeV1::Answered(_) => ContextScoutModelOutcomeV1::Succeeded, + DecisionEngineOutcomeV1::Unreachable => ContextScoutModelOutcomeV1::Unavailable, + DecisionEngineOutcomeV1::TimedOut => ContextScoutModelOutcomeV1::DeadlineExceeded, + DecisionEngineOutcomeV1::Refused { .. } => ContextScoutModelOutcomeV1::Denied, + DecisionEngineOutcomeV1::Malformed => ContextScoutModelOutcomeV1::InvalidOutput, + DecisionEngineOutcomeV1::Backpressured => ContextScoutModelOutcomeV1::Backpressured, + }, + } + } + + /// Yes/no probabilities per intent, `None` unless the engine answered. + /// Keys that name no intent are not answers. + fn answers(&self) -> Option> { + let Self::Asked(DecisionEngineOutcomeV1::Answered(answers)) = self else { + return None; + }; + Some( + answers + .answers + .iter() + .filter_map(|(key, answer)| match answer { + DecisionAnswerV1::Noul(probability) => { + Some((ContextScoutIntentKindV1::from_key(key)?, *probability)) + } + DecisionAnswerV1::Choice { .. } | DecisionAnswerV1::Score(_) => None, + }) + .collect(), + ) + } + + fn reported_model(&self) -> Option<&str> { + match self { + Self::Asked(DecisionEngineOutcomeV1::Answered(answers)) => answers.model.as_deref(), + _ => None, + } + } +} + +/// Asks every configured question in one exchange, bounded by the pass +/// deadline and cancellation. The engine's own timeout bounds the request. +async fn ask_engine( + pass: &ContextScoutIntentPassV1<'_>, + settings: &ContextScoutIntentSettingsV1, + window: &SessionWindowV1, +) -> EngineCallV1 { + if settings.questions.is_empty() { + return EngineCallV1::Finished(EngineOutcomeV1::NotRequested); + } + let Some(engine) = pass.engine.clone() else { + return EngineCallV1::Finished(EngineOutcomeV1::Unavailable); + }; + let request = DecisionRequestV1 { + state: render_window_state( + window, + None, + usize::try_from(settings.engine.max_state_bytes).unwrap_or(usize::MAX), + ), + questions: settings + .questions + .iter() + .map(|question| DecisionQuestionV1 { + key: question.intent.as_key().to_owned(), + kind: DecisionQuestionKindV1::Noul, + instructions: question.instructions.clone(), + }) + .collect(), + }; + let asked = native_runtime::spawn_blocking(move || engine.ask_blocking(&request)); + tokio::select! { + biased; + () = pass.cancellation.cancelled() => EngineCallV1::Cancelled, + answered = native_runtime::timeout_at(pass.deadline.instant(), asked) => { + EngineCallV1::Finished(match answered { + Ok(Ok(outcome)) => EngineOutcomeV1::Asked(outcome), + // A lost blocking worker reads as an unreachable engine. + Ok(Err(_)) => EngineOutcomeV1::Asked(DecisionEngineOutcomeV1::Unreachable), + Err(_) => EngineOutcomeV1::DeadlineExceeded, + }) + } + } +} + +/// Action facts of the boundary's turn: the newest user message and every +/// tool call recorded after it. Earlier turns were judged by earlier passes. +fn window_action_facts(window: &SessionWindowV1) -> Vec { + let turn_start = window + .messages + .iter() + .rposition(|message| message.role == SessionWindowRoleV1::User); + let mut facts = Vec::new(); + let mut push = |found: Vec| { + for intent in found { + if !facts.contains(&intent) { + facts.push(intent); + } + } + }; + let turn = match turn_start { + Some(index) => { + let prompt = window.messages[index].text.as_str(); + if !prompt.is_empty() { + push(action_facts(&ActionFactInputV1 { + prompt: Some(prompt), + ..ActionFactInputV1::default() + })); + } + &window.messages[index + 1..] + } + None => window.messages.as_slice(), + }; + for message in turn { + for call in &message.tool_calls { + push(tool_call_facts( + &call.name, + call.argument_excerpt.as_deref(), + )); + } + } + facts +} + +fn tool_call_facts( + tool_name: &str, + argument_excerpt: Option<&str>, +) -> Vec { + // An excerpt that is still whole JSON names its arguments; a truncated + // one leaves only the tool's shape. + let arguments = argument_excerpt + .and_then(|excerpt| serde_json::from_str::(excerpt).ok()) + .filter(serde_json::Value::is_object); + let text_argument = |keys: &[&str]| { + arguments.as_ref().and_then(|arguments| { + keys.iter() + .find_map(|key| arguments.get(*key).and_then(serde_json::Value::as_str)) + .map(str::to_owned) + }) + }; + let command = text_argument(&["command", "cmd"]); + let file_path = text_argument(&["file_path", "path", "filePath", "target_file"]); + let subagent_type = text_argument(&["subagent_type"]); + action_facts(&ActionFactInputV1 { + tool_name: Some(tool_name), + command: command.as_deref(), + file_path: file_path.as_deref(), + subagent_type: subagent_type.as_deref(), + ..ActionFactInputV1::default() + }) +} + +/// The intent an already-delivered suggestion withheld: the decision with +/// the session's deliveries ignored would have named it first. +fn delivered_duplicate( + answers: Option<&[(ContextScoutIntentKindV1, f64)]>, + facts: &[ContextScoutIntentKindV1], + settings: &ContextScoutIntentSettingsV1, + session: &ContextScoutIntentSessionStateV1, +) -> Option { + if session.delivered_intents.is_empty() { + return None; + } + match decide_intents(&IntentDecisionInputV1 { + answers, + facts, + questions: &settings.questions, + action_fact_score_permille: settings.action_fact_score_permille, + delivered: &BTreeSet::new(), + }) { + IntentDecisionV1::Candidates(candidates) => candidates + .first() + .map(|candidate| candidate.intent) + .filter(|intent| session.delivered_intents.contains(intent)), + IntentDecisionV1::BelowThreshold { .. } | IntentDecisionV1::NoSignal => None, + } +} + +struct IntentEvidenceV1<'a> { + provider_id: ProviderId, + window: &'a SessionWindowV1, + code_generation_id: CodeGenerationId, + engine_identity: Option<&'a super::decision_engine::DecisionEngineIdentityV1>, + engine_outcome: &'a EngineOutcomeV1, + facts: &'a [ContextScoutIntentKindV1], + selected: IntentCandidateV1, +} + +/// Builds the one session-evidence candidate of the pass. +fn intent_candidate( + pass: &ContextScoutIntentPassV1<'_>, + settings: &ContextScoutIntentSettingsV1, + evidence: IntentEvidenceV1<'_>, +) -> Option { + let IntentEvidenceV1 { + provider_id, + window, + code_generation_id, + engine_identity, + engine_outcome, + facts, + selected, + } = evidence; + let model_outcome = engine_outcome.model_outcome(); + let answers = if model_outcome == ContextScoutModelOutcomeV1::Succeeded { + let answered = engine_outcome.answers().unwrap_or_default(); + settings + .questions + .iter() + .filter_map(|question| { + let probability = answered + .iter() + .find(|(intent, _)| *intent == question.intent) + .map(|(_, probability)| *probability) + .filter(|probability| probability.is_finite())?; + Some(ContextScoutIntentAnswerV1 { + intent: question.intent, + probability_permille: probability_permille(probability), + threshold_permille: question.threshold_permille, + }) + }) + .collect() + } else { + Vec::new() + }; + let engine = match engine_identity { + Some(identity) => Some(ReceiptEngineIdentityV1 { + base_url_digest: ManifestDigest::from_sha256_bytes(&identity.base_url_digest).ok()?, + model: engine_outcome + .reported_model() + .filter(|model| receipt_model_is_valid(model)) + .map(str::to_owned) + .or_else(|| identity.model.clone()), + }), + None => None, + }; + let policy = PolicyDecisionRef::new( + INTENT_POLICY_ID, + pass.context.grant().revision, + pass.context.grant().digest.clone(), + ComponentVersion::new(INTENT_POLICY_VERSION).ok()?, + ) + .ok()?; + let authority = AuthorityReceipt::from_context(pass.context, policy, pass.observed_at).ok()?; + let redaction = ContextScoutRedactionReceiptV1::MetadataOnly { + disclosure: authority.disclosure, + }; + let session_evidence = ContextScoutSessionEvidenceV1::claim(ContextScoutSessionClaimInputV1 { + authorized_scope: pass.context.scope().clone(), + provider_id, + session_id: pass.lifecycle.session_id.clone(), + protected_session_id: pass.hook.envelope().protected_session_id, + window: ContextScoutSessionWindowV1 { + first_store_id: window.first_store_id, + last_store_id: window.last_store_id, + message_count: u16::try_from(window.messages.len()).ok()?, + window_digest: ManifestDigest::from_sha256_bytes(&window.window_digest).ok()?, + newest_timestamp: window.newest_timestamp_micros, + }, + prompt_excerpt_digest: None, + code_generation_id: Some(code_generation_id), + authority, + redaction, + availability: ContextScoutEvidenceAvailabilityV1::Complete, + decision: ContextScoutIntentReceiptV1 { + engine, + outcome: model_outcome, + answers, + facts: facts.to_vec(), + selected: selected.intent, + source: selected.source, + }, + claimed_at: pass.observed_at, + }) + .ok()?; + let dedupe_key = digest_bytes(canonical_sha256(&( + INTENT_CANDIDATE_DEDUPE_DOMAIN, + selected.intent, + pass.hook.envelope().protected_session_id, + )))?; + Some(ContextScoutCandidateV1 { + dedupe_key, + category: ContextScoutCategoryV1::Retrieval, + relevance_score: selected.relevance, + suggestion_text: suggestion_text(selected.intent), + evidence: session_evidence.into(), + expires_at: UtcMicros( + pass.observed_at + .0 + .saturating_add(i64::from(settings.ttl_secs).saturating_mul(1_000_000)), + ), + }) +} + +/// Envelope identity of the pass's suggestion. Derived from the boundary so a +/// stop boundary's intent suggestion never shares the feedback suggestion's +/// envelope identity. +fn intent_envelope_id(hook: &AdmittedContextScoutHookV1) -> Option<[u8; 16]> { + let envelope = hook.envelope(); + let digest = digest_bytes(canonical_sha256(&( + INTENT_ENVELOPE_DOMAIN, + envelope.project_id, + envelope.event_id, + )))?; + let mut envelope_id = [0; 16]; + envelope_id.copy_from_slice(&digest[..16]); + (envelope_id != [0; 16]).then_some(envelope_id) +} + +/// Records one suggestion the pass decided and withheld, with the same +/// identity a delivery would carry. +async fn record_withheld( + pass: &ContextScoutIntentPassV1<'_>, + event_kind: &str, + intent: ContextScoutIntentKindV1, + provider_id: &ProviderId, + envelope_id: Option<[u8; 16]>, +) { + let Some(analytics) = pass.analytics.as_ref() else { + return; + }; + let Some(envelope_id) = envelope_id.or_else(|| intent_envelope_id(pass.hook)) else { + return; + }; + let event = AnalyticsEventInsert { + provider: intent_analytics_provider(provider_id), + project_id: analytics.project_key.to_owned(), + session_id: Some(pass.lifecycle.session_id.as_str().to_owned()), + timestamp: pass.observed_at.0.div_euclid(1_000_000), + event_kind: event_kind.to_owned(), + hook_name: None, + tool_name: None, + tool_category: None, + skill_name: None, + hint_category: Some(intent.as_key().to_owned()), + hint_id: Some(hex::encode(envelope_id)), + outcome: None, + metadata_json: Some( + serde_json::json!({ + "source": "context_scout_intent_pass", + "route": "decision_engine", + }) + .to_string(), + ), + }; + if let Err(error) = analytics.analytics.append_analytics_events(&[event]).await { + tracing::debug!(%error, event_kind, "Context Scout intent analytics not recorded"); + } +} + +fn receipt_model_is_valid(model: &str) -> bool { + !model.trim().is_empty() + && model.len() <= MAX_DECISION_ENGINE_MODEL_BYTES + && !model.chars().any(char::is_control) +} + +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn probability_permille(probability: f64) -> u16 { + (probability.clamp(0.0, 1.0) * f64::from(INTENT_MAX_PERMILLE)).round() as u16 +} + +fn digest_bytes( + digest: Result, +) -> Option<[u8; 32]> { + let digest = digest.ok()?; + let mut bytes = [0; 32]; + hex::decode_to_slice(digest.hex_suffix()?, &mut bytes).ok()?; + Some(bytes) +} diff --git a/crates/tracedecay-agent-hosts/src/agents/context_scout/model.rs b/crates/tracedecay-agent-hosts/src/agents/context_scout/model.rs index 9f1130d1ac..9472c0c103 100644 --- a/crates/tracedecay-agent-hosts/src/agents/context_scout/model.rs +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout/model.rs @@ -183,7 +183,8 @@ impl ContextScoutModelAssistantV1 for UnavailableContextScoutModelAssistantV1 { let error = match self.route { ContextScoutModelBackendV1::Disabled => ContextScoutModelErrorV1::Disabled, ContextScoutModelBackendV1::CodexAppServer - | ContextScoutModelBackendV1::Unsupported => ContextScoutModelErrorV1::Unavailable, + | ContextScoutModelBackendV1::Unsupported + | ContextScoutModelBackendV1::DecisionEngine => ContextScoutModelErrorV1::Unavailable, }; Box::pin(async move { Err(error) }) } diff --git a/crates/tracedecay-agent-hosts/src/agents/context_scout/owner.rs b/crates/tracedecay-agent-hosts/src/agents/context_scout/owner.rs index 54f98361b3..939f47b782 100644 --- a/crates/tracedecay-agent-hosts/src/agents/context_scout/owner.rs +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout/owner.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::Mutex as StdMutex; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, OnceLock, Weak}; @@ -12,6 +12,7 @@ use tracedecay_contracts::context_scout::{ ContextScoutDurableQueueEntryV1, ContextScoutFeedbackV1, ContextScoutLeaseV1, ContextScoutModelBackendV1, ContextScoutModelOutcomeV1, ContextScoutWorkV1, }; +use tracedecay_domain::configuration::{ContextScoutIntentKindV1, ContextScoutIntentSettingsV1}; use tracedecay_domain::{UtcMicros, canonical_sha256}; use tracedecay_hooks::{ HookBoundaryV1, HookEventEnvelopeV2, HookEventV2, HookLifecyclePhaseV1, HookReadyGuidanceV1, @@ -23,6 +24,9 @@ use super::address_registry::{ ContextScoutConfigurationPinV1, ContextScoutLifecycleAddressV1, ProjectContextScoutAddressRegistryV1, }; +use super::decision_engine::{ + DecisionEngineClientV1, DecisionEngineConfigV1, DecisionEnginePortV1, +}; use super::model::{ContextScoutModelConfig, context_scout_model_assistant_from_project_config}; use super::{ ContextScoutBudgetStateV1, ContextScoutCapabilityStateV1, ContextScoutControlV1, @@ -32,9 +36,9 @@ use super::{ ContextScoutModelExecutionV1, ContextScoutModelFuture, ContextScoutModelRequestV1, ContextScoutMutationBindingV1, ContextScoutMutationResultV1, ContextScoutMutationSettlementOutcomeV1, ContextScoutPublicMutationV1, - ContextScoutRecentReadOutcomeV1, ContextScoutRecentStateV1, ContextScoutRuntimeOutcomeV1, - ContextScoutSelectionInputV1, ContextScoutServiceStateV1, ContextScoutStatusV1, - ProjectContextScoutDurableStoreV1, + ContextScoutRecentReadOutcomeV1, ContextScoutRecentStateV1, ContextScoutRouteV1, + ContextScoutRuntimeOutcomeV1, ContextScoutSelectionInputV1, ContextScoutServiceStateV1, + ContextScoutStatusV1, ContextScoutSuppressionV1, ProjectContextScoutDurableStoreV1, }; use tracedecay_runtime_core::db::Database; @@ -83,6 +87,30 @@ pub struct ProjectContextScoutOwnerV1 { next_inflight_id: AtomicU64, startup: ContextScoutDurableStartupOutcomeV1, claim_authorities: RwLock>, + intent_engine: StdMutex>, +} + +/// The decision engine built from one installed intent configuration. It is +/// rebuilt only when the pinned settings digest changes, so its concurrency +/// bound spans every pass of that configuration. +#[derive(Clone)] +struct InstalledIntentEngineV1 { + settings_digest: [u8; 32], + engine: Arc, +} + +/// What the session's Scout state says about one intent pass. Read across +/// every address of the session, unlike the single-address reads. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ContextScoutIntentSessionStateV1 { + /// Another address of the session holds a suggestion still pending. + pub pending_sibling: bool, + /// A suggestion was delivered to the session within the configured + /// minimum delivery gap. + pub delivered_within_gap: bool, + pub delivered_dedupe_keys: BTreeSet<[u8; 32]>, + /// Intents an intent-pass suggestion already delivered this session. + pub delivered_intents: BTreeSet, } fn registered_context_scout_owners() -> &'static StdMutex { @@ -174,6 +202,7 @@ impl ProjectContextScoutOwnerV1 { next_inflight_id: AtomicU64::new(1), startup, claim_authorities: RwLock::new(Vec::new()), + intent_engine: StdMutex::new(None), }); let mut owners = registered_context_scout_owners().lock().ok()?; if let Some(existing) = owners.get(&project_id).and_then(Weak::upgrade) @@ -297,7 +326,7 @@ impl ProjectContextScoutOwnerV1 { let authorities = self.claim_authorities.read().await; let mut matches = authorities .iter() - .filter(|mounted| same_routed_session(&mounted.lifecycle, lifecycle)); + .filter(|mounted| same_routed_native_session(&mounted.lifecycle, lifecycle)); let mounted = matches.next()?; matches.next().is_none().then(|| mounted.pin.clone()) } @@ -317,7 +346,7 @@ impl ProjectContextScoutOwnerV1 { let authorities = self.claim_authorities.read().await; let mut matches = authorities .iter() - .filter(|mounted| same_routed_session(&mounted.lifecycle, lifecycle)); + .filter(|mounted| same_routed_native_session(&mounted.lifecycle, lifecycle)); let mounted = matches.next()?.clone(); if matches.next().is_some() { return None; @@ -639,10 +668,127 @@ impl ProjectContextScoutOwnerV1 { let mut runtime = self.runtime.lock().await; runtime.status(control)?; runtime.replace_model(model); + self.refresh_intent_engine(&pin); *configuration = Some(pin); Ok(()) } + /// Keeps the installed decision engine when the pinned intent settings + /// are unchanged, builds one when they changed, and drops it when the + /// pass is disabled or absent. A configuration the client refuses leaves + /// no engine, which the pass reports as an unavailable engine. + fn refresh_intent_engine(&self, pin: &ContextScoutConfigurationPinV1) { + let Ok(mut installed) = self.intent_engine.lock() else { + return; + }; + let (Some(control), Some(settings)) = (pin.control().intent, pin.intent_settings()) else { + *installed = None; + return; + }; + if !control.enabled { + *installed = None; + return; + } + if installed + .as_ref() + .is_some_and(|current| current.settings_digest == control.settings_digest) + { + return; + } + *installed = intent_engine_client(settings).map(|engine| InstalledIntentEngineV1 { + settings_digest: control.settings_digest, + engine, + }); + } + + /// The decision engine of the currently installed intent configuration. + pub async fn intent_engine(&self) -> Option> { + let digest = self + .configuration + .read() + .await + .as_ref()? + .control() + .intent + .filter(|intent| intent.enabled)? + .settings_digest; + self.intent_engine + .lock() + .ok()? + .as_ref() + .filter(|installed| installed.settings_digest == digest) + .map(|installed| Arc::clone(&installed.engine)) + } + + /// Reads the session's Scout state for one intent pass whose suggestions + /// live at `intent_address`. Pending work at that address is the pass's + /// own superseded generation, never a sibling. + pub async fn intent_session_state( + &self, + intent_address: ContextScoutAddressV1, + now: UtcMicros, + min_delivery_gap_micros: i64, + ) -> Result { + let configuration = self.configuration.read().await; + let control = configuration + .as_ref() + .ok_or(ContextScoutErrorV1::ConfigurationUnavailable)? + .control(); + let recent = match self + .store + .recent_for_protected_session( + intent_address.protected_session_id, + control.configuration_revision, + now, + STARTUP_RECOVERY_LIMIT, + ) + .await + { + ContextScoutRecentReadOutcomeV1::Ready(recent) => recent, + ContextScoutRecentReadOutcomeV1::Unavailable => { + return Err(ContextScoutErrorV1::ConfigurationUnavailable); + } + }; + Ok(intent_session_state( + &recent, + intent_address, + now, + min_delivery_gap_micros, + )) + } + + /// Prepares one intent-pass selection. A suggestion pending at another + /// address of the same session is suppressed as `PendingSibling`: the + /// hook claim path refuses a session with more than one pending entry. + pub async fn prepare_intent( + &self, + input: &ContextScoutSelectionInputV1, + deadline: MonotonicDeadline, + cancellation: CancellationToken, + ) -> Result { + let state = self + .intent_session_state(input.address, input.now, 0) + .await?; + if state.pending_sibling { + let mut runtime = self.runtime.lock().await; + runtime.last_route = Some(ContextScoutRouteV1::DecisionEngine); + runtime.last_suppression = Some(ContextScoutSuppressionV1::PendingSibling); + return Ok(ContextScoutRuntimeOutcomeV1::Suppressed { + reason: ContextScoutSuppressionV1::PendingSibling, + }); + } + self.prepare_configured(input, deadline, cancellation).await + } + + /// Records an intent pass that ended before selection (no candidate, or + /// an engine failure with no action fact). The model slot describes the + /// configured model and is left untouched. + pub async fn record_intent_outcome(&self, suppression: ContextScoutSuppressionV1) { + let mut runtime = self.runtime.lock().await; + runtime.last_route = Some(ContextScoutRouteV1::DecisionEngine); + runtime.last_suppression = Some(suppression); + } + /// Installs only an admitted active/paused control transition while /// preserving the already-selected model authority. #[tracing::instrument( @@ -936,15 +1082,69 @@ impl ProjectContextScoutOwnerV1 { } } -fn same_routed_session( - left: &ContextScoutLifecycleAddressV1, - right: &ContextScoutLifecycleAddressV1, +/// A producer mounted for a native lifecycle of the same routed session. The +/// session's intent-scoped mount is resolved by its own exact address, so it +/// never makes the native producer ambiguous. +fn same_routed_native_session( + mounted: &ContextScoutLifecycleAddressV1, + requested: &ContextScoutLifecycleAddressV1, ) -> bool { - left.profile_id == right.profile_id - && left.provider_id == right.provider_id - && left.project_id == right.project_id - && left.worktree_id == right.worktree_id - && left.session_id == right.session_id + mounted.profile_id == requested.profile_id + && mounted.provider_id == requested.provider_id + && mounted.project_id == requested.project_id + && mounted.worktree_id == requested.worktree_id + && mounted.session_id == requested.session_id + && !mounted.is_intent_scoped() +} + +fn intent_engine_client( + settings: &ContextScoutIntentSettingsV1, +) -> Option> { + let client = DecisionEngineClientV1::new(DecisionEngineConfigV1 { + base_url: settings.engine.base_url.clone(), + model: settings.engine.model.clone(), + timeout: std::time::Duration::from_millis(u64::from(settings.engine.timeout_ms)), + max_concurrent: usize::from(settings.engine.max_concurrent), + max_state_bytes: usize::try_from(settings.engine.max_state_bytes).ok()?, + }); + match client { + Ok(client) => Some(Arc::new(client)), + Err(error) => { + tracing::warn!(%error, "Context Scout decision engine configuration refused"); + None + } + } +} + +fn intent_session_state( + recent: &ContextScoutRecentStateV1, + intent_address: ContextScoutAddressV1, + now: UtcMicros, + min_delivery_gap_micros: i64, +) -> ContextScoutIntentSessionStateV1 { + let gap_start = now.0.saturating_sub(min_delivery_gap_micros); + ContextScoutIntentSessionStateV1 { + pending_sibling: recent + .pending + .iter() + .any(|entry| entry.work.address != intent_address), + delivered_within_gap: min_delivery_gap_micros > 0 + && recent + .deliveries + .iter() + .any(|delivery| delivery.receipt.delivered_at.0 > gap_start), + delivered_dedupe_keys: recent + .deliveries + .iter() + .map(|delivery| delivery.entry.envelope.candidate.dedupe_key) + .collect(), + delivered_intents: recent + .deliveries + .iter() + .filter_map(|delivery| delivery.entry.envelope.candidate.evidence.as_session()) + .map(|evidence| evidence.decision.selected) + .collect(), + } } fn context_scout_state_transition_is_exact( @@ -955,6 +1155,7 @@ fn context_scout_state_transition_is_exact( && current.mode == next.mode && current.model_path == next.model_path && current.limits == next.limits + && current.intent == next.intent && matches!( (current.state, next.state), ( @@ -1014,17 +1215,25 @@ fn status_with_recent( recent: &ContextScoutRecentStateV1, ) -> ContextScoutStatusV1 { status.active_suggestions = recent.pending.len(); - let Some(entry) = recent - .pending - .iter() - .chain(recent.deliveries.iter().map(|delivery| &delivery.entry)) - .max_by_key(|entry| entry.envelope.candidate.expires_at) - else { + let entries = || { + recent + .pending + .iter() + .chain(recent.deliveries.iter().map(|delivery| &delivery.entry)) + }; + let Some(entry) = entries().max_by_key(|entry| entry.envelope.candidate.expires_at) else { return status; }; status.last_route = Some(entry.route); - status.last_model_outcome = Some(entry.model_outcome); - status.last_model_receipt.clone_from(&entry.model_receipt); + if let Some(model_entry) = entries() + .filter(|entry| entry.route != ContextScoutRouteV1::DecisionEngine) + .max_by_key(|entry| entry.envelope.candidate.expires_at) + { + status.last_model_outcome = Some(model_entry.model_outcome); + status + .last_model_receipt + .clone_from(&model_entry.model_receipt); + } if let Some(delivered) = recent .deliveries .iter() @@ -1096,6 +1305,9 @@ mod tests { use super::super::address_registry::ContextScoutAddressBindOutcomeV1; use super::*; use std::collections::{BTreeMap, BTreeSet}; + use tracedecay_contracts::context_scout::{ + ContextScoutCandidateV1, ContextScoutCategoryV1, ContextScoutModelReceiptV1, + }; use tracedecay_contracts::{ CancellationContext, CapabilityGrantId, CapabilityGrantSnapshot, Deadline, DisclosureClass, RequestId, ResolvedScope, @@ -1105,7 +1317,9 @@ mod tests { use tracedecay_domain::configuration::{ CONTEXT_SCOUT_SETTINGS_SETTING_KEY, CandidateDispositionV1, ConfigurationCandidateV1, ConfigurationLayerIdV1, ConfigurationRevisionId, ConfigurationSnapshotV1, - ConfigurationValueV1, ContextScoutSettingsV1, SettingKey, + ConfigurationValueV1, ContextScoutConfigurationModeV1, ContextScoutConfigurationStateV1, + ContextScoutConfiguredModelPathV1, ContextScoutSettingsV1, DecisionEngineSettingsV1, + IntentWindowSettingsV1, SettingKey, }; use tracedecay_domain::feedback::FeedbackScopeV1; use tracedecay_domain::{ActorId, RepositoryId, WorktreeId}; @@ -1124,6 +1338,7 @@ mod tests { mode: super::super::ContextScoutRuntimeModeV1::ConfiguredModel, model_path: Some(ContextScoutModelBackendV1::CodexAppServer), limits: super::super::ContextScoutLimitsV1::bounded_defaults(), + intent: None, }; let paused = ContextScoutControlV1 { configuration_revision: [2; 32], @@ -1152,6 +1367,18 @@ mod tests { current, changed_limits )); + + let changed_intent = ContextScoutControlV1 { + intent: Some(super::super::ContextScoutIntentControlV1 { + enabled: true, + settings_digest: [9; 32], + }), + ..paused + }; + assert!(!context_scout_state_transition_is_exact( + current, + changed_intent + )); } #[test] @@ -1213,6 +1440,46 @@ mod tests { ContextScoutDeliveryWindowV1::IdleWindow, &session_end, )); + + // An intent-pass suggestion is claimable at the first later boundary + // of any kind, including the phase-two prompt lookup, unless quiet + // mode or a delivery inside the spacing gap withholds it from hooks. + let intent_window = |quiet_mode, has_recent_delivery| { + super::super::select_context_scout_delivery_window( + &super::super::ContextScoutDeliverySelectionInputV1 { + trigger: super::super::ContextScoutTriggerV1::IntentBoundary, + quiet_mode, + has_recent_delivery, + has_unresolved_interaction: false, + critical_safety_evidence: false, + delivered_dedupe_keys: BTreeSet::new(), + }, + ) + }; + assert_eq!( + intent_window(false, false), + ContextScoutDeliveryWindowV1::Immediate + ); + for boundary in [&prompt_boundary, &tool_completed, &session_end] { + assert!(delivery_window_admitted_at_hook( + intent_window(false, false), + boundary, + )); + } + assert_eq!( + intent_window(true, false), + ContextScoutDeliveryWindowV1::Suppressed + ); + assert_eq!( + intent_window(false, true), + ContextScoutDeliveryWindowV1::IdleWindow + ); + for boundary in [&prompt_boundary, &tool_completed, &session_end] { + assert!(!delivery_window_admitted_at_hook( + intent_window(false, true), + boundary, + )); + } } #[test] @@ -1614,6 +1881,62 @@ mod tests { Some((producer_address, [1; 32])) ); + // The session's intent-scoped lifecycle is one address for every + // turn: it binds beside the native lifecycle, rebinds to the same + // address, and resolves only by its own exact lifecycle, so it never + // makes the native producer ambiguous. + let intent_lifecycle = later_lifecycle.intent_scoped().expect("intent lifecycle"); + assert!(intent_lifecycle.is_intent_scoped()); + assert!(!later_lifecycle.is_intent_scoped()); + assert_eq!( + claim_lifecycle(7).intent_scoped(), + Some(intent_lifecycle.clone()) + ); + let intent_address = match registry.bind(&hook, &pin, intent_lifecycle.clone()).await { + ContextScoutAddressBindOutcomeV1::Bound(address) => address, + other => panic!("expected intent address, got {other:?}"), + }; + assert_eq!( + registry.bind(&hook, &pin, intent_lifecycle.clone()).await, + ContextScoutAddressBindOutcomeV1::Existing(intent_address) + ); + assert_eq!( + owner + .mount_current_claim_authority( + Arc::clone(®istry), + &hook, + 1, + pin.clone(), + context.clone(), + intent_lifecycle.clone(), + intent_address, + [3; 32], + observed_at, + true, + ) + .await, + ContextScoutClaimAdmissionV1::Mounted + ); + assert_eq!( + owner + .resolve_current_session_claim_authority( + &hook, + &later_lifecycle, + &pin, + observed_at, + true, + ) + .await, + Some((producer_address, [1; 32])), + "the intent mount never makes the session's native producer ambiguous" + ); + assert_eq!( + owner + .resolve_current_claim_authority(&hook, &intent_lifecycle, observed_at, true) + .await, + Some((intent_address, [3; 32])) + ); + let second_address = match registry.bind(&hook, &pin, later_lifecycle.clone()).await { ContextScoutAddressBindOutcomeV1::Bound(address) => address, other => panic!("expected second producer address, got {other:?}"), @@ -1782,13 +2105,12 @@ mod tests { input_watermark, configuration_revision: pin.configuration().control().configuration_revision, delivery_window: ContextScoutDeliveryWindowV1::Immediate, - candidate: tracedecay_contracts::context_scout::ContextScoutCandidateV1 { + candidate: ContextScoutCandidateV1 { dedupe_key: [18; 32], - category: - tracedecay_contracts::context_scout::ContextScoutCategoryV1::Retrieval, + category: ContextScoutCategoryV1::Retrieval, relevance_score: 10, suggestion_text: "Use the saved diagnostic anchor.".to_owned(), - evidence: super::super::evidence::fixture_context_scout_evidence(), + evidence: super::super::evidence::fixture_context_scout_evidence().into(), expires_at: UtcMicros(1_000), }, }, @@ -1838,6 +2160,387 @@ mod tests { unregister(address.project_id, &owner); } + fn active_intent_configuration(base: ContextScoutSettingsV1) -> ContextScoutConfigurationPinV1 { + let setting_key = SettingKey::new(CONTEXT_SCOUT_SETTINGS_SETTING_KEY).expect("setting"); + let revision_id = ConfigurationRevisionId::new("revision.scout.intent").expect("revision"); + let settings = ContextScoutSettingsV1 { + state: ContextScoutConfigurationStateV1::Active, + intent: Some(ContextScoutIntentSettingsV1 { + enabled: true, + engine: DecisionEngineSettingsV1 { + base_url: "http://127.0.0.1:9".to_owned(), + model: Some("intent-small".to_owned()), + timeout_ms: 250, + max_concurrent: 1, + max_state_bytes: 4_096, + }, + window: IntentWindowSettingsV1 { + max_user_turns: 2, + max_messages: 8, + max_bytes: 4_096, + max_age_secs: 600, + freshen_budget_ms: 0, + }, + questions: ContextScoutIntentSettingsV1::reference_questions(), + action_fact_score_permille: 700, + ttl_secs: 600, + min_delivery_gap_secs: 30, + prompt_lookup_deadline_ms: 200, + }), + ..base + }; + settings.validate().expect("intent settings"); + let snapshot = ConfigurationSnapshotV1::new( + BTreeMap::from([( + setting_key.clone(), + ConfigurationValueV1::ContextScoutSettings(settings), + )]), + BTreeMap::from([( + setting_key, + vec![ConfigurationCandidateV1 { + layer: ConfigurationLayerIdV1::Project { + project_id: claim_id("project.scout.fixture"), + }, + revision_id: revision_id.clone(), + disposition: CandidateDispositionV1::Winning, + safe_reason: None, + }], + )]), + ) + .expect("snapshot"); + ContextScoutConfigurationPinV1::from_current(&ConfigurationCurrentStateV1 { + revision_id, + snapshot, + }) + .expect("intent configuration pin") + } + + #[tokio::test] + async fn intent_entries_refuse_a_pending_sibling_and_supersede_their_own_generation() { + let (_registry_temporary, registry_database) = test_database().await; + let (_registry, admitted, _pin, _context, observed_at) = + claim_mount_authority(registry_database); + let sibling_address = ContextScoutAddressV1 { + profile_id: [1; 16], + provider_id: [2; 16], + protected_session_id: admitted.envelope().protected_session_id, + thread_id: [3; 16], + turn_id: [4; 16], + agent_id: [5; 16], + logical_message_id: [6; 16], + project_id: admitted.envelope().project_id, + }; + let intent_address = ContextScoutAddressV1 { + thread_id: [11; 16], + turn_id: [11; 16], + agent_id: [11; 16], + logical_message_id: [11; 16], + ..sibling_address + }; + let (_owner_temporary, owner_database) = test_database().await; + let owner = ProjectContextScoutOwnerV1::startup( + owner_database, + sibling_address.project_id, + UtcMicros(1), + None, + ) + .await + .expect("owner"); + let configuration = active_intent_configuration(ContextScoutSettingsV1::disabled()); + owner + .install_configuration(configuration.clone(), None) + .await + .expect("intent configuration"); + let engine = owner.intent_engine().await.expect("installed engine"); + owner + .install_configuration(configuration.clone(), None) + .await + .expect("reinstalled intent configuration"); + assert!( + Arc::ptr_eq( + &engine, + &owner.intent_engine().await.expect("installed engine") + ), + "unchanged intent settings keep the installed engine" + ); + let revision = configuration.control().configuration_revision; + // Recent reads filter on the wall clock; keep the fixture unexpired. + let expires_at = UtcMicros(4_000_000_000_000_000); + + let sibling = ContextScoutDurableQueueEntryV1 { + work: ContextScoutWorkV1 { + address: sibling_address, + generation: 1, + input_watermark: [14; 32], + }, + route: super::super::ContextScoutRouteV1::Deterministic, + model_outcome: ContextScoutModelOutcomeV1::NotRequested, + model_receipt: None, + envelope: tracedecay_contracts::context_scout::ContextScoutSuggestionEnvelopeV1 { + envelope_id: [17; 16], + address: sibling_address, + input_watermark: [14; 32], + configuration_revision: revision, + delivery_window: ContextScoutDeliveryWindowV1::NextBoundary, + candidate: ContextScoutCandidateV1 { + dedupe_key: [18; 32], + category: ContextScoutCategoryV1::Diagnostic, + relevance_score: 10, + suggestion_text: "Use the saved diagnostic anchor.".to_owned(), + evidence: super::super::evidence::fixture_context_scout_evidence().into(), + expires_at, + }, + }, + }; + assert_eq!( + owner.store().enqueue(sibling.clone()).await, + ContextScoutDurableStoreOutcomeV1::Stored + ); + + let evidence = super::super::evidence::fixture_context_scout_session_evidence(); + let intent_input = |envelope: u8, watermark: u8| ContextScoutSelectionInputV1 { + address: intent_address, + input_watermark: [watermark; 32], + configuration_revision: revision, + envelope_id: [envelope; 16], + now: UtcMicros(observed_at.0 + 1), + delivery_window: ContextScoutDeliveryWindowV1::Immediate, + delivered_dedupe_keys: BTreeSet::new(), + candidates: vec![ContextScoutCandidateV1 { + dedupe_key: [21; 32], + category: ContextScoutCategoryV1::Retrieval, + relevance_score: 900, + suggestion_text: "tracedecay hint: recall the earlier session.".to_owned(), + evidence: evidence.clone().into(), + expires_at, + }], + }; + let deadline = + || MonotonicDeadline::at(std::time::Instant::now() + std::time::Duration::from_secs(1)); + + assert_eq!( + owner + .prepare_intent(&intent_input(22, 23), deadline(), CancellationToken::new()) + .await + .expect("prepare beside a sibling"), + ContextScoutRuntimeOutcomeV1::Suppressed { + reason: ContextScoutSuppressionV1::PendingSibling, + } + ); + assert_eq!( + owner + .configured_status() + .await + .expect("status") + .last_suppression, + Some(ContextScoutSuppressionV1::PendingSibling) + ); + assert_eq!( + owner + .recent_exact(intent_address, 8) + .await + .expect("intent address") + .pending + .len(), + 0, + "a suppressed pass enqueues nothing" + ); + + assert_eq!( + owner.store().cancel_work(sibling.work).await, + ContextScoutDurableStoreOutcomeV1::Stored + ); + let ContextScoutRuntimeOutcomeV1::Enqueued { entry: first, .. } = owner + .prepare_intent(&intent_input(22, 23), deadline(), CancellationToken::new()) + .await + .expect("prepare without a sibling") + else { + panic!("an intent entry without a pending sibling must enqueue"); + }; + assert_eq!(first.route, ContextScoutRouteV1::DecisionEngine); + assert_eq!(first.model_outcome, evidence.decision.outcome); + assert_eq!(first.model_receipt, None); + assert_eq!(first.work.generation, 1); + let status = owner.configured_status().await.expect("status"); + assert_eq!(status.last_route, Some(ContextScoutRouteV1::DecisionEngine)); + assert_eq!( + status.last_model_outcome, None, + "the model slot describes the configured model, not the intent engine" + ); + + let ContextScoutRuntimeOutcomeV1::Enqueued { entry: second, .. } = owner + .prepare_intent(&intent_input(24, 25), deadline(), CancellationToken::new()) + .await + .expect("newer pass") + else { + panic!("a newer pass at the intent address must enqueue"); + }; + assert_eq!(second.work.generation, 2); + let recent = owner + .recent_exact(intent_address, 8) + .await + .expect("intent address"); + assert_eq!( + recent.pending, + vec![*second.clone()], + "a newer pass supersedes the older generation at the one intent address" + ); + + let (guidance, claim) = owner + .claim_ready_guidance_exact( + admitted.envelope(), + intent_address, + [25; 32], + 1, + UtcMicros(observed_at.0 + 2), + ) + .await + .expect("intent guidance claims at the next hook boundary"); + assert_eq!(guidance.guidance_id, [24; 16]); + let delivered_at = UtcMicros(observed_at.0 + 3); + assert_eq!( + owner + .record_delivery( + &claim, + &ContextScoutDeliveryReceiptV1 { + receipt_id: super::super::context_scout_delivery_receipt_id( + admitted.envelope().event_id, + guidance.guidance_id, + ), + envelope_id: guidance.guidance_id, + delivered_at, + outcome: super::super::ContextScoutDeliveryOutcomeV1::Displayed, + }, + ) + .await, + ContextScoutDurableStoreOutcomeV1::Stored + ); + let gap = 30_000_000; + let spaced = owner + .intent_session_state(intent_address, UtcMicros(delivered_at.0 + 1), gap) + .await + .expect("session state"); + assert!(!spaced.pending_sibling); + assert!(spaced.delivered_within_gap); + assert_eq!( + spaced.delivered_intents, + BTreeSet::from([ContextScoutIntentKindV1::SessionRecall]) + ); + assert!(spaced.delivered_dedupe_keys.contains(&[21; 32])); + let after_gap = owner + .intent_session_state(intent_address, UtcMicros(delivered_at.0 + gap + 1), gap) + .await + .expect("session state after the gap"); + assert!( + !after_gap.delivered_within_gap, + "spacing counts deliveries inside the gap, not every delivery ever" + ); + assert_eq!(after_gap.delivered_intents, spaced.delivered_intents); + unregister(sibling_address.project_id, &owner); + } + + #[tokio::test] + async fn intent_pass_outcomes_leave_the_configured_model_slot_alone() { + let (_temporary, database) = test_database().await; + let owner = ProjectContextScoutOwnerV1::startup(database, [43; 16], UtcMicros(1), None) + .await + .expect("owner"); + let configuration = active_intent_configuration(ContextScoutSettingsV1 { + mode: ContextScoutConfigurationModeV1::ConfiguredModel, + model_path: Some(ContextScoutConfiguredModelPathV1::CodexAppServer), + model_id: Some("configured-test-model".to_owned()), + model_timeout_secs: Some(60), + ..ContextScoutSettingsV1::disabled() + }); + let revision = configuration.control().configuration_revision; + owner + .install_configuration(configuration, None) + .await + .expect("configured model installation"); + + owner.runtime.lock().await.last_model_outcome = Some(ContextScoutModelOutcomeV1::Succeeded); + owner + .record_intent_outcome(ContextScoutSuppressionV1::NoEligibleCandidate) + .await; + let capability = owner.capability().await.expect("capability"); + assert!(capability.configured_model_available); + assert_eq!( + capability.last_model_outcome, + Some(ContextScoutModelOutcomeV1::Succeeded) + ); + + { + let mut runtime = owner.runtime.lock().await; + runtime.last_model_outcome = Some(ContextScoutModelOutcomeV1::TokenBudgetExceeded); + runtime.last_model_receipt = Some(ContextScoutModelReceiptV1 { + requested_backend: ContextScoutModelBackendV1::CodexAppServer, + actual_provider: Some("codex".to_owned()), + actual_model: Some("configured-test-model".to_owned()), + input_tokens: Some(10), + output_tokens: Some(5), + estimated_cost_microusd: Some(1), + }); + } + let address = ContextScoutAddressV1 { + profile_id: [1; 16], + provider_id: [2; 16], + protected_session_id: [3; 32], + thread_id: [4; 16], + turn_id: [5; 16], + agent_id: [6; 16], + logical_message_id: [7; 16], + project_id: [43; 16], + }; + let evidence = super::super::evidence::fixture_context_scout_session_evidence(); + let intent_entry = ContextScoutDurableQueueEntryV1 { + work: ContextScoutWorkV1 { + address, + generation: 1, + input_watermark: [8; 32], + }, + route: ContextScoutRouteV1::DecisionEngine, + model_outcome: evidence.decision.outcome, + model_receipt: None, + envelope: tracedecay_contracts::context_scout::ContextScoutSuggestionEnvelopeV1 { + envelope_id: [9; 16], + address, + input_watermark: [8; 32], + configuration_revision: revision, + delivery_window: ContextScoutDeliveryWindowV1::NextBoundary, + candidate: ContextScoutCandidateV1 { + dedupe_key: [10; 32], + category: ContextScoutCategoryV1::Retrieval, + relevance_score: 900, + suggestion_text: "tracedecay hint: recall the earlier session.".to_owned(), + evidence: evidence.into(), + expires_at: UtcMicros(4_000_000_000_000_000), + }, + }, + }; + assert_eq!( + owner.store().enqueue(intent_entry).await, + ContextScoutDurableStoreOutcomeV1::Stored + ); + owner + .record_intent_outcome(ContextScoutSuppressionV1::NoEligibleCandidate) + .await; + + let budget = owner.budget().await.expect("budget"); + assert!(budget.exhausted); + assert_eq!( + budget.last_model_outcome, + Some(ContextScoutModelOutcomeV1::TokenBudgetExceeded) + ); + assert_eq!( + (budget.last_input_tokens, budget.last_output_tokens), + (Some(10), Some(5)) + ); + assert_eq!( + owner.configured_status().await.expect("status").last_route, + Some(ContextScoutRouteV1::DecisionEngine) + ); + } + #[tokio::test] async fn two_hundred_fifty_seventh_claim_is_a_typed_denial() { let project_id = [42; 16]; diff --git a/crates/tracedecay-agent-hosts/src/agents/context_scout/session_window.rs b/crates/tracedecay-agent-hosts/src/agents/context_scout/session_window.rs new file mode 100644 index 0000000000..4d687e8e61 --- /dev/null +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout/session_window.rs @@ -0,0 +1,1179 @@ +//! Bounded, transient reader of a session's most recent turns. +//! +//! The decision engine ([`super::decision_engine`]) judges a prompt better +//! with the last few turns of its own session than with the prompt alone. This +//! module reads that tail from the daemon's own LCM raw-message store (the +//! same transcript store every host ingests into) and returns a bounded +//! [`SessionWindowV1`] for model input. A window is never persisted; only its +//! [`SessionWindowV1::window_digest`] is safe to keep as a cache or receipt +//! key. +//! +//! # Store API +//! +//! The reader is an `async fn` over [`RegisteredGlobalDb`] and uses two of its +//! LCM reads, both of which verify and redact like every other transcript +//! read: +//! +//! 1. `lcm_session_replay_slice` with only a tail asks for the newest `K` +//! store ids of the session. `lcm_load_session` pages forward only, and +//! store ids interleave across sessions, so the tail is how the first +//! store id to load from is found without paging the whole session. +//! 2. `lcm_load_session` loads exactly those rows with content and metadata. +//! +//! `K` is the message cap clamped to [`SESSION_WINDOW_FETCH_CEILING`], the +//! store's page size, so a larger `max_messages` is served as that ceiling. +//! +//! # Tool calls +//! +//! The load API returns content and `metadata_json` but not the `tool_names` +//! column, and hosts record tool use differently: +//! +//! * Cursor transcripts carry canonical `metadata_json.tool_calls` +//! (`{"type":"function","function":{"name","arguments"}}`) and +//! `tool_events` (`{"type":"tool_use","tool_name",...}`). +//! * Claude, Codex and the other typed-fact hosts keep authored text in +//! `content` and put tool names only in the `tool_names` column +//! (comma-joined); their tool-only records are rows of kind +//! `tool_invocation`/`tool_call`/`tool_dispatch`/`file_edit` whose text is +//! the argument payload. +//! +//! Assistant rows with no tool metadata are therefore completed from +//! `get_session_message` (one bounded local read each), through +//! [`tracedecay_automation::analytics::split_tool_names`], the splitter hint +//! settlement already uses. + +use sha2::{Digest, Sha256}; +use tracedecay_automation::analytics::split_tool_names; +use tracedecay_global_db::RegisteredGlobalDb; +use tracedecay_lcm::{ + LcmContentSlice, LcmLoadSessionMessage, LcmLoadSessionRequest, LcmSessionReplayRequest, +}; + +/// Rows one window can load: the LCM store's page size. +pub const SESSION_WINDOW_FETCH_CEILING: usize = 100; + +/// Longest argument excerpt kept for one tool call, in characters. +pub const SESSION_WINDOW_ARGUMENT_EXCERPT_CHARS: usize = 160; + +/// Stored timestamps at or above this are milliseconds, below it seconds. +const MILLIS_THRESHOLD: i64 = 1_000_000_000_000; + +const ELLIPSIS: &str = "…"; + +/// How much of a session one window may carry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SessionWindowLimitsV1 { + /// User messages kept, newest first. + pub max_user_turns: usize, + /// Messages kept in total, newest first. + pub max_messages: usize, + /// Text and tool-call bytes kept in total, newest first. + pub max_bytes: usize, + /// A window whose newest message is older than this is stale. + pub max_age_micros: Option, +} + +#[derive(Debug, Clone, Copy)] +pub struct SessionWindowRequestV1<'a> { + pub provider: &'a str, + pub session_id: &'a str, + pub limits: SessionWindowLimitsV1, + pub now_micros: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionWindowRoleV1 { + User, + Assistant, + Tool, + /// System, goal, compaction and any other non-conversational row. + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionWindowToolCallV1 { + pub name: String, + pub argument_excerpt: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionWindowMessageV1 { + pub role: SessionWindowRoleV1, + pub text: String, + pub tool_calls: Vec, + pub store_id: i64, + /// Normalized to microseconds; `None` when the host recorded no time. + pub timestamp_micros: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionWindowFreshnessV1 { + Fresh, + Stale { + age_micros: u64, + }, + /// No message carried a timestamp. + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionWindowV1 { + /// Oldest first. + pub messages: Vec, + pub first_store_id: i64, + pub last_store_id: i64, + pub newest_timestamp_micros: Option, + /// Digest of the window's identity and content; the only part safe to + /// persist. + pub window_digest: [u8; 32], + pub freshness: SessionWindowFreshnessV1, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionWindowOutcomeV1 { + Ready(SessionWindowV1), + /// The session has no usable messages (or the limits allow none). + Empty, + /// The store could not be read; the reason is for logs, not for users. + Unavailable(String), +} + +/// Reads the newest turns of one session as a bounded transient window. +/// +/// Selection: the last `max_user_turns` user messages and everything after the +/// earliest of them, capped at `max_messages` and `max_bytes` with the oldest +/// messages dropped first. A single newest message larger than `max_bytes` +/// keeps its leading bytes. +pub async fn read_session_window( + db: &RegisteredGlobalDb, + request: &SessionWindowRequestV1<'_>, +) -> SessionWindowOutcomeV1 { + let limits = request.limits; + if limits.max_user_turns == 0 || limits.max_messages == 0 || limits.max_bytes == 0 { + return SessionWindowOutcomeV1::Empty; + } + let fetch = limits.max_messages.min(SESSION_WINDOW_FETCH_CEILING); + + let tail = match db + .lcm_session_replay_slice(&LcmSessionReplayRequest { + provider: request.provider.to_owned(), + session_id: request.session_id.to_owned(), + head_limit: 0, + tail_limit: fetch, + max_snippet_chars: 1, + summary_limit: 0, + max_summary_chars: 0, + }) + .await + { + Ok(slice) => slice.tail, + Err(error) => return unavailable("tail", &error), + }; + let Some(oldest) = tail.iter().map(|message| message.store_id).min() else { + return SessionWindowOutcomeV1::Empty; + }; + + let page = match db + .lcm_load_session(LcmLoadSessionRequest { + provider: request.provider.to_owned(), + session_id: request.session_id.to_owned(), + after_store_id: Some(oldest.saturating_sub(1)), + limit: fetch, + roles: Vec::new(), + start_time: None, + end_time: None, + content_slice: Some(LcmContentSlice { + offset: 0, + limit: limits.max_bytes, + }), + }) + .await + { + Ok(page) => page.messages, + Err(error) => return unavailable("load", &error), + }; + + let mut messages = Vec::with_capacity(page.len()); + for loaded in page { + if let Some(message) = window_message(db, loaded).await { + messages.push(message); + } + } + // A concurrent ingest can add rows after the tail was read; keep the + // newest `fetch` of what loaded. + if messages.len() > fetch { + messages.drain(..messages.len() - fetch); + } + match select(messages, limits) { + Some(messages) => SessionWindowOutcomeV1::Ready(assemble(request, messages)), + None => SessionWindowOutcomeV1::Empty, + } +} + +fn unavailable(step: &str, error: &dyn std::fmt::Display) -> SessionWindowOutcomeV1 { + tracing::debug!(step, %error, "session window unavailable"); + SessionWindowOutcomeV1::Unavailable(format!("session window {step} failed: {error}")) +} + +async fn window_message( + db: &RegisteredGlobalDb, + loaded: LcmLoadSessionMessage, +) -> Option { + let role = role_of(&loaded.role); + let mut text = loaded.content.trim().to_owned(); + let mut tool_calls = loaded + .metadata_json + .as_deref() + .map(tool_calls_from_metadata) + .unwrap_or_default(); + if tool_calls.is_empty() && role == SessionWindowRoleV1::Assistant { + // Typed-fact hosts keep tool names only in the `tool_names` column. + match db + .get_session_message(&loaded.provider, &loaded.message_id) + .await + { + Ok(Some(record)) => { + let names = record + .tool_names + .as_deref() + .map(|names| split_tool_names(names).collect::>()) + .unwrap_or_default(); + let payload_row = matches!( + record.kind.as_deref(), + Some("tool_invocation" | "tool_call" | "tool_dispatch" | "file_edit") + ); + if payload_row && names.len() == 1 { + // The row's text is the call's argument payload. + tool_calls.push(SessionWindowToolCallV1 { + name: names.into_iter().next().unwrap_or_default(), + argument_excerpt: excerpt(&text), + }); + text.clear(); + } else { + tool_calls.extend(names.into_iter().map(|name| SessionWindowToolCallV1 { + name, + argument_excerpt: None, + })); + } + } + Ok(None) => {} + Err(error) => { + tracing::debug!(%error, "session window tool names unavailable"); + } + } + } + if text.is_empty() && tool_calls.is_empty() { + return None; + } + Some(SessionWindowMessageV1 { + role, + text, + tool_calls, + store_id: loaded.store_id, + timestamp_micros: loaded.timestamp.map(timestamp_micros), + }) +} + +fn role_of(role: &str) -> SessionWindowRoleV1 { + match role { + "user" => SessionWindowRoleV1::User, + "assistant" => SessionWindowRoleV1::Assistant, + "tool" => SessionWindowRoleV1::Tool, + _ => SessionWindowRoleV1::Other, + } +} + +/// Hosts store seconds or milliseconds; the window speaks microseconds. +fn timestamp_micros(stored: i64) -> i64 { + if stored >= MILLIS_THRESHOLD { + stored.saturating_mul(1_000) + } else { + stored.saturating_mul(1_000_000) + } +} + +/// Canonical `tool_calls`, else the names of `tool_events`. +fn tool_calls_from_metadata(metadata: &str) -> Vec { + let Ok(value) = serde_json::from_str::(metadata) else { + return Vec::new(); + }; + let calls = value + .get("tool_calls") + .and_then(serde_json::Value::as_array) + .map(|calls| { + calls + .iter() + .filter_map(|call| { + let function = call.get("function")?; + let name = function.get("name")?.as_str()?.trim(); + if name.is_empty() { + return None; + } + let arguments = + function + .get("arguments") + .and_then(|arguments| match arguments { + serde_json::Value::Null => None, + serde_json::Value::String(text) => excerpt(text), + other => excerpt(&other.to_string()), + }); + Some(SessionWindowToolCallV1 { + name: name.to_owned(), + argument_excerpt: arguments, + }) + }) + .collect::>() + }) + .unwrap_or_default(); + if !calls.is_empty() { + return calls; + } + value + .get("tool_events") + .and_then(serde_json::Value::as_array) + .map(|events| { + events + .iter() + .filter_map(|event| { + let name = event.get("tool_name")?.as_str()?.trim(); + (!name.is_empty()).then(|| SessionWindowToolCallV1 { + name: name.to_owned(), + argument_excerpt: None, + }) + }) + .collect() + }) + .unwrap_or_default() +} + +/// Whitespace-collapsed, length-bounded one-line excerpt. +fn excerpt(text: &str) -> Option { + let collapsed = text.split_whitespace().collect::>().join(" "); + if collapsed.is_empty() { + return None; + } + Some( + match collapsed + .char_indices() + .nth(SESSION_WINDOW_ARGUMENT_EXCERPT_CHARS) + { + Some((end, _)) => format!("{}{ELLIPSIS}", &collapsed[..end]), + None => collapsed, + }, + ) +} + +fn message_bytes(message: &SessionWindowMessageV1) -> usize { + message.text.len() + + message + .tool_calls + .iter() + .map(|call| call.name.len() + call.argument_excerpt.as_ref().map_or(0, String::len)) + .sum::() +} + +/// Applies the turn, message and byte caps to messages ordered oldest first. +fn select( + mut messages: Vec, + limits: SessionWindowLimitsV1, +) -> Option> { + let user_indices = messages + .iter() + .enumerate() + .filter(|(_, message)| message.role == SessionWindowRoleV1::User) + .map(|(index, _)| index) + .collect::>(); + let start = user_indices + .len() + .checked_sub(limits.max_user_turns) + .map_or(0, |skipped| user_indices[skipped]); + messages.drain(..start); + if messages.len() > limits.max_messages { + messages.drain(..messages.len() - limits.max_messages); + } + + // Newest first until the byte budget is spent. + let mut spent = 0usize; + let mut keep_from = messages.len(); + for (index, message) in messages.iter().enumerate().rev() { + let bytes = message_bytes(message); + if spent.saturating_add(bytes) > limits.max_bytes { + break; + } + spent += bytes; + keep_from = index; + } + if keep_from == messages.len() { + // Even the newest message alone is over budget: keep its leading bytes. + let newest = messages.last_mut()?; + newest.tool_calls.clear(); + newest.text = head_on_char_boundary(&newest.text, limits.max_bytes).to_owned(); + if newest.text.is_empty() { + return None; + } + keep_from = messages.len() - 1; + } + messages.drain(..keep_from); + (!messages.is_empty()).then_some(messages) +} + +fn head_on_char_boundary(text: &str, max_bytes: usize) -> &str { + let mut end = max_bytes.min(text.len()); + while !text.is_char_boundary(end) { + end -= 1; + } + &text[..end] +} + +fn tail_on_char_boundary(text: &str, max_bytes: usize) -> &str { + if text.len() <= max_bytes { + return text; + } + let mut start = text.len() - max_bytes; + while !text.is_char_boundary(start) { + start += 1; + } + &text[start..] +} + +fn assemble( + request: &SessionWindowRequestV1<'_>, + messages: Vec, +) -> SessionWindowV1 { + let first_store_id = messages.first().map_or(0, |message| message.store_id); + let last_store_id = messages.last().map_or(0, |message| message.store_id); + let newest_timestamp_micros = messages + .iter() + .filter_map(|message| message.timestamp_micros) + .max(); + SessionWindowV1 { + window_digest: window_digest(request.provider, request.session_id, &messages), + freshness: freshness( + newest_timestamp_micros, + request.now_micros, + request.limits.max_age_micros, + ), + messages, + first_store_id, + last_store_id, + newest_timestamp_micros, + } +} + +fn freshness( + newest_micros: Option, + now_micros: u64, + max_age_micros: Option, +) -> SessionWindowFreshnessV1 { + let Some(newest) = newest_micros else { + return SessionWindowFreshnessV1::Unknown; + }; + let newest = u64::try_from(newest).unwrap_or(0); + let age_micros = now_micros.saturating_sub(newest); + match max_age_micros { + Some(max_age) if age_micros > max_age => SessionWindowFreshnessV1::Stale { age_micros }, + _ => SessionWindowFreshnessV1::Fresh, + } +} + +fn digest_field(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update((bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); +} + +/// Length-framed digest over the session identity and, per message, its store +/// id, role, text and tool calls. Timestamps are excluded so the digest names +/// content, not observation time. +fn window_digest( + provider: &str, + session_id: &str, + messages: &[SessionWindowMessageV1], +) -> [u8; 32] { + let mut hasher = Sha256::new(); + digest_field(&mut hasher, b"tracedecay.session_window.v1"); + digest_field(&mut hasher, provider.as_bytes()); + digest_field(&mut hasher, session_id.as_bytes()); + for message in messages { + hasher.update(message.store_id.to_le_bytes()); + hasher.update([match message.role { + SessionWindowRoleV1::User => 0u8, + SessionWindowRoleV1::Assistant => 1, + SessionWindowRoleV1::Tool => 2, + SessionWindowRoleV1::Other => 3, + }]); + digest_field(&mut hasher, message.text.as_bytes()); + hasher.update((message.tool_calls.len() as u64).to_le_bytes()); + for call in &message.tool_calls { + digest_field(&mut hasher, call.name.as_bytes()); + digest_field( + &mut hasher, + call.argument_excerpt + .as_deref() + .unwrap_or_default() + .as_bytes(), + ); + } + } + hasher.finalize().into() +} + +/// Renders the window as a plain-text transcript, oldest to newest. +/// +/// Lines are `User: …`, `Assistant: …`, `Tool result: …`, `Other: …` and, for +/// each recorded call, `Tool ()`. `prompt_excerpt`, +/// the turn being decided on, is appended last as a user turn. When the whole +/// exceeds `max_bytes` the front is cut on a character boundary so the newest +/// text survives. +pub fn render_window_state( + window: &SessionWindowV1, + prompt_excerpt: Option<&str>, + max_bytes: usize, +) -> String { + let mut lines = Vec::new(); + for message in &window.messages { + let label = match message.role { + SessionWindowRoleV1::User => "User", + SessionWindowRoleV1::Assistant => "Assistant", + SessionWindowRoleV1::Tool => "Tool result", + SessionWindowRoleV1::Other => "Other", + }; + if !message.text.is_empty() { + lines.push(format!("{label}: {}", message.text)); + } + for call in &message.tool_calls { + lines.push(format!( + "Tool {}({})", + call.name, + call.argument_excerpt.as_deref().unwrap_or_default() + )); + } + } + if let Some(prompt) = prompt_excerpt + .map(str::trim) + .filter(|prompt| !prompt.is_empty()) + { + lines.push(format!("User: {prompt}")); + } + tail_on_char_boundary(&lines.join("\n"), max_bytes).to_owned() +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use serde_json::json; + use tracedecay_global_db::tests::harness::RegisteredGlobalDbHarness; + use tracedecay_sessions::runtime::{SessionMessageRecord, SessionRecord}; + + use super::*; + + const PROVIDER: &str = "cursor"; + const SESSION: &str = "window-session"; + const NOW_SECS: i64 = 1_800_000_000; + + struct Seed { + harness: RegisteredGlobalDbHarness, + } + + impl Seed { + async fn open(label: &str) -> Self { + Self { + harness: RegisteredGlobalDbHarness::open(label).await, + } + } + + fn db(&self) -> &RegisteredGlobalDb { + self.harness.registered.as_ref() + } + + async fn session(&self, provider: &str, session_id: &str) { + assert!( + self.db() + .upsert_session(&SessionRecord { + provider: provider.to_owned(), + session_id: session_id.to_owned(), + project_key: "/project".to_owned(), + project_path: "/project".to_owned(), + title: None, + started_at: None, + ended_at: None, + transcript_path: None, + metadata_json: None, + parent_session_id: None, + is_subagent: false, + agent_id: None, + parent_tool_use_id: None, + }) + .await + ); + } + + async fn message(&self, message: SessionMessageRecord) { + let root = self.db().db_path().parent().unwrap().to_path_buf(); + self.db() + .lcm_ingest_raw_message(&root, &message) + .await + .unwrap(); + } + } + + fn record( + provider: &str, + session_id: &str, + ordinal: i64, + role: &str, + text: &str, + ) -> SessionMessageRecord { + SessionMessageRecord { + provider: provider.to_owned(), + message_id: format!("{provider}-{session_id}-{ordinal:04}"), + session_id: session_id.to_owned(), + role: role.to_owned(), + timestamp: Some(NOW_SECS - 1_000 + ordinal), + ordinal, + text: text.to_owned(), + kind: Some("message".to_owned()), + model: None, + tool_names: None, + source_path: None, + source_offset: None, + metadata_json: None, + } + } + + fn limits() -> SessionWindowLimitsV1 { + SessionWindowLimitsV1 { + max_user_turns: 2, + max_messages: 20, + max_bytes: 4096, + max_age_micros: Some(600 * 1_000_000), + } + } + + fn request(limits: SessionWindowLimitsV1) -> SessionWindowRequestV1<'static> { + SessionWindowRequestV1 { + provider: PROVIDER, + session_id: SESSION, + limits, + now_micros: u64::try_from(NOW_SECS).unwrap() * 1_000_000, + } + } + + fn ready(outcome: SessionWindowOutcomeV1) -> SessionWindowV1 { + match outcome { + SessionWindowOutcomeV1::Ready(window) => window, + other => panic!("expected a window, got {other:?}"), + } + } + + fn texts(window: &SessionWindowV1) -> Vec<&str> { + window + .messages + .iter() + .map(|message| message.text.as_str()) + .collect() + } + + /// Three user turns with replies, interleaved with another session so the + /// store ids of the target session are not contiguous. + async fn seed_conversation(seed: &Seed) { + seed.session(PROVIDER, SESSION).await; + seed.session(PROVIDER, "other-session").await; + let turns = [ + ("user", "first question"), + ("assistant", "first answer"), + ("user", "second question"), + ("assistant", "second answer"), + ("user", "third question"), + ("assistant", "third answer"), + ]; + for (ordinal, (role, text)) in turns.into_iter().enumerate() { + let ordinal = i64::try_from(ordinal).unwrap(); + seed.message(record(PROVIDER, SESSION, ordinal, role, text)) + .await; + seed.message(record(PROVIDER, "other-session", ordinal, "user", "noise")) + .await; + } + } + + #[asupersync::test] + async fn an_unknown_or_empty_session_yields_an_empty_window() { + let seed = Seed::open("window-empty").await; + assert_eq!( + read_session_window(seed.db(), &request(limits())).await, + SessionWindowOutcomeV1::Empty + ); + seed_conversation(&seed).await; + let mut other = request(limits()); + other.session_id = "never-ingested"; + assert_eq!( + read_session_window(seed.db(), &other).await, + SessionWindowOutcomeV1::Empty + ); + for zero in [ + SessionWindowLimitsV1 { + max_user_turns: 0, + ..limits() + }, + SessionWindowLimitsV1 { + max_messages: 0, + ..limits() + }, + SessionWindowLimitsV1 { + max_bytes: 0, + ..limits() + }, + ] { + assert_eq!( + read_session_window(seed.db(), &request(zero)).await, + SessionWindowOutcomeV1::Empty + ); + } + } + + #[asupersync::test] + async fn the_last_user_turns_and_their_replies_are_kept_oldest_first() { + let seed = Seed::open("window-turns").await; + seed_conversation(&seed).await; + + let window = ready(read_session_window(seed.db(), &request(limits())).await); + assert_eq!( + texts(&window), + vec![ + "second question", + "second answer", + "third question", + "third answer" + ] + ); + assert_eq!(window.first_store_id, window.messages[0].store_id); + assert_eq!(window.last_store_id, window.messages[3].store_id); + assert!( + window + .messages + .windows(2) + .all(|pair| pair[0].store_id < pair[1].store_id) + ); + assert_eq!(window.messages[0].role, SessionWindowRoleV1::User); + assert_eq!(window.messages[1].role, SessionWindowRoleV1::Assistant); + + let one_turn = ready( + read_session_window( + seed.db(), + &request(SessionWindowLimitsV1 { + max_user_turns: 1, + ..limits() + }), + ) + .await, + ); + assert_eq!(texts(&one_turn), vec!["third question", "third answer"]); + + let everything = ready( + read_session_window( + seed.db(), + &request(SessionWindowLimitsV1 { + max_user_turns: 10, + ..limits() + }), + ) + .await, + ); + assert_eq!(everything.messages.len(), 6); + } + + #[asupersync::test] + async fn the_message_cap_drops_the_oldest_first() { + let seed = Seed::open("window-message-cap").await; + seed_conversation(&seed).await; + let window = ready( + read_session_window( + seed.db(), + &request(SessionWindowLimitsV1 { + max_user_turns: 10, + max_messages: 3, + ..limits() + }), + ) + .await, + ); + assert_eq!( + texts(&window), + vec!["second answer", "third question", "third answer"] + ); + } + + #[asupersync::test] + async fn the_byte_cap_keeps_the_newest_messages() { + let seed = Seed::open("window-byte-cap").await; + seed_conversation(&seed).await; + // "third question" (14) + "third answer" (12) = 26 bytes. + let window = ready( + read_session_window( + seed.db(), + &request(SessionWindowLimitsV1 { + max_user_turns: 10, + max_bytes: 30, + ..limits() + }), + ) + .await, + ); + assert_eq!(texts(&window), vec!["third question", "third answer"]); + + // One message over budget keeps its leading bytes rather than nothing. + let tiny = ready( + read_session_window( + seed.db(), + &request(SessionWindowLimitsV1 { + max_user_turns: 10, + max_bytes: 5, + ..limits() + }), + ) + .await, + ); + assert_eq!(texts(&tiny), vec!["third"]); + } + + #[asupersync::test] + async fn cursor_tool_calls_come_from_canonical_metadata() { + let seed = Seed::open("window-cursor-tools").await; + seed.session(PROVIDER, SESSION).await; + seed.message(record(PROVIDER, SESSION, 0, "user", "list the files")) + .await; + let mut assistant = record(PROVIDER, SESSION, 1, "assistant", "Running a command."); + assistant.metadata_json = Some( + json!({ + "tool_calls": [ + {"type": "function", "id": "call_1", + "function": {"name": "Shell", "arguments": {"command": "ls -la"}}}, + {"type": "function", + "function": {"name": "Read", "arguments": "path=lib.rs"}}, + ], + "tool_events": [ + {"type": "tool_use", "tool_name": "Shell", "call_id": "call_1", "input_bytes": 20}, + ], + }) + .to_string(), + ); + seed.message(assistant).await; + + let window = ready(read_session_window(seed.db(), &request(limits())).await); + let assistant = &window.messages[1]; + assert_eq!(assistant.text, "Running a command."); + assert_eq!( + assistant.tool_calls, + vec![ + SessionWindowToolCallV1 { + name: "Shell".to_owned(), + argument_excerpt: Some(r#"{"command":"ls -la"}"#.to_owned()), + }, + SessionWindowToolCallV1 { + name: "Read".to_owned(), + argument_excerpt: Some("path=lib.rs".to_owned()), + }, + ] + ); + } + + #[asupersync::test] + async fn tool_events_alone_still_name_the_tools() { + let calls = tool_calls_from_metadata( + &json!({"tool_events": [ + {"type": "tool_use", "tool_name": "Grep"}, + {"type": "tool_use", "tool_name": ""}, + ]}) + .to_string(), + ); + assert_eq!( + calls, + vec![SessionWindowToolCallV1 { + name: "Grep".to_owned(), + argument_excerpt: None + }] + ); + assert!(tool_calls_from_metadata("not json").is_empty()); + } + + #[asupersync::test] + async fn typed_fact_hosts_name_tools_through_the_tool_names_column() { + let seed = Seed::open("window-claude-tools").await; + seed.session("claude", SESSION).await; + seed.message(record("claude", SESSION, 0, "user", "fix the parser")) + .await; + let mut reply = record("claude", SESSION, 1, "assistant", "Looking at it."); + reply.tool_names = Some("Read,Grep".to_owned()); + seed.message(reply).await; + let mut dispatch = record( + "claude", + SESSION, + 2, + "assistant", + r#"{"path":"src/lib.rs"}"#, + ); + dispatch.kind = Some("tool_invocation".to_owned()); + dispatch.tool_names = Some("Read".to_owned()); + seed.message(dispatch).await; + + let mut claude = request(limits()); + claude.provider = "claude"; + let window = ready(read_session_window(seed.db(), &claude).await); + assert_eq!(window.messages.len(), 3); + assert_eq!(window.messages[1].text, "Looking at it."); + assert_eq!( + window.messages[1] + .tool_calls + .iter() + .map(|call| call.name.as_str()) + .collect::>(), + vec!["Read", "Grep"] + ); + assert_eq!(window.messages[2].text, ""); + assert_eq!( + window.messages[2].tool_calls, + vec![SessionWindowToolCallV1 { + name: "Read".to_owned(), + argument_excerpt: Some(r#"{"path":"src/lib.rs"}"#.to_owned()), + }] + ); + } + + /// Hermes and Codex project a tool-only turn as a `tool_invocation` row + /// whose text is the argument payload and whose `tool_names` holds the + /// tool (`terminal` / `exec_command`), as asserted by the ingest suites + /// `transcript_ingest_suite/hermes.rs` and `codex_response_items.rs`. + #[asupersync::test] + async fn hermes_and_codex_tool_invocation_rows_become_tool_calls() { + for (provider, tool, payload) in [ + ("hermes", "terminal", "cargo test billing"), + ("codex", "exec_command", "gh pr merge 366 --squash"), + ] { + let seed = Seed::open(&format!("window-{provider}-tools")).await; + seed.session(provider, SESSION).await; + seed.message(record(provider, SESSION, 0, "user", "run the tests")) + .await; + let mut invocation = record(provider, SESSION, 1, "assistant", payload); + invocation.kind = Some("tool_invocation".to_owned()); + invocation.tool_names = Some(tool.to_owned()); + seed.message(invocation).await; + seed.message(record(provider, SESSION, 2, "assistant", "Done.")) + .await; + + let mut host = request(limits()); + host.provider = provider; + let window = ready(read_session_window(seed.db(), &host).await); + assert_eq!(window.messages.len(), 3, "{provider}"); + assert_eq!(window.messages[1].text, "", "{provider}"); + assert_eq!( + window.messages[1].tool_calls, + vec![SessionWindowToolCallV1 { + name: tool.to_owned(), + argument_excerpt: Some(payload.to_owned()), + }], + "{provider}" + ); + assert!(window.messages[2].tool_calls.is_empty(), "{provider}"); + assert!( + render_window_state(&window, None, 4096) + .contains(&format!("Tool {tool}({payload})")), + "{provider}" + ); + } + } + + #[asupersync::test] + async fn freshness_follows_the_newest_message_age() { + let seed = Seed::open("window-freshness").await; + seed_conversation(&seed).await; + + // Newest message is ~995s old at NOW_SECS. + let stale = ready(read_session_window(seed.db(), &request(limits())).await); + let SessionWindowFreshnessV1::Stale { age_micros } = stale.freshness else { + panic!("expected stale, got {:?}", stale.freshness); + }; + assert_eq!(age_micros, 995 * 1_000_000); + assert_eq!( + stale.newest_timestamp_micros, + Some((NOW_SECS - 1_000 + 5) * 1_000_000) + ); + + let fresh = ready( + read_session_window( + seed.db(), + &request(SessionWindowLimitsV1 { + max_age_micros: Some(2_000 * 1_000_000), + ..limits() + }), + ) + .await, + ); + assert_eq!(fresh.freshness, SessionWindowFreshnessV1::Fresh); + + let unbounded = ready( + read_session_window( + seed.db(), + &request(SessionWindowLimitsV1 { + max_age_micros: None, + ..limits() + }), + ) + .await, + ); + assert_eq!(unbounded.freshness, SessionWindowFreshnessV1::Fresh); + } + + #[test] + fn timestamps_normalize_seconds_and_milliseconds_to_micros() { + assert_eq!(timestamp_micros(1_800_000_000), 1_800_000_000_000_000); + assert_eq!(timestamp_micros(1_800_000_000_123), 1_800_000_000_123_000); + assert_eq!( + freshness(None, 10, Some(1)), + SessionWindowFreshnessV1::Unknown + ); + // A timestamp in the future is not stale. + assert_eq!( + freshness(Some(2_000_000), 1_000_000, Some(1)), + SessionWindowFreshnessV1::Fresh + ); + } + + #[asupersync::test] + async fn the_digest_is_stable_and_tracks_content_and_identity() { + let seed = Seed::open("window-digest").await; + seed_conversation(&seed).await; + let first = ready(read_session_window(seed.db(), &request(limits())).await); + let again = ready(read_session_window(seed.db(), &request(limits())).await); + assert_eq!(first.window_digest, again.window_digest); + + // The clock does not enter the digest. + let mut later = request(limits()); + later.now_micros += 5_000_000; + assert_eq!( + ready(read_session_window(seed.db(), &later).await).window_digest, + first.window_digest + ); + + // A different window of the same session differs. + let narrower = ready( + read_session_window( + seed.db(), + &request(SessionWindowLimitsV1 { + max_user_turns: 1, + ..limits() + }), + ) + .await, + ); + assert_ne!(narrower.window_digest, first.window_digest); + + // A new message changes it. + seed.message(record(PROVIDER, SESSION, 6, "user", "fourth question")) + .await; + let grown = ready(read_session_window(seed.db(), &request(limits())).await); + assert_ne!(grown.window_digest, first.window_digest); + + // Identity is part of it: the same messages under another session id. + let messages = first.messages.clone(); + assert_ne!( + window_digest(PROVIDER, SESSION, &messages), + window_digest(PROVIDER, "other", &messages) + ); + assert_ne!( + window_digest(PROVIDER, SESSION, &messages), + window_digest("claude", SESSION, &messages) + ); + } + + fn message( + role: SessionWindowRoleV1, + text: &str, + calls: &[(&str, Option<&str>)], + ) -> SessionWindowMessageV1 { + SessionWindowMessageV1 { + role, + text: text.to_owned(), + tool_calls: calls + .iter() + .map(|(name, args)| SessionWindowToolCallV1 { + name: (*name).to_owned(), + argument_excerpt: args.map(str::to_owned), + }) + .collect(), + store_id: 1, + timestamp_micros: None, + } + } + + fn window_of(messages: Vec) -> SessionWindowV1 { + SessionWindowV1 { + first_store_id: 1, + last_store_id: 1, + newest_timestamp_micros: None, + window_digest: [0; 32], + freshness: SessionWindowFreshnessV1::Unknown, + messages, + } + } + + #[test] + fn the_rendered_state_is_a_plain_transcript_ending_in_the_prompt() { + let window = window_of(vec![ + message(SessionWindowRoleV1::User, "where is the parser?", &[]), + message( + SessionWindowRoleV1::Assistant, + "Searching.", + &[("Grep", Some("parse_expr")), ("Read", None)], + ), + message(SessionWindowRoleV1::Tool, "src/parse.rs:10", &[]), + ]); + assert_eq!( + render_window_state(&window, Some(" now fix it "), 4096), + "User: where is the parser?\n\ + Assistant: Searching.\n\ + Tool Grep(parse_expr)\n\ + Tool Read()\n\ + Tool result: src/parse.rs:10\n\ + User: now fix it" + ); + assert_eq!( + render_window_state(&window, None, 4096).lines().last(), + Some("Tool result: src/parse.rs:10") + ); + assert_eq!( + render_window_state(&window, Some(" "), 4096), + render_window_state(&window, None, 4096) + ); + } + + #[test] + fn the_rendered_state_truncates_from_the_front_on_char_boundaries() { + let window = window_of(vec![ + message(SessionWindowRoleV1::User, "old context é", &[]), + message(SessionWindowRoleV1::Assistant, "recent reply", &[]), + ]); + let full = render_window_state(&window, Some("latest prompt"), usize::MAX); + let cut = render_window_state(&window, Some("latest prompt"), 30); + assert!(cut.len() <= 30); + assert!(full.ends_with(&cut), "{cut:?} must be a suffix of {full:?}"); + assert!(cut.ends_with("User: latest prompt")); + + // A multi-byte character straddling the cut is dropped, not split. + let accented = window_of(vec![message(SessionWindowRoleV1::User, "ééééé", &[])]); + let rendered = render_window_state(&accented, None, 7); + assert_eq!(rendered, "ééé"); + assert_eq!(render_window_state(&accented, None, 0), ""); + } + + #[test] + fn long_arguments_are_collapsed_and_bounded() { + assert_eq!(excerpt(" a \n b\t c ").as_deref(), Some("a b c")); + assert_eq!(excerpt(" "), None); + let long = "x".repeat(SESSION_WINDOW_ARGUMENT_EXCERPT_CHARS + 50); + let bounded = excerpt(&long).unwrap(); + assert_eq!( + bounded.chars().count(), + SESSION_WINDOW_ARGUMENT_EXCERPT_CHARS + 1 + ); + assert!(bounded.ends_with(ELLIPSIS)); + } +} diff --git a/crates/tracedecay-agent-hosts/src/agents/context_scout/store.rs b/crates/tracedecay-agent-hosts/src/agents/context_scout/store.rs index 4b63f31fd0..f7884f878b 100644 --- a/crates/tracedecay-agent-hosts/src/agents/context_scout/store.rs +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout/store.rs @@ -327,6 +327,9 @@ fn document_needs_provenance_reset(encoded: &str, project_id: [u8; 16]) -> bool if stored_project != project_id { return false; } + if has_untagged_candidate_evidence(object) { + return true; + } if !object.contains_key("delivery_addresses") || !object.contains_key("delivery_provenance_complete") { @@ -340,6 +343,25 @@ fn document_needs_provenance_reset(encoded: &str, project_id: [u8; 16]) -> bool } } +/// A queued candidate written before candidate evidence became the tagged +/// code/session union carries a bare evidence object with no `kind`. Such a +/// document cannot decode into the current shape, so startup resets it +/// instead of reporting the durable state unavailable forever. +fn has_untagged_candidate_evidence(object: &serde_json::Map) -> bool { + let untagged = |stored: &serde_json::Value| { + stored + .pointer("/entry/envelope/candidate/evidence") + .and_then(serde_json::Value::as_object) + .is_some_and(|evidence| !evidence.contains_key("kind")) + }; + ["entries", "delivery_addresses"].into_iter().any(|field| { + object + .get(field) + .and_then(serde_json::Value::as_array) + .is_some_and(|rows| rows.iter().any(untagged)) + }) +} + fn decode_mutation_settlement(encoded: &str) -> Option { if encoded.len() > MAX_MUTATION_RECEIPT_BYTES_V1 { return None; @@ -1287,6 +1309,24 @@ impl ProjectContextScoutDurableStoreV1 { return ContextScoutDurableStoreOutcomeV1::Unavailable; } self.update_state("enqueue Context Scout suggestion", move |state| { + // A re-judged copy of a suggestion already claimed or delivered at + // this address never replaces it: the session would see it twice. + let same_suggestion = |other: &ContextScoutDurableQueueEntryV1| { + other.work.address == entry.work.address + && other.envelope.candidate.dedupe_key == entry.envelope.candidate.dedupe_key + }; + if state + .entries + .iter() + .any(|stored| stored.lease.is_some() && same_suggestion(&stored.entry)) + || state + .delivery_addresses + .iter() + .filter_map(|binding| binding.entry.as_ref()) + .any(same_suggestion) + { + return ContextScoutDurableStoreOutcomeV1::Duplicate; + } if state.tombstones.contains(&entry.work) { return ContextScoutDurableStoreOutcomeV1::Superseded; } diff --git a/crates/tracedecay-agent-hosts/src/agents/context_scout/store_tests.rs b/crates/tracedecay-agent-hosts/src/agents/context_scout/store_tests.rs index a8ba580e02..b57c119763 100644 --- a/crates/tracedecay-agent-hosts/src/agents/context_scout/store_tests.rs +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout/store_tests.rs @@ -58,7 +58,7 @@ pub(super) fn entry(project_id: [u8; 16], generation: u64) -> ContextScoutDurabl category: ContextScoutCategoryV1::Retrieval, relevance_score: 10, suggestion_text: "Use the saved diagnostic anchor.".to_owned(), - evidence: super::evidence::fixture_context_scout_evidence(), + evidence: super::evidence::fixture_context_scout_evidence().into(), expires_at: UtcMicros(1_000), }, }, @@ -721,6 +721,65 @@ async fn older_work_generation_cannot_replace_newer_durable_entry() { ); } +#[tokio::test] +async fn a_claimed_or_delivered_suggestion_is_never_replaced_by_its_rejudged_copy() { + let (_temporary, database) = database().await; + let project_id = [70; 16]; + let store = + ProjectContextScoutDurableStoreV1::from_project_database(database, project_id).unwrap(); + let first = entry(project_id, 1); + assert_eq!( + store.enqueue(first.clone()).await, + ContextScoutDurableStoreOutcomeV1::Stored + ); + let ContextScoutDurableClaimOutcomeV1::Claimed(claim) = store + .claim(address(project_id), UtcMicros(10), lease(71, 40)) + .await + else { + panic!("the first suggestion claims"); + }; + let mut rejudged = entry(project_id, 2); + rejudged.envelope.envelope_id = [72; 16]; + assert_eq!( + store.enqueue(rejudged.clone()).await, + ContextScoutDurableStoreOutcomeV1::Duplicate, + "a suggestion mid-delivery keeps its claim" + ); + let receipt = ContextScoutDeliveryReceiptV1 { + receipt_id: [73; 16], + envelope_id: first.envelope.envelope_id, + delivered_at: UtcMicros(20), + outcome: ContextScoutDeliveryOutcomeV1::Displayed, + }; + assert_eq!( + store.record_delivery(&claim, &receipt).await, + ContextScoutDurableStoreOutcomeV1::Stored, + "the claimed suggestion is still the one delivered" + ); + let mut rejudged_work = first.clone(); + rejudged_work.envelope.envelope_id = [77; 16]; + assert_eq!( + store.enqueue(rejudged_work).await, + ContextScoutDurableStoreOutcomeV1::Duplicate, + "the delivered work judged again under a restarted generation is a duplicate" + ); + rejudged.work.generation = 3; + rejudged.envelope.envelope_id = [74; 16]; + assert_eq!( + store.enqueue(rejudged.clone()).await, + ContextScoutDurableStoreOutcomeV1::Duplicate, + "a delivered suggestion is not queued again" + ); + rejudged.work.generation = 4; + rejudged.envelope.envelope_id = [75; 16]; + rejudged.envelope.candidate.dedupe_key = [76; 32]; + assert_eq!( + store.enqueue(rejudged).await, + ContextScoutDurableStoreOutcomeV1::Stored, + "another suggestion at the address still enqueues" + ); +} + #[tokio::test] async fn exact_project_scope_and_durable_generation_are_enforced() { let (_temporary, database) = database().await; @@ -734,18 +793,23 @@ async fn exact_project_scope_and_durable_generation_are_enforced() { ); let mut dirty = entry(project_id, 1); - dirty.envelope.candidate.evidence.content = - tracedecay_domain::feedback::FeedbackContentIdentityV1::EphemeralOverlay { - session_id: tracedecay_domain::SessionId::new("session.overlay").unwrap(), - owner_client_id: tracedecay_domain::HostInstanceId::new("client.overlay").unwrap(), - agent_id: None, - document_version: 1, - overlay_digest: tracedecay_domain::ManifestDigest::new(format!( - "sha256:{}", - "f".repeat(64) - )) - .unwrap(), - }; + dirty + .envelope + .candidate + .evidence + .as_code_mut() + .unwrap() + .content = tracedecay_domain::feedback::FeedbackContentIdentityV1::EphemeralOverlay { + session_id: tracedecay_domain::SessionId::new("session.overlay").unwrap(), + owner_client_id: tracedecay_domain::HostInstanceId::new("client.overlay").unwrap(), + agent_id: None, + document_version: 1, + overlay_digest: tracedecay_domain::ManifestDigest::new(format!( + "sha256:{}", + "f".repeat(64) + )) + .unwrap(), + }; assert_eq!( store.enqueue(dirty).await, ContextScoutDurableStoreOutcomeV1::Unavailable @@ -958,6 +1022,62 @@ async fn legacy_delivery_provenance_resets_without_replaying_receipts() { ); } +#[tokio::test] +async fn untagged_candidate_evidence_resets_instead_of_failing_decode() { + let (_temporary, database) = database().await; + let project_id = [8; 16]; + let store = + ProjectContextScoutDurableStoreV1::from_project_database(database.clone(), project_id) + .expect("owned project store"); + let queued = entry(project_id, 1); + assert_eq!( + store.enqueue(queued.clone()).await, + ContextScoutDurableStoreOutcomeV1::Stored + ); + let current = database + .get_metadata("agents.context-scout.durable.v1") + .await + .expect("current metadata read") + .expect("current document"); + let mut legacy = serde_json::from_str::(¤t).expect("current json"); + let evidence = legacy + .pointer_mut("/entries/0/entry/envelope/candidate/evidence") + .and_then(serde_json::Value::as_object_mut) + .expect("tagged evidence"); + assert_eq!( + evidence.remove("kind"), + Some(serde_json::Value::String("code".to_owned())) + ); + let legacy_json = legacy.to_string(); + assert!( + serde_json::from_value::( + legacy["entries"][0]["entry"].clone() + ) + .is_err(), + "the pre-union shape must not decode into the current entry" + ); + database + .set_metadata("agents.context-scout.durable.v1", &legacy_json) + .await + .expect("legacy Context Scout state"); + + assert_eq!( + store.startup(UtcMicros(31), 8).await, + ContextScoutDurableStartupOutcomeV1::Reset + ); + assert_eq!( + store.startup(UtcMicros(32), 8).await, + ContextScoutDurableStartupOutcomeV1::Ready { + entries: Vec::new(), + truncated: false, + } + ); + assert_eq!( + store.enqueue(entry(project_id, 2)).await, + ContextScoutDurableStoreOutcomeV1::Stored + ); +} + #[tokio::test] async fn cancellation_tombstone_blocks_stale_generation_but_allows_newer_work() { let (_temporary, database) = database().await; diff --git a/crates/tracedecay-agent-hosts/src/hooks/dispatch.rs b/crates/tracedecay-agent-hosts/src/hooks/dispatch.rs index 742035c229..b69ea5f5ac 100644 --- a/crates/tracedecay-agent-hosts/src/hooks/dispatch.rs +++ b/crates/tracedecay-agent-hosts/src/hooks/dispatch.rs @@ -430,12 +430,11 @@ fn native_context_scout_lifecycle( fields: &NativeIdentityFields, event_id: [u8; 16], ) -> Option { - matches!( - host, - NativeHostIdentityV1::KimiCode | NativeHostIdentityV1::OpenCode - ) - .then(|| NativeContextScoutLifecycleV1::new(fields.session_id()?, fields.call_id()?, event_id)) - .flatten() + NativeContextScoutLifecycleV1::host_mounts_lifecycle(host) + .then(|| { + NativeContextScoutLifecycleV1::new(fields.session_id()?, fields.call_id()?, event_id) + }) + .flatten() } const HOOK_ADMISSION_ACK_BUDGET_MICROS: u64 = 25_000; diff --git a/crates/tracedecay-agent-hosts/src/hooks/tool_hints.rs b/crates/tracedecay-agent-hosts/src/hooks/tool_hints.rs index 7208833d7e..fd77eb4219 100644 --- a/crates/tracedecay-agent-hosts/src/hooks/tool_hints.rs +++ b/crates/tracedecay-agent-hosts/src/hooks/tool_hints.rs @@ -8,419 +8,26 @@ use std::collections::HashMap; use std::path::Path; use tracedecay_domain::HostIntegrationIdV1; +use tracedecay_domain::configuration::ContextScoutIntentKindV1; use tracedecay_policy::hint_delivery::{ HintDeliveryDecisionV1, HintDeliveryInputV1, decide_hint_delivery, }; +use tracedecay_policy::intent_action_facts::{ActionFactInputV1, first_action_fact}; +use tracedecay_policy::intent_catalog; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub enum HintCategory { - Search, - SemanticSearch, - FileRead, - ToolDescriptorRead, - BroadRead, - CallGraph, - Impact, - SymbolLookup, - FileLookup, - ProjectContext, - SessionRecall, - AtomicEdit, - TypeOrientation, - ExploreSubagent, - SubagentStartContext, - BuildDiagnostics, - ReviewChanges, - MemoryStore, - EditRedundancy, - UnexpectedChanges, -} - -impl HintCategory { - fn spec(self) -> &'static HintCategorySpec { - match CATEGORY_SPECS.iter().find(|spec| spec.category == self) { - Some(spec) => spec, - None => unreachable!("every HintCategory has a spec"), - } - } - - pub(crate) fn as_key(self) -> &'static str { - self.spec().key - } - - /// Human-readable name used in the escalation message prefix - /// ("Repeated native