Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
31372ed
fix(hooks): drop two session-recall phrases that hint on ordinary tasks
claude Oct 16, 2026
c382149
chore(context-scout): checkpoint the daemon intent pass groundwork
claude Oct 16, 2026
33c93d8
feat(context-scout): decision-engine client and session-window reader
claude Oct 16, 2026
7b6457c
chore(context-scout): checkpoint the intent contracts and policy move
claude Oct 16, 2026
5daca69
docs(plans): daemon intent pass design and intent-engine eval record
claude Oct 16, 2026
cff0a28
chore(context-scout): checkpoint intent decision policy and SDK regen
claude Oct 16, 2026
a51f1cc
docs(evals): land the intent-engine comparison and pick Kev-4B
claude Oct 16, 2026
786be02
feat(context-scout): daemon intent pass behind configuration
claude Oct 16, 2026
4465e90
Merge remote-tracking branch 'origin/master' into claude/unified-inte…
ScriptedAlchemy Oct 18, 2026
daaf96f
chore(evals): drop intent-engine run outputs from source
ScriptedAlchemy Oct 18, 2026
3e36429
docs(plans): align plan 41 timing with the shipped intent pass
ScriptedAlchemy Oct 18, 2026
b5be8bd
feat(api): accept context scout intent in project settings patch
ScriptedAlchemy Oct 18, 2026
6aa78db
test(context-scout): drive the intent pass through a real daemon
ScriptedAlchemy Oct 18, 2026
8a810a9
Merge branch 'pr4121/settings-intent' into claude/unified-intent-sugg…
ScriptedAlchemy Oct 18, 2026
b0f6b3a
fix(hooks): mount the Claude tool call as the Scout session lifecycle
ScriptedAlchemy Oct 18, 2026
1488a37
fix(scout): keep the model status slot for the configured model
ScriptedAlchemy Oct 18, 2026
c53a962
Merge branch 'pr4121/intent-status-slot' into claude/unified-intent-s…
ScriptedAlchemy Oct 18, 2026
96cccb1
test(context-scout): assert one delivery and no engine call when absent
ScriptedAlchemy Oct 18, 2026
d3b693c
fix(context-scout): keep a claimed intent suggestion from re-enqueueing
ScriptedAlchemy Oct 18, 2026
a35ce5f
test(context-scout): pin the claimed-suggestion enqueue guard
ScriptedAlchemy Oct 18, 2026
ef993bd
test(context-scout): retry the stop boundary past the ack budget
ScriptedAlchemy Oct 18, 2026
0330910
fix(context-scout): judge a delivered copy a duplicate before tombstones
ScriptedAlchemy Oct 18, 2026
a79515a
fix(hooks): mint the Claude lifecycle only when Scout intent is on
ScriptedAlchemy Oct 18, 2026
78517b8
Merge branch 'pr4121/e2e-intent' into claude/unified-intent-suggestions
ScriptedAlchemy Oct 18, 2026
593fd0c
Merge remote-tracking branch 'origin/master' into claude/unified-inte…
ScriptedAlchemy Oct 18, 2026
7ffe612
fix(daemon): box the capacity release future clippy flags
ScriptedAlchemy Oct 18, 2026
ea34ad5
docs(hooks): quote OpenCode in the lifecycle gate doc
ScriptedAlchemy Oct 18, 2026
55ff49a
test(context-scout): wait out daemon mount under parallel load
ScriptedAlchemy Oct 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

143 changes: 117 additions & 26 deletions crates/tracedecay-agent-hosts/src/agents/context_scout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
use tracedecay_contracts::context_scout::{
ContextScoutAddressV1, ContextScoutCandidateV1, ContextScoutCategoryV1,
ContextScoutDeliveryOutcomeV1, ContextScoutDeliveryReceiptV1, ContextScoutDeliveryWindowV1,
ContextScoutDurableClaimV1, ContextScoutDurableQueueEntryV1,
ContextScoutAddressV1, ContextScoutCandidateEvidenceV1, ContextScoutCandidateV1,
ContextScoutCategoryV1, ContextScoutDeliveryOutcomeV1, ContextScoutDeliveryReceiptV1,
ContextScoutDeliveryWindowV1, ContextScoutDurableClaimV1, ContextScoutDurableQueueEntryV1,
ContextScoutEvidenceAvailabilityV1, ContextScoutFeedbackKindV1, ContextScoutFeedbackV1,
ContextScoutLeaseV1, ContextScoutModelBackendV1, ContextScoutModelOutcomeV1,
ContextScoutModelReceiptV1, ContextScoutRouteV1, ContextScoutSuggestionEnvelopeV1,
Expand All @@ -31,8 +31,11 @@ use tracedecay_runtime_core::cancellation::{CancellationToken, MonotonicDeadline
use tracedecay_tokenizer::o200k_base;

pub mod address_registry;
pub mod decision_engine;
pub mod intent_pass;
pub mod model;
pub mod owner;
pub mod session_window;

const MAX_SCOUT_TEXT_BYTES: usize = 4 * 1024;
const MAX_SCOUT_CANDIDATES: usize = 32;
Expand All @@ -47,7 +50,10 @@ mod store;
#[cfg(test)]
mod store_tests;

pub use evidence::ContextScoutEvidenceEnvelopeExt;
pub use evidence::{
ContextScoutCandidateEvidenceExt, ContextScoutEvidenceEnvelopeExt,
ContextScoutSessionClaimInputV1, ContextScoutSessionEvidenceExt,
};
pub use store::ProjectContextScoutDurableStoreV1;

pub(crate) trait ContextScoutAddressExt {
Expand Down Expand Up @@ -78,11 +84,18 @@ pub(crate) trait ContextScoutCandidateExt {

impl ContextScoutCandidateExt for ContextScoutCandidateV1 {
fn validate(&self, limits: ContextScoutLimitsV1) -> Result<(), ContextScoutErrorV1> {
// Code evidence cites at least one anchor; session evidence cites
// none, its window digest is the citation.
let anchors_in_bounds = match &self.evidence {
ContextScoutCandidateEvidenceV1::Code(_) => {
(1..=limits.max_evidence).contains(&self.evidence.anchor_count())
}
ContextScoutCandidateEvidenceV1::Session(_) => self.evidence.anchor_count() == 0,
};
if self.dedupe_key == [0; 32]
|| !safe_suggestion_text(&self.suggestion_text)
|| self.suggestion_text.len() > limits.max_text_bytes
|| self.evidence.anchor_count() == 0
|| self.evidence.anchor_count() > limits.max_evidence
|| !anchors_in_bounds
|| self.expires_at.0 <= 0
{
return Err(ContextScoutErrorV1::InvalidCandidate);
Expand Down Expand Up @@ -148,6 +161,8 @@ pub enum ContextScoutTriggerV1 {
SavedEdit,
StopBoundary,
ExplicitRequest,
/// A session intent pass at a prompt, completed-tool, or stop boundary.
IntentBoundary,
}

/// Content-free daemon receptivity state. The selector owns delivery timing;
Expand Down Expand Up @@ -183,7 +198,9 @@ pub const fn select_context_scout_delivery_window(
}
match input.trigger {
ContextScoutTriggerV1::SavedEdit => ContextScoutDeliveryWindowV1::NextBoundary,
ContextScoutTriggerV1::StopBoundary => ContextScoutDeliveryWindowV1::Immediate,
ContextScoutTriggerV1::StopBoundary | ContextScoutTriggerV1::IntentBoundary => {
ContextScoutDeliveryWindowV1::Immediate
}
ContextScoutTriggerV1::ExplicitRequest => ContextScoutDeliveryWindowV1::OnRequest,
}
}
Expand All @@ -203,6 +220,8 @@ pub enum ContextScoutSuppressionV1 {
EvidencePartial,
EvidenceStale,
EvidenceUnavailable,
/// A suggestion for the same session is already pending delivery.
PendingSibling,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
Expand Down Expand Up @@ -391,7 +410,7 @@ pub fn select_deterministic_context_scout(
saw_unavailable = true;
continue;
}
match candidate.evidence.availability {
match candidate.evidence.availability() {
ContextScoutEvidenceAvailabilityV1::Complete => {}
ContextScoutEvidenceAvailabilityV1::Partial
if input.delivery_window == ContextScoutDeliveryWindowV1::OnRequest => {}
Expand Down Expand Up @@ -1224,6 +1243,9 @@ fn validate_durable_envelope(
|| envelope.configuration_revision == [0; 32]
|| envelope.delivery_window == ContextScoutDeliveryWindowV1::Suppressed
|| !envelope.candidate.durable()
// The public projection pins a content generation, so session
// evidence must carry the generation it was judged against.
|| envelope.candidate.evidence.code_generation_id().is_none()
{
return Err(ContextScoutErrorV1::DirtyOverlayDurabilityViolation);
}
Expand Down Expand Up @@ -1260,8 +1282,23 @@ impl ContextScoutDurableQueueEntryExt for ContextScoutDurableQueueEntryV1 {
| ContextScoutModelOutcomeV1::InvalidOutput,
None,
) => {}
// The intent pass records its engine outcome in the session
// evidence's decision receipt, never in a Codex model receipt.
(ContextScoutRouteV1::DecisionEngine, outcome, None)
if self
.envelope
.candidate
.evidence
.as_session()
.is_some_and(|evidence| evidence.decision.outcome == outcome) => {}
_ => return Err(ContextScoutErrorV1::InvalidCandidate),
}
// Session evidence is produced only by the intent pass.
if self.envelope.candidate.evidence.as_session().is_some()
!= (self.route == ContextScoutRouteV1::DecisionEngine)
{
return Err(ContextScoutErrorV1::InvalidCandidate);
}
validate_durable_envelope(&self.envelope)?;
if self.work.address != self.envelope.address
|| self.work.input_watermark != self.envelope.input_watermark
Expand Down Expand Up @@ -1343,6 +1380,18 @@ pub struct ContextScoutControlV1 {
pub mode: ContextScoutRuntimeModeV1,
pub model_path: Option<ContextScoutModelBackendV1>,
pub limits: ContextScoutLimitsV1,
/// Pinned session intent pass, absent when none is configured.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intent: Option<ContextScoutIntentControlV1>,
}

/// 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 {
Expand All @@ -1354,6 +1403,9 @@ impl ContextScoutControlV1 {
| (ContextScoutRuntimeModeV1::ConfiguredModel, None)
)
|| self.model_path == Some(ContextScoutModelBackendV1::Disabled)
|| self
.intent
.is_some_and(|intent| intent.settings_digest == [0; 32])
{
return Err(ContextScoutErrorV1::InvalidLimits);
}
Expand Down Expand Up @@ -1551,21 +1603,35 @@ where
mode: ContextScoutRuntimeModeV1,
model_execution: ContextScoutModelExecutionV1,
) -> Result<ContextScoutRuntimeOutcomeV1, ContextScoutErrorV1> {
let selection = match mode {
ContextScoutRuntimeModeV1::Deterministic => ContextScoutSelectionV1 {
route: ContextScoutRouteV1::Deterministic,
let selection = if let Some(model_outcome) = intent_pass_model_outcome(input)? {
// Intent-pass candidates were judged by the decision engine when
// they were built; selection among them is deterministic, and the
// route records the engine outcome their receipts carry.
ContextScoutSelectionV1 {
route: ContextScoutRouteV1::DecisionEngine,
decision: select_deterministic_context_scout(input, limits)?,
model_outcome: ContextScoutModelOutcomeV1::NotRequested,
model_outcome,
model_receipt: None,
},
ContextScoutRuntimeModeV1::ConfiguredModel => {
select_model_assisted_context_scout(input, limits, &self.model, model_execution)
.await?
}
} else {
match mode {
ContextScoutRuntimeModeV1::Deterministic => ContextScoutSelectionV1 {
route: ContextScoutRouteV1::Deterministic,
decision: select_deterministic_context_scout(input, limits)?,
model_outcome: ContextScoutModelOutcomeV1::NotRequested,
model_receipt: None,
},
ContextScoutRuntimeModeV1::ConfiguredModel => {
select_model_assisted_context_scout(input, limits, &self.model, model_execution)
.await?
}
}
};
self.last_route = Some(selection.route);
self.last_model_outcome = Some(selection.model_outcome);
self.last_model_receipt.clone_from(&selection.model_receipt);
if selection.route != ContextScoutRouteV1::DecisionEngine {
self.last_model_outcome = Some(selection.model_outcome);
self.last_model_receipt.clone_from(&selection.model_receipt);
}
if selection.model_outcome == ContextScoutModelOutcomeV1::Cancelled {
self.last_suppression = Some(ContextScoutSuppressionV1::Cancelled);
return Ok(ContextScoutRuntimeOutcomeV1::Suppressed {
Expand Down Expand Up @@ -1660,14 +1726,35 @@ where
}
}

/// The engine outcome of an intent-pass selection, `None` for code-evidence
/// selections. One intent pass offers session evidence only, all of it from
/// one engine exchange; mixing evidence kinds is a malformed input.
fn intent_pass_model_outcome(
input: &ContextScoutSelectionInputV1,
) -> Result<Option<ContextScoutModelOutcomeV1>, ContextScoutErrorV1> {
let mut outcomes = input.candidates.iter().map(|candidate| {
candidate
.evidence
.as_session()
.map(|evidence| evidence.decision.outcome)
});
let Some(first) = outcomes.next() else {
return Ok(None);
};
if outcomes.any(|outcome| outcome != first) {
return Err(ContextScoutErrorV1::InvalidCandidate);
}
Ok(first)
}

#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
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 {
Expand Down Expand Up @@ -1737,8 +1824,8 @@ mod tests {
);
}

fn evidence() -> ContextScoutEvidenceEnvelopeV1 {
super::evidence::fixture_context_scout_evidence()
fn evidence() -> ContextScoutCandidateEvidenceV1 {
super::evidence::fixture_context_scout_evidence().into()
}

fn candidate(key: u8, score: u16) -> ContextScoutCandidateV1 {
Expand Down Expand Up @@ -1940,7 +2027,7 @@ mod tests {
#[test]
fn dirty_overlay_never_crosses_durable_checkpoint_or_receipt_boundary() {
let mut overlay = candidate(1, 10);
overlay.evidence.content =
overlay.evidence.as_code_mut().unwrap().content =
tracedecay_domain::feedback::FeedbackContentIdentityV1::EphemeralOverlay {
session_id: tracedecay_domain::SessionId::new("session.overlay").unwrap(),
owner_client_id: tracedecay_domain::HostInstanceId::new("client.overlay").unwrap(),
Expand Down Expand Up @@ -2313,11 +2400,11 @@ mod tests {

#[cfg(feature = "token-counting")]
fn stale_candidate(key: u8, score: u16) -> ContextScoutCandidateV1 {
let fresh = evidence();
let fresh = super::evidence::fixture_context_scout_evidence();
let mut sources = fresh.sources.clone();
sources[0].contribution_state = tracedecay_contracts::RetrieverContributionState::Stale;
sources[0].temporal.freshness = tracedecay_contracts::FreshnessState::Stale;
let evidence = ContextScoutEvidenceEnvelopeV1::claim(
let evidence = tracedecay_contracts::context_scout::ContextScoutEvidenceEnvelopeV1::claim(
fresh.scope,
fresh.authorized_scope,
fresh.content,
Expand All @@ -2333,7 +2420,7 @@ mod tests {
ContextScoutEvidenceAvailabilityV1::Stale
);
ContextScoutCandidateV1 {
evidence,
evidence: evidence.into(),
..candidate(key, score)
}
}
Expand Down Expand Up @@ -2731,6 +2818,7 @@ mod tests {
mode: ContextScoutRuntimeModeV1::ConfiguredModel,
model_path: Some(ContextScoutModelBackendV1::CodexAppServer),
limits: ContextScoutLimitsV1::bounded_defaults(),
intent: None,
})
.unwrap();
assert_eq!(
Expand Down Expand Up @@ -2851,6 +2939,7 @@ mod tests {
mode: ContextScoutRuntimeModeV1::ConfiguredModel,
model_path: Some(ContextScoutModelBackendV1::CodexAppServer),
limits,
intent: None,
};
let widened_execution = ContextScoutModelExecutionV1 {
deadline: MonotonicDeadline::at(Instant::now() + Duration::from_secs(5)),
Expand Down Expand Up @@ -2901,6 +2990,7 @@ mod tests {
mode: ContextScoutRuntimeModeV1::ConfiguredModel,
model_path: Some(ContextScoutModelBackendV1::CodexAppServer),
limits: ContextScoutLimitsV1::bounded_defaults(),
intent: None,
})
.unwrap();
assert_eq!(
Expand Down Expand Up @@ -2980,6 +3070,7 @@ mod tests {
mode: ContextScoutRuntimeModeV1::Deterministic,
model_path: None,
limits: ContextScoutLimitsV1::bounded_defaults(),
intent: None,
})
.unwrap();
assert_eq!(
Expand All @@ -3006,7 +3097,7 @@ mod tests {
},
);
let mut dirty = candidate(2, 10);
dirty.evidence.content =
dirty.evidence.as_code_mut().unwrap().content =
tracedecay_domain::feedback::FeedbackContentIdentityV1::EphemeralOverlay {
session_id: tracedecay_domain::SessionId::new("session.overlay").unwrap(),
owner_client_id: tracedecay_domain::HostInstanceId::new("client.overlay").unwrap(),
Expand Down
Loading
Loading