use std::collections::BTreeMap;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use base64::Engine;
use futures_util::StreamExt;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::error::{LlmError, Result, SkadooshError};
use crate::llm::splitter::ClauseSplitter;
use crate::memory::MemoryStore;
use crate::rag::{OnnxEmbedder, RagStore};
use crate::tools::{execute_parallel, ShellExecutor, ToolExecutor};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ContentBlock {
#[serde(rename = "text")]
Text {
text: String,
},
#[serde(rename = "image_url")]
Image {
image_url: ImageUrl,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageUrl {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MessageContent {
Text(String),
Blocks(Vec<ContentBlock>),
}
impl MessageContent {
pub fn as_text(&self) -> Option<&str> {
match self {
MessageContent::Text(s) => Some(s),
MessageContent::Blocks(_) => None,
}
}
}
impl PartialEq<&str> for MessageContent {
fn eq(&self, other: &&str) -> bool {
matches!(self, MessageContent::Text(s) if s == other)
}
}
impl PartialEq<MessageContent> for &str {
fn eq(&self, other: &MessageContent) -> bool {
matches!(other, MessageContent::Text(s) if s == *self)
}
}
impl From<String> for MessageContent {
fn from(s: String) -> Self {
MessageContent::Text(s)
}
}
impl From<&str> for MessageContent {
fn from(s: &str) -> Self {
MessageContent::Text(s.to_string())
}
}
impl From<serde_json::Value> for MessageContent {
fn from(v: serde_json::Value) -> Self {
match v {
serde_json::Value::String(s) => MessageContent::Text(s),
serde_json::Value::Array(ref arr) => {
let val = serde_json::Value::Array(arr.clone());
if let Ok(blocks) = serde_json::from_value::<Vec<ContentBlock>>(val) {
MessageContent::Blocks(blocks)
} else {
MessageContent::Text(v.to_string())
}
}
other => MessageContent::Text(other.to_string()),
}
}
}
pub fn image_to_data_uri(path: &Path) -> std::result::Result<String, std::io::Error> {
let bytes = std::fs::read(path)?;
let mime = mime_from_ext(path);
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
Ok(format!("data:{mime};base64,{b64}"))
}
pub fn load_tools_file(path: &Path) -> std::result::Result<Vec<Tool>, std::io::Error> {
let bytes = std::fs::read(path)?;
let tools: Vec<Tool> = serde_json::from_slice(&bytes).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("invalid tools JSON: {e}"),
)
})?;
Ok(tools)
}
fn mime_from_ext(path: &Path) -> &'static str {
match path.extension().and_then(|e| e.to_str()) {
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("bmp") => "image/bmp",
Some("tiff") | Some("tif") => "image/tiff",
Some("pdf") => "application/pdf",
_ => "image/png", }
}
pub(crate) const CLAUSE_MIN_LEN: usize = 4;
pub(crate) const CLAUSE_MAX_LEN: usize = 160;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub role: String,
pub content: MessageContent,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionDef {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub parameters: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tool {
#[serde(rename = "type")]
pub tool_type: String,
pub function: FunctionDef,
}
impl Tool {
pub fn function(name: &str, description: &str, parameters: serde_json::Value) -> Self {
Self {
tool_type: "function".to_string(),
function: FunctionDef {
name: name.to_string(),
description: Some(description.to_string()),
parameters,
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallFunction {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub index: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(rename = "type")]
#[serde(skip_serializing_if = "Option::is_none")]
pub call_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub function: Option<ToolCallFunction>,
}
#[derive(Debug, Clone)]
pub enum SseDelta {
Text(String),
ToolCall(ToolCall),
Done,
}
pub struct LlmClient {
http: reqwest::Client,
base_url: String,
model: String,
api_key: Option<String>,
max_history_turns: usize,
system_prompt: String,
history: Vec<Message>,
image_paths: Vec<std::path::PathBuf>,
tools: Vec<Tool>,
max_tool_rounds: usize,
tool_executor: Option<Box<dyn ToolExecutor>>,
forward_tool: Option<crate::forward::ForwardTool>,
plugin_manager: Option<crate::plugins::PluginManager>,
sandbox_tool: Option<Arc<crate::sandbox::SandboxExecutor>>,
mesh: Option<crate::mesh::MeshNode>,
pub(crate) hold_music_active: Option<Arc<AtomicBool>>,
memory: Option<Arc<Mutex<MemoryStore>>>,
rag: Option<RagStore>,
}
impl LlmClient {
pub fn new(
base_url: &str,
model: &str,
system_prompt: &str,
max_history_turns: usize,
api_key: Option<String>,
) -> Self {
Self {
http: reqwest::Client::new(),
base_url: base_url.trim_end_matches('/').to_string(),
model: model.to_string(),
api_key,
max_history_turns,
system_prompt: system_prompt.to_string(),
history: vec![Message {
role: "system".to_string(),
content: MessageContent::Text(system_prompt.to_string()),
tool_call_id: None,
tool_calls: None,
}],
image_paths: Vec::new(),
tools: Vec::new(),
max_tool_rounds: 5,
tool_executor: None,
forward_tool: None,
plugin_manager: None,
sandbox_tool: None,
mesh: None,
hold_music_active: None,
memory: None,
rag: None,
}
}
pub fn model_name(&self) -> &str {
&self.model
}
pub fn with_images(mut self, paths: Vec<std::path::PathBuf>) -> Self {
self.image_paths = paths;
self
}
pub fn clear_images(&mut self) {
self.image_paths.clear();
}
pub fn with_tools(mut self, tools: Vec<Tool>) -> Self {
self.tools = tools;
self
}
pub fn with_max_tool_rounds(mut self, max: usize) -> Self {
self.max_tool_rounds = max;
self
}
pub fn with_tool_executor(mut self, executor: Box<dyn ToolExecutor>) -> Self {
self.tool_executor = Some(executor);
self
}
pub fn with_hold_music(mut self, flag: Arc<AtomicBool>) -> Self {
self.hold_music_active = Some(flag);
self
}
pub fn hold_music_flag(&self) -> Option<&Arc<AtomicBool>> {
self.hold_music_active.as_ref()
}
pub fn with_mesh(mut self, node: crate::mesh::MeshNode) -> Self {
self.mesh = Some(node);
self
}
pub fn with_rag(mut self, store: RagStore) -> Self {
self.rag = Some(store);
self
}
pub(crate) fn from_config(config: &crate::config::Config) -> Self {
let mut tools = if let Some(ref path) = config.tools_file {
load_tools_file(path).unwrap_or_default()
} else {
Vec::new()
};
let tool_executor: Option<Box<dyn ToolExecutor>> = if config.tools_file.is_some() {
Some(Box::new(ShellExecutor::new()))
} else {
None
};
let mesh = if config.mesh {
let agent_name = config
.agent_name
.clone()
.unwrap_or_else(|| format!("skadoosh-{}", std::process::id()));
tracing::info!(
agent = %agent_name,
port = config.mesh_port,
"mesh networking enabled"
);
tools.push(crate::forward::mesh_forward_tool_definition());
Some(crate::mesh::MeshNode::start(&agent_name, config.mesh_port))
} else {
None
};
let forward_tool = if let Some(ref url) = config.forward_url {
tracing::info!(forward_url = %url, "call forwarding enabled");
if mesh.is_none() {
tools.push(crate::forward::forward_tool_definition());
}
Some(crate::forward::ForwardTool::new(
crate::forward::ForwardConfig::new(url.clone()),
))
} else {
None
};
let sandbox_tool = if let Some(secs) = config.code_exec_timeout {
tracing::info!(
timeout_secs = secs,
sandbox = ?config.code_exec_sandbox,
"code_exec tool enabled"
);
tools.push(crate::sandbox::code_exec_tool_definition());
Some(Arc::new(crate::sandbox::SandboxExecutor::new(
secs,
config.code_exec_sandbox,
)))
} else {
None
};
let hold_music_active = if config.hold_music {
tracing::info!("hold music enabled: will play during tool execution");
Some(Arc::new(AtomicBool::new(false)))
} else {
None
};
let memory = if let Some(ref path) = config.memory_file {
tracing::info!(path = %path.display(), "conversation memory enabled");
Some(Arc::new(Mutex::new(MemoryStore::open(path))))
} else {
None
};
let rag = if let Some(ref dir) = config.rag_dir {
let model = &config.rag_model;
let vocab = OnnxEmbedder::companion_vocab(model);
if !model.is_file() || !vocab.is_file() {
tracing::warn!(
rag_model = %model.display(),
rag_vocab = %vocab.display(),
"RAG enabled (--rag-dir) but the embedding model or vocab is missing; \
retrieval disabled (run scripts/download_models.sh --with-rag)"
);
None
} else {
match OnnxEmbedder::load(model, &vocab, crate::rag::DEFAULT_MAX_SEQ_LEN) {
Ok(embedder) => {
match RagStore::build(dir, Box::new(embedder), config.rag_top_k) {
Ok(store) => {
tracing::info!(chunks = store.len(), "RAG index ready");
Some(store)
}
Err(e) => {
tracing::warn!(error = %e, "RAG index build failed; retrieval disabled");
None
}
}
}
Err(e) => {
tracing::warn!(
error = %e,
"RAG embedding model load failed; retrieval disabled"
);
None
}
}
}
} else {
None
};
let plugin_manager = match config.plugins_dir.as_deref() {
Some(dir) => match crate::plugins::PluginManager::load_dir(dir) {
Ok(pm) => {
let count = pm.len();
tools.extend(pm.tool_definitions());
tracing::info!(dir = %dir.display(), count, "WASM plugins loaded");
Some(pm)
}
Err(e) => {
tracing::warn!(
dir = %dir.display(),
error = %e,
"failed to load plugins dir; continuing without plugins"
);
None
}
},
None => crate::plugins::default_plugins_dir()
.filter(|dir| dir.exists())
.and_then(|dir| match crate::plugins::PluginManager::load_dir(&dir) {
Ok(pm) => {
let count = pm.len();
tools.extend(pm.tool_definitions());
tracing::info!(
dir = %dir.display(),
count,
"WASM plugins loaded from default dir"
);
Some(pm)
}
Err(e) => {
tracing::warn!(
dir = %dir.display(),
error = %e,
"failed to load default plugins dir"
);
None
}
}),
};
let system_prompt = build_system_prompt(
&config.system_prompt,
config.agent_name.as_deref(),
memory.as_deref(),
);
Self {
image_paths: config.images.clone(),
tools,
max_tool_rounds: config.max_tool_rounds,
tool_executor,
forward_tool,
plugin_manager,
sandbox_tool,
mesh,
hold_music_active,
memory,
rag,
..Self::new(
&config.llm_url,
&config.llm_model,
&system_prompt,
config.max_history_turns,
config.api_key.clone(),
)
}
}
pub async fn stream_reply(
&mut self,
user: &str,
clauses: mpsc::Sender<String>,
cancel: CancellationToken,
) -> Result<()> {
let images = std::mem::take(&mut self.image_paths);
let user_content = if images.is_empty() {
MessageContent::Text(user.to_string())
} else {
let mut blocks = vec![ContentBlock::Text {
text: user.to_string(),
}];
for path in &images {
match image_to_data_uri(path) {
Ok(data_uri) => blocks.push(ContentBlock::Image {
image_url: ImageUrl {
url: data_uri,
detail: Some("auto".to_string()),
},
}),
Err(e) => {
tracing::warn!(path=%path.display(), error=%e, "failed to load image; skipping");
}
}
}
if blocks.len() == 1 {
MessageContent::Text(user.to_string())
} else {
MessageContent::Blocks(blocks)
}
};
self.history.push(Message {
role: "user".to_string(),
content: user_content,
tool_call_id: None,
tool_calls: None,
});
self.refresh_system_message(user);
let result = self.stream_reply_inner(user, &clauses, &cancel).await;
if matches!(result, Err(SkadooshError::Llm(LlmError::Cancelled))) {
self.truncate_history();
}
result
}
async fn stream_reply_inner(
&mut self,
user: &str,
clauses: &mpsc::Sender<String>,
cancel: &CancellationToken,
) -> Result<()> {
let tool_count = self.tools.len();
let mut total_reply = String::new();
for tool_round in 0..=self.max_tool_rounds {
let send_tools = tool_round < self.max_tool_rounds && tool_count > 0;
let mut body = serde_json::json!({
"model": self.model,
"messages": self.history,
"stream": true,
});
if send_tools {
body["tools"] = serde_json::to_value(&self.tools).unwrap_or_default();
}
let url = format!("{}/chat/completions", self.base_url);
let mut request = self.http.post(&url).json(&body);
if let Some(key) = &self.api_key {
request = request.bearer_auth(key);
}
let resp = tokio::select! {
_ = cancel.cancelled() => return Err(LlmError::Cancelled.into()),
r = request.send() => r.map_err(LlmError::Http)?,
};
let resp = ensure_success(resp).await?;
let mut stream = resp.bytes_stream();
let mut splitter = ClauseSplitter::new(CLAUSE_MIN_LEN, CLAUSE_MAX_LEN);
let mut round_reply = String::new();
let mut tool_calls: BTreeMap<usize, ToolCall> = BTreeMap::new();
let mut lines = SseLineBuffer::default();
let mut done = false;
let mut eof = false;
while !done && !eof {
let chunk = tokio::select! {
_ = cancel.cancelled() => return Err(LlmError::Cancelled.into()),
c = stream.next() => c,
};
match chunk {
Some(Ok(bytes)) => {
if !lines.feed(&bytes) {
return Err(
LlmError::Sse("SSE line exceeded maximum size".into()).into()
);
}
}
Some(Err(e)) => return Err(LlmError::Http(e).into()),
None => {
lines.close();
eof = true;
}
}
while let Some(line) = lines.next_line() {
match parse_sse_delta(&line) {
None => {}
Some(Ok(SseDelta::Done)) => {
done = true;
break;
}
Some(Ok(SseDelta::Text(token))) => {
total_reply.push_str(&token);
round_reply.push_str(&token);
for clause in splitter.push(&token) {
if !send_clause(clauses, cancel, clause).await? {
tracing::debug!("clauses receiver dropped");
return Ok(());
}
}
}
Some(Ok(SseDelta::ToolCall(tc))) => {
let idx = tc.index.unwrap_or(0) as usize;
let entry = tool_calls.entry(idx).or_insert_with(|| ToolCall {
index: None,
id: None,
call_type: None,
function: None,
});
if tc.index.is_some() {
entry.index = tc.index;
}
if tc.id.is_some() {
entry.id = tc.id;
}
if tc.call_type.is_some() {
entry.call_type = tc.call_type;
}
if let Some(ref f) = tc.function {
let ef = entry.function.get_or_insert(ToolCallFunction {
name: None,
arguments: None,
});
if f.name.is_some() {
ef.name = f.name.clone();
}
if let Some(ref args) = f.arguments {
ef.arguments =
Some(ef.arguments.take().unwrap_or_default() + args);
}
}
}
Some(Err(e)) => {
tracing::warn!(error = %e, "skipping malformed SSE data line");
}
}
}
}
if let Some(rest) = splitter.flush() {
if !send_clause(clauses, cancel, rest).await? {
return Ok(());
}
}
if tool_calls.is_empty() {
self.maybe_summarize_turn(user, &round_reply);
self.history.push(Message {
role: "assistant".to_string(),
content: MessageContent::Text(round_reply),
tool_call_id: None,
tool_calls: None,
});
self.truncate_history();
return Ok(());
}
let calls: Vec<ToolCall> = tool_calls.into_values().collect();
tracing::info!(
round = tool_round,
count = calls.len(),
"tool calls received"
);
if let Some(ref flag) = self.hold_music_active {
flag.store(true, Ordering::Relaxed);
}
self.history.push(Message {
role: "assistant".to_string(),
content: MessageContent::Text(String::new()),
tool_call_id: None,
tool_calls: Some(calls.clone()),
});
for tc in &calls {
let name = tc
.function
.as_ref()
.and_then(|f| f.name.as_deref())
.unwrap_or("?");
let args = tc
.function
.as_ref()
.and_then(|f| f.arguments.as_deref())
.unwrap_or("{}");
let _ = clauses.send(format!("\x00TOOL:{name}:{args}")).await;
}
let has_executor = self.tool_executor.is_some()
|| self.forward_tool.is_some()
|| self.mesh.is_some()
|| self.sandbox_tool.is_some()
|| self.plugin_manager.is_some();
if has_executor {
let known: std::collections::HashSet<&str> = self
.tools
.iter()
.map(|t| t.function.name.as_str())
.collect();
let batch: Vec<(String, String, String)> = calls
.iter()
.map(|tc| {
let call_id = tc.id.clone().unwrap_or_else(|| "call_unknown".to_string());
let name = tc
.function
.as_ref()
.and_then(|f| f.name.as_deref())
.unwrap_or("")
.to_string();
let args = tc
.function
.as_ref()
.and_then(|f| f.arguments.as_deref())
.unwrap_or("{}")
.to_string();
(name, args, call_id)
})
.collect();
if batch.len() > 1 {
tracing::info!(count = batch.len(), "tool calls received");
}
let mut results: BTreeMap<String, std::result::Result<String, SkadooshError>> =
BTreeMap::new();
let mesh = self.mesh.as_ref();
if self.forward_tool.is_some() || mesh.is_some() {
let current_query = last_user_text(&self.history);
let mut forwarded = 0usize;
for (name, args, call_id) in &batch {
if name == crate::forward::FORWARD_TOOL_NAME
&& known.contains(name.as_str())
{
let (target, reason, summary) =
crate::forward::parse_forward_args_full(args);
let res = if let (Some(target), Some(mesh)) = (&target, mesh) {
mesh.forward_to_peer(
target,
&self.history,
¤t_query,
&reason,
&summary,
)
.await
} else if let Some(ref forward) = self.forward_tool {
forward
.forward(&self.history, ¤t_query, &reason, &summary)
.await
} else {
Err(SkadooshError::Other(anyhow::anyhow!(
"forward_call set target {target:?} but mesh is not enabled \
and no --forward-url endpoint is configured"
)))
};
forwarded += 1;
if let Err(ref e) = res {
tracing::warn!(tool = %name, error = %e, "forward call failed");
}
results.insert(call_id.clone(), res);
}
}
if forwarded > 0 {
tracing::info!(count = forwarded, "forwarded calls");
}
}
if let Some(ref sandbox) = self.sandbox_tool {
let mut sandboxed = 0usize;
let mut handles = Vec::new();
for (name, args, call_id) in &batch {
if name == crate::sandbox::CODE_EXEC_TOOL_NAME
&& known.contains(name.as_str())
{
sandboxed += 1;
let sb = Arc::clone(sandbox);
let (n, a, cid) = (name.clone(), args.clone(), call_id.clone());
handles.push(tokio::spawn(async move { (cid, sb.execute(&n, &a)) }));
}
}
for h in handles {
match h.await {
Ok((cid, r)) => {
results.insert(cid, r);
}
Err(e) => tracing::error!(error = %e, "code_exec task panicked"),
}
}
if sandboxed > 0 {
tracing::info!(count = sandboxed, "executed code_exec calls in sandbox");
}
}
if let Some(ref plugins) = self.plugin_manager {
let mut ran = 0usize;
for (name, args, call_id) in &batch {
if plugins.has(name) && known.contains(name.as_str()) {
ran += 1;
let res = plugins.execute(name, args);
if let Err(ref e) = res {
tracing::warn!(tool = %name, error = %e, "plugin execution failed");
}
results.insert(call_id.clone(), res);
}
}
if ran > 0 {
tracing::info!(count = ran, "plugin tool calls executed");
}
}
let shell_batch: Vec<(String, String, String)> = batch
.iter()
.filter(|(name, _, _)| {
if name == crate::forward::FORWARD_TOOL_NAME
|| name == crate::sandbox::CODE_EXEC_TOOL_NAME
|| self.plugin_manager.as_ref().is_some_and(|p| p.has(name))
{
false
} else if known.contains(name.as_str()) {
true
} else {
tracing::warn!(tool=%name, "rejected unknown tool call");
false
}
})
.cloned()
.collect();
if self.tool_executor.is_some() && !shell_batch.is_empty() {
if shell_batch.len() > 1 {
tracing::info!(
count = shell_batch.len(),
"executing tool calls in parallel"
);
}
let shell_results = execute_parallel(shell_batch).await;
for (call_id, result) in shell_results {
results.insert(call_id, result);
}
}
for tc in &calls {
let call_id = tc.id.clone().unwrap_or_else(|| "call_unknown".to_string());
let content = match results.get(&call_id) {
Some(Ok(out)) => out.clone(),
Some(Err(e)) => {
let name = tc
.function
.as_ref()
.and_then(|f| f.name.as_deref())
.unwrap_or("?");
tracing::warn!(tool = %name, error = %e, "tool execution failed");
let body = serde_json::to_string(&e.to_string())
.unwrap_or_else(|_| "\"<unprintable error>\"".to_string());
format!("{{\"error\":{body}}}")
}
None => {
tracing::warn!(call_id = %call_id, "tool call result missing");
"{\"error\":\"tool execution result missing\"}".to_string()
}
};
self.history.push(Message {
role: "tool".to_string(),
content: MessageContent::Text(content),
tool_call_id: Some(call_id),
tool_calls: None,
});
}
} else {
for tc in &calls {
let call_id = tc.id.clone().unwrap_or_else(|| "call_unknown".to_string());
self.history.push(Message {
role: "tool".to_string(),
content: MessageContent::Text(
"{\"error\":\"tool execution not configured; respond with text\"}"
.to_string(),
),
tool_call_id: Some(call_id),
tool_calls: None,
});
}
}
if let Some(ref flag) = self.hold_music_active {
flag.store(false, Ordering::Relaxed);
}
}
self.maybe_summarize_turn(user, &total_reply);
self.history.push(Message {
role: "assistant".to_string(),
content: MessageContent::Text(total_reply),
tool_call_id: None,
tool_calls: None,
});
self.truncate_history();
Ok(())
}
pub fn history(&self) -> &[Message] {
&self.history
}
pub fn clear_history(&mut self) {
self.history.clear();
self.history.push(Message {
role: "system".to_string(),
content: MessageContent::Text(self.system_prompt.clone()),
tool_call_id: None,
tool_calls: None,
});
}
fn truncate_history(&mut self) {
let keep = 2 * self.max_history_turns;
if self.history.len() > 1 + keep {
let drop = self.history.len() - 1 - keep;
self.history.drain(1..=drop);
}
}
fn refresh_system_message(&mut self, query: &str) {
let rag_context = if let Some(rag) = self.rag.as_mut() {
let top_k = rag.top_k;
let chunks = rag.search(query, top_k);
if chunks.is_empty() {
None
} else {
Some(format!(
"Relevant context:\n{}\n\nAnswer using this context if helpful.",
chunks.join("\n")
))
}
} else {
None
};
if self.rag.is_some() {
if let Some(msg) = self.history.first_mut() {
let content = match rag_context {
Some(ctx) => format!("{}\n\n{}", self.system_prompt, ctx),
None => self.system_prompt.clone(),
};
msg.content = MessageContent::Text(content);
}
}
}
fn maybe_summarize_turn(&self, user: &str, reply: &str) {
if let Some(memory) = &self.memory {
match memory.lock() {
Ok(mut store) => store.summarize_turn(user, reply),
Err(err) => tracing::warn!(error = %err, "memory lock poisoned; turn not saved"),
}
}
}
}
fn last_user_text(history: &[Message]) -> String {
history
.iter()
.rev()
.find(|m| m.role == "user")
.and_then(|m| m.content.as_text())
.unwrap_or("")
.to_string()
}
fn build_system_prompt(
base: &str,
agent_name: Option<&str>,
memory: Option<&Mutex<MemoryStore>>,
) -> String {
let mut prompt = match agent_name.map(str::trim).filter(|name| !name.is_empty()) {
Some(name) => format!("You are {name}, a helpful voice assistant. {base}"),
None => base.to_string(),
};
if let Some(memory) = memory {
if let Ok(store) = memory.lock() {
if let Some(prefs) = store.preferences_summary() {
prompt.push_str("\nThe user previously mentioned: ");
prompt.push_str(&prefs);
}
}
}
prompt
}
pub(crate) async fn ensure_success(resp: reqwest::Response) -> Result<reqwest::Response> {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
let text = resp.text().await.unwrap_or_default();
Err(LlmError::Api {
status: status.as_u16(),
body: text.chars().take(1024).collect(),
}
.into())
}
async fn send_clause(
clauses: &mpsc::Sender<String>,
cancel: &CancellationToken,
clause: String,
) -> std::result::Result<bool, LlmError> {
tokio::select! {
_ = cancel.cancelled() => Err(LlmError::Cancelled),
sent = clauses.send(clause) => Ok(sent.is_ok()),
}
}
const SSE_MAX_LINE_BYTES: usize = 1_048_576;
#[derive(Default)]
pub(crate) struct SseLineBuffer {
buf: Vec<u8>,
eof: bool,
}
impl SseLineBuffer {
pub(crate) fn feed(&mut self, chunk: &[u8]) -> bool {
if self.buf.len() + chunk.len() > SSE_MAX_LINE_BYTES {
return false;
}
self.buf.extend_from_slice(chunk);
true
}
pub(crate) fn close(&mut self) {
self.eof = true;
}
pub(crate) fn next_line(&mut self) -> Option<String> {
if let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
let line_bytes: Vec<u8> = self.buf.drain(..=nl).collect();
return Some(String::from_utf8_lossy(&line_bytes).into_owned());
}
if self.eof && !self.buf.is_empty() {
let rest = std::mem::take(&mut self.buf);
return Some(String::from_utf8_lossy(&rest).into_owned());
}
None
}
}
pub fn parse_sse_line(line: &str) -> Option<Result<Option<String>>> {
match parse_sse_delta(line)? {
Ok(SseDelta::Text(t)) => Some(Ok(Some(t))),
Ok(SseDelta::Done) => Some(Ok(None)),
Ok(SseDelta::ToolCall(_)) => None, Err(e) => Some(Err(e)),
}
}
pub fn parse_sse_delta(line: &str) -> Option<Result<SseDelta>> {
let line = line.trim();
if line.is_empty() || line.starts_with(':') {
return None;
}
let data = line.strip_prefix("data:")?;
let data = data.trim();
if data == "[DONE]" {
return Some(Ok(SseDelta::Done));
}
let parsed: serde_json::Value = match serde_json::from_str(data) {
Ok(v) => v,
Err(e) => {
return Some(Err(LlmError::Sse(format!("malformed SSE data: {e}")).into()));
}
};
let choices = parsed.get("choices")?.as_array()?;
let choice = choices.first()?;
let delta = choice.get("delta")?;
if let Some(tool_calls) = delta.get("tool_calls").and_then(|v| v.as_array()) {
if let Some(tc) = tool_calls.first() {
if let Ok(tool_call) = serde_json::from_value::<ToolCall>(tc.clone()) {
return Some(Ok(SseDelta::ToolCall(tool_call)));
}
}
return None;
}
let token = delta.get("content")?.as_str()?;
if token.is_empty() {
None
} else {
Some(Ok(SseDelta::Text(token.to_string())))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
#[test]
fn from_config_registers_forward_tool_when_url_set() {
let config = Config {
forward_url: Some("http://example/forward".to_string()),
..Default::default()
};
let client = LlmClient::from_config(&config);
let names: Vec<&str> = client
.tools
.iter()
.map(|t| t.function.name.as_str())
.collect();
assert!(
names.contains(&crate::forward::FORWARD_TOOL_NAME),
"forward_call tool must be registered: {names:?}"
);
assert!(
client.forward_tool.is_some(),
"ForwardTool executor must be wired when --forward-url is set"
);
let bare = LlmClient::from_config(&Config::default());
let bare_names: Vec<&str> = bare
.tools
.iter()
.map(|t| t.function.name.as_str())
.collect();
assert!(
!bare_names.contains(&crate::forward::FORWARD_TOOL_NAME),
"forward_call must not be registered without --forward-url: {bare_names:?}"
);
assert!(bare.forward_tool.is_none());
}
#[test]
fn from_config_registers_code_exec_when_timeout_set() {
let config = Config {
code_exec_timeout: Some(30),
..Default::default()
};
let client = LlmClient::from_config(&config);
let names: Vec<&str> = client
.tools
.iter()
.map(|t| t.function.name.as_str())
.collect();
assert!(
names.contains(&crate::sandbox::CODE_EXEC_TOOL_NAME),
"code_exec tool must be registered: {names:?}"
);
assert!(
client.sandbox_tool.is_some(),
"SandboxExecutor must be wired when --code-exec-timeout is set"
);
let bare = LlmClient::from_config(&Config::default());
let bare_names: Vec<&str> = bare
.tools
.iter()
.map(|t| t.function.name.as_str())
.collect();
assert!(
!bare_names.contains(&crate::sandbox::CODE_EXEC_TOOL_NAME),
"code_exec must not be registered without --code-exec-timeout: {bare_names:?}"
);
assert!(bare.sandbox_tool.is_none());
}
}