use std::fmt::Write as _;
use std::time::Instant;
use zeph_config::ContextFormat;
use zeph_llm::provider::{Message, MessagePart, Role};
use zeph_memory::{RetrievalFailureRecord, RetrievalFailureType, TokenCounter};
use crate::error::ContextError;
use crate::state::ContextAssemblyView;
pub const PERSONA_PREFIX: &str = "[Persona context]\n";
pub const TRAJECTORY_PREFIX: &str = "[Past experience]\n";
pub const TREE_MEMORY_PREFIX: &str = "[Memory summary]\n";
pub const REASONING_PREFIX: &str = "[Reasoning Strategy]\n";
pub const GRAPH_FACTS_PREFIX: &str = "[known facts]\n";
pub const RECALL_PREFIX: &str = "[semantic recall]\n";
pub const SUMMARY_PREFIX: &str = "[conversation summaries]\n";
pub const CROSS_SESSION_PREFIX: &str = "[cross-session context]\n";
pub const CORRECTIONS_PREFIX: &str = "[past corrections]\n";
pub const CODE_CONTEXT_PREFIX: &str = "[code context]\n";
pub const SESSION_DIGEST_PREFIX: &str = "[Session digest from previous interaction]\n";
pub const LSP_NOTE_PREFIX: &str = "[lsp ";
pub const DOCUMENT_RAG_PREFIX: &str = "## Relevant documents\n";
#[must_use]
pub fn truncate_chars(s: &str, max_chars: usize) -> String {
zeph_common::text::truncate_to_chars(s, max_chars)
}
#[must_use]
pub fn format_correction_note(correction_text: &str) -> String {
format!(
"- Past user correction: \"{}\"",
truncate_chars(correction_text, 200)
)
}
pub fn effective_recall_timeout_ms(configured: u64) -> u64 {
if configured == 0 {
tracing::warn!(
"recall_timeout_ms is 0, which would disable spreading activation recall; \
clamping to 100ms"
);
100
} else {
configured
}
}
pub struct SemanticRecallRawParams<'a> {
pub recall_limit: usize,
pub context_format: ContextFormat,
pub query: &'a str,
pub token_budget: usize,
pub tc: &'a TokenCounter,
pub low_confidence_threshold: Option<f32>,
}
#[tracing::instrument(
name = "agent_context.helpers.fetch_semantic_recall_raw",
skip_all,
err
)]
pub async fn fetch_semantic_recall_raw(
memory: Option<&zeph_memory::semantic::SemanticMemory>,
params: SemanticRecallRawParams<'_>,
router: Option<&dyn zeph_memory::AsyncMemoryRouter>,
) -> Result<(Option<Message>, Option<f32>), zeph_memory::MemoryError> {
let Some(memory) = memory else {
return Ok((None, None));
};
if params.recall_limit == 0 || params.token_budget == 0 {
return Ok((None, None));
}
let t0 = Instant::now();
let recalled = if let Some(r) = router {
memory
.recall_routed_async(params.query, params.recall_limit, None, r, None)
.await?
} else {
memory
.recall(params.query, params.recall_limit, None)
.await?
};
let latency_ms = t0.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
if recalled.is_empty() {
memory.log_retrieval_failure(RetrievalFailureRecord {
conversation_id: None,
turn_index: 0,
failure_type: RetrievalFailureType::NoHit,
retrieval_strategy: "semantic".to_owned(),
query_text: params.query.to_owned(),
query_len: params.query.len(),
top_score: None,
confidence_threshold: params.low_confidence_threshold,
result_count: 0,
latency_ms,
edge_types: None,
error_context: None,
});
return Ok((None, None));
}
let top_score = recalled.first().map(|r| r.score);
if let (Some(score), Some(threshold)) = (top_score, params.low_confidence_threshold)
&& score < threshold
{
memory.log_retrieval_failure(RetrievalFailureRecord {
conversation_id: None,
turn_index: 0,
failure_type: RetrievalFailureType::LowConfidence,
retrieval_strategy: "semantic".to_owned(),
query_text: params.query.to_owned(),
query_len: params.query.len(),
top_score: Some(score),
confidence_threshold: Some(threshold),
result_count: recalled.len(),
latency_ms,
edge_types: None,
error_context: None,
});
}
let initial_cap = (params.recall_limit * 512).min(params.token_budget * 3);
let mut recall_text = String::with_capacity(initial_cap);
recall_text.push_str(RECALL_PREFIX);
let mut tokens_used = params.tc.count_tokens(&recall_text);
for item in &recalled {
if item.message.content.starts_with("[skipped]")
|| item.message.content.starts_with("[stopped]")
{
continue;
}
let entry = match params.context_format {
ContextFormat::Structured => format_structured_recall_entry(item),
_ => format_plain_recall_entry(item),
};
let entry_tokens = params.tc.count_tokens(&entry);
if tokens_used + entry_tokens > params.token_budget {
break;
}
recall_text.push_str(&entry);
tokens_used += entry_tokens;
}
if tokens_used > params.tc.count_tokens(RECALL_PREFIX) {
Ok((
Some(Message::from_parts(
Role::System,
vec![MessagePart::Recall { text: recall_text }],
)),
top_score,
))
} else {
Ok((None, None))
}
}
#[tracing::instrument(name = "agent_context.helpers.fetch_summaries_raw", skip_all, err)]
pub async fn fetch_summaries_raw(
memory: Option<&zeph_memory::semantic::SemanticMemory>,
conversation_id: Option<zeph_memory::ConversationId>,
token_budget: usize,
tc: &TokenCounter,
) -> Result<Option<Message>, zeph_memory::MemoryError> {
let (Some(memory), Some(cid)) = (memory, conversation_id) else {
return Ok(None);
};
if token_budget == 0 {
return Ok(None);
}
let summaries = memory.load_summaries(cid).await?;
if summaries.is_empty() {
return Ok(None);
}
let mut summary_text = String::from(SUMMARY_PREFIX);
let mut tokens_used = tc.count_tokens(&summary_text);
for summary in summaries.iter().rev() {
let first = summary.first_message_id.map_or(0, |m| m.0);
let last = summary.last_message_id.map_or(0, |m| m.0);
let entry = format!("- Messages {first}-{last}: {}\n", summary.content);
let cost = tc.count_tokens(&entry);
if tokens_used + cost > token_budget {
break;
}
summary_text.push_str(&entry);
tokens_used += cost;
}
if tokens_used > tc.count_tokens(SUMMARY_PREFIX) {
Ok(Some(Message::from_parts(
Role::System,
vec![MessagePart::Summary { text: summary_text }],
)))
} else {
Ok(None)
}
}
#[tracing::instrument(name = "agent_context.helpers.fetch_cross_session_raw", skip_all, err)]
pub async fn fetch_cross_session_raw(
memory: Option<&zeph_memory::semantic::SemanticMemory>,
conversation_id: Option<zeph_memory::ConversationId>,
cross_session_score_threshold: f32,
query: &str,
token_budget: usize,
tc: &TokenCounter,
) -> Result<Option<Message>, zeph_memory::MemoryError> {
let (Some(memory), Some(cid)) = (memory, conversation_id) else {
return Ok(None);
};
if token_budget == 0 {
return Ok(None);
}
let results: Vec<_> = memory
.search_session_summaries(query, 5, Some(cid))
.await?
.into_iter()
.filter(|r| r.score >= cross_session_score_threshold)
.collect();
if results.is_empty() {
return Ok(None);
}
let mut text = String::from(CROSS_SESSION_PREFIX);
let mut tokens_used = tc.count_tokens(&text);
for item in &results {
let entry = format!("- {}\n", item.summary_text);
let cost = tc.count_tokens(&entry);
if tokens_used + cost > token_budget {
break;
}
text.push_str(&entry);
tokens_used += cost;
}
if tokens_used > tc.count_tokens(CROSS_SESSION_PREFIX) {
Ok(Some(Message::from_parts(
Role::System,
vec![MessagePart::CrossSession { text }],
)))
} else {
Ok(None)
}
}
#[tracing::instrument(name = "agent_context.helpers.fetch_semantic_recall", skip_all, err)]
pub async fn fetch_semantic_recall(
view: &ContextAssemblyView<'_>,
query: &str,
token_budget: usize,
tc: &TokenCounter,
router: Option<&dyn zeph_memory::AsyncMemoryRouter>,
) -> Result<(Option<Message>, Option<f32>), ContextError> {
fetch_semantic_recall_raw(
view.memory.as_deref(),
SemanticRecallRawParams {
recall_limit: view.recall_limit,
context_format: view.context_format,
query,
token_budget,
tc,
low_confidence_threshold: None,
},
router,
)
.await
.map_err(ContextError::Memory)
}
fn format_plain_recall_entry(item: &zeph_memory::RecalledMessage) -> String {
let role_label = match item.message.role {
Role::Assistant => "assistant",
Role::System => "system",
Role::User | _ => "user",
};
format!("- [{}] {}\n", role_label, item.message.content)
}
#[allow(clippy::map_unwrap_or)]
fn format_structured_recall_entry(item: &zeph_memory::RecalledMessage) -> String {
let source = match item.message.role {
Role::Assistant => "assistant",
Role::System => "system",
Role::User | _ => "user",
};
let date = item
.message
.metadata
.compacted_at
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "unknown".to_owned());
format!(
"[Memory | {} | {} | relevance: {:.2}]\n{}\n",
source, date, item.score, item.message.content
)
}
#[tracing::instrument(name = "agent_context.helpers.fetch_summaries", skip_all, err)]
pub async fn fetch_summaries(
view: &ContextAssemblyView<'_>,
token_budget: usize,
tc: &TokenCounter,
) -> Result<Option<Message>, ContextError> {
fetch_summaries_raw(
view.memory.as_deref(),
view.conversation_id,
token_budget,
tc,
)
.await
.map_err(ContextError::Memory)
}
#[tracing::instrument(name = "agent_context.helpers.fetch_cross_session", skip_all, err)]
pub async fn fetch_cross_session(
view: &ContextAssemblyView<'_>,
query: &str,
token_budget: usize,
tc: &TokenCounter,
) -> Result<Option<Message>, ContextError> {
fetch_cross_session_raw(
view.memory.as_deref(),
view.conversation_id,
view.cross_session_score_threshold,
query,
token_budget,
tc,
)
.await
.map_err(ContextError::Memory)
}
pub struct BudgetHint {
pub remaining_cost_cents: Option<f64>,
pub total_budget_cents: Option<f64>,
pub remaining_tool_calls: usize,
pub max_tool_calls: usize,
}
impl BudgetHint {
#[must_use]
pub fn format_xml(&self) -> Option<String> {
let has_cost = self.remaining_cost_cents.is_some();
if !has_cost && self.max_tool_calls == 0 {
return None;
}
let mut s = String::from("<budget>");
if let Some(remaining) = self.remaining_cost_cents {
let _ = write!(
s,
"\n<remaining_cost_cents>{remaining:.2}</remaining_cost_cents>"
);
}
if let Some(total) = self.total_budget_cents {
let _ = write!(s, "\n<total_budget_cents>{total:.2}</total_budget_cents>");
}
if self.max_tool_calls > 0 {
let _ = write!(
s,
"\n<remaining_tool_calls>{}</remaining_tool_calls>",
self.remaining_tool_calls
);
let _ = write!(
s,
"\n<max_tool_calls>{}</max_tool_calls>",
self.max_tool_calls
);
}
s.push_str("\n</budget>");
Some(s)
}
}
#[cfg(test)]
mod budget_hint_tests {
use super::*;
#[test]
fn format_xml_none_when_no_data() {
let hint = BudgetHint {
remaining_cost_cents: None,
total_budget_cents: None,
remaining_tool_calls: 0,
max_tool_calls: 0,
};
assert!(hint.format_xml().is_none());
}
#[test]
fn format_xml_with_cost_only() {
let hint = BudgetHint {
remaining_cost_cents: Some(25.5),
total_budget_cents: Some(100.0),
remaining_tool_calls: 0,
max_tool_calls: 0,
};
let xml = hint.format_xml().unwrap();
assert!(xml.contains("<remaining_cost_cents>25.50</remaining_cost_cents>"));
assert!(xml.contains("<total_budget_cents>100.00</total_budget_cents>"));
}
#[test]
fn format_xml_with_tool_calls_only() {
let hint = BudgetHint {
remaining_cost_cents: None,
total_budget_cents: None,
remaining_tool_calls: 3,
max_tool_calls: 10,
};
let xml = hint.format_xml().unwrap();
assert!(xml.contains("<remaining_tool_calls>3</remaining_tool_calls>"));
assert!(xml.contains("<max_tool_calls>10</max_tool_calls>"));
}
#[test]
fn format_xml_with_all_fields() {
let hint = BudgetHint {
remaining_cost_cents: Some(50.0),
total_budget_cents: Some(100.0),
remaining_tool_calls: 8,
max_tool_calls: 10,
};
let xml = hint.format_xml().unwrap();
assert!(xml.starts_with("<budget>"));
assert!(xml.ends_with("</budget>"));
}
}