use std::sync::Arc;
use std::time::Instant;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::automaton::infra::llm_interface::{CallResult, LlmCall, ToolDef};
use crate::automaton::infra::shell_tools::ToolRegistry;
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum OutputMode {
#[default]
Full,
FinalOnly,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainStep {
pub step: String,
pub description: String,
pub at: String,
}
pub type StepCallback = Box<dyn Fn(&ChainStep) + Send + Sync>;
#[derive(Debug, Clone)]
pub struct ZebtuneConfig {
pub step_budget: u32,
pub system_prompt: Option<String>,
pub output_mode: OutputMode,
}
impl Default for ZebtuneConfig {
fn default() -> Self {
Self {
step_budget: 10,
system_prompt: None,
output_mode: OutputMode::Full,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZebtuneResult {
pub final_content: String,
pub chain: Vec<ChainStep>,
pub budget_exhausted: bool,
pub trace: Vec<String>,
}
pub struct ZebtuneAgent {
pub config: ZebtuneConfig,
pub llm: Option<Arc<dyn LlmCall>>,
}
impl ZebtuneAgent {
pub fn new(config: ZebtuneConfig, llm: Option<Arc<dyn LlmCall>>) -> Self {
let step_budget = if config.step_budget == 0 {
10
} else {
config.step_budget
};
Self {
config: ZebtuneConfig {
step_budget,
..config
},
llm,
}
}
pub async fn run(
&self,
goal: &str,
tool_registry: &ToolRegistry,
step_callback: Option<&StepCallback>,
) -> ZebtuneResult {
let Some(ref llm) = self.llm else {
return ZebtuneResult {
final_content:
"LLM not configured. Set ZEBTUNE_OPENAI_API_KEY or ZEBTUNE_ANTHROPIC_API_KEY."
.to_string(),
chain: vec![],
budget_exhausted: false,
trace: vec!["zebtune_no_llm".to_string()],
};
};
let tool_defs: Vec<ToolDef> = tool_registry
.tool_names()
.into_iter()
.map(|name| {
let desc = tool_registry
.get(&name)
.map(|t| t.description().to_string())
.unwrap_or_default();
ToolDef {
name,
description: desc,
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Optional path argument" }
}
}),
}
})
.collect();
let system_content = self.config.system_prompt.clone().unwrap_or_else(|| {
"You are Zebtune, an autonomous assistant. Use the available tools when helpful. \
Provide a clear, concise final answer."
.to_string()
});
let work_dir =
std::env::current_dir().unwrap_or_else(|_| std::path::Path::new(".").to_path_buf());
let start = Instant::now();
let mut trace = vec!["agent=zebtune".to_string()];
let mut chain: Vec<ChainStep> = Vec::new();
let mut budget = self.config.step_budget;
let mut messages: Vec<Value> = vec![
json!({ "role": "system", "content": system_content }),
json!({ "role": "user", "content": goal }),
];
self.emit(
&mut chain,
step_callback,
"start",
"Starting Zebtune",
&format_elapsed(start),
);
let final_content = loop {
if budget == 0 {
self.emit(
&mut chain,
step_callback,
"budget_exhausted",
"Step budget exhausted.",
&format_elapsed(start),
);
break "Step budget exhausted.".to_string();
}
let result = match llm.call_with_tools(messages.clone(), &tool_defs).await {
Ok(r) => r,
Err(e) => {
let msg = format!("LLM error: {}", e);
self.emit(
&mut chain,
step_callback,
"error",
&msg,
&format_elapsed(start),
);
break msg;
}
};
trace.push("turn".to_string());
budget = budget.saturating_sub(1);
match result {
CallResult::Text(text) => {
let trimmed = crate::automaton::infra::repl::strip_thinking(&text);
let short: String = trimmed.chars().take(120).collect();
let desc = if trimmed.chars().count() > 120 {
format!("{}...", short)
} else {
short
};
self.emit(
&mut chain,
step_callback,
"final",
&desc,
&format_elapsed(start),
);
break trimmed;
}
CallResult::ToolCalls(calls) => {
let thinking_desc = calls
.first()
.map(|c| format!("calling {}", c.name))
.unwrap_or_else(|| "tool use".to_string());
self.emit(
&mut chain,
step_callback,
"thinking",
&thinking_desc,
&format_elapsed(start),
);
let tool_calls_json: Vec<Value> = calls
.iter()
.map(|tc| {
json!({
"id": tc.id,
"type": "function",
"function": { "name": tc.name, "arguments": tc.arguments }
})
})
.collect();
messages.push(json!({
"role": "assistant",
"content": null,
"tool_calls": tool_calls_json,
}));
for tc in &calls {
let args: Value = serde_json::from_str(&tc.arguments).unwrap_or(json!({}));
self.emit(
&mut chain,
step_callback,
"tool_call",
&format!("RUN: {} {:?}", tc.name, args),
&format_elapsed(start),
);
trace.push(format!("tool:{}", tc.name));
let tool_out = match tool_registry.run_tool(&tc.name, &args, &work_dir) {
Some(Ok(out)) => out,
Some(Err(e)) => format!("Tool error: {}", e),
None => format!("Unknown tool: {}", tc.name),
};
let short_out = if tool_out.len() > 200 {
format!(
"{}...",
tool_out.trim().chars().take(197).collect::<String>()
)
} else {
tool_out.trim().to_string()
};
self.emit(
&mut chain,
step_callback,
"tool_result",
&format!("{}: {}", tc.name, short_out),
&format_elapsed(start),
);
messages.push(json!({
"role": "tool",
"tool_call_id": tc.id,
"content": tool_out,
}));
}
}
}
};
ZebtuneResult {
final_content: final_content.trim().to_string(),
chain: if self.config.output_mode == OutputMode::FinalOnly {
vec![]
} else {
chain
},
budget_exhausted: budget == 0,
trace,
}
}
fn emit(
&self,
chain: &mut Vec<ChainStep>,
callback: Option<&StepCallback>,
step: &str,
description: &str,
at: &str,
) {
let s = ChainStep {
step: step.to_string(),
description: description.to_string(),
at: at.to_string(),
};
if let Some(cb) = callback {
cb(&s);
}
chain.push(s);
}
}
fn format_elapsed(instant: Instant) -> String {
let s = instant.elapsed().as_secs();
let h = s / 3600;
let m = (s % 3600) / 60;
let s = s % 60;
format!("{:02}:{:02}:{:02}", h, m, s)
}