use std::sync::Arc;
use uuid::Uuid;
use crate::core::sm::context::SmContextEngine;
use crate::core::sm::control::SessionControl;
use crate::core::sm::prompt::resolve_sm_prompt_default;
use crate::core::sm::providers::{ChatMessage, LlmRequest, SmModelTier};
use super::SessionManagerAgent;
use super::chat::{SmAgentError, degraded_notice, map_resolve_error};
use super::chat_action_protocol::{Decided, action_instructions, execute_verb, parse_action};
const MAX_ACTION_ITERATIONS: usize = 8;
const ACTION_MAX_TOKENS: u32 = 4_096;
const ACTION_RESULT_MAX_CHARS: usize = 2_000;
#[derive(Debug, Clone, PartialEq)]
pub struct SmChatActionOutcome {
pub reply: String,
pub conv_id: String,
pub cost_usd: f64,
pub actions_taken: Vec<String>,
}
impl SessionManagerAgent {
pub async fn chat_with_actions(
&self,
message: &str,
conv_id: Option<&str>,
control: &Arc<dyn SessionControl>,
) -> Result<SmChatActionOutcome, SmAgentError> {
let Some(runtime) = self.runtime_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 _turn = self.conv_locks.acquire(&conv_id).await;
let mut engine = SmContextEngine::open(&conv_id, &runtime.data_root, inference, rounds)?;
let system_prompt = format!(
"{}\n\n---\n\n{}",
resolve_sm_prompt_default()?,
action_instructions()
);
let recall = self.delegate_recall(runtime, message).await;
let base = engine.assemble_working_prompt(&system_prompt, recall.as_deref(), message);
let (system, base_turns) = split_system_message(base);
let mut transcript: Vec<String> = Vec::new();
let mut actions_taken: Vec<String> = Vec::new();
let mut total_cost = 0.0_f64;
let mut final_reply: Option<String> = None;
for iteration in 0..MAX_ACTION_ITERATIONS {
let is_final_iteration = iteration + 1 == MAX_ACTION_ITERATIONS;
let mut turns = base_turns.clone();
if !transcript.is_empty() {
turns.push(ChatMessage {
role: "user".to_string(),
content: format!(
"Verb results so far this turn:\n{}\n\nDecide your next action \
(call another verb, or give your final answer).",
transcript.join("\n")
),
});
}
if is_final_iteration {
turns.push(ChatMessage {
role: "user".to_string(),
content: "You have reached the action limit for this turn. Do NOT \
call another verb — reply now with the `final` answer shape."
.to_string(),
});
}
let resolved = runtime
.resolver
.resolve(inference, SmModelTier::Orchestration)
.await
.map_err(map_resolve_error)?;
let req = LlmRequest {
model: resolved.model.clone(),
system: system.clone(),
messages: turns,
temperature: inference.temperature,
max_tokens: ACTION_MAX_TOKENS,
};
let response = resolved
.provider
.complete(req)
.await
.map_err(SmAgentError::Inference)?;
total_cost += response.cost_usd;
match parse_action(&response.text) {
Decided::Final { message } => {
final_reply = Some(message);
break;
}
Decided::Verb { verb, args } => {
if is_final_iteration {
break;
}
let (label, result_line) = run_one_verb(control, &verb, &args).await;
actions_taken.push(label);
transcript.push(result_line);
}
}
}
let reply = final_reply.unwrap_or_else(|| forced_summary(&transcript));
self.record_round(runtime, &mut engine, message, &reply)
.await?;
Ok(SmChatActionOutcome {
reply,
conv_id,
cost_usd: total_cost,
actions_taken,
})
}
}
async fn run_one_verb(
control: &Arc<dyn SessionControl>,
verb: &str,
args: &serde_json::Value,
) -> (String, String) {
let label = match args.get("session_id").and_then(|v| v.as_str()) {
Some(id) if !id.trim().is_empty() => format!("{verb} {id}"),
_ => verb.to_string(),
};
match execute_verb(control, verb, args).await {
Ok(result) => (
label,
format!("{verb} -> {}", truncate_result(&result.to_string())),
),
Err(e) => (
label,
format!("{verb} -> error: {}", truncate_result(&e.to_string())),
),
}
}
fn truncate_result(rendered: &str) -> String {
let total = rendered.chars().count();
if total <= ACTION_RESULT_MAX_CHARS {
return rendered.to_string();
}
let kept: String = rendered.chars().take(ACTION_RESULT_MAX_CHARS).collect();
let dropped = total - ACTION_RESULT_MAX_CHARS;
format!("{kept}… [truncated {dropped} chars]")
}
fn forced_summary(transcript: &[String]) -> String {
if transcript.is_empty() {
"I reached the action limit for this turn without completing the request.".to_string()
} else {
format!(
"I reached the action limit for this turn. Here is what I did:\n{}",
transcript.join("\n")
)
}
}
fn split_system_message(mut messages: Vec<ChatMessage>) -> (String, Vec<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_action_loop_tests.rs"]
mod chat_action_loop_tests;