1use std::fmt::Write as _;
14use std::time::Instant;
15
16use zeph_config::ContextFormat;
17use zeph_llm::provider::{Message, MessagePart, Role};
18use zeph_memory::{RetrievalFailureRecord, RetrievalFailureType, TokenCounter};
19
20use crate::error::ContextError;
21use crate::state::ContextAssemblyView;
22
23pub const PERSONA_PREFIX: &str = "[Persona context]\n";
25pub const TRAJECTORY_PREFIX: &str = "[Past experience]\n";
27pub const TREE_MEMORY_PREFIX: &str = "[Memory summary]\n";
29pub const REASONING_PREFIX: &str = "[Reasoning Strategy]\n";
31
32pub const GRAPH_FACTS_PREFIX: &str = "[known facts]\n";
34pub const RECALL_PREFIX: &str = "[semantic recall]\n";
36pub const SUMMARY_PREFIX: &str = "[conversation summaries]\n";
38pub const CROSS_SESSION_PREFIX: &str = "[cross-session context]\n";
40
41pub const CORRECTIONS_PREFIX: &str = "[past corrections]\n";
43pub const CODE_CONTEXT_PREFIX: &str = "[code context]\n";
45pub const SESSION_DIGEST_PREFIX: &str = "[Session digest from previous interaction]\n";
47pub const LSP_NOTE_PREFIX: &str = "[lsp ";
49pub const DOCUMENT_RAG_PREFIX: &str = "## Relevant documents\n";
51
52#[must_use]
56pub fn truncate_chars(s: &str, max_chars: usize) -> String {
57 zeph_common::text::truncate_to_chars(s, max_chars)
58}
59
60#[must_use]
65pub fn format_correction_note(correction_text: &str) -> String {
66 format!(
67 "- Past user correction: \"{}\"",
68 truncate_chars(correction_text, 200)
69 )
70}
71
72pub fn effective_recall_timeout_ms(configured: u64) -> u64 {
77 if configured == 0 {
78 tracing::warn!(
79 "recall_timeout_ms is 0, which would disable spreading activation recall; \
80 clamping to 100ms"
81 );
82 100
83 } else {
84 configured
85 }
86}
87
88pub struct SemanticRecallRawParams<'a> {
98 pub recall_limit: usize,
100 pub context_format: ContextFormat,
102 pub query: &'a str,
104 pub token_budget: usize,
106 pub tc: &'a TokenCounter,
108 pub low_confidence_threshold: Option<f32>,
111}
112
113#[tracing::instrument(
122 name = "agent_context.helpers.fetch_semantic_recall_raw",
123 skip_all,
124 err
125)]
126pub async fn fetch_semantic_recall_raw(
127 memory: Option<&zeph_memory::semantic::SemanticMemory>,
128 params: SemanticRecallRawParams<'_>,
129 router: Option<&dyn zeph_memory::AsyncMemoryRouter>,
130) -> Result<(Option<Message>, Option<f32>), zeph_memory::MemoryError> {
131 let Some(memory) = memory else {
132 return Ok((None, None));
133 };
134 if params.recall_limit == 0 || params.token_budget == 0 {
135 return Ok((None, None));
136 }
137
138 let t0 = Instant::now();
139 let recalled = if let Some(r) = router {
140 memory
141 .recall_routed_async(params.query, params.recall_limit, None, r, None)
142 .await?
143 } else {
144 memory
145 .recall(params.query, params.recall_limit, None)
146 .await?
147 };
148 let latency_ms = t0.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
149
150 if recalled.is_empty() {
151 memory.log_retrieval_failure(RetrievalFailureRecord {
152 conversation_id: None,
153 turn_index: 0,
154 failure_type: RetrievalFailureType::NoHit,
155 retrieval_strategy: "semantic".to_owned(),
156 query_text: params.query.to_owned(),
157 query_len: params.query.len(),
158 top_score: None,
159 confidence_threshold: params.low_confidence_threshold,
160 result_count: 0,
161 latency_ms,
162 edge_types: None,
163 error_context: None,
164 });
165 return Ok((None, None));
166 }
167
168 let top_score = recalled.first().map(|r| r.score);
169
170 if let (Some(score), Some(threshold)) = (top_score, params.low_confidence_threshold)
171 && score < threshold
172 {
173 memory.log_retrieval_failure(RetrievalFailureRecord {
174 conversation_id: None,
175 turn_index: 0,
176 failure_type: RetrievalFailureType::LowConfidence,
177 retrieval_strategy: "semantic".to_owned(),
178 query_text: params.query.to_owned(),
179 query_len: params.query.len(),
180 top_score: Some(score),
181 confidence_threshold: Some(threshold),
182 result_count: recalled.len(),
183 latency_ms,
184 edge_types: None,
185 error_context: None,
186 });
187 }
188 let initial_cap = (params.recall_limit * 512).min(params.token_budget * 3);
189 let mut recall_text = String::with_capacity(initial_cap);
190 recall_text.push_str(RECALL_PREFIX);
191 let mut tokens_used = params.tc.count_tokens(&recall_text);
192
193 for item in &recalled {
194 if item.message.content.starts_with("[skipped]")
195 || item.message.content.starts_with("[stopped]")
196 {
197 continue;
198 }
199 let entry = match params.context_format {
200 ContextFormat::Structured => format_structured_recall_entry(item),
201 _ => format_plain_recall_entry(item),
202 };
203 let entry_tokens = params.tc.count_tokens(&entry);
204 if tokens_used + entry_tokens > params.token_budget {
205 break;
206 }
207 recall_text.push_str(&entry);
208 tokens_used += entry_tokens;
209 }
210
211 if tokens_used > params.tc.count_tokens(RECALL_PREFIX) {
212 Ok((
213 Some(Message::from_parts(
214 Role::System,
215 vec![MessagePart::Recall { text: recall_text }],
216 )),
217 top_score,
218 ))
219 } else {
220 Ok((None, None))
221 }
222}
223
224#[tracing::instrument(name = "agent_context.helpers.fetch_summaries_raw", skip_all, err)]
232pub async fn fetch_summaries_raw(
233 memory: Option<&zeph_memory::semantic::SemanticMemory>,
234 conversation_id: Option<zeph_memory::ConversationId>,
235 token_budget: usize,
236 tc: &TokenCounter,
237) -> Result<Option<Message>, zeph_memory::MemoryError> {
238 let (Some(memory), Some(cid)) = (memory, conversation_id) else {
239 return Ok(None);
240 };
241 if token_budget == 0 {
242 return Ok(None);
243 }
244
245 let summaries = memory.load_summaries(cid).await?;
246 if summaries.is_empty() {
247 return Ok(None);
248 }
249
250 let mut summary_text = String::from(SUMMARY_PREFIX);
251 let mut tokens_used = tc.count_tokens(&summary_text);
252
253 for summary in summaries.iter().rev() {
254 let first = summary.first_message_id.map_or(0, |m| m.0);
255 let last = summary.last_message_id.map_or(0, |m| m.0);
256 let entry = format!("- Messages {first}-{last}: {}\n", summary.content);
257 let cost = tc.count_tokens(&entry);
258 if tokens_used + cost > token_budget {
259 break;
260 }
261 summary_text.push_str(&entry);
262 tokens_used += cost;
263 }
264
265 if tokens_used > tc.count_tokens(SUMMARY_PREFIX) {
266 Ok(Some(Message::from_parts(
267 Role::System,
268 vec![MessagePart::Summary { text: summary_text }],
269 )))
270 } else {
271 Ok(None)
272 }
273}
274
275#[tracing::instrument(name = "agent_context.helpers.fetch_cross_session_raw", skip_all, err)]
283pub async fn fetch_cross_session_raw(
284 memory: Option<&zeph_memory::semantic::SemanticMemory>,
285 conversation_id: Option<zeph_memory::ConversationId>,
286 cross_session_score_threshold: f32,
287 query: &str,
288 token_budget: usize,
289 tc: &TokenCounter,
290) -> Result<Option<Message>, zeph_memory::MemoryError> {
291 let (Some(memory), Some(cid)) = (memory, conversation_id) else {
292 return Ok(None);
293 };
294 if token_budget == 0 {
295 return Ok(None);
296 }
297
298 let results: Vec<_> = memory
299 .search_session_summaries(query, 5, Some(cid))
300 .await?
301 .into_iter()
302 .filter(|r| r.score >= cross_session_score_threshold)
303 .collect();
304 if results.is_empty() {
305 return Ok(None);
306 }
307
308 let mut text = String::from(CROSS_SESSION_PREFIX);
309 let mut tokens_used = tc.count_tokens(&text);
310
311 for item in &results {
312 let entry = format!("- {}\n", item.summary_text);
313 let cost = tc.count_tokens(&entry);
314 if tokens_used + cost > token_budget {
315 break;
316 }
317 text.push_str(&entry);
318 tokens_used += cost;
319 }
320
321 if tokens_used > tc.count_tokens(CROSS_SESSION_PREFIX) {
322 Ok(Some(Message::from_parts(
323 Role::System,
324 vec![MessagePart::CrossSession { text }],
325 )))
326 } else {
327 Ok(None)
328 }
329}
330
331#[tracing::instrument(name = "agent_context.helpers.fetch_semantic_recall", skip_all, err)]
345pub async fn fetch_semantic_recall(
346 view: &ContextAssemblyView<'_>,
347 query: &str,
348 token_budget: usize,
349 tc: &TokenCounter,
350 router: Option<&dyn zeph_memory::AsyncMemoryRouter>,
351) -> Result<(Option<Message>, Option<f32>), ContextError> {
352 fetch_semantic_recall_raw(
353 view.memory.as_deref(),
354 SemanticRecallRawParams {
355 recall_limit: view.recall_limit,
356 context_format: view.context_format,
357 query,
358 token_budget,
359 tc,
360 low_confidence_threshold: None,
361 },
362 router,
363 )
364 .await
365 .map_err(ContextError::Memory)
366}
367
368fn format_plain_recall_entry(item: &zeph_memory::RecalledMessage) -> String {
369 let role_label = match item.message.role {
370 Role::Assistant => "assistant",
371 Role::System => "system",
372 Role::User | _ => "user",
373 };
374 format!("- [{}] {}\n", role_label, item.message.content)
375}
376
377#[allow(clippy::map_unwrap_or)]
378fn format_structured_recall_entry(item: &zeph_memory::RecalledMessage) -> String {
379 let source = match item.message.role {
380 Role::Assistant => "assistant",
381 Role::System => "system",
382 Role::User | _ => "user",
383 };
384 let date = item
389 .message
390 .metadata
391 .compacted_at
392 .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
393 .map(|dt| dt.format("%Y-%m-%d").to_string())
394 .unwrap_or_else(|| "unknown".to_owned());
395 format!(
396 "[Memory | {} | {} | relevance: {:.2}]\n{}\n",
397 source, date, item.score, item.message.content
398 )
399}
400
401#[tracing::instrument(name = "agent_context.helpers.fetch_summaries", skip_all, err)]
412pub async fn fetch_summaries(
413 view: &ContextAssemblyView<'_>,
414 token_budget: usize,
415 tc: &TokenCounter,
416) -> Result<Option<Message>, ContextError> {
417 fetch_summaries_raw(
418 view.memory.as_deref(),
419 view.conversation_id,
420 token_budget,
421 tc,
422 )
423 .await
424 .map_err(ContextError::Memory)
425}
426
427#[tracing::instrument(name = "agent_context.helpers.fetch_cross_session", skip_all, err)]
441pub async fn fetch_cross_session(
442 view: &ContextAssemblyView<'_>,
443 query: &str,
444 token_budget: usize,
445 tc: &TokenCounter,
446) -> Result<Option<Message>, ContextError> {
447 fetch_cross_session_raw(
448 view.memory.as_deref(),
449 view.conversation_id,
450 view.cross_session_score_threshold,
451 query,
452 token_budget,
453 tc,
454 )
455 .await
456 .map_err(ContextError::Memory)
457}
458
459pub struct BudgetHint {
467 pub remaining_cost_cents: Option<f64>,
469 pub total_budget_cents: Option<f64>,
471 pub remaining_tool_calls: usize,
473 pub max_tool_calls: usize,
475}
476
477impl BudgetHint {
478 #[must_use]
499 pub fn format_xml(&self) -> Option<String> {
500 let has_cost = self.remaining_cost_cents.is_some();
501 if !has_cost && self.max_tool_calls == 0 {
503 return None;
504 }
505 let mut s = String::from("<budget>");
506 if let Some(remaining) = self.remaining_cost_cents {
507 let _ = write!(
508 s,
509 "\n<remaining_cost_cents>{remaining:.2}</remaining_cost_cents>"
510 );
511 }
512 if let Some(total) = self.total_budget_cents {
513 let _ = write!(s, "\n<total_budget_cents>{total:.2}</total_budget_cents>");
514 }
515 if self.max_tool_calls > 0 {
516 let _ = write!(
517 s,
518 "\n<remaining_tool_calls>{}</remaining_tool_calls>",
519 self.remaining_tool_calls
520 );
521 let _ = write!(
522 s,
523 "\n<max_tool_calls>{}</max_tool_calls>",
524 self.max_tool_calls
525 );
526 }
527 s.push_str("\n</budget>");
528 Some(s)
529 }
530}
531
532#[cfg(test)]
533mod budget_hint_tests {
534 use super::*;
535
536 #[test]
537 fn format_xml_none_when_no_data() {
538 let hint = BudgetHint {
539 remaining_cost_cents: None,
540 total_budget_cents: None,
541 remaining_tool_calls: 0,
542 max_tool_calls: 0,
543 };
544 assert!(hint.format_xml().is_none());
545 }
546
547 #[test]
548 fn format_xml_with_cost_only() {
549 let hint = BudgetHint {
550 remaining_cost_cents: Some(25.5),
551 total_budget_cents: Some(100.0),
552 remaining_tool_calls: 0,
553 max_tool_calls: 0,
554 };
555 let xml = hint.format_xml().unwrap();
556 assert!(xml.contains("<remaining_cost_cents>25.50</remaining_cost_cents>"));
557 assert!(xml.contains("<total_budget_cents>100.00</total_budget_cents>"));
558 }
559
560 #[test]
561 fn format_xml_with_tool_calls_only() {
562 let hint = BudgetHint {
563 remaining_cost_cents: None,
564 total_budget_cents: None,
565 remaining_tool_calls: 3,
566 max_tool_calls: 10,
567 };
568 let xml = hint.format_xml().unwrap();
569 assert!(xml.contains("<remaining_tool_calls>3</remaining_tool_calls>"));
570 assert!(xml.contains("<max_tool_calls>10</max_tool_calls>"));
571 }
572
573 #[test]
574 fn format_xml_with_all_fields() {
575 let hint = BudgetHint {
576 remaining_cost_cents: Some(50.0),
577 total_budget_cents: Some(100.0),
578 remaining_tool_calls: 8,
579 max_tool_calls: 10,
580 };
581 let xml = hint.format_xml().unwrap();
582 assert!(xml.starts_with("<budget>"));
583 assert!(xml.ends_with("</budget>"));
584 }
585}