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