From 31372ed455bf4f06c58f3fe15dcfe280f757df5e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:32:44 +0000 Subject: [PATCH 01/23] fix(hooks): drop two session-recall phrases that hint on ordinary tasks "memory curator" and "transcript files" fired the session-recall hint on "document how the memory curator works in the README" and "delete transcript files older than 30 days"; both are now silent regression cases. The eval cases that used those phrases still match through "automation run" and "raw codex jsonl transcript". The held-out prompt set from the recall-model experiment is kept under evals/intent_engine/ for the daemon-owned intent pass that replaces hook-local hints. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HB4wWE2tsLyyxJNkBCqRpD --- .../src/hooks/tool_hints/classifiers.rs | 2 - .../tool_hints/evals/cases_real_world.rs | 12 + evals/intent_engine/heldout_prompts.json | 470 ++++++++++++++++++ 3 files changed, 482 insertions(+), 2 deletions(-) create mode 100644 evals/intent_engine/heldout_prompts.json diff --git a/crates/tracedecay-agent-hosts/src/hooks/tool_hints/classifiers.rs b/crates/tracedecay-agent-hosts/src/hooks/tool_hints/classifiers.rs index b6137b3b12..1a3c0f3328 100644 --- a/crates/tracedecay-agent-hosts/src/hooks/tool_hints/classifiers.rs +++ b/crates/tracedecay-agent-hosts/src/hooks/tool_hints/classifiers.rs @@ -689,7 +689,6 @@ pub(super) fn asks_for_session_recall(text: &str) -> bool { "prior run", "previous run", "automation run", - "memory curator", "self-improvement run", "past session", "prior conversation", @@ -711,7 +710,6 @@ pub(super) fn asks_for_session_recall(text: &str) -> bool { &[ "raw codex jsonl transcript", "raw codex jsonl transcripts", - "transcript files", "hook input", "hook usage", "hint displayed", diff --git a/crates/tracedecay-agent-hosts/src/hooks/tool_hints/evals/cases_real_world.rs b/crates/tracedecay-agent-hosts/src/hooks/tool_hints/evals/cases_real_world.rs index 42a4cf247f..be7d902970 100644 --- a/crates/tracedecay-agent-hosts/src/hooks/tool_hints/evals/cases_real_world.rs +++ b/crates/tracedecay-agent-hosts/src/hooks/tool_hints/evals/cases_real_world.rs @@ -27,6 +27,18 @@ pub(super) fn real_world_prompt_cases() -> Vec { None, &[], ), + prompt_eval( + "memory-curator-docs-task-is-not-recall", + "document how the memory curator works in the README", + None, + &[], + ), + prompt_eval( + "transcript-cleanup-task-is-not-recall", + "delete transcript files older than 30 days in the cleanup job", + None, + &[], + ), prompt_eval( "what-did-we-decide-before", "where did we decide how memory curation should work before?", diff --git a/evals/intent_engine/heldout_prompts.json b/evals/intent_engine/heldout_prompts.json new file mode 100644 index 0000000000..20a787ad42 --- /dev/null +++ b/evals/intent_engine/heldout_prompts.json @@ -0,0 +1,470 @@ +[ + { + "name": "ho-recall-0", + "prompt": "didn't we already figure out why the indexer was stalling? what was the fix", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-1", + "prompt": "what was the conclusion yesterday about switching to the bazel remote cache", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-2", + "prompt": "pull up our earlier discussion on the hook budget", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-3", + "prompt": "I explained the deploy steps to you on Monday, use those", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-4", + "prompt": "go back to what I said about naming conventions a couple of chats ago", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-5", + "prompt": "we went through this exact error a week ago, find how we solved it", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-6", + "prompt": "what approach did the other agent take on the scheduler refactor", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-7", + "prompt": "summarize what happened in yesterday's session", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-8", + "prompt": "what did I tell you about not touching the migrations", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-9", + "prompt": "find the conversation where we picked sqlite over postgres", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-10", + "prompt": "you suggested a fix for this flaky test before, what was it?", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-11", + "prompt": "recover the plan we agreed on before the context got compacted", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-12", + "prompt": "which options did we rule out previously for the cache key?", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-13", + "prompt": "the codex session from this morning was working on this, see what it found", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-14", + "prompt": "we had a long thread about retry semantics, what did we land on", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-15", + "prompt": "check our chat history for the API key rotation steps", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-16", + "prompt": "I already gave you the repro steps in another session", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-17", + "prompt": "what were the open questions we left unresolved yesterday", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-18", + "prompt": "look through previous transcripts for when the dashboard build first broke", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-19", + "prompt": "earlier today you found the root cause of the leak, what was it again?", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-20", + "prompt": "restore context from the session before the crash", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-21", + "prompt": "what did the reviewer agent say in its last pass", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-22", + "prompt": "have we hit this panic before?", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-23", + "prompt": "recap our previous decisions on error handling", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-24", + "prompt": "find where I asked for dark mode support in a past chat", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-25", + "prompt": "what was my preferred commit message style that I mentioned before", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-26", + "prompt": "what did we try last week that didn't work for the deadlock", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-27", + "prompt": "search old conversations for 'lease expired'", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-28", + "prompt": "is there a session where someone already investigated the OOM?", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-recall-29", + "prompt": "continue from where the previous agent left off on the migration", + "expected": "SessionRecall", + "split": "recall" + }, + { + "name": "ho-neg-0", + "prompt": "fix the memory leak in the session store", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-1", + "prompt": "add a session timeout setting to the config", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-2", + "prompt": "why is memory usage so high in the daemon", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-3", + "prompt": "write a unit test for the fact store search scoring", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-4", + "prompt": "rename SessionId to ConversationId across the crate", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-5", + "prompt": "document how the memory curator works in the README", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-6", + "prompt": "the session cookie is not being set on login", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-7", + "prompt": "implement a last-used timestamp on sessions", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-8", + "prompt": "add a git history view to the dashboard", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-9", + "prompt": "update the CHANGELOG with the last release notes", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-10", + "prompt": "optimize the history buffer to avoid reallocations", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-11", + "prompt": "explain how LRU caches work", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-12", + "prompt": "delete transcript files older than 30 days in the cleanup job", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-13", + "prompt": "the previous button in the pager is misaligned", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-14", + "prompt": "make the conversation list in the UI sortable by date", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-15", + "prompt": "what's the difference between stack and heap memory", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-16", + "prompt": "bump the version since the last release", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-17", + "prompt": "add a remember-me checkbox to the login form", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-18", + "prompt": "set up memory limits for the docker container", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-19", + "prompt": "write a migration that adds a sessions table", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-20", + "prompt": "run the tests again", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-21", + "prompt": "refactor the transcript parser to stream lines", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-22", + "prompt": "earlier versions of clippy didn't flag this, add an allow", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-23", + "prompt": "make the dashboard remember the selected tab", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-neg-24", + "prompt": "profile memory allocations in the indexer", + "expected": null, + "split": "hard_negative" + }, + { + "name": "ho-other-0", + "prompt": "trace everything that ends up invoking flush_ledger", + "expected": "CallGraph", + "split": "other" + }, + { + "name": "ho-other-1", + "prompt": "what functions does parse_envelope call into", + "expected": "CallGraph", + "split": "other" + }, + { + "name": "ho-other-2", + "prompt": "if I delete the legacy adapter, what tests break?", + "expected": "Impact", + "split": "other" + }, + { + "name": "ho-other-3", + "prompt": "which modules depend on SchedulerConfig and would be affected by changing it", + "expected": "Impact", + "split": "other" + }, + { + "name": "ho-other-4", + "prompt": "look over my staged changes before I open the PR", + "expected": "ReviewChanges", + "split": "other" + }, + { + "name": "ho-other-5", + "prompt": "review this pull request's diff for bugs", + "expected": "ReviewChanges", + "split": "other" + }, + { + "name": "ho-other-6", + "prompt": "where is struct HintCategorySpec declared", + "expected": "SymbolLookup", + "split": "other" + }, + { + "name": "ho-other-7", + "prompt": "jump to the definition of decide_hint_delivery", + "expected": "SymbolLookup", + "split": "other" + }, + { + "name": "ho-other-8", + "prompt": "list all types implementing the Store trait", + "expected": "TypeOrientation", + "split": "other" + }, + { + "name": "ho-other-9", + "prompt": "where is the scheduler config struct constructed", + "expected": "TypeOrientation", + "split": "other" + }, + { + "name": "ho-other-10", + "prompt": "check how the sibling api-gateway repo handles auth", + "expected": "ProjectContext", + "split": "other" + }, + { + "name": "ho-other-11", + "prompt": "compare this with our other repo tracedecay-cloud", + "expected": "ProjectContext", + "split": "other" + }, + { + "name": "ho-other-12", + "prompt": "there are commits on my branch I don't recognize, who made them", + "expected": "UnexpectedChanges", + "split": "other" + }, + { + "name": "ho-other-13", + "prompt": "someone force pushed to my branch, what changed", + "expected": "UnexpectedChanges", + "split": "other" + }, + { + "name": "ho-other-14", + "prompt": "which files contain the codex adapter", + "expected": "FileLookup", + "split": "other" + }, + { + "name": "ho-other-15", + "prompt": "find the config files for the scheduler", + "expected": "FileLookup", + "split": "other" + }, + { + "name": "ho-other-16", + "prompt": "read through the entire repo and give me an architecture overview", + "expected": "BroadRead", + "split": "other" + }, + { + "name": "ho-other-17", + "prompt": "the build is failing with type errors, help me fix them", + "expected": "BuildDiagnostics", + "split": "other" + }, + { + "name": "ho-other-18", + "prompt": "write a haiku about rust", + "expected": null, + "split": "other" + }, + { + "name": "ho-other-19", + "prompt": "what's 15% of 80", + "expected": null, + "split": "other" + }, + { + "name": "ho-other-20", + "prompt": "commit and push", + "expected": null, + "split": "other" + }, + { + "name": "ho-other-21", + "prompt": "translate this paragraph to Spanish", + "expected": null, + "split": "other" + }, + { + "name": "ho-other-22", + "prompt": "open a PR for this branch", + "expected": null, + "split": "other" + } +] \ No newline at end of file From c382149daa47cfbcbc4b86a91fe2d2908c6eff4e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:47:56 +0000 Subject: [PATCH 02/23] chore(context-scout): checkpoint the daemon intent pass groundwork Work-in-progress snapshot taken after a container restart, so the partial files survive: the pluggable decision-engine client (/v1/systemone), the session-window reader over the LCM raw-message store, and the intent settings block in the domain configuration. Not yet compiled or wired; later commits finish and verify them. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HB4wWE2tsLyyxJNkBCqRpD --- .../src/agents/context_scout.rs | 2 + .../agents/context_scout/decision_engine.rs | 1146 +++++++++++++++++ .../agents/context_scout/session_window.rs | 1110 ++++++++++++++++ crates/tracedecay-domain/src/configuration.rs | 11 + .../src/configuration/context_scout_intent.rs | 516 ++++++++ 5 files changed, 2785 insertions(+) create mode 100644 crates/tracedecay-agent-hosts/src/agents/context_scout/decision_engine.rs create mode 100644 crates/tracedecay-agent-hosts/src/agents/context_scout/session_window.rs create mode 100644 crates/tracedecay-domain/src/configuration/context_scout_intent.rs diff --git a/crates/tracedecay-agent-hosts/src/agents/context_scout.rs b/crates/tracedecay-agent-hosts/src/agents/context_scout.rs index 0babf51434..75521ddaf0 100644 --- a/crates/tracedecay-agent-hosts/src/agents/context_scout.rs +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout.rs @@ -31,8 +31,10 @@ use tracedecay_runtime_core::cancellation::{CancellationToken, MonotonicDeadline use tracedecay_tokenizer::o200k_base; pub mod address_registry; +pub mod decision_engine; pub mod model; pub mod owner; +pub mod session_window; const MAX_SCOUT_TEXT_BYTES: usize = 4 * 1024; const MAX_SCOUT_CANDIDATES: usize = 32; 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..41dc443897 --- /dev/null +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout/decision_engine.rs @@ -0,0 +1,1146 @@ +//! 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(_) + )); + } + + #[tokio::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/session_window.rs b/crates/tracedecay-agent-hosts/src/agents/context_scout/session_window.rs new file mode 100644 index 0000000000..c3642de51d --- /dev/null +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout/session_window.rs @@ -0,0 +1,1110 @@ +//! 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; + } + } + + #[tokio::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 + ); + } + } + + #[tokio::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); + } + + #[tokio::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"] + ); + } + + #[tokio::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"]); + } + + #[tokio::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()), + }, + ] + ); + } + + #[tokio::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()); + } + + #[tokio::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", "{\"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()), + }] + ); + } + + #[tokio::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 + ); + } + + #[tokio::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-domain/src/configuration.rs b/crates/tracedecay-domain/src/configuration.rs index 6acf9f50be..8b771a20c6 100644 --- a/crates/tracedecay-domain/src/configuration.rs +++ b/crates/tracedecay-domain/src/configuration.rs @@ -18,11 +18,13 @@ use crate::research::{ ProjectId, UtcMicros, canonical_sha256, }; +mod context_scout_intent; mod lcm_summarizer_executables; pub mod topology; mod work_executable_bindings; mod work_expertise_consent; +pub use context_scout_intent::*; pub use lcm_summarizer_executables::*; pub use topology::*; pub use work_executable_bindings::*; @@ -929,6 +931,11 @@ pub struct ContextScoutSettingsV1 { pub model_path: Option, pub model_id: Option, pub model_timeout_secs: Option, + /// Session intent pass. Absent means the Scout runs no intent pass; the + /// field is omitted from the canonical encoding so settings written before + /// it existed keep their behavior digest. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intent: Option, } impl ContextScoutSettingsV1 { @@ -943,6 +950,7 @@ impl ContextScoutSettingsV1 { model_path: None, model_id: None, model_timeout_secs: None, + intent: None, } } @@ -972,6 +980,9 @@ impl ContextScoutSettingsV1 { if let Some(model_id) = self.model_id.as_deref() { validate_canonical_label(model_id, "context scout model id")?; } + if let Some(intent) = &self.intent { + intent.validate()?; + } self.limits.validate() } } diff --git a/crates/tracedecay-domain/src/configuration/context_scout_intent.rs b/crates/tracedecay-domain/src/configuration/context_scout_intent.rs new file mode 100644 index 0000000000..06dfc91813 --- /dev/null +++ b/crates/tracedecay-domain/src/configuration/context_scout_intent.rs @@ -0,0 +1,516 @@ +//! Context Scout intent-pass vocabulary and configuration. +//! +//! The intent pass reads a bounded window of recent session turns, asks a +//! configured decision engine one typed yes/no question per intent, and +//! combines the answers with deterministic action facts. This module owns only +//! the intent vocabulary and the operator-configured bounds. The engine URL +//! and model are never defaulted: an intent pass without an explicit engine is +//! not configured. + +use std::collections::BTreeSet; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::DomainError; +use crate::research::{ManifestDigest, canonical_sha256}; + +const INTENT_SETTINGS_DIGEST_DOMAIN: &str = "tracedecay.context-scout.intent-settings.v1"; + +/// One user intent the Scout can recognize. The serialized keys are the +/// historical hook hint category keys, so persisted hint analytics, hint +/// settlement, and evals keep one vocabulary. +#[derive( + Clone, + Copy, + Debug, + Serialize, + Deserialize, + JsonSchema, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, +)] +#[serde(rename_all = "snake_case")] +pub enum ContextScoutIntentKindV1 { + Search, + SemanticSearch, + FileRead, + ToolDescriptorRead, + BroadRead, + CallGraph, + Impact, + SymbolLookup, + FileLookup, + ProjectContext, + SessionRecall, + AtomicEdit, + TypeOrientation, + ExploreSubagent, + SubagentStartContext, + BuildDiagnostics, + ReviewChanges, + MemoryStore, + EditRedundancy, + UnexpectedChanges, +} + +impl ContextScoutIntentKindV1 { + pub const ALL: [Self; 20] = [ + Self::Search, + Self::SemanticSearch, + Self::FileRead, + Self::ToolDescriptorRead, + Self::BroadRead, + Self::CallGraph, + Self::Impact, + Self::SymbolLookup, + Self::FileLookup, + Self::ProjectContext, + Self::SessionRecall, + Self::AtomicEdit, + Self::TypeOrientation, + Self::ExploreSubagent, + Self::SubagentStartContext, + Self::BuildDiagnostics, + Self::ReviewChanges, + Self::MemoryStore, + Self::EditRedundancy, + Self::UnexpectedChanges, + ]; + + /// The serialized key, identical to the serde representation. + pub const fn as_key(self) -> &'static str { + match self { + Self::Search => "search", + Self::SemanticSearch => "semantic_search", + Self::FileRead => "file_read", + Self::ToolDescriptorRead => "tool_descriptor_read", + Self::BroadRead => "broad_read", + Self::CallGraph => "call_graph", + Self::Impact => "impact", + Self::SymbolLookup => "symbol_lookup", + Self::FileLookup => "file_lookup", + Self::ProjectContext => "project_context", + Self::SessionRecall => "session_recall", + Self::AtomicEdit => "atomic_edit", + Self::TypeOrientation => "type_orientation", + Self::ExploreSubagent => "explore_subagent", + Self::SubagentStartContext => "subagent_start_context", + Self::BuildDiagnostics => "build_diagnostics", + Self::ReviewChanges => "review_changes", + Self::MemoryStore => "memory_store", + Self::EditRedundancy => "edit_redundancy", + Self::UnexpectedChanges => "unexpected_changes", + } + } + + pub fn from_key(key: &str) -> Option { + Self::ALL.into_iter().find(|kind| kind.as_key() == key) + } +} + +/// Bounds on the decision-engine round trip. `timeout_ms` bounds one request; +/// `max_concurrent` bounds in-flight requests across every session. +pub const DECISION_ENGINE_TIMEOUT_MS_RANGE: std::ops::RangeInclusive = 20..=5_000; +pub const DECISION_ENGINE_MAX_CONCURRENT_RANGE: std::ops::RangeInclusive = 1..=8; +pub const DECISION_ENGINE_MAX_STATE_BYTES_RANGE: std::ops::RangeInclusive = 1..=1024 * 1024; +const DECISION_ENGINE_MAX_BASE_URL_BYTES: usize = 2_048; +const DECISION_ENGINE_MAX_MODEL_BYTES: usize = 256; + +/// Bounds on the session window one intent pass may read. +pub const INTENT_WINDOW_MAX_USER_TURNS_RANGE: std::ops::RangeInclusive = 1..=16; +pub const INTENT_WINDOW_MAX_MESSAGES_RANGE: std::ops::RangeInclusive = 1..=64; +pub const INTENT_WINDOW_MAX_BYTES_RANGE: std::ops::RangeInclusive = 256..=256 * 1024; +pub const INTENT_WINDOW_MAX_AGE_SECS_RANGE: std::ops::RangeInclusive = 1..=86_400; +pub const INTENT_WINDOW_MAX_FRESHEN_BUDGET_MS: u32 = 2_000; + +/// Bounds on one configured question. +pub const INTENT_QUESTION_MAX_INSTRUCTION_BYTES: usize = 512; +pub const INTENT_MAX_PERMILLE: u16 = 1_000; + +/// Bounds on the suggestion lifecycle the intent pass produces. +pub const INTENT_TTL_SECS_RANGE: std::ops::RangeInclusive = 1..=86_400; +pub const INTENT_MAX_DELIVERY_GAP_SECS: u32 = 86_400; +pub const INTENT_PROMPT_LOOKUP_DEADLINE_MS_RANGE: std::ops::RangeInclusive = 1..=5_000; + +/// Threshold the reference question set starts from. +pub const INTENT_REFERENCE_THRESHOLD_PERMILLE: u16 = 600; + +/// The decision engine the intent pass asks. There is no default endpoint or +/// model; both come only from explicit operator configuration. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DecisionEngineSettingsV1 { + pub base_url: String, + pub model: Option, + pub timeout_ms: u32, + pub max_concurrent: u8, + pub max_state_bytes: u32, +} + +impl DecisionEngineSettingsV1 { + pub fn validate(&self) -> Result<(), DomainError> { + let field = "context scout decision engine"; + let url = self.base_url.as_str(); + let has_scheme_and_host = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://")) + .is_some_and(|rest| !rest.is_empty() && !rest.starts_with('/')); + if !has_scheme_and_host + || url.len() > DECISION_ENGINE_MAX_BASE_URL_BYTES + || url + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + || !DECISION_ENGINE_TIMEOUT_MS_RANGE.contains(&self.timeout_ms) + || !DECISION_ENGINE_MAX_CONCURRENT_RANGE.contains(&self.max_concurrent) + || !DECISION_ENGINE_MAX_STATE_BYTES_RANGE.contains(&self.max_state_bytes) + || self.model.as_deref().is_some_and(|model| { + model.trim().is_empty() + || model.len() > DECISION_ENGINE_MAX_MODEL_BYTES + || model.chars().any(char::is_control) + }) + { + return Err(DomainError::NonCanonical { field }); + } + Ok(()) + } +} + +/// Bounds on the recent-session window one intent pass reads. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct IntentWindowSettingsV1 { + pub max_user_turns: u8, + pub max_messages: u8, + pub max_bytes: u32, + pub max_age_secs: u32, + /// Time the pass may wait for the transcript store to ingest the newest + /// turn before it reads the window as it stands. + pub freshen_budget_ms: u32, +} + +impl IntentWindowSettingsV1 { + pub fn validate(&self) -> Result<(), DomainError> { + if !INTENT_WINDOW_MAX_USER_TURNS_RANGE.contains(&self.max_user_turns) + || !INTENT_WINDOW_MAX_MESSAGES_RANGE.contains(&self.max_messages) + || self.max_messages < self.max_user_turns + || !INTENT_WINDOW_MAX_BYTES_RANGE.contains(&self.max_bytes) + || !INTENT_WINDOW_MAX_AGE_SECS_RANGE.contains(&self.max_age_secs) + || self.freshen_budget_ms > INTENT_WINDOW_MAX_FRESHEN_BUDGET_MS + { + return Err(DomainError::NonCanonical { + field: "context scout intent window", + }); + } + Ok(()) + } +} + +/// One yes/no question asked of the decision engine for one intent. The +/// intent is a candidate when the engine's probability reaches +/// `threshold_permille` and, when set, exceeds the runner-up by +/// `margin_permille`. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct IntentQuestionSettingsV1 { + pub intent: ContextScoutIntentKindV1, + pub instructions: String, + pub threshold_permille: u16, + pub margin_permille: Option, +} + +impl IntentQuestionSettingsV1 { + pub fn validate(&self) -> Result<(), DomainError> { + if self.instructions.trim().is_empty() + || self.instructions.len() > INTENT_QUESTION_MAX_INSTRUCTION_BYTES + || self.instructions.chars().any(char::is_control) + || self.threshold_permille > INTENT_MAX_PERMILLE + || self + .margin_permille + .is_some_and(|margin| margin > INTENT_MAX_PERMILLE) + { + return Err(DomainError::NonCanonical { + field: "context scout intent question", + }); + } + Ok(()) + } +} + +/// Operator configuration for the Context Scout intent pass. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ContextScoutIntentSettingsV1 { + pub enabled: bool, + pub engine: DecisionEngineSettingsV1, + pub window: IntentWindowSettingsV1, + pub questions: Vec, + /// Score a deterministic action fact contributes for its intent. + pub action_fact_score_permille: u16, + pub ttl_secs: u32, + pub min_delivery_gap_secs: u32, + pub prompt_lookup_deadline_ms: u32, +} + +impl ContextScoutIntentSettingsV1 { + pub fn validate(&self) -> Result<(), DomainError> { + self.engine.validate()?; + self.window.validate()?; + let mut intents = BTreeSet::new(); + for question in &self.questions { + question.validate()?; + if !intents.insert(question.intent) { + return Err(DomainError::NonCanonical { + field: "context scout intent questions", + }); + } + } + if self.action_fact_score_permille > INTENT_MAX_PERMILLE + || !INTENT_TTL_SECS_RANGE.contains(&self.ttl_secs) + || self.min_delivery_gap_secs > INTENT_MAX_DELIVERY_GAP_SECS + || !INTENT_PROMPT_LOOKUP_DEADLINE_MS_RANGE.contains(&self.prompt_lookup_deadline_ms) + { + return Err(DomainError::NonCanonical { + field: "context scout intent settings", + }); + } + Ok(()) + } + + /// Behavior digest of this block. Runtime configuration pins compare it + /// so a changed question set or engine is never treated as the same pass. + pub fn settings_digest(&self) -> Result { + canonical_sha256(&(INTENT_SETTINGS_DIGEST_DOMAIN, self)) + } + + /// The starting question set for the model-judged intents. Tool-shape + /// intents (search, file reads, subagent handoffs, edits) are decided by + /// deterministic action facts and have no question. + pub fn reference_questions() -> Vec { + REFERENCE_QUESTIONS + .iter() + .map(|(intent, instructions)| IntentQuestionSettingsV1 { + intent: *intent, + instructions: (*instructions).to_owned(), + threshold_permille: INTENT_REFERENCE_THRESHOLD_PERMILLE, + margin_permille: None, + }) + .collect() + } +} + +const REFERENCE_QUESTIONS: [(ContextScoutIntentKindV1, &str); 10] = [ + ( + ContextScoutIntentKindV1::SessionRecall, + "Is the user asking to look up or reuse something from an earlier conversation, a previous session, or another agent's transcript?", + ), + ( + ContextScoutIntentKindV1::MemoryStore, + "Did the user state a durable preference, convention, or fact about this project or their workflow that should be remembered across sessions?", + ), + ( + ContextScoutIntentKindV1::CallGraph, + "Is the user asking who calls, what is called by, or what depends on a specific function, method, or symbol?", + ), + ( + ContextScoutIntentKindV1::Impact, + "Is the user asking what a change would affect or break, such as its blast radius, dependent code, or the tests that cover it?", + ), + ( + ContextScoutIntentKindV1::ReviewChanges, + "Is the user asking to review, summarize, or address feedback on a diff, a pull request, or a set of changed files?", + ), + ( + ContextScoutIntentKindV1::BuildDiagnostics, + "Is the user trying to locate or fix a compiler, type-check, lint, or build error?", + ), + ( + ContextScoutIntentKindV1::AtomicEdit, + "Is the user asking for one mechanical change applied consistently across many sites, such as a rename, a signature change, or a structural refactor?", + ), + ( + ContextScoutIntentKindV1::TypeOrientation, + "Is the user asking how a type is constructed, implemented, extended, or used, such as its constructor sites, trait implementations, field uses, or type hierarchy?", + ), + ( + ContextScoutIntentKindV1::ProjectContext, + "Is the user asking about the overall architecture or design of this project, or about a repository or project other than the current one?", + ), + ( + ContextScoutIntentKindV1::UnexpectedChanges, + "Is the user reporting commits, file changes, or branch history that they did not make and cannot account for?", + ), +]; + +#[cfg(test)] +mod tests { + use super::*; + + fn engine() -> DecisionEngineSettingsV1 { + DecisionEngineSettingsV1 { + base_url: "http://127.0.0.1:8088".to_owned(), + model: Some("intent-small".to_owned()), + timeout_ms: 250, + max_concurrent: 2, + max_state_bytes: 64 * 1024, + } + } + + fn settings() -> ContextScoutIntentSettingsV1 { + ContextScoutIntentSettingsV1 { + enabled: true, + engine: engine(), + window: IntentWindowSettingsV1 { + max_user_turns: 3, + max_messages: 12, + max_bytes: 16 * 1024, + max_age_secs: 1_800, + freshen_budget_ms: 150, + }, + questions: ContextScoutIntentSettingsV1::reference_questions(), + action_fact_score_permille: 700, + ttl_secs: 600, + min_delivery_gap_secs: 30, + prompt_lookup_deadline_ms: 200, + } + } + + #[test] + fn intent_keys_round_trip_through_serde() { + for kind in ContextScoutIntentKindV1::ALL { + let encoded = serde_json::to_value(kind).unwrap(); + assert_eq!(encoded, serde_json::Value::String(kind.as_key().to_owned())); + assert_eq!(ContextScoutIntentKindV1::from_key(kind.as_key()), Some(kind)); + } + assert_eq!(ContextScoutIntentKindV1::from_key("unknown"), None); + let unique = ContextScoutIntentKindV1::ALL + .into_iter() + .collect::>(); + assert_eq!(unique.len(), ContextScoutIntentKindV1::ALL.len()); + } + + #[test] + fn reference_settings_validate() { + let settings = settings(); + settings.validate().unwrap(); + assert_eq!(settings.questions.len(), 10); + assert!( + settings + .questions + .iter() + .all(|question| question.threshold_permille == 600 + && question.margin_permille.is_none()) + ); + } + + #[test] + fn duplicate_question_intents_are_refused() { + let mut settings = settings(); + let duplicate = settings.questions[0].clone(); + settings.questions.push(duplicate); + assert!(settings.validate().is_err()); + } + + #[test] + fn question_instructions_are_bounded_and_control_free() { + for instructions in [ + String::new(), + " ".to_owned(), + "a".repeat(INTENT_QUESTION_MAX_INSTRUCTION_BYTES + 1), + "Is this\na question?".to_owned(), + ] { + let mut settings = settings(); + settings.questions[0].instructions = instructions; + assert!(settings.validate().is_err()); + } + let mut settings = settings(); + settings.questions[0].instructions = "a".repeat(INTENT_QUESTION_MAX_INSTRUCTION_BYTES); + settings.validate().unwrap(); + } + + #[test] + fn thresholds_and_margins_are_permille() { + let mut settings = settings(); + settings.questions[0].threshold_permille = 1_001; + assert!(settings.validate().is_err()); + let mut settings = self::settings(); + settings.questions[0].margin_permille = Some(1_001); + assert!(settings.validate().is_err()); + let mut settings = self::settings(); + settings.action_fact_score_permille = 1_001; + assert!(settings.validate().is_err()); + } + + #[test] + fn engine_bounds_are_enforced() { + let cases: [fn(&mut DecisionEngineSettingsV1); 9] = [ + |engine| engine.timeout_ms = 19, + |engine| engine.timeout_ms = 5_001, + |engine| engine.max_concurrent = 0, + |engine| engine.max_concurrent = 9, + |engine| engine.max_state_bytes = 0, + |engine| engine.base_url = "127.0.0.1:8088".to_owned(), + |engine| engine.base_url = "http://".to_owned(), + |engine| engine.base_url = "http://host name".to_owned(), + |engine| engine.model = Some(" ".to_owned()), + ]; + for mutate in cases { + let mut settings = settings(); + mutate(&mut settings.engine); + assert!(settings.validate().is_err(), "{:?}", settings.engine); + } + let mut settings = settings(); + settings.engine.timeout_ms = 20; + settings.engine.max_concurrent = 8; + settings.engine.model = None; + settings.validate().unwrap(); + } + + #[test] + fn window_bounds_are_enforced() { + let cases: [fn(&mut IntentWindowSettingsV1); 6] = [ + |window| window.max_user_turns = 0, + |window| window.max_messages = 2, + |window| window.max_bytes = 0, + |window| window.max_age_secs = 0, + |window| window.freshen_budget_ms = INTENT_WINDOW_MAX_FRESHEN_BUDGET_MS + 1, + |window| window.max_messages = 65, + ]; + for mutate in cases { + let mut settings = settings(); + mutate(&mut settings.window); + assert!(settings.validate().is_err(), "{:?}", settings.window); + } + } + + #[test] + fn lifecycle_bounds_are_enforced() { + let cases: [fn(&mut ContextScoutIntentSettingsV1); 4] = [ + |settings| settings.ttl_secs = 0, + |settings| settings.min_delivery_gap_secs = INTENT_MAX_DELIVERY_GAP_SECS + 1, + |settings| settings.prompt_lookup_deadline_ms = 0, + |settings| settings.prompt_lookup_deadline_ms = 5_001, + ]; + for mutate in cases { + let mut settings = settings(); + mutate(&mut settings); + assert!(settings.validate().is_err()); + } + } + + #[test] + fn settings_digest_tracks_every_behavioral_field() { + let base = settings(); + let digest = base.settings_digest().unwrap(); + assert_eq!(digest, settings().settings_digest().unwrap()); + let mut changed = settings(); + changed.questions[0].threshold_permille = 650; + assert_ne!(changed.settings_digest().unwrap(), digest); + let mut disabled = settings(); + disabled.enabled = false; + assert_ne!(disabled.settings_digest().unwrap(), digest); + } +} From 33c93d850691e6b8e3125b52e3a6e54b2e28397e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:47:02 +0000 Subject: [PATCH 03/23] feat(context-scout): decision-engine client and session-window reader The daemon intent pass needs two inputs: a pluggable typed-question client speaking the shared /v1/systemone shape (noul, choice, score; laya-serve, Kev, Ollama, llama.cpp), with disabled, unreachable, timed-out, refused, malformed and backpressured as distinct outcomes; and a bounded reader of the last N turns of a session from the LCM raw-message store, with tool calls extracted per provider and a rendered transcript truncated from the front so the newest text survives. Neither is wired yet; 33 unit tests cover both. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HB4wWE2tsLyyxJNkBCqRpD --- .../agents/context_scout/decision_engine.rs | 53 ++++--- .../agents/context_scout/session_window.rs | 129 ++++++++++++++---- 2 files changed, 122 insertions(+), 60 deletions(-) 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 index 41dc443897..9675113355 100644 --- a/crates/tracedecay-agent-hosts/src/agents/context_scout/decision_engine.rs +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout/decision_engine.rs @@ -95,7 +95,9 @@ pub enum DecisionEngineOutcomeV1 { /// The whole-request budget elapsed. TimedOut, /// The server answered with a non-200 status. - Refused { status: u16 }, + 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 @@ -567,8 +569,7 @@ pub mod test_server { } 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(); + *captured.lock().unwrap() = String::from_utf8_lossy(&request_body).into_owned(); seen.fetch_add(1, Ordering::SeqCst); std::thread::sleep(delay); let _ = write!( @@ -761,13 +762,9 @@ mod tests { #[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), - ); + 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); } @@ -940,7 +937,11 @@ mod tests { String::new(), DecisionEngineOutcomeV1::Refused { status: 404 }, ), - (200, "not json".to_owned(), DecisionEngineOutcomeV1::Malformed), + ( + 200, + "not json".to_owned(), + DecisionEngineOutcomeV1::Malformed, + ), ( 200, r#"{"answers":{}}"#.to_owned(), @@ -1034,18 +1035,18 @@ mod tests { request("s", vec![choice("q", &["a", "a"])]), request("s", vec![score("q", &[])]), ] { - assert_eq!(client.ask_blocking(&bad), DecisionEngineOutcomeV1::Malformed); + 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 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(); @@ -1076,11 +1077,8 @@ mod tests { #[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 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); @@ -1113,16 +1111,11 @@ mod tests { )); } - #[tokio::test] + #[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) - ); + let outcome = client(&server.url).ask(request("s", vec![noul("q")])).await; + assert_eq!(answered(outcome).answers["q"], DecisionAnswerV1::Noul(0.25)); } #[test] 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 index c3642de51d..4d687e8e61 100644 --- a/crates/tracedecay-agent-hosts/src/agents/context_scout/session_window.rs +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout/session_window.rs @@ -109,7 +109,9 @@ pub struct SessionWindowMessageV1 { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionWindowFreshnessV1 { Fresh, - Stale { age_micros: u64 }, + Stale { + age_micros: u64, + }, /// No message carried a timestamp. Unknown, } @@ -307,13 +309,14 @@ fn tool_calls_from_metadata(metadata: &str) -> Vec { 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()), - } - }); + 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, @@ -349,10 +352,15 @@ fn excerpt(text: &str) -> Option { 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, - }) + 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 { @@ -360,9 +368,7 @@ fn message_bytes(message: &SessionWindowMessageV1) -> usize { + message .tool_calls .iter() - .map(|call| { - call.name.len() + call.argument_excerpt.as_ref().map_or(0, String::len) - }) + .map(|call| call.name.len() + call.argument_excerpt.as_ref().map_or(0, String::len)) .sum::() } @@ -501,7 +507,10 @@ fn window_digest( digest_field(&mut hasher, call.name.as_bytes()); digest_field( &mut hasher, - call.argument_excerpt.as_deref().unwrap_or_default().as_bytes(), + call.argument_excerpt + .as_deref() + .unwrap_or_default() + .as_bytes(), ); } } @@ -539,7 +548,10 @@ pub fn render_window_state( )); } } - if let Some(prompt) = prompt_excerpt.map(str::trim).filter(|prompt| !prompt.is_empty()) { + 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() @@ -683,7 +695,7 @@ mod tests { } } - #[tokio::test] + #[asupersync::test] async fn an_unknown_or_empty_session_yields_an_empty_window() { let seed = Seed::open("window-empty").await; assert_eq!( @@ -718,7 +730,7 @@ mod tests { } } - #[tokio::test] + #[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; @@ -726,7 +738,12 @@ mod tests { let window = ready(read_session_window(seed.db(), &request(limits())).await); assert_eq!( texts(&window), - vec!["second question", "second answer", "third question", "third answer"] + 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); @@ -764,7 +781,7 @@ mod tests { assert_eq!(everything.messages.len(), 6); } - #[tokio::test] + #[asupersync::test] async fn the_message_cap_drops_the_oldest_first() { let seed = Seed::open("window-message-cap").await; seed_conversation(&seed).await; @@ -785,7 +802,7 @@ mod tests { ); } - #[tokio::test] + #[asupersync::test] async fn the_byte_cap_keeps_the_newest_messages() { let seed = Seed::open("window-byte-cap").await; seed_conversation(&seed).await; @@ -818,7 +835,7 @@ mod tests { assert_eq!(texts(&tiny), vec!["third"]); } - #[tokio::test] + #[asupersync::test] async fn cursor_tool_calls_come_from_canonical_metadata() { let seed = Seed::open("window-cursor-tools").await; seed.session(PROVIDER, SESSION).await; @@ -859,7 +876,7 @@ mod tests { ); } - #[tokio::test] + #[asupersync::test] async fn tool_events_alone_still_name_the_tools() { let calls = tool_calls_from_metadata( &json!({"tool_events": [ @@ -878,7 +895,7 @@ mod tests { assert!(tool_calls_from_metadata("not json").is_empty()); } - #[tokio::test] + #[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; @@ -887,7 +904,13 @@ mod tests { 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", "{\"path\": \"src/lib.rs\"}"); + 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; @@ -910,12 +933,55 @@ mod tests { window.messages[2].tool_calls, vec![SessionWindowToolCallV1 { name: "Read".to_owned(), - argument_excerpt: Some(r#"{"path": "src/lib.rs"}"#.to_owned()), + argument_excerpt: Some(r#"{"path":"src/lib.rs"}"#.to_owned()), }] ); } - #[tokio::test] + /// 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; @@ -960,7 +1026,10 @@ mod tests { 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); + 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)), @@ -968,7 +1037,7 @@ mod tests { ); } - #[tokio::test] + #[asupersync::test] async fn the_digest_is_stable_and_tracks_content_and_identity() { let seed = Seed::open("window-digest").await; seed_conversation(&seed).await; From 7b6457c5481b868a549401b8fff1fbde2b590cff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:47:52 +0000 Subject: [PATCH 04/23] chore(context-scout): checkpoint the intent contracts and policy move Work-in-progress snapshot while the contracts, intent settings, and the move of the hint catalog and action-fact predicates into tracedecay-policy are still being finished and verified. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HB4wWE2tsLyyxJNkBCqRpD --- Cargo.lock | 1 + .../src/agents/context_scout.rs | 83 +- .../agents/context_scout/address_registry.rs | 65 +- .../src/agents/context_scout/evidence.rs | 478 ++++++- .../src/agents/context_scout/model.rs | 3 +- .../src/agents/context_scout/owner.rs | 16 +- .../src/agents/context_scout/store.rs | 22 + .../src/agents/context_scout/store_tests.rs | 87 +- .../src/hooks/tool_hints.rs | 632 +-------- .../src/hooks/tool_hints/classifiers.rs | 908 +------------ crates/tracedecay-agent-hosts/src/shell.rs | 192 +-- .../tests/context_scout_evidence.rs | 7 +- .../src/settings_control.rs | 156 ++- .../tracedecay-contracts/src/context_scout.rs | 176 ++- .../src/invocation/primitive.rs | 75 +- .../src/settings_api.rs | 1 + .../src/configuration/context_scout_intent.rs | 39 +- .../src/handlers/hook_runtime/test_support.rs | 2 +- crates/tracedecay-policy/Cargo.toml | 1 + .../src/intent_action_facts.rs | 1192 +++++++++++++++++ .../tracedecay-policy/src/intent_catalog.rs | 451 +++++++ .../tracedecay-policy/src/intent_decision.rs | 465 +++++++ crates/tracedecay-policy/src/lib.rs | 4 + crates/tracedecay-policy/src/shell.rs | 191 +++ .../advisory_runtime/scout_journey_tests.rs | 7 +- .../daemon_runtime_acceptance.rs | 2 +- evals/agent_adoption/README.md | 6 +- evals/agent_adoption/grade.py | 10 +- evals/agent_adoption/run.sh | 6 +- 29 files changed, 3467 insertions(+), 1811 deletions(-) create mode 100644 crates/tracedecay-policy/src/intent_action_facts.rs create mode 100644 crates/tracedecay-policy/src/intent_catalog.rs create mode 100644 crates/tracedecay-policy/src/intent_decision.rs create mode 100644 crates/tracedecay-policy/src/shell.rs diff --git a/Cargo.lock b/Cargo.lock index b6095a08a7..4a6341b1a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7003,6 +7003,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 75521ddaf0..3ce9e5f2f9 100644 --- a/crates/tracedecay-agent-hosts/src/agents/context_scout.rs +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout.rs @@ -15,8 +15,8 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use thiserror::Error; use tracedecay_contracts::context_scout::{ - ContextScoutAddressV1, ContextScoutCandidateV1, ContextScoutCategoryV1, - ContextScoutDeliveryOutcomeV1, ContextScoutDeliveryReceiptV1, ContextScoutDeliveryWindowV1, + ContextScoutAddressV1, ContextScoutCandidateEvidenceV1, ContextScoutCandidateV1, + ContextScoutCategoryV1, ContextScoutDeliveryOutcomeV1, ContextScoutDeliveryReceiptV1, ContextScoutDeliveryWindowV1, ContextScoutDurableClaimV1, ContextScoutDurableQueueEntryV1, ContextScoutEvidenceAvailabilityV1, ContextScoutFeedbackKindV1, ContextScoutFeedbackV1, ContextScoutLeaseV1, ContextScoutModelBackendV1, ContextScoutModelOutcomeV1, @@ -49,7 +49,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 { @@ -80,11 +83,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); @@ -150,6 +160,8 @@ pub enum ContextScoutTriggerV1 { SavedEdit, StopBoundary, ExplicitRequest, + /// A session intent pass at a user-prompt boundary. + IntentBoundary, } /// Content-free daemon receptivity state. The selector owns delivery timing; @@ -185,7 +197,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, } } @@ -205,6 +219,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)] @@ -393,7 +409,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 => {} @@ -1226,6 +1242,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); } @@ -1262,8 +1281,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 @@ -1345,6 +1379,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 { @@ -1356,6 +1402,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); } @@ -1669,7 +1718,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 { @@ -1739,8 +1788,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 { @@ -1942,7 +1991,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(), @@ -2315,11 +2364,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, @@ -2335,7 +2384,7 @@ mod tests { ContextScoutEvidenceAvailabilityV1::Stale ); ContextScoutCandidateV1 { - evidence, + evidence: evidence.into(), ..candidate(key, score) } } @@ -2733,6 +2782,7 @@ mod tests { mode: ContextScoutRuntimeModeV1::ConfiguredModel, model_path: Some(ContextScoutModelBackendV1::CodexAppServer), limits: ContextScoutLimitsV1::bounded_defaults(), + intent: None, }) .unwrap(); assert_eq!( @@ -2853,6 +2903,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)), @@ -2903,6 +2954,7 @@ mod tests { mode: ContextScoutRuntimeModeV1::ConfiguredModel, model_path: Some(ContextScoutModelBackendV1::CodexAppServer), limits: ContextScoutLimitsV1::bounded_defaults(), + intent: None, }) .unwrap(); assert_eq!( @@ -2982,6 +3034,7 @@ mod tests { mode: ContextScoutRuntimeModeV1::Deterministic, model_path: None, limits: ContextScoutLimitsV1::bounded_defaults(), + intent: None, }) .unwrap(); assert_eq!( @@ -3008,7 +3061,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..c40db6a1d1 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 @@ -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; @@ -145,6 +145,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,6 +159,7 @@ impl ContextScoutConfigurationPinV1 { mode, model_path, limits, + intent, }; Some(Self { revision_id: current.revision_id.clone(), @@ -1009,7 +1017,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 +1263,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 +1300,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/evidence.rs b/crates/tracedecay-agent-hosts/src/agents/context_scout/evidence.rs index 737dff0c50..9156013508 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"; +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/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 9192ce37da..7bebcabfcc 100644 --- a/crates/tracedecay-agent-hosts/src/agents/context_scout/owner.rs +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout/owner.rs @@ -1068,6 +1068,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), ( @@ -1237,6 +1238,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], @@ -1265,6 +1267,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] @@ -1901,7 +1915,7 @@ mod tests { tracedecay_contracts::context_scout::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), }, }, 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 ad15f29e77..9ca00243b9 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; 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 56c3239ec3..962db0c0e3 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), }, }, @@ -695,18 +695,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 @@ -919,6 +924,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/tool_hints.rs b/crates/tracedecay-agent-hosts/src/hooks/tool_hints.rs index 0b62ec185f..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