Skip to content

Commit a0cf990

Browse files
authored
feat(memory): wire StoreRoutingConfig and goal_text into memory recall path (#2484, #2483) (#2520)
* feat(memory): wire StoreRoutingConfig and goal_text into memory recall path (#2484, #2483) HeuristicRouter, LlmRouter, or HybridRouter based on strategy. Router is stored as Box<dyn AsyncMemoryRouter> and recall_routed_async() is called so LLM-based classification actually fires instead of silently degrading to heuristic. routing_classifier_provider is resolved from provider_pool at config-apply time using the existing resolve_background_provider pattern. passes it to remember() and remember_with_parts() enabling goal-conditioned write gating in A-MAC admission control. All test call sites updated with None. Zero behavior change when goal_conditioned_write = false. Breaking: RoutingConfig and RoutingStrategy removed; use StoreRoutingConfig and [memory.store_routing] instead. * fix(memory): correct goal_text doc comment in MemoryState
1 parent 71f2eb8 commit a0cf990

File tree

21 files changed

+350
-117
lines changed

21 files changed

+350
-117
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
2323

2424
- metrics: add `sanitizer_injection_fp_local` counter for injection flags on local (`ToolResult`) sources (#2515)
2525
- metrics: add `pii_ner_timeouts` counter for NER classifier timeout events (#2516)
26+
- feat(memory): `StoreRoutingConfig` (`[memory.store_routing]`) is now wired into `build_router()` — strategy `heuristic`/`llm`/`hybrid` and `routing_classifier_provider` are resolved at config-apply time; the router is constructed each turn and uses the async `route_async()` path so LLM-based classification actually fires (closes #2484)
27+
- feat(memory): `goal_text` from raw user input is now propagated to A-MAC admission control — `MemoryState.goal_text` is set at the start of each user turn and passed to `remember()` and `remember_with_parts()` enabling goal-conditioned write gating when `goal_conditioned_write = true` (closes #2483)
28+
- feat(memory): `AsyncMemoryRouter` trait now implemented for `HeuristicRouter` and `HybridRouter`; `SemanticMemory::recall_routed_async()` added to dispatch routing via the async path; `parse_route_str` is now public
29+
30+
### Removed
31+
32+
- **BREAKING**: `RoutingConfig` and `RoutingStrategy` removed from `zeph-config` — superseded by `StoreRoutingConfig` / `StoreRoutingStrategy`; the `[memory.routing]` TOML section is no longer recognised (use `[memory.store_routing]` instead)
2633

2734
## [0.18.1] - 2026-03-31
2835

crates/zeph-config/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ pub use logging::{LogRotation, LoggingConfig};
5555
pub use memory::{
5656
AdmissionConfig, AdmissionStrategy, AdmissionWeights, BeliefRevisionConfig, CompressionConfig,
5757
CompressionStrategy, ContextStrategy, DigestConfig, DocumentConfig, GraphConfig, MemoryConfig,
58-
NoteLinkingConfig, PruningStrategy, RoutingConfig, RoutingStrategy, RpeConfig, SemanticConfig,
59-
SessionsConfig, SidequestConfig, TierConfig, VectorBackend,
58+
NoteLinkingConfig, PruningStrategy, RpeConfig, SemanticConfig, SessionsConfig, SidequestConfig,
59+
StoreRoutingConfig, StoreRoutingStrategy, TierConfig, VectorBackend,
6060
};
6161
pub use providers::{
6262
BanditConfig, CandleConfig, CandleInlineConfig, CascadeClassifierMode, CascadeConfig,

crates/zeph-config/src/memory.rs

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -722,8 +722,6 @@ pub struct MemoryConfig {
722722
#[serde(default)]
723723
pub sidequest: SidequestConfig,
724724
#[serde(default)]
725-
pub routing: RoutingConfig,
726-
#[serde(default)]
727725
pub graph: GraphConfig,
728726
/// Store a lightweight session summary to the vector store on shutdown when no session
729727
/// summary exists yet for this conversation. Enables cross-session recall for short or
@@ -934,23 +932,6 @@ impl Default for SemanticConfig {
934932
}
935933
}
936934

937-
/// Routing strategy for memory backend selection.
938-
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
939-
#[serde(rename_all = "snake_case")]
940-
pub enum RoutingStrategy {
941-
/// Heuristic-based routing using query characteristics.
942-
#[default]
943-
Heuristic,
944-
}
945-
946-
/// Configuration for query-aware memory routing (#1162).
947-
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
948-
#[serde(default)]
949-
pub struct RoutingConfig {
950-
/// Routing strategy. Currently only `heuristic` is supported.
951-
pub strategy: RoutingStrategy,
952-
}
953-
954935
/// Compression strategy for active context compression (#1161).
955936
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
956937
#[serde(tag = "strategy", rename_all = "snake_case")]

crates/zeph-config/src/root.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ use crate::features::{
1919
use crate::learning::LearningConfig;
2020
use crate::logging::LoggingConfig;
2121
use crate::memory::{
22-
CompressionConfig, DocumentConfig, GraphConfig, MemoryConfig, RoutingConfig, SemanticConfig,
23-
SessionsConfig, SidequestConfig, TierConfig, VectorBackend,
22+
CompressionConfig, DocumentConfig, GraphConfig, MemoryConfig, SemanticConfig, SessionsConfig,
23+
SidequestConfig, TierConfig, VectorBackend,
2424
};
2525
use crate::providers::{
2626
LlmConfig, get_default_embedding_model, get_default_response_cache_ttl_secs,
@@ -192,7 +192,6 @@ impl Default for Config {
192192
eviction: zeph_memory::EvictionConfig::default(),
193193
compression: CompressionConfig::default(),
194194
sidequest: SidequestConfig::default(),
195-
routing: RoutingConfig::default(),
196195
graph: GraphConfig::default(),
197196
compression_guidelines: zeph_memory::CompressionGuidelinesConfig::default(),
198197
shutdown_summary: true,

crates/zeph-core/src/agent/builder.rs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ use super::session_config::{AgentSessionConfig, CONTEXT_BUDGET_RESERVE_RATIO};
1414
use crate::agent::state::ProviderConfigSnapshot;
1515
use crate::channel::Channel;
1616
use crate::config::{
17-
CompressionConfig, LearningConfig, ProviderEntry, RoutingConfig, SecurityConfig, TimeoutConfig,
17+
CompressionConfig, LearningConfig, ProviderEntry, SecurityConfig, StoreRoutingConfig,
18+
TimeoutConfig,
1819
};
1920
use crate::config_watcher::ConfigEvent;
2021
use crate::context::ContextBudget;
@@ -980,7 +981,7 @@ impl<C: Channel> Agent<C> {
980981
}
981982

982983
#[must_use]
983-
pub fn with_routing(mut self, routing: RoutingConfig) -> Self {
984+
pub fn with_routing(mut self, routing: StoreRoutingConfig) -> Self {
984985
self.context_manager.routing = routing;
985986
self
986987
}
@@ -1407,7 +1408,7 @@ mod tests {
14071408
MockChannel, MockToolExecutor, create_test_registry, mock_provider,
14081409
};
14091410
use super::*;
1410-
use crate::config::{CompressionStrategy, RoutingStrategy};
1411+
use crate::config::{CompressionStrategy, StoreRoutingConfig, StoreRoutingStrategy};
14111412

14121413
fn make_agent() -> Agent<MockChannel> {
14131414
Agent::new(
@@ -1448,13 +1449,14 @@ mod tests {
14481449

14491450
#[test]
14501451
fn with_routing_sets_routing_config() {
1451-
let routing = RoutingConfig {
1452-
strategy: RoutingStrategy::Heuristic,
1452+
let routing = StoreRoutingConfig {
1453+
strategy: StoreRoutingStrategy::Heuristic,
1454+
..StoreRoutingConfig::default()
14531455
};
14541456
let agent = make_agent().with_routing(routing);
14551457
assert_eq!(
14561458
agent.context_manager.routing.strategy,
1457-
RoutingStrategy::Heuristic,
1459+
StoreRoutingStrategy::Heuristic,
14581460
"routing strategy must be set by with_routing"
14591461
);
14601462
}
@@ -1474,7 +1476,7 @@ mod tests {
14741476
let agent = make_agent();
14751477
assert_eq!(
14761478
agent.context_manager.routing.strategy,
1477-
RoutingStrategy::Heuristic,
1479+
StoreRoutingStrategy::Heuristic,
14781480
"default routing strategy must be Heuristic"
14791481
);
14801482
}

crates/zeph-core/src/agent/context/assembly.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ impl<C: Channel> Agent<C> {
275275
query: &str,
276276
token_budget: usize,
277277
tc: &TokenCounter,
278-
router: Option<&dyn zeph_memory::MemoryRouter>,
278+
router: Option<&dyn zeph_memory::AsyncMemoryRouter>,
279279
) -> Result<(Option<Message>, Option<f32>), super::super::error::AgentError> {
280280
let Some(memory) = &memory_state.memory else {
281281
return Ok((None, None));
@@ -286,7 +286,7 @@ impl<C: Channel> Agent<C> {
286286

287287
let recalled = if let Some(r) = router {
288288
memory
289-
.recall_routed(query, memory_state.recall_limit, None, r)
289+
.recall_routed_async(query, memory_state.recall_limit, None, r)
290290
.await?
291291
} else {
292292
memory
@@ -891,6 +891,7 @@ impl<C: Channel> Agent<C> {
891891

892892
let tc = self.metrics.token_counter.clone();
893893
let router = self.context_manager.build_router();
894+
let router_ref: &dyn zeph_memory::AsyncMemoryRouter = router.as_ref();
894895
let memory_state = &self.memory_state;
895896
let index = &self.index;
896897

@@ -914,7 +915,7 @@ impl<C: Channel> Agent<C> {
914915
&query,
915916
alloc.semantic_recall,
916917
&tc,
917-
Some(&router),
918+
Some(router_ref),
918919
)
919920
.await
920921
.map(|(msg, score)| ContextSlot::SemanticRecall(msg, score))

crates/zeph-core/src/agent/context/tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3185,6 +3185,7 @@ fn make_mem_state(
31853185
context_strategy: crate::config::ContextStrategy::default(),
31863186
crossover_turn_threshold: 20,
31873187
rpe_router: None,
3188+
goal_text: None,
31883189
}
31893190
}
31903191

crates/zeph-core/src/agent/context_manager.rs

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
22
// SPDX-License-Identifier: MIT OR Apache-2.0
33

4-
use crate::config::{CompressionConfig, RoutingConfig};
4+
use std::sync::Arc;
5+
6+
use crate::config::{CompressionConfig, StoreRoutingConfig};
57
use crate::context::ContextBudget;
68

79
/// Lifecycle state of the compaction subsystem within a single session.
@@ -119,8 +121,11 @@ pub(crate) struct ContextManager {
119121
pub(super) prune_protect_tokens: usize,
120122
/// Compression configuration for proactive compression (#1161).
121123
pub(super) compression: CompressionConfig,
122-
/// Routing configuration for query-aware memory routing (#1162).
123-
pub(super) routing: RoutingConfig,
124+
/// Routing configuration for query-aware memory routing (#1162, #2484).
125+
pub(super) routing: StoreRoutingConfig,
126+
/// Resolved provider for LLM/hybrid routing. `None` when strategy is `Heuristic`
127+
/// or when the named provider could not be resolved from the pool.
128+
pub(super) store_routing_provider: Option<Arc<zeph_llm::any::AnyProvider>>,
124129
/// Compaction lifecycle state. Replaces four independent boolean/u8 fields to make
125130
/// invalid states unrepresentable. See [`CompactionState`] for the full transition map.
126131
pub(super) compaction: CompactionState,
@@ -144,7 +149,8 @@ impl ContextManager {
144149
compaction_preserve_tail: 6,
145150
prune_protect_tokens: 40_000,
146151
compression: CompressionConfig::default(),
147-
routing: RoutingConfig::default(),
152+
routing: StoreRoutingConfig::default(),
153+
store_routing_provider: None,
148154
compaction: CompactionState::Ready,
149155
compaction_cooldown_turns: 2,
150156
turns_since_last_hard_compaction: None,
@@ -204,11 +210,44 @@ impl ContextManager {
204210

205211
/// Build a memory router from the current routing configuration.
206212
///
207-
/// The router is stateless and cheap to construct per turn.
208-
pub(super) fn build_router(&self) -> zeph_memory::HeuristicRouter {
209-
use crate::config::RoutingStrategy;
213+
/// Returns a `Box<dyn AsyncMemoryRouter>` so callers can use `route_async()` for LLM-based
214+
/// classification. `HeuristicRouter` implements `AsyncMemoryRouter` via a blanket impl that
215+
/// delegates to the sync `route_with_confidence`.
216+
pub(super) fn build_router(&self) -> Box<dyn zeph_memory::AsyncMemoryRouter + Send + Sync> {
217+
use crate::config::StoreRoutingStrategy;
218+
if !self.routing.enabled {
219+
return Box::new(zeph_memory::HeuristicRouter);
220+
}
221+
let fallback = zeph_memory::router::parse_route_str(
222+
&self.routing.fallback_route,
223+
zeph_memory::MemoryRoute::Hybrid,
224+
);
210225
match self.routing.strategy {
211-
RoutingStrategy::Heuristic => zeph_memory::HeuristicRouter,
226+
StoreRoutingStrategy::Heuristic => Box::new(zeph_memory::HeuristicRouter),
227+
StoreRoutingStrategy::Llm => {
228+
let Some(provider) = self.store_routing_provider.clone() else {
229+
tracing::warn!(
230+
"store_routing: strategy=llm but no provider resolved; \
231+
falling back to heuristic"
232+
);
233+
return Box::new(zeph_memory::HeuristicRouter);
234+
};
235+
Box::new(zeph_memory::LlmRouter::new(provider, fallback))
236+
}
237+
StoreRoutingStrategy::Hybrid => {
238+
let Some(provider) = self.store_routing_provider.clone() else {
239+
tracing::warn!(
240+
"store_routing: strategy=hybrid but no provider resolved; \
241+
falling back to heuristic"
242+
);
243+
return Box::new(zeph_memory::HeuristicRouter);
244+
};
245+
Box::new(zeph_memory::HybridRouter::new(
246+
provider,
247+
fallback,
248+
self.routing.confidence_threshold,
249+
))
250+
}
212251
}
213252
}
214253

crates/zeph-core/src/agent/learning.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1017,7 +1017,10 @@ impl<C: Channel> Agent<C> {
10171017

10181018
/// Resolve a named provider from the pool, falling back to the primary provider.
10191019
/// Returns a clone of the primary provider if the name is empty, unknown, or resolution fails.
1020-
fn resolve_background_provider(&self, provider_name: &str) -> zeph_llm::any::AnyProvider {
1020+
pub(super) fn resolve_background_provider(
1021+
&self,
1022+
provider_name: &str,
1023+
) -> zeph_llm::any::AnyProvider {
10211024
if provider_name.is_empty() {
10221025
return self.provider.clone();
10231026
}

crates/zeph-core/src/agent/mod.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ impl<C: Channel> Agent<C> {
326326
context_strategy: crate::config::ContextStrategy::default(),
327327
crossover_turn_threshold: 20,
328328
rpe_router: None,
329+
goal_text: None,
329330
},
330331
skill_state: SkillState {
331332
registry,
@@ -3813,6 +3814,10 @@ impl<C: Channel> Agent<C> {
38133814
set.extend(urls);
38143815
}
38153816

3817+
// Capture raw user input as goal text for A-MAC goal-conditioned write gating (#2483).
3818+
// Derived from the raw input text before context assembly to avoid timing dependencies.
3819+
self.memory_state.goal_text = Some(text.clone());
3820+
38163821
// Image parts intentionally excluded — base64 payloads too large for message history.
38173822
self.persist_message(Role::User, &text, &[], false).await;
38183823
self.push_message(user_msg);
@@ -4848,7 +4853,21 @@ impl<C: Channel> Agent<C> {
48484853
self.context_manager.compaction_cooldown_turns = config.memory.compaction_cooldown_turns;
48494854
self.context_manager.prune_protect_tokens = config.memory.prune_protect_tokens;
48504855
self.context_manager.compression = config.memory.compression.clone();
4851-
self.context_manager.routing = config.memory.routing.clone();
4856+
self.context_manager.routing = config.memory.store_routing.clone();
4857+
// Resolve routing_classifier_provider from the provider pool (#2484).
4858+
self.context_manager.store_routing_provider = if config
4859+
.memory
4860+
.store_routing
4861+
.routing_classifier_provider
4862+
.is_empty()
4863+
{
4864+
None
4865+
} else {
4866+
let resolved = self.resolve_background_provider(
4867+
&config.memory.store_routing.routing_classifier_provider,
4868+
);
4869+
Some(std::sync::Arc::new(resolved))
4870+
};
48524871
self.memory_state.cross_session_score_threshold =
48534872
config.memory.cross_session_score_threshold;
48544873

0 commit comments

Comments
 (0)