Skip to main content

zeph_agent_context/
helpers.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Pure helper functions for context assembly.
5//!
6//! These functions are called by `assembly.rs` in `zeph-core` (via a module alias)
7//! and by the [`crate::service::ContextService`] stubs that will be filled in during
8//! subsequent migration steps.
9//!
10//! All functions operate on [`crate::state::ContextAssemblyView`] instead of the
11//! `zeph-core`-internal `MemoryState`, keeping this crate free of `zeph-core` types.
12
13use std::fmt::Write as _;
14use std::future::Future;
15use std::time::Instant;
16
17use zeph_config::ContextFormat;
18use zeph_llm::provider::{Message, MessagePart, Role};
19use zeph_memory::{RetrievalFailureRecord, RetrievalFailureType, TokenCounter};
20
21use crate::error::ContextError;
22use crate::state::ContextAssemblyView;
23
24/// System message prefix for persona context injected into the system prompt.
25pub const PERSONA_PREFIX: &str = "[Persona context]\n";
26/// System message prefix for trajectory (past experience) context.
27pub const TRAJECTORY_PREFIX: &str = "[Past experience]\n";
28/// System message prefix for tree-based memory summaries.
29pub const TREE_MEMORY_PREFIX: &str = "[Memory summary]\n";
30/// System message prefix for reasoning strategy context.
31pub const REASONING_PREFIX: &str = "[Reasoning Strategy]\n";
32
33/// System message prefix for graph memory facts injected into context.
34pub const GRAPH_FACTS_PREFIX: &str = "[known facts]\n";
35/// System message prefix for semantic recall entries.
36pub const RECALL_PREFIX: &str = "[semantic recall]\n";
37/// System message prefix for session summary entries.
38pub const SUMMARY_PREFIX: &str = "[conversation summaries]\n";
39/// System message prefix for cross-session context entries.
40pub const CROSS_SESSION_PREFIX: &str = "[cross-session context]\n";
41
42/// System message prefix for past user corrections injected into context.
43pub const CORRECTIONS_PREFIX: &str = "[past corrections]\n";
44/// System message prefix for code-context (repo-map / file context) injections.
45pub const CODE_CONTEXT_PREFIX: &str = "[code context]\n";
46/// User message prefix for session digest summaries from the previous interaction.
47pub const SESSION_DIGEST_PREFIX: &str = "[Session digest from previous interaction]\n";
48/// System message prefix for LSP context notes (diagnostics, hover data, etc.).
49pub const LSP_NOTE_PREFIX: &str = "[lsp ";
50/// System message prefix for document RAG results.
51pub const DOCUMENT_RAG_PREFIX: &str = "## Relevant documents\n";
52
53/// Truncate `s` to at most `max_chars` Unicode scalar values.
54///
55/// Delegates to `zeph_common::text::truncate_to_chars` which respects UTF-8 boundaries.
56#[must_use]
57pub fn truncate_chars(s: &str, max_chars: usize) -> String {
58    zeph_common::text::truncate_to_chars(s, max_chars)
59}
60
61/// Format a user correction as a single bullet point for injection into the system prompt.
62///
63/// The `correction_text` must already be scrubbed by the caller before being passed here.
64/// Truncated to 200 characters to avoid inflating the context with verbose correction notes.
65#[must_use]
66pub fn format_correction_note(correction_text: &str) -> String {
67    format!(
68        "- Past user correction: \"{}\"",
69        truncate_chars(correction_text, 200)
70    )
71}
72
73/// Return the effective spreading-activation recall timeout in milliseconds.
74///
75/// A configured value of `0` would silently disable recall; this function clamps it to
76/// `100ms` and emits a warning so operators notice the misconfiguration without a crash.
77pub fn effective_recall_timeout_ms(configured: u64) -> u64 {
78    if configured == 0 {
79        tracing::warn!(
80            "recall_timeout_ms is 0, which would disable spreading activation recall; \
81             clamping to 100ms"
82        );
83        100
84    } else {
85        configured
86    }
87}
88
89/// Fetch graph memory facts for the given query and inject them into the context budget.
90///
91/// Delegates to [`fetch_graph_facts_raw`] using fields from `view`.
92///
93/// Returns `None` when graph recall is disabled, the budget is zero, no memory is
94/// attached, or the recalled fact set is empty after budget enforcement.
95///
96/// # Errors
97///
98/// Returns [`ContextError::Memory`] when the graph recall backend returns an error.
99#[tracing::instrument(name = "agent_context.helpers.fetch_graph_facts", skip_all, err)]
100pub async fn fetch_graph_facts(
101    view: &ContextAssemblyView<'_>,
102    query: &str,
103    budget_tokens: usize,
104    tc: &TokenCounter,
105) -> Result<Option<Message>, ContextError> {
106    fetch_graph_facts_raw(
107        view.memory.as_deref(),
108        &view.graph_config,
109        query,
110        budget_tokens,
111        tc,
112    )
113    .await
114    .map_err(ContextError::Memory)
115}
116
117/// Read-only inputs threaded through the graph-retrieval-strategy call chain
118/// (`dispatch_graph_strategy` → `run_graph_strategy` / `run_synapse_strategy` →
119/// `run_hybrid_strategy` → `recall_by_classified_strategy`).
120///
121/// Bundles the query and its graph-traversal tuning knobs so they don't have to be
122/// threaded positionally through every hop of the chain. `edge_types_json` is kept as a
123/// separate argument alongside this struct because its ownership (moved on its last use,
124/// cloned when a call site needs it again afterward) varies per call site — folding it in
125/// here would force every caller to clone it even when a move would do.
126#[derive(Clone, Copy)]
127struct GraphStrategyParams<'a> {
128    query: &'a str,
129    recall_limit: usize,
130    max_hops: u32,
131    temporal_decay_rate: f64,
132    edge_types: &'a [zeph_memory::graph::EdgeType],
133    strategy_str: &'a str,
134}
135
136/// Graph-config references shared by the strategy-classification and per-strategy recall
137/// calls in the graph-retrieval chain (see [`GraphStrategyParams`]).
138///
139/// Kept separate from `GraphStrategyParams` because it is config, not per-call query state,
140/// and separate from [`GraphRecallBudget`] because it is never mutated.
141#[derive(Clone, Copy)]
142struct GraphRecallConfig<'a> {
143    graph_config: &'a zeph_config::GraphConfig,
144    sa_config: &'a zeph_config::memory::SpreadingActivationConfig,
145}
146
147/// Token-budget accumulator state shared across graph-retrieval calls: the in-progress
148/// system-message body, tokens consumed so far, the total budget, and the token counter
149/// used to measure both.
150///
151/// Threaded by unique `&mut` reference (reborrowed at each call) instead of `body`/
152/// `tokens_so_far` as separate positional `&mut` arguments.
153struct GraphRecallBudget<'a> {
154    body: &'a mut String,
155    tokens_so_far: &'a mut usize,
156    budget_tokens: usize,
157    tc: &'a TokenCounter,
158}
159
160/// Append graph facts to `budget.body` respecting the token budget; returns result count.
161fn append_graph_facts(
162    facts: &[zeph_memory::graph::types::GraphFact],
163    budget: &mut GraphRecallBudget<'_>,
164) -> usize {
165    let mut count = 0;
166    for f in facts {
167        let fact_text = f.fact.replace(['\n', '\r', '<', '>'], " ");
168        let line = format!("- {} (confidence: {:.2})\n", fact_text, f.confidence);
169        let line_tokens = budget.tc.count_tokens(&line);
170        if *budget.tokens_so_far + line_tokens > budget.budget_tokens {
171            break;
172        }
173        budget.body.push_str(&line);
174        *budget.tokens_so_far += line_tokens;
175        count += 1;
176    }
177    count
178}
179
180/// Await a graph recall future, logging retrieval failures and appending any results
181/// to `body` via [`append_graph_facts`].
182///
183/// Shared by the `Bfs`/`AStar`/`WaterCircles`/`BeamSearch` match arms and the `Hybrid`
184/// arm of [`fetch_graph_facts_raw`] — they differ only in which future produces the
185/// fact list. `Synapse` is handled separately because its activation-score fact
186/// formatting differs from [`append_graph_facts`].
187///
188/// On success with a non-empty result, appends facts to `body`/`tokens_so_far`. On
189/// success with an empty result, logs a `NoHit` failure record and leaves `body`
190/// unchanged, which the caller's tail check (`body == GRAPH_FACTS_PREFIX`) turns into
191/// `Ok(None)`. On error, logs an `Error` failure record and propagates it.
192///
193/// `start` is the instant recall latency should be measured from. Callers with a
194/// genuinely lazy `recall` future can pass `Instant::now()` right before calling; the
195/// `Hybrid` caller passes the instant captured before its own (already-awaited)
196/// classification + recall, since by the time it hands off an already-resolved
197/// `std::future::ready(...)` here, starting a fresh clock would measure only the time
198/// to poll an already-completed future instead of real recall latency.
199async fn run_graph_strategy<F>(
200    memory: &zeph_memory::semantic::SemanticMemory,
201    params: GraphStrategyParams<'_>,
202    edge_types_json: Option<String>,
203    start: Instant,
204    recall: F,
205    budget: &mut GraphRecallBudget<'_>,
206) -> Result<(), zeph_memory::MemoryError>
207where
208    F: Future<Output = Result<Vec<zeph_memory::graph::types::GraphFact>, zeph_memory::MemoryError>>,
209{
210    let facts = match recall.await {
211        Ok(f) => f,
212        Err(e) => {
213            let latency_ms = start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
214            memory.log_retrieval_failure(RetrievalFailureRecord {
215                conversation_id: None,
216                turn_index: 0,
217                failure_type: RetrievalFailureType::Error,
218                retrieval_strategy: params.strategy_str.to_owned(),
219                query_text: params.query.to_owned(),
220                query_len: params.query.len(),
221                top_score: None,
222                confidence_threshold: None,
223                result_count: 0,
224                latency_ms,
225                edge_types: edge_types_json,
226                error_context: Some(format!("{e:#}")),
227            });
228            return Err(e);
229        }
230    };
231    let latency_ms = start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
232    if facts.is_empty() {
233        memory.log_retrieval_failure(RetrievalFailureRecord {
234            conversation_id: None,
235            turn_index: 0,
236            failure_type: RetrievalFailureType::NoHit,
237            retrieval_strategy: params.strategy_str.to_owned(),
238            query_text: params.query.to_owned(),
239            query_len: params.query.len(),
240            top_score: None,
241            confidence_threshold: None,
242            result_count: 0,
243            latency_ms,
244            edge_types: edge_types_json,
245            error_context: None,
246        });
247        return Ok(());
248    }
249    append_graph_facts(&facts, budget);
250    Ok(())
251}
252
253/// Run the `Synapse` (spreading-activation) graph retrieval strategy.
254///
255/// Kept separate from [`run_graph_strategy`] because it has its own recall timeout
256/// (in addition to the shared `Error`/`NoHit` cases, it logs a `Timeout` failure) and
257/// formats facts with an extra `activation` score column that [`append_graph_facts`]
258/// does not support.
259///
260/// On success, appends facts to `body`/`tokens_so_far`. On an empty result, logs a
261/// `NoHit` failure record and leaves `body` unchanged — the caller's tail check
262/// (`body == GRAPH_FACTS_PREFIX`) turns this into `Ok(None)`.
263async fn run_synapse_strategy(
264    memory: &zeph_memory::semantic::SemanticMemory,
265    sa_config: &zeph_config::memory::SpreadingActivationConfig,
266    params: GraphStrategyParams<'_>,
267    edge_types_json: Option<String>,
268    budget: &mut GraphRecallBudget<'_>,
269) {
270    let sa_params = zeph_memory::graph::SpreadingActivationParams {
271        decay_lambda: sa_config.decay_lambda,
272        max_hops: sa_config.max_hops,
273        activation_threshold: sa_config.activation_threshold,
274        inhibition_threshold: sa_config.inhibition_threshold,
275        max_activated_nodes: sa_config.max_activated_nodes,
276        temporal_decay_rate: params.temporal_decay_rate,
277        seed_structural_weight: sa_config.seed_structural_weight,
278        seed_community_cap: sa_config.seed_community_cap,
279        alpha: sa_config.alpha,
280    };
281    let timeout_ms = effective_recall_timeout_ms(sa_config.recall_timeout_ms);
282    let t0 = Instant::now();
283    let activated_facts = match tokio::time::timeout(
284        std::time::Duration::from_millis(timeout_ms),
285        memory.recall_graph_activated(
286            params.query,
287            params.recall_limit,
288            sa_params,
289            params.edge_types,
290        ),
291    )
292    .await
293    {
294        Ok(Ok(facts)) => facts,
295        Ok(Err(e)) => {
296            let latency_ms = t0.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
297            tracing::warn!("spreading activation recall failed: {e:#}");
298            // TODO(#3576): conversation_id and turn_index not yet propagated into
299            // context helpers; tracked for future enhancement when
300            // ContextAssemblyView exposes them.
301            memory.log_retrieval_failure(RetrievalFailureRecord {
302                conversation_id: None,
303                turn_index: 0,
304                failure_type: RetrievalFailureType::Error,
305                retrieval_strategy: params.strategy_str.to_owned(),
306                query_text: params.query.to_owned(),
307                query_len: params.query.len(),
308                top_score: None,
309                confidence_threshold: None,
310                result_count: 0,
311                latency_ms,
312                edge_types: edge_types_json.clone(),
313                error_context: Some(format!("{e:#}")),
314            });
315            Vec::new()
316        }
317        Err(_) => {
318            let latency_ms = t0.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
319            tracing::warn!("spreading activation recall timed out ({timeout_ms}ms)");
320            memory.log_retrieval_failure(RetrievalFailureRecord {
321                conversation_id: None,
322                turn_index: 0,
323                failure_type: RetrievalFailureType::Timeout,
324                retrieval_strategy: params.strategy_str.to_owned(),
325                query_text: params.query.to_owned(),
326                query_len: params.query.len(),
327                top_score: None,
328                confidence_threshold: None,
329                result_count: 0,
330                latency_ms,
331                edge_types: edge_types_json.clone(),
332                error_context: Some(format!("timeout after {timeout_ms}ms")),
333            });
334            Vec::new()
335        }
336    };
337    let latency_ms = t0.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
338    if activated_facts.is_empty() {
339        memory.log_retrieval_failure(RetrievalFailureRecord {
340            conversation_id: None,
341            turn_index: 0,
342            failure_type: RetrievalFailureType::NoHit,
343            retrieval_strategy: params.strategy_str.to_owned(),
344            query_text: params.query.to_owned(),
345            query_len: params.query.len(),
346            top_score: None,
347            confidence_threshold: None,
348            result_count: 0,
349            latency_ms,
350            edge_types: edge_types_json,
351            error_context: None,
352        });
353        return;
354    }
355    for f in &activated_facts {
356        let fact_text = f.edge.fact.replace(['\n', '\r', '<', '>'], " ");
357        let line = format!(
358            "- {} (confidence: {:.2}, activation: {:.2})\n",
359            fact_text, f.edge.confidence, f.activation_score
360        );
361        let line_tokens = budget.tc.count_tokens(&line);
362        if *budget.tokens_so_far + line_tokens > budget.budget_tokens {
363            break;
364        }
365        budget.body.push_str(&line);
366        *budget.tokens_so_far += line_tokens;
367    }
368}
369
370/// Classify `query` into a concrete graph sub-strategy for the `Hybrid` retrieval
371/// strategy, via `memory.classify_graph_strategy` under a fixed timeout.
372///
373/// Falls back to `"synapse"` and logs a `Timeout` failure record if the classifier
374/// doesn't resolve in time.
375async fn classify_hybrid_strategy(
376    memory: &zeph_memory::semantic::SemanticMemory,
377    query: &str,
378    edge_types_json: Option<String>,
379) -> String {
380    const CLASSIFIER_TIMEOUT_MS: u64 = 2_000;
381    let classifier_t0 = Instant::now();
382    let classified = if let Ok(s) = tokio::time::timeout(
383        std::time::Duration::from_millis(CLASSIFIER_TIMEOUT_MS),
384        memory.classify_graph_strategy(query),
385    )
386    .await
387    {
388        s
389    } else {
390        let latency_ms = classifier_t0
391            .elapsed()
392            .as_millis()
393            .try_into()
394            .unwrap_or(u64::MAX);
395        tracing::warn!(
396            "hybrid strategy classifier timed out after {CLASSIFIER_TIMEOUT_MS}ms, \
397             falling back to synapse"
398        );
399        memory.log_retrieval_failure(RetrievalFailureRecord {
400            conversation_id: None,
401            turn_index: 0,
402            failure_type: RetrievalFailureType::Timeout,
403            retrieval_strategy: "hybrid_classifier".to_owned(),
404            query_text: query.to_owned(),
405            query_len: query.len(),
406            top_score: None,
407            confidence_threshold: None,
408            result_count: 0,
409            latency_ms,
410            edge_types: edge_types_json,
411            error_context: Some(format!(
412                "classifier timeout after {CLASSIFIER_TIMEOUT_MS}ms"
413            )),
414        });
415        "synapse".to_owned()
416    };
417    tracing::debug!(classified_strategy = %classified, "hybrid dispatch: classified");
418    classified
419}
420
421/// Run graph recall for the sub-strategy `classified` by [`classify_hybrid_strategy`].
422///
423/// The `astar`/`watercircles`/`beam_search`/synapse-fallback branches used to each
424/// carry their own copy of the error-logging block; here they only need to produce a
425/// `Result`, and the shared `Error`/`NoHit` logging happens once in the caller via
426/// [`run_graph_strategy`].
427async fn recall_by_classified_strategy(
428    classified: &str,
429    memory: &zeph_memory::semantic::SemanticMemory,
430    config: GraphRecallConfig<'_>,
431    params: GraphStrategyParams<'_>,
432) -> Result<Vec<zeph_memory::graph::types::GraphFact>, zeph_memory::MemoryError> {
433    match classified {
434        "astar" => {
435            memory
436                .recall_graph_astar(
437                    params.query,
438                    params.recall_limit,
439                    params.max_hops,
440                    params.temporal_decay_rate,
441                    params.edge_types,
442                )
443                .await
444        }
445        "watercircles" => {
446            let ring_limit = config.graph_config.watercircles.ring_limit;
447            memory
448                .recall_graph_watercircles(
449                    params.query,
450                    params.recall_limit,
451                    params.max_hops,
452                    ring_limit,
453                    params.temporal_decay_rate,
454                    params.edge_types,
455                )
456                .await
457        }
458        "beam_search" => {
459            let beam_width = config.graph_config.beam_search.beam_width;
460            memory
461                .recall_graph_beam(
462                    params.query,
463                    params.recall_limit,
464                    beam_width,
465                    params.max_hops,
466                    params.temporal_decay_rate,
467                    params.edge_types,
468                )
469                .await
470        }
471        _ => {
472            let sa_params = zeph_memory::graph::SpreadingActivationParams {
473                decay_lambda: config.sa_config.decay_lambda,
474                max_hops: config.sa_config.max_hops,
475                activation_threshold: config.sa_config.activation_threshold,
476                inhibition_threshold: config.sa_config.inhibition_threshold,
477                max_activated_nodes: config.sa_config.max_activated_nodes,
478                temporal_decay_rate: params.temporal_decay_rate,
479                seed_structural_weight: config.sa_config.seed_structural_weight,
480                seed_community_cap: config.sa_config.seed_community_cap,
481                alpha: config.sa_config.alpha,
482            };
483            memory
484                .recall_graph_activated(
485                    params.query,
486                    params.recall_limit,
487                    sa_params,
488                    params.edge_types,
489                )
490                .await
491                .map(|activated| {
492                    activated
493                        .into_iter()
494                        .map(|f| zeph_memory::graph::types::GraphFact {
495                            entity_name: f.edge.source_entity_id.to_string(),
496                            relation: f.edge.relation.clone(),
497                            target_name: f.edge.target_entity_id.to_string(),
498                            fact: f.edge.fact.clone(),
499                            entity_match_score: f.activation_score,
500                            hop_distance: 0,
501                            confidence: f.edge.confidence,
502                            valid_from: Some(f.edge.valid_from.clone()),
503                            edge_type: f.edge.edge_type,
504                            retrieval_count: f.edge.retrieval_count,
505                            edge_id: Some(f.edge.id),
506                        })
507                        .collect()
508                })
509        }
510    }
511}
512
513/// Fetch graph memory facts using individual field arguments.
514///
515/// This is the raw-args variant used by `zeph-core` test bridge methods and by
516/// [`fetch_graph_facts`] internally. It accepts only the fields that the graph recall
517/// logic actually accesses, avoiding the need to construct a full [`ContextAssemblyView`]
518/// in test harnesses.
519///
520/// # Errors
521///
522/// Returns [`zeph_memory::MemoryError`] when the graph recall backend returns an error.
523#[tracing::instrument(
524    name = "agent_context.helpers.fetch_graph_facts_raw",
525    skip_all,
526    err,
527    fields(effective_strategy)
528)]
529pub async fn fetch_graph_facts_raw(
530    memory: Option<&zeph_memory::semantic::SemanticMemory>,
531    graph_config: &zeph_config::GraphConfig,
532    query: &str,
533    budget_tokens: usize,
534    tc: &TokenCounter,
535) -> Result<Option<Message>, zeph_memory::MemoryError> {
536    if budget_tokens == 0 || !graph_config.enabled {
537        return Ok(None);
538    }
539    let Some(memory) = memory else {
540        return Ok(None);
541    };
542    let recall_limit = graph_config.recall_limit;
543    let temporal_decay_rate = graph_config.temporal_decay_rate;
544    let edge_types = zeph_memory::classify_graph_subgraph(query);
545    let sa_config = &graph_config.spreading_activation;
546
547    let mut body = String::from(GRAPH_FACTS_PREFIX);
548    let mut tokens_so_far = tc.count_tokens(&body);
549    let max_hops = graph_config.max_hops;
550
551    use zeph_config::memory::GraphRetrievalStrategy;
552    let effective_strategy = if sa_config.enabled {
553        GraphRetrievalStrategy::Synapse
554    } else {
555        graph_config.retrieval_strategy
556    };
557
558    tracing::Span::current().record(
559        "effective_strategy",
560        tracing::field::debug(&effective_strategy),
561    );
562    let strategy_str = format!("{effective_strategy:?}").to_lowercase();
563    let edge_types_json = serde_json::to_string(&edge_types).ok();
564
565    let params = GraphStrategyParams {
566        query,
567        recall_limit,
568        max_hops,
569        temporal_decay_rate,
570        edge_types: &edge_types,
571        strategy_str: &strategy_str,
572    };
573    let config = GraphRecallConfig {
574        graph_config,
575        sa_config,
576    };
577    let mut budget = GraphRecallBudget {
578        body: &mut body,
579        tokens_so_far: &mut tokens_so_far,
580        budget_tokens,
581        tc,
582    };
583
584    dispatch_graph_strategy(
585        effective_strategy,
586        memory,
587        config,
588        params,
589        edge_types_json,
590        &mut budget,
591    )
592    .await?;
593
594    if body == GRAPH_FACTS_PREFIX {
595        return Ok(None);
596    }
597
598    Ok(Some(Message::from_legacy(Role::System, body)))
599}
600
601/// Dispatch to the recall implementation for `effective_strategy` and append any
602/// resulting facts to `body`.
603///
604/// One match arm per [`zeph_config::memory::GraphRetrievalStrategy`] variant; each arm
605/// only selects which recall future to run (and, for `Hybrid`, first resolves the
606/// classified sub-strategy) — the shared error/no-hit logging and budget-aware
607/// appending live in [`run_synapse_strategy`] / [`run_graph_strategy`]. The line count
608/// here comes from enumerating six variants side by side, not from duplicated logic;
609/// splitting each arm into its own one-call wrapper function would trade this for five
610/// near-identical trivial functions without reducing complexity, so the length lint is
611/// suppressed instead.
612#[allow(clippy::too_many_lines)]
613async fn dispatch_graph_strategy(
614    effective_strategy: zeph_config::memory::GraphRetrievalStrategy,
615    memory: &zeph_memory::semantic::SemanticMemory,
616    config: GraphRecallConfig<'_>,
617    params: GraphStrategyParams<'_>,
618    edge_types_json: Option<String>,
619    budget: &mut GraphRecallBudget<'_>,
620) -> Result<(), zeph_memory::MemoryError> {
621    use zeph_config::memory::GraphRetrievalStrategy;
622    match effective_strategy {
623        GraphRetrievalStrategy::Synapse => {
624            run_synapse_strategy(memory, config.sa_config, params, edge_types_json, budget).await;
625        }
626        GraphRetrievalStrategy::Bfs => {
627            run_graph_strategy(
628                memory,
629                params,
630                edge_types_json,
631                Instant::now(),
632                memory.recall_graph(
633                    params.query,
634                    params.recall_limit,
635                    params.max_hops,
636                    None,
637                    params.temporal_decay_rate,
638                    params.edge_types,
639                ),
640                budget,
641            )
642            .await?;
643        }
644        GraphRetrievalStrategy::AStar => {
645            run_graph_strategy(
646                memory,
647                params,
648                edge_types_json,
649                Instant::now(),
650                memory.recall_graph_astar(
651                    params.query,
652                    params.recall_limit,
653                    params.max_hops,
654                    params.temporal_decay_rate,
655                    params.edge_types,
656                ),
657                budget,
658            )
659            .await?;
660        }
661        GraphRetrievalStrategy::WaterCircles => {
662            let ring_limit = config.graph_config.watercircles.ring_limit;
663            run_graph_strategy(
664                memory,
665                params,
666                edge_types_json,
667                Instant::now(),
668                memory.recall_graph_watercircles(
669                    params.query,
670                    params.recall_limit,
671                    params.max_hops,
672                    ring_limit,
673                    params.temporal_decay_rate,
674                    params.edge_types,
675                ),
676                budget,
677            )
678            .await?;
679        }
680        GraphRetrievalStrategy::BeamSearch => {
681            let beam_width = config.graph_config.beam_search.beam_width;
682            run_graph_strategy(
683                memory,
684                params,
685                edge_types_json,
686                Instant::now(),
687                memory.recall_graph_beam(
688                    params.query,
689                    params.recall_limit,
690                    beam_width,
691                    params.max_hops,
692                    params.temporal_decay_rate,
693                    params.edge_types,
694                ),
695                budget,
696            )
697            .await?;
698        }
699        GraphRetrievalStrategy::Hybrid => {
700            run_hybrid_strategy(memory, config, params, edge_types_json, budget).await?;
701        }
702        _ => {}
703    }
704    Ok(())
705}
706
707/// Run the `Hybrid` graph retrieval strategy: classify the query into a concrete
708/// sub-strategy, run recall for it, then apply the shared failure-logging/append path.
709async fn run_hybrid_strategy(
710    memory: &zeph_memory::semantic::SemanticMemory,
711    config: GraphRecallConfig<'_>,
712    params: GraphStrategyParams<'_>,
713    edge_types_json: Option<String>,
714    budget: &mut GraphRecallBudget<'_>,
715) -> Result<(), zeph_memory::MemoryError> {
716    let classified = classify_hybrid_strategy(memory, params.query, edge_types_json.clone()).await;
717    // Capture the start instant here, before the real recall work, rather than inside
718    // run_graph_strategy: by the time facts_result is ready, the future handed to
719    // run_graph_strategy below is already resolved (`std::future::ready`), so starting
720    // a fresh clock there would measure only the time to poll an already-done future
721    // instead of actual recall latency.
722    let recall_t0 = Instant::now();
723    let facts_result = recall_by_classified_strategy(&classified, memory, config, params).await;
724
725    run_graph_strategy(
726        memory,
727        params,
728        edge_types_json,
729        recall_t0,
730        std::future::ready(facts_result),
731        budget,
732    )
733    .await
734}
735
736/// Read-only inputs for [`fetch_semantic_recall_raw`]: the query, its retrieval
737/// limits/format, and the confidence threshold used to flag low-confidence recall for
738/// telemetry.
739///
740/// Distinct from [`crate::service::SemanticRecallParams`] (the service-level façade
741/// struct, which additionally carries tiered-retrieval provider/config fields) — this is
742/// the smaller subset of fields actually read by the flat (non-tiered) recall path.
743/// `memory` and `router` are kept as separate arguments on the function since they are
744/// resource handles rather than per-call query configuration.
745pub struct SemanticRecallRawParams<'a> {
746    /// Maximum number of memories to retrieve.
747    pub recall_limit: usize,
748    /// Format applied when serialising recalled memories.
749    pub context_format: ContextFormat,
750    /// Query string used for retrieval.
751    pub query: &'a str,
752    /// Maximum number of tokens the injected recall may consume.
753    pub token_budget: usize,
754    /// Token counter used to enforce `token_budget`.
755    pub tc: &'a TokenCounter,
756    /// When `Some(t)`, results with a top score below `t` are classified as
757    /// low-confidence and logged via the memory's retrieval failure logger.
758    pub low_confidence_threshold: Option<f32>,
759}
760
761/// Fetch semantically recalled messages using individual field arguments.
762///
763/// Raw-args variant used by [`fetch_semantic_recall`] and by
764/// [`crate::service::ContextService`]'s flat (non-tiered) recall path.
765///
766/// # Errors
767///
768/// Returns [`zeph_memory::MemoryError`] when the memory backend returns an error.
769#[tracing::instrument(
770    name = "agent_context.helpers.fetch_semantic_recall_raw",
771    skip_all,
772    err
773)]
774pub async fn fetch_semantic_recall_raw(
775    memory: Option<&zeph_memory::semantic::SemanticMemory>,
776    params: SemanticRecallRawParams<'_>,
777    router: Option<&dyn zeph_memory::AsyncMemoryRouter>,
778) -> Result<(Option<Message>, Option<f32>), zeph_memory::MemoryError> {
779    let Some(memory) = memory else {
780        return Ok((None, None));
781    };
782    if params.recall_limit == 0 || params.token_budget == 0 {
783        return Ok((None, None));
784    }
785
786    let t0 = Instant::now();
787    let recalled = if let Some(r) = router {
788        memory
789            .recall_routed_async(params.query, params.recall_limit, None, r, None)
790            .await?
791    } else {
792        memory
793            .recall(params.query, params.recall_limit, None)
794            .await?
795    };
796    let latency_ms = t0.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
797
798    if recalled.is_empty() {
799        memory.log_retrieval_failure(RetrievalFailureRecord {
800            conversation_id: None,
801            turn_index: 0,
802            failure_type: RetrievalFailureType::NoHit,
803            retrieval_strategy: "semantic".to_owned(),
804            query_text: params.query.to_owned(),
805            query_len: params.query.len(),
806            top_score: None,
807            confidence_threshold: params.low_confidence_threshold,
808            result_count: 0,
809            latency_ms,
810            edge_types: None,
811            error_context: None,
812        });
813        return Ok((None, None));
814    }
815
816    let top_score = recalled.first().map(|r| r.score);
817
818    if let (Some(score), Some(threshold)) = (top_score, params.low_confidence_threshold)
819        && score < threshold
820    {
821        memory.log_retrieval_failure(RetrievalFailureRecord {
822            conversation_id: None,
823            turn_index: 0,
824            failure_type: RetrievalFailureType::LowConfidence,
825            retrieval_strategy: "semantic".to_owned(),
826            query_text: params.query.to_owned(),
827            query_len: params.query.len(),
828            top_score: Some(score),
829            confidence_threshold: Some(threshold),
830            result_count: recalled.len(),
831            latency_ms,
832            edge_types: None,
833            error_context: None,
834        });
835    }
836    let initial_cap = (params.recall_limit * 512).min(params.token_budget * 3);
837    let mut recall_text = String::with_capacity(initial_cap);
838    recall_text.push_str(RECALL_PREFIX);
839    let mut tokens_used = params.tc.count_tokens(&recall_text);
840
841    for item in &recalled {
842        if item.message.content.starts_with("[skipped]")
843            || item.message.content.starts_with("[stopped]")
844        {
845            continue;
846        }
847        let entry = match params.context_format {
848            ContextFormat::Structured => format_structured_recall_entry(item),
849            _ => format_plain_recall_entry(item),
850        };
851        let entry_tokens = params.tc.count_tokens(&entry);
852        if tokens_used + entry_tokens > params.token_budget {
853            break;
854        }
855        recall_text.push_str(&entry);
856        tokens_used += entry_tokens;
857    }
858
859    if tokens_used > params.tc.count_tokens(RECALL_PREFIX) {
860        Ok((
861            Some(Message::from_parts(
862                Role::System,
863                vec![MessagePart::Recall { text: recall_text }],
864            )),
865            top_score,
866        ))
867    } else {
868        Ok((None, None))
869    }
870}
871
872/// Fetch session summaries using individual field arguments.
873///
874/// Raw-args variant used by `zeph-core` test bridge methods and by [`fetch_summaries`].
875///
876/// # Errors
877///
878/// Returns [`zeph_memory::MemoryError`] when the memory backend returns an error.
879#[tracing::instrument(name = "agent_context.helpers.fetch_summaries_raw", skip_all, err)]
880pub async fn fetch_summaries_raw(
881    memory: Option<&zeph_memory::semantic::SemanticMemory>,
882    conversation_id: Option<zeph_memory::ConversationId>,
883    token_budget: usize,
884    tc: &TokenCounter,
885) -> Result<Option<Message>, zeph_memory::MemoryError> {
886    let (Some(memory), Some(cid)) = (memory, conversation_id) else {
887        return Ok(None);
888    };
889    if token_budget == 0 {
890        return Ok(None);
891    }
892
893    let summaries = memory.load_summaries(cid).await?;
894    if summaries.is_empty() {
895        return Ok(None);
896    }
897
898    let mut summary_text = String::from(SUMMARY_PREFIX);
899    let mut tokens_used = tc.count_tokens(&summary_text);
900
901    for summary in summaries.iter().rev() {
902        let first = summary.first_message_id.map_or(0, |m| m.0);
903        let last = summary.last_message_id.map_or(0, |m| m.0);
904        let entry = format!("- Messages {first}-{last}: {}\n", summary.content);
905        let cost = tc.count_tokens(&entry);
906        if tokens_used + cost > token_budget {
907            break;
908        }
909        summary_text.push_str(&entry);
910        tokens_used += cost;
911    }
912
913    if tokens_used > tc.count_tokens(SUMMARY_PREFIX) {
914        Ok(Some(Message::from_parts(
915            Role::System,
916            vec![MessagePart::Summary { text: summary_text }],
917        )))
918    } else {
919        Ok(None)
920    }
921}
922
923/// Fetch cross-session context summaries using individual field arguments.
924///
925/// Raw-args variant used by `zeph-core` test bridge methods and by [`fetch_cross_session`].
926///
927/// # Errors
928///
929/// Returns [`zeph_memory::MemoryError`] when the memory backend returns an error.
930#[tracing::instrument(name = "agent_context.helpers.fetch_cross_session_raw", skip_all, err)]
931pub async fn fetch_cross_session_raw(
932    memory: Option<&zeph_memory::semantic::SemanticMemory>,
933    conversation_id: Option<zeph_memory::ConversationId>,
934    cross_session_score_threshold: f32,
935    query: &str,
936    token_budget: usize,
937    tc: &TokenCounter,
938) -> Result<Option<Message>, zeph_memory::MemoryError> {
939    let (Some(memory), Some(cid)) = (memory, conversation_id) else {
940        return Ok(None);
941    };
942    if token_budget == 0 {
943        return Ok(None);
944    }
945
946    let results: Vec<_> = memory
947        .search_session_summaries(query, 5, Some(cid))
948        .await?
949        .into_iter()
950        .filter(|r| r.score >= cross_session_score_threshold)
951        .collect();
952    if results.is_empty() {
953        return Ok(None);
954    }
955
956    let mut text = String::from(CROSS_SESSION_PREFIX);
957    let mut tokens_used = tc.count_tokens(&text);
958
959    for item in &results {
960        let entry = format!("- {}\n", item.summary_text);
961        let cost = tc.count_tokens(&entry);
962        if tokens_used + cost > token_budget {
963            break;
964        }
965        text.push_str(&entry);
966        tokens_used += cost;
967    }
968
969    if tokens_used > tc.count_tokens(CROSS_SESSION_PREFIX) {
970        Ok(Some(Message::from_parts(
971            Role::System,
972            vec![MessagePart::CrossSession { text }],
973        )))
974    } else {
975        Ok(None)
976    }
977}
978
979/// Fetch semantically recalled messages for the given query and enforce the token budget.
980///
981/// Delegates to [`fetch_semantic_recall_raw`] using fields from `view`.
982///
983/// Returns `(None, None)` when memory is absent, recall is disabled, the budget is zero,
984/// or the recalled set is empty.
985///
986/// The second element of the tuple is the similarity score of the top recalled entry, used
987/// by the caller to track recall confidence for telemetry.
988///
989/// # Errors
990///
991/// Returns [`ContextError::Memory`] when the memory recall backend returns an error.
992#[tracing::instrument(name = "agent_context.helpers.fetch_semantic_recall", skip_all, err)]
993pub async fn fetch_semantic_recall(
994    view: &ContextAssemblyView<'_>,
995    query: &str,
996    token_budget: usize,
997    tc: &TokenCounter,
998    router: Option<&dyn zeph_memory::AsyncMemoryRouter>,
999) -> Result<(Option<Message>, Option<f32>), ContextError> {
1000    fetch_semantic_recall_raw(
1001        view.memory.as_deref(),
1002        SemanticRecallRawParams {
1003            recall_limit: view.recall_limit,
1004            context_format: view.context_format,
1005            query,
1006            token_budget,
1007            tc,
1008            low_confidence_threshold: None,
1009        },
1010        router,
1011    )
1012    .await
1013    .map_err(ContextError::Memory)
1014}
1015
1016fn format_plain_recall_entry(item: &zeph_memory::RecalledMessage) -> String {
1017    let role_label = match item.message.role {
1018        Role::Assistant => "assistant",
1019        Role::System => "system",
1020        Role::User | _ => "user",
1021    };
1022    format!("- [{}] {}\n", role_label, item.message.content)
1023}
1024
1025#[allow(clippy::map_unwrap_or)]
1026fn format_structured_recall_entry(item: &zeph_memory::RecalledMessage) -> String {
1027    let source = match item.message.role {
1028        Role::Assistant => "assistant",
1029        Role::System => "system",
1030        Role::User | _ => "user",
1031    };
1032    // Use compacted_at as a proxy for message age when available; otherwise "unknown".
1033    // A full timestamp lookup from SQLite would require an async DB call in the assembler
1034    // and is deferred to a future enhancement (TODO: enhance when message timestamps are
1035    // propagated into RecalledMessage).
1036    let date = item
1037        .message
1038        .metadata
1039        .compacted_at
1040        .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
1041        .map(|dt| dt.format("%Y-%m-%d").to_string())
1042        .unwrap_or_else(|| "unknown".to_owned());
1043    format!(
1044        "[Memory | {} | {} | relevance: {:.2}]\n{}\n",
1045        source, date, item.score, item.message.content
1046    )
1047}
1048
1049/// Fetch session summaries for the current conversation and enforce the token budget.
1050///
1051/// Delegates to [`fetch_summaries_raw`] using fields from `view`.
1052///
1053/// Returns `None` when memory or the conversation ID is absent, the budget is zero,
1054/// or no summaries exist yet.
1055///
1056/// # Errors
1057///
1058/// Returns [`ContextError::Memory`] when the memory backend returns an error.
1059#[tracing::instrument(name = "agent_context.helpers.fetch_summaries", skip_all, err)]
1060pub async fn fetch_summaries(
1061    view: &ContextAssemblyView<'_>,
1062    token_budget: usize,
1063    tc: &TokenCounter,
1064) -> Result<Option<Message>, ContextError> {
1065    fetch_summaries_raw(
1066        view.memory.as_deref(),
1067        view.conversation_id,
1068        token_budget,
1069        tc,
1070    )
1071    .await
1072    .map_err(ContextError::Memory)
1073}
1074
1075/// Fetch cross-session context summaries for the given query and enforce the token budget.
1076///
1077/// Delegates to [`fetch_cross_session_raw`] using fields from `view`.
1078///
1079/// Results are filtered by `view.cross_session_score_threshold` before token counting,
1080/// and the current conversation is excluded from the search results.
1081///
1082/// Returns `None` when memory or the conversation ID is absent, the budget is zero,
1083/// no results exceed the threshold, or the result set is empty.
1084///
1085/// # Errors
1086///
1087/// Returns [`ContextError::Memory`] when the memory backend returns an error.
1088#[tracing::instrument(name = "agent_context.helpers.fetch_cross_session", skip_all, err)]
1089pub async fn fetch_cross_session(
1090    view: &ContextAssemblyView<'_>,
1091    query: &str,
1092    token_budget: usize,
1093    tc: &TokenCounter,
1094) -> Result<Option<Message>, ContextError> {
1095    fetch_cross_session_raw(
1096        view.memory.as_deref(),
1097        view.conversation_id,
1098        view.cross_session_score_threshold,
1099        query,
1100        token_budget,
1101        tc,
1102    )
1103    .await
1104    .map_err(ContextError::Memory)
1105}
1106
1107/// Budget state injected into the volatile system prompt section.
1108///
1109/// All fields are optional — omitted when the corresponding data source is unavailable.
1110/// [`BudgetHint::format_xml`] returns `None` when all fields would be absent.
1111///
1112/// Callers should construct this from cost-tracker and tool-orchestrator state, then call
1113/// `format_xml` and append the result to the system prompt when `Some`.
1114pub struct BudgetHint {
1115    /// Remaining daily budget in US cents, if a daily limit is configured.
1116    pub remaining_cost_cents: Option<f64>,
1117    /// Total daily budget in US cents, if a daily limit is configured.
1118    pub total_budget_cents: Option<f64>,
1119    /// Remaining tool-call iterations this turn.
1120    pub remaining_tool_calls: usize,
1121    /// Maximum allowed tool-call iterations per turn (0 = no limit configured).
1122    pub max_tool_calls: usize,
1123}
1124
1125impl BudgetHint {
1126    /// Render the budget hint as an XML fragment for injection into the system prompt.
1127    ///
1128    /// Returns `None` when no meaningful budget data is available — callers must skip
1129    /// injection rather than injecting an empty `<budget></budget>` block.
1130    ///
1131    /// # Examples
1132    ///
1133    /// ```
1134    /// use zeph_agent_context::helpers::BudgetHint;
1135    ///
1136    /// let hint = BudgetHint {
1137    ///     remaining_cost_cents: Some(50.0),
1138    ///     total_budget_cents: Some(100.0),
1139    ///     remaining_tool_calls: 8,
1140    ///     max_tool_calls: 10,
1141    /// };
1142    /// let xml = hint.format_xml().unwrap();
1143    /// assert!(xml.contains("<remaining_cost_cents>50.00</remaining_cost_cents>"));
1144    /// assert!(xml.contains("<remaining_tool_calls>8</remaining_tool_calls>"));
1145    /// ```
1146    #[must_use]
1147    pub fn format_xml(&self) -> Option<String> {
1148        let has_cost = self.remaining_cost_cents.is_some();
1149        // Always include tool call budget — max_tool_calls > 0 in any real config.
1150        if !has_cost && self.max_tool_calls == 0 {
1151            return None;
1152        }
1153        let mut s = String::from("<budget>");
1154        if let Some(remaining) = self.remaining_cost_cents {
1155            let _ = write!(
1156                s,
1157                "\n<remaining_cost_cents>{remaining:.2}</remaining_cost_cents>"
1158            );
1159        }
1160        if let Some(total) = self.total_budget_cents {
1161            let _ = write!(s, "\n<total_budget_cents>{total:.2}</total_budget_cents>");
1162        }
1163        if self.max_tool_calls > 0 {
1164            let _ = write!(
1165                s,
1166                "\n<remaining_tool_calls>{}</remaining_tool_calls>",
1167                self.remaining_tool_calls
1168            );
1169            let _ = write!(
1170                s,
1171                "\n<max_tool_calls>{}</max_tool_calls>",
1172                self.max_tool_calls
1173            );
1174        }
1175        s.push_str("\n</budget>");
1176        Some(s)
1177    }
1178}
1179
1180#[cfg(test)]
1181mod budget_hint_tests {
1182    use super::*;
1183
1184    #[test]
1185    fn format_xml_none_when_no_data() {
1186        let hint = BudgetHint {
1187            remaining_cost_cents: None,
1188            total_budget_cents: None,
1189            remaining_tool_calls: 0,
1190            max_tool_calls: 0,
1191        };
1192        assert!(hint.format_xml().is_none());
1193    }
1194
1195    #[test]
1196    fn format_xml_with_cost_only() {
1197        let hint = BudgetHint {
1198            remaining_cost_cents: Some(25.5),
1199            total_budget_cents: Some(100.0),
1200            remaining_tool_calls: 0,
1201            max_tool_calls: 0,
1202        };
1203        let xml = hint.format_xml().unwrap();
1204        assert!(xml.contains("<remaining_cost_cents>25.50</remaining_cost_cents>"));
1205        assert!(xml.contains("<total_budget_cents>100.00</total_budget_cents>"));
1206    }
1207
1208    #[test]
1209    fn format_xml_with_tool_calls_only() {
1210        let hint = BudgetHint {
1211            remaining_cost_cents: None,
1212            total_budget_cents: None,
1213            remaining_tool_calls: 3,
1214            max_tool_calls: 10,
1215        };
1216        let xml = hint.format_xml().unwrap();
1217        assert!(xml.contains("<remaining_tool_calls>3</remaining_tool_calls>"));
1218        assert!(xml.contains("<max_tool_calls>10</max_tool_calls>"));
1219    }
1220
1221    #[test]
1222    fn format_xml_with_all_fields() {
1223        let hint = BudgetHint {
1224            remaining_cost_cents: Some(50.0),
1225            total_budget_cents: Some(100.0),
1226            remaining_tool_calls: 8,
1227            max_tool_calls: 10,
1228        };
1229        let xml = hint.format_xml().unwrap();
1230        assert!(xml.starts_with("<budget>"));
1231        assert!(xml.ends_with("</budget>"));
1232    }
1233}
1234
1235#[cfg(test)]
1236mod run_graph_strategy_latency_tests {
1237    use std::time::Duration;
1238
1239    use tokio_util::sync::CancellationToken;
1240    use zeph_llm::any::AnyProvider;
1241    use zeph_memory::RetrievalFailureLogger;
1242
1243    use super::*;
1244
1245    /// Regression test for the `Hybrid` `latency_ms` telemetry drift fixed alongside
1246    /// this test: `run_hybrid_strategy` awaits the real recall work *before* handing an
1247    /// already-resolved `std::future::ready(...)` to `run_graph_strategy`. If
1248    /// `run_graph_strategy` captured its own `Instant::now()` internally (as it used
1249    /// to) instead of taking `start` as a parameter, it would measure only the
1250    /// near-zero time to poll an already-completed future, silently zeroing out
1251    /// `latency_ms` on every `Hybrid` failure/no-hit record.
1252    ///
1253    /// This reproduces that exact shape — real work happens, *then* an already-resolved
1254    /// future is passed to `run_graph_strategy` alongside the `start` captured before
1255    /// that work — using a real `SemanticMemory` + `RetrievalFailureLogger` so the
1256    /// persisted `latency_ms` reflects what a caller would actually observe.
1257    #[tokio::test]
1258    async fn latency_is_measured_from_caller_supplied_start_not_from_an_internal_clock() {
1259        let memory = zeph_memory::semantic::SemanticMemory::new(
1260            ":memory:",
1261            "http://127.0.0.1:1",
1262            None,
1263            AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
1264            "test-model",
1265        )
1266        .await
1267        .unwrap();
1268        let sup = zeph_common::TaskSupervisor::new(CancellationToken::new());
1269        let logger = RetrievalFailureLogger::new(
1270            memory.sqlite().clone(),
1271            256,
1272            1, // flush as soon as one record is queued, no need to wait on the interval
1273            Duration::from_millis(10),
1274            90,
1275            &sup,
1276        );
1277        let memory = memory.with_retrieval_failure_logger(logger);
1278
1279        let mut body = String::from(GRAPH_FACTS_PREFIX);
1280        let mut tokens_so_far = 0usize;
1281        let tc = TokenCounter::new();
1282        let edge_types: Vec<zeph_memory::graph::EdgeType> = Vec::new();
1283
1284        // Simulate the real recall work `recall_by_classified_strategy` performs inside
1285        // `run_hybrid_strategy` before it hands off an already-resolved future.
1286        let start = Instant::now();
1287        tokio::time::sleep(Duration::from_millis(30)).await;
1288        let facts_result: Result<
1289            Vec<zeph_memory::graph::types::GraphFact>,
1290            zeph_memory::MemoryError,
1291        > = Ok(Vec::new());
1292
1293        let params = GraphStrategyParams {
1294            query: "test query",
1295            recall_limit: 0,
1296            max_hops: 0,
1297            temporal_decay_rate: 0.0,
1298            edge_types: &edge_types,
1299            strategy_str: "hybrid",
1300        };
1301        let mut budget = GraphRecallBudget {
1302            body: &mut body,
1303            tokens_so_far: &mut tokens_so_far,
1304            budget_tokens: 1000,
1305            tc: &tc,
1306        };
1307
1308        run_graph_strategy(
1309            &memory,
1310            params,
1311            None,
1312            start,
1313            std::future::ready(facts_result),
1314            &mut budget,
1315        )
1316        .await
1317        .unwrap();
1318
1319        // The writer flushes asynchronously; poll briefly instead of a fixed sleep guess.
1320        let mut latency_ms: Option<i64> = None;
1321        for _ in 0..50 {
1322            let rows: Vec<(i64,)> = sqlx::query_as(
1323                "SELECT latency_ms FROM memory_retrieval_failures WHERE retrieval_strategy = 'hybrid'",
1324            )
1325            .fetch_all(memory.sqlite().pool())
1326            .await
1327            .unwrap();
1328            if let Some(row) = rows.first() {
1329                latency_ms = Some(row.0);
1330                break;
1331            }
1332            tokio::time::sleep(Duration::from_millis(10)).await;
1333        }
1334
1335        // Drop `memory` (and the `RetrievalFailureLogger` sender it owns) before shutting
1336        // down the supervisor, so the writer task's `rx.recv()` observes a closed channel
1337        // and exits its loop promptly instead of needing a forced abort at the timeout.
1338        drop(memory);
1339        sup.shutdown_all(Duration::from_secs(5)).await;
1340
1341        let latency_ms =
1342            latency_ms.expect("expected a hybrid NoHit failure record to be persisted");
1343        assert!(
1344            latency_ms >= 25,
1345            "latency_ms should reflect the ~30ms of work done before run_graph_strategy was \
1346             called via the `start` parameter, not ~0ms from a freshly-captured internal \
1347             Instant polling an already-resolved future; got {latency_ms}"
1348        );
1349    }
1350}