Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion crates/contracts/ironclaw_loop_contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ pub use loop_exit::{
LoopCompletionKind, LoopExit, LoopFailed, LoopFailureKind,
};
pub use memory_context::{
EmptyMemoryPromptContextService, MemoryPromptContextRequest, MemoryPromptContextService,
EmptyMemoryPromptContextService, MemoryPromptContextLoad, MemoryPromptContextRequest,
MemoryPromptContextService, MemoryRetrievalDegradation, MemoryRetrievalFailureKind,
MemoryRetrievalLane,
};
pub use milestones::{
HookDecisionSummary, HookMilestoneSink, InMemoryHookMilestoneSink,
Expand Down
89 changes: 86 additions & 3 deletions crates/contracts/ironclaw_loop_contracts/src/memory_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,95 @@ pub struct MemoryPromptContextRequest {
/// [`AgentLoopHostErrorKind::Unavailable`](super::host::AgentLoopHostErrorKind::Unavailable).
/// Raw backend error messages, filesystem paths, and internal identifiers must
/// never appear in the error's `safe_summary`.
/// Which retrieval lane a degradation came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemoryRetrievalLane {
ShortTerm,
LongTerm,
}

impl MemoryRetrievalLane {
pub fn as_str(self) -> &'static str {
match self {
Self::ShortTerm => "short_term",
Self::LongTerm => "long_term",
}
}
}

/// Why a retrieval lane produced nothing. A closed vocabulary, never free text
/// and never a backend message — the label is safe to record and to render in
/// operator surfaces.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemoryRetrievalFailureKind {
/// The request itself was rejected (bad query or profile).
Input,
/// The provider or its storage backend was unreachable or errored.
Unavailable,
}

impl MemoryRetrievalFailureKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Input => "input",
Self::Unavailable => "unavailable",
}
}
}

/// One lane that failed rather than returning nothing.
///
/// This is the distinction the previous contract could not express: a lane
/// error and a lane with no matching memory both arrived as an empty snippet
/// list, so "the memory backend is down" was indistinguishable from "the user
/// has no memory about this" in tests and in operator diagnostics alike.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MemoryRetrievalDegradation {
pub lane: MemoryRetrievalLane,
pub kind: MemoryRetrievalFailureKind,
}

impl MemoryRetrievalDegradation {
pub fn new(lane: MemoryRetrievalLane, kind: MemoryRetrievalFailureKind) -> Self {
Self { lane, kind }
}
}

/// The result of one memory prompt-context load: the snippets that were
/// admitted, plus a typed record of every lane that failed on the way.
///
/// Mirrors [`LoopContextBundle`](super::host::LoopContextBundle), which carries
/// its successful payload alongside an explicit description of what was lost
/// (`recent_window_truncation`). Memory retrieval is best-effort — a failing
/// lane must never fail the turn — but "best-effort" is not "invisible":
/// `degradations` is empty exactly when every queried lane answered.
#[derive(Debug, Clone, Default)]
pub struct MemoryPromptContextLoad {
pub snippets: Vec<LoopContextSnippet>,
pub degradations: Vec<MemoryRetrievalDegradation>,
}

impl MemoryPromptContextLoad {
/// A load where every queried lane answered.
pub fn healthy(snippets: Vec<LoopContextSnippet>) -> Self {
Self {
snippets,
degradations: Vec::new(),
}
}

/// Whether any queried lane failed rather than simply matching nothing.
pub fn is_degraded(&self) -> bool {
!self.degradations.is_empty()
}
}

#[async_trait]
pub trait MemoryPromptContextService: Send + Sync {
async fn load_memory_snippets(
&self,
request: MemoryPromptContextRequest,
) -> Result<Vec<LoopContextSnippet>, AgentLoopHostError>;
) -> Result<MemoryPromptContextLoad, AgentLoopHostError>;
}

/// No-op implementation that always returns an empty snippet list.
Expand All @@ -82,7 +165,7 @@ impl MemoryPromptContextService for EmptyMemoryPromptContextService {
async fn load_memory_snippets(
&self,
_request: MemoryPromptContextRequest,
) -> Result<Vec<LoopContextSnippet>, AgentLoopHostError> {
Ok(Vec::new())
) -> Result<MemoryPromptContextLoad, AgentLoopHostError> {
Ok(MemoryPromptContextLoad::default())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ async fn empty_service_returns_empty_vec() {
let result = service
.load_memory_snippets(test_request("tenant-a", "user-x"))
.await;
let snippets = result.expect("empty service should not fail");
assert!(snippets.is_empty());
let load = result.expect("empty service should not fail");
assert!(load.snippets.is_empty());
// "No memory backend wired" is not a retrieval failure.
assert!(!load.is_degraded());
}

#[tokio::test]
Expand Down
11 changes: 10 additions & 1 deletion crates/extensions/packages/memory-native/src/repo/filesystem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -932,10 +932,19 @@ where
.await?;

let full_text_results = if request.full_text() {
let filter = Filter::Fts {
// #7185: memory recall is conversational — the query is a whole
// user sentence, and requiring EVERY content term (`Filter::Fts`)
// means a paraphrased question almost never matches the stored
// wording. `Filter::FtsRanked` matches on any content term and
// orders by backend relevance (bm25 / ts_rank), which is what the
// rank-then-fuse step below already assumes it is being handed.
let filter = Filter::FtsRanked {
key: Self::index_key(fs_keys::CONTENT),
query: request.query().to_string(),
limit: request.pre_fusion_limit().min(Page::MAX_LIMIT as usize) as u32,
};
// `FtsRanked` overrides `Page::limit` (it is a top-k operation);
// the bounded page still satisfies the API.
let page = Page::new(
0,
request.pre_fusion_limit().min(Page::MAX_LIMIT as usize) as u32,
Expand Down
64 changes: 50 additions & 14 deletions crates/kernel/ironclaw_host_runtime/src/memory_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ use ironclaw_host_api::{
resource::ResourceScope,
};
use ironclaw_loop_contracts::{
AgentLoopHostError, AgentLoopHostErrorKind, LoopContextSnippet, MemoryPromptContextRequest,
MemoryPromptContextService, memory_snippet_display_ref,
AgentLoopHostError, AgentLoopHostErrorKind, LoopContextSnippet, MemoryPromptContextLoad,
MemoryPromptContextRequest, MemoryPromptContextService, MemoryRetrievalDegradation,
MemoryRetrievalFailureKind, MemoryRetrievalLane, memory_snippet_display_ref,
};
use ironclaw_memory::{
MemoryContextProfileId, MemoryInvocation, MemoryService, MemoryServiceContextRequest,
Expand Down Expand Up @@ -69,15 +70,15 @@ impl MemoryPromptContextService for ProductionMemoryPromptContextService {
async fn load_memory_snippets(
&self,
request: MemoryPromptContextRequest,
) -> Result<Vec<LoopContextSnippet>, AgentLoopHostError> {
) -> Result<MemoryPromptContextLoad, AgentLoopHostError> {
if request.max_snippets == 0 {
return Ok(Vec::new());
return Ok(MemoryPromptContextLoad::default());
}
// Fail closed at the host before any provider call: a memory-disabled
// profile returns no snippets without touching the memory service (the
// memory service keeps an equivalent check as defense in depth).
if memory_context_disabled(request.context_profile_id.as_str()) {
return Ok(Vec::new());
return Ok(MemoryPromptContextLoad::default());
}
// The host-resolved `ContextProfileId` is already validated, so this
// construction won't fail in practice — but propagate rather than unwrap.
Expand All @@ -95,7 +96,7 @@ impl MemoryPromptContextService for ProductionMemoryPromptContextService {
let query_long = self.lifecycle.declares(MemoryLifecycleHook::ReadLongTerm);
let query_short = self.lifecycle.declares(MemoryLifecycleHook::ReadShortTerm);
if !query_long && !query_short {
return Ok(Vec::new());
return Ok(MemoryPromptContextLoad::default());
}
let invocation = invocation_for_context_request(&request);
let expected = ExpectedScope::from_scope(&invocation.scope);
Expand Down Expand Up @@ -128,11 +129,28 @@ impl MemoryPromptContextService for ProductionMemoryPromptContextService {
}
},
);
let mut degradations = Vec::new();
let short_term = short_term
.map(|lane| admit_lane(&expected, lane, request.max_snippets, "short_term"))
.map(|lane| {
admit_lane(
&expected,
lane,
request.max_snippets,
MemoryRetrievalLane::ShortTerm,
&mut degradations,
)
})
.unwrap_or_default();
let long_term = long_term
.map(|lane| admit_lane(&expected, lane, request.max_snippets, "long_term"))
.map(|lane| {
admit_lane(
&expected,
lane,
request.max_snippets,
MemoryRetrievalLane::LongTerm,
&mut degradations,
)
})
.unwrap_or_default();

// Concatenate short-term before long-term so active-thread memory keeps
Expand All @@ -155,20 +173,31 @@ impl MemoryPromptContextService for ProductionMemoryPromptContextService {
total_bytes = total_bytes.saturating_add(snippet_bytes);
admitted.push(loop_snippet);
}
Ok(admitted)
Ok(MemoryPromptContextLoad {
snippets: admitted,
degradations,
})
}
}

/// Host admission for one raw provider lane: drop any out-of-scope snippet,
/// sanitize the rest into untrusted-enveloped, size-capped text, and cap the
/// lane at `max_snippets`. A lane retrieval failure degrades to empty
/// lane at `max_snippets`. A lane retrieval failure still degrades to empty
/// (best-effort: memory never breaks a turn) — including the `unavailable` an
/// unimplemented lane reports.
/// unimplemented lane reports — but it is now RECORDED in `degradations`
/// instead of vanishing into a log line, so the caller can tell "the backend
/// failed" from "there was nothing to recall".
///
/// The log half stays at `debug!` deliberately: `info!`/`warn!` output is
/// rendered by the REPL and corrupts the terminal UI, and this runs on a
/// background prompt-build path. Operator visibility comes from the returned
/// value and the driver note the loop host emits from it, not from the level.
fn admit_lane(
expected: &ExpectedScope,
lane: Result<Vec<MemoryServiceContextSnippet>, MemoryServiceError>,
max_snippets: usize,
lane_label: &'static str,
lane_label: MemoryRetrievalLane,
degradations: &mut Vec<MemoryRetrievalDegradation>,
) -> Vec<MemoryServiceContextSnippet> {
match lane {
Ok(raw) => raw
Expand All @@ -177,11 +206,18 @@ fn admit_lane(
.take(max_snippets)
.collect(),
Err(error) => {
let kind = match error.kind() {
MemoryServiceErrorKind::Input => MemoryRetrievalFailureKind::Input,
MemoryServiceErrorKind::Operation | MemoryServiceErrorKind::Unavailable => {
MemoryRetrievalFailureKind::Unavailable
}
};
tracing::debug!(
lane = lane_label,
kind = ?error.kind(),
lane = lane_label.as_str(),
kind = kind.as_str(),
"memory context lane retrieval failed; degrading lane to empty"
);
degradations.push(MemoryRetrievalDegradation::new(lane_label, kind));
Vec::new()
}
}
Expand Down
Loading
Loading