use chrono::Utc;
use uuid::Uuid;
use crate::core::sm::context::{SmContextEngine, SmContextError};
use crate::core::sm::prompt::resolve_sm_prompt_default;
use crate::core::sm::providers::{LlmRequest, SmLlmError, SmModelTier};
use super::SessionManagerAgent;
const SM_CHAT_MAX_TOKENS: u32 = 4_096;
#[derive(Debug, Clone, PartialEq)]
pub struct SmChatOutcome {
pub reply: String,
pub conv_id: String,
pub cost_usd: f64,
}
#[derive(Debug, thiserror::Error)]
pub enum SmAgentError {
#[error("{0}")]
Degraded(String),
#[error("session-manager inference failed: {0}")]
Inference(#[source] SmLlmError),
#[error("session-manager context update failed: {0}")]
Context(#[from] SmContextError),
}
impl SessionManagerAgent {
pub async fn chat(
&self,
message: &str,
conv_id: Option<&str>,
) -> Result<SmChatOutcome, SmAgentError> {
let Some(runtime) = self.runtime.as_ref() else {
return Err(SmAgentError::Degraded(degraded_notice()));
};
let conv_id = conv_id
.filter(|c| !c.trim().is_empty())
.map(str::to_string)
.unwrap_or_else(|| Uuid::new_v4().to_string());
let inference = &self.config.inference;
let rounds = &self.config.rounds;
let mut engine = SmContextEngine::open(&conv_id, &runtime.data_root, inference, rounds)?;
let system_prompt = resolve_sm_prompt_default();
let recall = self.recall_block(runtime, message).await;
let messages = engine.assemble_working_prompt(&system_prompt, recall.as_deref(), message);
let resolved = runtime
.resolver
.resolve(inference, SmModelTier::Orchestration)
.await
.map_err(map_resolve_error)?;
let (system, turns) = split_system_message(messages);
let req = LlmRequest {
model: resolved.model.clone(),
system,
messages: turns,
temperature: inference.temperature,
max_tokens: SM_CHAT_MAX_TOKENS,
};
let response = resolved
.provider
.complete(req)
.await
.map_err(SmAgentError::Inference)?;
self.record_round(runtime, &mut engine, message, &response.text)
.await?;
Ok(SmChatOutcome {
reply: response.text,
conv_id,
cost_usd: response.cost_usd,
})
}
#[cfg(feature = "sm-memory")]
async fn recall_block(&self, runtime: &super::AgentRuntime, message: &str) -> Option<String> {
let memory = runtime.memory.as_ref()?;
match memory.recall(message).await {
Ok(hits) if !hits.is_empty() => {
let joined = hits
.iter()
.map(|h| h.drawer.content.trim())
.filter(|c| !c.is_empty())
.collect::<Vec<_>>()
.join("\n");
(!joined.trim().is_empty()).then_some(joined)
}
Ok(_) => None,
Err(e) => {
tracing::debug!("sm chat: memory recall failed, continuing without it: {e}");
None
}
}
}
#[cfg(not(feature = "sm-memory"))]
async fn recall_block(&self, _runtime: &super::AgentRuntime, _message: &str) -> Option<String> {
None
}
async fn record_round(
&self,
runtime: &super::AgentRuntime,
engine: &mut SmContextEngine,
message: &str,
reply: &str,
) -> Result<(), SmAgentError> {
let inference = &self.config.inference;
let resolved = runtime
.resolver
.resolve(inference, SmModelTier::Compaction)
.await;
match resolved {
Ok(call) => {
engine
.record(
call.provider.as_ref(),
&call.model,
message.to_string(),
reply.to_string(),
Utc::now(),
Vec::new(),
)
.await?;
Ok(())
}
Err(compaction_err) => {
match runtime
.resolver
.resolve(inference, SmModelTier::Orchestration)
.await
{
Ok(call) => {
engine
.record(
call.provider.as_ref(),
&call.model,
message.to_string(),
reply.to_string(),
Utc::now(),
Vec::new(),
)
.await?;
Ok(())
}
Err(orchestration_err) => {
tracing::warn!(
compaction_error = %compaction_err,
orchestration_error = %orchestration_err,
"sm chat: no provider resolvable for compaction; \
recording round verbatim without compaction \
(window may sit over the soft cap until a later turn)"
);
engine.record_without_compaction(
message.to_string(),
reply.to_string(),
Utc::now(),
Vec::new(),
)?;
Ok(())
}
}
}
}
}
}
pub(crate) fn degraded_notice() -> String {
"session manager has no inference provider configured \
(set ANTHROPIC_API_KEY, AWS credentials, or OPENROUTER_API_KEY)"
.to_string()
}
fn map_resolve_error(err: SmLlmError) -> SmAgentError {
if err.is_degraded() {
SmAgentError::Degraded(degraded_notice())
} else {
SmAgentError::Inference(err)
}
}
fn split_system_message(
mut messages: Vec<crate::core::sm::providers::ChatMessage>,
) -> (String, Vec<crate::core::sm::providers::ChatMessage>) {
if messages.first().is_some_and(|m| m.role == "system") {
let system = messages.remove(0).content;
(system, messages)
} else {
(String::new(), messages)
}
}
#[cfg(test)]
#[path = "chat_tests.rs"]
mod chat_tests;