1use std::sync::atomic::{AtomicBool, Ordering};
2use std::sync::Arc;
3
4#[cfg(feature = "tools")]
5use async_trait::async_trait;
6use log;
7use tokio::sync::mpsc;
8
9use crate::backend::{GenerationResult, InferenceParams, LlmBackend};
10use crate::context::{plan_prune, prepare_context, ContextConfig, PruneStrategy};
11use crate::error::{CoreError, CoreResult};
12use crate::events::AgentEvent;
13use crate::messages::{Message, Role, ToolCall};
14use crate::template::{ChatMLTemplate, ChatTemplate};
15use crate::tools::{parse_tool_calls, ToolSchema};
16#[cfg(feature = "tools")]
17use crate::{
18 messages::ToolResult,
19 tools::{Tool, ToolOutput, ToolUpdateCallback},
20};
21
22const SUMMARY_MARKER: &str = "[Summary of earlier conversation]";
25
26const SUMMARY_MAX_TOKENS: u32 = 320;
28
29#[derive(Debug, Clone)]
31pub struct AgentConfig {
32 pub system_prompt: String,
34 pub inference_params: InferenceParams,
36 pub context_config: ContextConfig,
38 pub max_tool_iterations: usize,
42}
43
44impl Default for AgentConfig {
45 fn default() -> Self {
46 Self {
47 system_prompt: "You are a helpful assistant.".to_string(),
48 inference_params: InferenceParams::default(),
49 context_config: ContextConfig::default(),
50 max_tool_iterations: 8,
51 }
52 }
53}
54
55#[cfg(feature = "tools")]
57#[derive(Debug, Clone)]
58pub enum ApprovalDecision {
59 Approve,
61 Deny {
64 reason: String,
66 },
67}
68
69#[cfg(feature = "tools")]
110#[async_trait]
111pub trait ApprovalHook: Send + Sync {
112 async fn review(&self, call: &ToolCall) -> ApprovalDecision;
114}
115
116pub struct Agent {
144 config: AgentConfig,
145 messages: Vec<Message>,
146 #[cfg(feature = "tools")]
147 tools: Vec<Box<dyn Tool>>,
148 #[cfg(feature = "tools")]
149 approval_hook: Option<Arc<dyn ApprovalHook>>,
150 template: Arc<dyn ChatTemplate>,
151 abort: Arc<AtomicBool>,
152 msg_counter: u64,
153}
154
155impl Agent {
156 pub fn new(config: AgentConfig) -> Self {
158 Self::with_template(config, Arc::new(ChatMLTemplate))
159 }
160
161 pub fn with_template(config: AgentConfig, template: Arc<dyn ChatTemplate>) -> Self {
163 log::debug!(
164 "Agent created: system_prompt_len={}, max_ctx={}, max_resp={}, template={}",
165 config.system_prompt.len(),
166 config.context_config.max_context_tokens,
167 config.context_config.max_response_tokens,
168 template.name(),
169 );
170 Self {
171 config,
172 messages: Vec::new(),
173 #[cfg(feature = "tools")]
174 tools: Vec::new(),
175 #[cfg(feature = "tools")]
176 approval_hook: None,
177 template,
178 abort: Arc::new(AtomicBool::new(false)),
179 msg_counter: 0,
180 }
181 }
182
183 pub fn messages(&self) -> &[Message] {
185 &self.messages
186 }
187
188 pub fn config(&self) -> &AgentConfig {
190 &self.config
191 }
192
193 pub fn template(&self) -> &dyn ChatTemplate {
195 self.template.as_ref()
196 }
197
198 pub fn set_system_prompt(&mut self, prompt: impl Into<String>) {
200 let prompt = prompt.into();
201 log::debug!("Agent system prompt updated: len={}", prompt.len());
202 self.config.system_prompt = prompt;
203 }
204
205 pub fn set_inference_params(&mut self, params: InferenceParams) {
207 log::debug!(
208 "Agent inference params: max_tokens={}, temp={}, ctx={}, threads={}",
209 params.max_tokens,
210 params.temperature,
211 params.context_size,
212 params.n_threads,
213 );
214 self.config.inference_params = params;
215 }
216
217 pub fn set_context_config(&mut self, config: ContextConfig) {
219 log::debug!(
220 "Agent context config: max_ctx={}, max_resp={}",
221 config.max_context_tokens,
222 config.max_response_tokens,
223 );
224 self.config.context_config = config;
225 }
226
227 pub fn set_prune_strategy(&mut self, strategy: PruneStrategy) {
229 log::debug!("Agent prune strategy: {strategy:?}");
230 self.config.context_config.prune_strategy = strategy;
231 }
232
233 pub fn set_pinned(&mut self, message_id: &str, pinned: bool) -> bool {
236 match self.messages.iter_mut().find(|m| m.id == message_id) {
237 Some(msg) => {
238 msg.pinned = pinned;
239 log::debug!("Agent message {message_id} pinned={pinned}");
240 true
241 }
242 None => {
243 log::warn!("set_pinned: message {message_id} not found");
244 false
245 }
246 }
247 }
248
249 pub fn set_template(&mut self, template: Arc<dyn ChatTemplate>) {
251 log::debug!("Agent template updated: {}", template.name());
252 self.template = template;
253 }
254
255 #[cfg(feature = "tools")]
259 pub fn set_tools(&mut self, tools: Vec<Box<dyn Tool>>) {
260 log::debug!("Agent tools set: count={}", tools.len());
261 self.tools = tools;
262 }
263
264 #[cfg(feature = "tools")]
271 pub fn set_approval_hook(&mut self, hook: Arc<dyn ApprovalHook>) {
272 log::debug!("Agent approval hook installed");
273 self.approval_hook = Some(hook);
274 }
275
276 pub fn clear(&mut self) {
278 let count = self.messages.len();
279 self.messages.clear();
280 log::debug!("Agent conversation cleared: {count} messages removed");
281 }
282
283 pub fn replace_messages(&mut self, messages: Vec<Message>) {
288 log::debug!("Agent messages replaced: count={}", messages.len());
289 let max_loaded = messages
293 .iter()
294 .filter_map(|m| m.id.strip_prefix("msg-"))
295 .filter_map(|n| n.parse::<u64>().ok())
296 .max()
297 .unwrap_or(0);
298 self.msg_counter = self.msg_counter.max(max_loaded);
299 self.messages = messages;
300 }
301
302 pub fn abort(&self) {
304 log::debug!("Agent abort requested");
305 self.abort.store(true, Ordering::Relaxed);
306 }
307
308 pub fn abort_flag(&self) -> Arc<AtomicBool> {
310 self.abort.clone()
311 }
312
313 fn next_id(&mut self) -> String {
314 self.msg_counter += 1;
315 format!("msg-{}", self.msg_counter)
316 }
317
318 #[cfg(feature = "tools")]
319 fn tool_schemas(&self) -> Vec<ToolSchema> {
320 self.tools.iter().map(|t| t.schema()).collect()
321 }
322
323 #[cfg(not(feature = "tools"))]
325 fn tool_schemas(&self) -> Vec<ToolSchema> {
326 Vec::new()
327 }
328
329 pub async fn prompt(
345 &mut self,
346 text: impl Into<String>,
347 backend: Arc<dyn LlmBackend>,
348 tx: mpsc::UnboundedSender<AgentEvent>,
349 ) -> CoreResult<()> {
350 let text = text.into().trim().to_string();
351 if text.is_empty() {
352 return Err(CoreError::Agent("Empty message".into()));
353 }
354
355 self.abort.store(false, Ordering::Relaxed);
356
357 let user_msg = Message::user(self.next_id(), &text);
358 self.messages.push(user_msg.clone());
359
360 tx.send(AgentEvent::AgentStart).ok();
361 tx.send(AgentEvent::MessageStart {
362 message: user_msg.clone(),
363 })
364 .ok();
365 tx.send(AgentEvent::MessageEnd { message: user_msg }).ok();
366
367 self.compress_if_needed(&backend, &tx).await;
370
371 let mut new_messages: Vec<Message> = Vec::new();
374 #[cfg(feature = "tools")]
375 let has_tools = !self.tools.is_empty();
376 #[cfg(not(feature = "tools"))]
377 let has_tools = false;
378
379 for iteration in 0..self.config.max_tool_iterations {
380 tx.send(AgentEvent::TurnStart).ok();
381
382 let gen = match self.generate_once(backend.clone(), &tx).await {
383 Ok(gen) => gen,
384 Err(CoreError::Aborted) => {
385 log::info!("Agent::prompt: generation aborted by user");
388 let assistant_msg = Message::assistant(self.next_id(), "");
389 self.messages.push(assistant_msg.clone());
390 new_messages.push(assistant_msg.clone());
391 tx.send(AgentEvent::MessageEnd {
392 message: assistant_msg.clone(),
393 })
394 .ok();
395 tx.send(AgentEvent::TurnEnd {
396 message: assistant_msg,
397 tool_results: vec![],
398 })
399 .ok();
400 tx.send(AgentEvent::AgentEnd {
401 messages: new_messages,
402 })
403 .ok();
404 return Ok(());
405 }
406 Err(e) => {
407 log::error!("Agent::prompt: generation error: {e}");
409 if iteration == 0 {
413 self.messages.pop();
414 }
415 tx.send(AgentEvent::Error {
416 message: e.to_string(),
417 })
418 .ok();
419 tx.send(AgentEvent::AgentEnd { messages: vec![] }).ok();
420 return Ok(());
421 }
422 };
423
424 log::debug!(
425 "Agent::prompt: turn {} → {} tokens, {:.1} t/s, {:.1}ms ttft",
426 iteration,
427 gen.tokens_generated,
428 gen.tokens_per_sec,
429 gen.time_to_first_token_ms,
430 );
431
432 let mut assistant_msg = Message::assistant(self.next_id(), &gen.text);
433 let parsed = if has_tools {
434 parse_tool_calls(&gen.text)
435 } else {
436 Vec::new()
437 };
438 let tool_calls: Vec<ToolCall> = parsed
439 .iter()
440 .enumerate()
441 .map(|(i, p)| ToolCall {
442 id: format!("{}-call-{}", assistant_msg.id, i + 1),
443 name: p.name.clone(),
444 arguments: p.arguments.clone(),
445 })
446 .collect();
447 assistant_msg.tool_calls = tool_calls.clone();
448
449 self.messages.push(assistant_msg.clone());
450 new_messages.push(assistant_msg.clone());
451
452 tx.send(AgentEvent::GenerationStats {
453 tokens_generated: gen.tokens_generated,
454 prompt_tokens: gen.prompt_tokens,
455 tokens_per_sec: gen.tokens_per_sec,
456 time_to_first_token_ms: gen.time_to_first_token_ms,
457 generation_time_ms: gen.generation_time_ms,
458 })
459 .ok();
460 tx.send(AgentEvent::MessageEnd {
461 message: assistant_msg.clone(),
462 })
463 .ok();
464
465 if tool_calls.is_empty() {
467 tx.send(AgentEvent::TurnEnd {
468 message: assistant_msg,
469 tool_results: vec![],
470 })
471 .ok();
472 tx.send(AgentEvent::AgentEnd {
473 messages: new_messages,
474 })
475 .ok();
476 return Ok(());
477 }
478
479 #[cfg(feature = "tools")]
483 {
484 let aborted = self
485 .run_tool_calls(&tool_calls, assistant_msg, &mut new_messages, &tx)
486 .await;
487 if aborted {
488 tx.send(AgentEvent::AgentEnd {
489 messages: new_messages,
490 })
491 .ok();
492 return Ok(());
493 }
494 }
496 }
497
498 log::warn!(
500 "Agent::prompt: stopped after {} tool iterations",
501 self.config.max_tool_iterations
502 );
503 tx.send(AgentEvent::Warning {
504 message: format!(
505 "Stopped after {} tool iterations without a final answer",
506 self.config.max_tool_iterations
507 ),
508 })
509 .ok();
510 tx.send(AgentEvent::AgentEnd {
511 messages: new_messages,
512 })
513 .ok();
514 Ok(())
515 }
516
517 #[cfg(feature = "tools")]
521 async fn run_tool_calls(
522 &mut self,
523 tool_calls: &[ToolCall],
524 assistant_msg: Message,
525 new_messages: &mut Vec<Message>,
526 tx: &mpsc::UnboundedSender<AgentEvent>,
527 ) -> bool {
528 let mut tool_results: Vec<ToolResult> = Vec::new();
529 for call in tool_calls {
530 let decision = match &self.approval_hook {
534 Some(hook) => hook.review(call).await,
535 None => ApprovalDecision::Approve,
536 };
537
538 let (content, is_error) = match decision {
539 ApprovalDecision::Deny { reason } => {
540 log::info!("Agent::prompt: tool '{}' denied: {reason}", call.name);
541 tx.send(AgentEvent::ToolDenied {
542 tool_call_id: call.id.clone(),
543 tool_name: call.name.clone(),
544 reason: reason.clone(),
545 })
546 .ok();
547 (reason, true)
548 }
549 ApprovalDecision::Approve => {
550 tx.send(AgentEvent::ToolExecStart {
551 tool_call_id: call.id.clone(),
552 tool_name: call.name.clone(),
553 args: call.arguments.clone(),
554 })
555 .ok();
556
557 let (content, is_error) = match self.execute_tool(call, tx).await {
558 Ok(out) => (out.content, false),
559 Err(e) => {
560 log::warn!("Agent::prompt: tool '{}' failed: {e}", call.name);
561 (e.to_string(), true)
562 }
563 };
564
565 tx.send(AgentEvent::ToolExecEnd {
566 tool_call_id: call.id.clone(),
567 tool_name: call.name.clone(),
568 result: ToolResult {
569 tool_call_id: call.id.clone(),
570 tool_name: call.name.clone(),
571 content: content.clone(),
572 is_error,
573 },
574 })
575 .ok();
576
577 (content, is_error)
578 }
579 };
580
581 let result = ToolResult {
582 tool_call_id: call.id.clone(),
583 tool_name: call.name.clone(),
584 content: content.clone(),
585 is_error,
586 };
587 let result_msg =
588 Message::tool_result(self.next_id(), &call.id, &call.name, content, is_error);
589 self.messages.push(result_msg.clone());
590 new_messages.push(result_msg.clone());
591 tx.send(AgentEvent::MessageStart {
592 message: result_msg.clone(),
593 })
594 .ok();
595 tx.send(AgentEvent::MessageEnd {
596 message: result_msg,
597 })
598 .ok();
599
600 tool_results.push(result);
601 }
602
603 tx.send(AgentEvent::TurnEnd {
604 message: assistant_msg,
605 tool_results,
606 })
607 .ok();
608
609 self.abort.load(Ordering::Relaxed)
610 }
611
612 pub fn prompt_stream(
658 &mut self,
659 text: impl Into<String>,
660 backend: Arc<dyn LlmBackend>,
661 ) -> (
662 mpsc::UnboundedReceiver<AgentEvent>,
663 impl std::future::Future<Output = CoreResult<()>> + '_,
664 ) {
665 let (tx, rx) = mpsc::unbounded_channel();
666 let text = text.into();
667 let fut = async move { self.prompt(text, backend, tx).await };
668 (rx, fut)
669 }
670
671 async fn generate_once(
678 &self,
679 backend: Arc<dyn LlmBackend>,
680 tx: &mpsc::UnboundedSender<AgentEvent>,
681 ) -> CoreResult<GenerationResult> {
682 let messages = self.messages.clone();
683 let system_prompt = self.config.system_prompt.clone();
684 let ctx_config = self.config.context_config.clone();
685 let tool_schemas = self.tool_schemas();
686 let params = self.config.inference_params.clone();
687 let abort = self.abort.clone();
688 let max_ctx = self.config.context_config.max_context_tokens;
689 let template = self.template.clone();
690 let token_tx = tx.clone();
691 let budget_tx = tx.clone();
692
693 log::debug!(
694 "Agent::generate_once: spawning blocking (max_tokens={}, temp={}, ctx={}, threads={})",
695 params.max_tokens,
696 params.temperature,
697 params.context_size,
698 params.n_threads,
699 );
700
701 let handle = tokio::task::spawn_blocking(move || {
702 if !backend.is_ready() {
703 return Err(CoreError::Backend("No model loaded".into()));
704 }
705
706 let prepared = prepare_context(
707 template.as_ref(),
708 &system_prompt,
709 &messages,
710 &tool_schemas,
711 &ctx_config,
712 &|text| backend.tokenize_count(text).unwrap_or(0),
713 )?;
714
715 log::debug!(
716 "Context prepared: tokens={}, kept={}, pruned={}",
717 prepared.token_count,
718 prepared.messages_included,
719 prepared.messages_pruned,
720 );
721
722 budget_tx
723 .send(AgentEvent::ContextBudget {
724 used_tokens: prepared.token_count,
725 max_tokens: max_ctx,
726 messages_in_context: prepared.messages_included,
727 messages_pruned: prepared.messages_pruned,
728 })
729 .ok();
730
731 backend.generate(
732 &prepared.prompt,
733 ¶ms,
734 abort,
735 Box::new(move |token, count, tps| {
736 token_tx
737 .send(AgentEvent::MessageDelta {
738 delta: token.to_string(),
739 tokens_generated: count,
740 tokens_per_sec: tps,
741 })
742 .ok();
743 }),
744 )
745 });
746
747 handle.await.map_err(|e| {
748 log::error!("Agent::generate_once: blocking task panicked: {e}");
749 CoreError::Agent(format!("Inference task failed: {e}"))
750 })?
751 }
752
753 #[cfg(feature = "tools")]
758 async fn execute_tool(
759 &self,
760 call: &ToolCall,
761 tx: &mpsc::UnboundedSender<AgentEvent>,
762 ) -> CoreResult<ToolOutput> {
763 let Some(tool) = self.tools.iter().find(|t| t.name() == call.name) else {
764 return Err(CoreError::Tool(format!("unknown tool: {}", call.name)));
765 };
766
767 let update_tx = tx.clone();
768 let tool_call_id = call.id.clone();
769 let tool_name = call.name.clone();
770 let on_update: ToolUpdateCallback = Box::new(move |partial: &str| {
771 update_tx
772 .send(AgentEvent::ToolExecUpdate {
773 tool_call_id: tool_call_id.clone(),
774 tool_name: tool_name.clone(),
775 partial: partial.to_string(),
776 })
777 .ok();
778 });
779
780 tool.execute(&call.id, call.arguments.clone(), Some(on_update))
781 .await
782 }
783
784 async fn compress_if_needed(
792 &mut self,
793 backend: &Arc<dyn LlmBackend>,
794 tx: &mpsc::UnboundedSender<AgentEvent>,
795 ) {
796 if self.config.context_config.prune_strategy != PruneStrategy::Summarize {
797 return;
798 }
799
800 let messages = self.messages.clone();
801 let system_prompt = self.config.system_prompt.clone();
802 let tools = self.tool_schemas();
803 let ctx_config = self.config.context_config.clone();
804 let template = self.template.clone();
805 let abort = self.abort.clone();
806 let params = self.config.inference_params.clone();
807 let backend = backend.clone();
808
809 let outcome = tokio::task::spawn_blocking(move || -> Option<(Vec<usize>, String)> {
811 if !backend.is_ready() {
812 return None;
813 }
814 let counter = |t: &str| backend.tokenize_count(t).unwrap_or(0);
815 let plan = plan_prune(
816 template.as_ref(),
817 &system_prompt,
818 &messages,
819 &tools,
820 &ctx_config,
821 &counter,
822 )
823 .ok()?;
824 if plan.dropped.is_empty() {
825 return None; }
827
828 let mut remove: Vec<usize> = plan.dropped.iter().flat_map(|r| r.clone()).collect();
831 let prior_summary = messages
832 .iter()
833 .position(|m| m.pinned && m.content.starts_with(SUMMARY_MARKER));
834 let prior_body = prior_summary.map(|i| {
835 remove.push(i);
836 messages[i]
837 .content
838 .strip_prefix(SUMMARY_MARKER)
839 .unwrap_or(&messages[i].content)
840 .trim()
841 .to_string()
842 });
843 remove.sort_unstable();
844 remove.dedup();
845
846 let transcript = render_transcript(&messages, &remove);
847 let mut body = String::new();
848 if let Some(prev) = prior_body.filter(|s| !s.is_empty()) {
849 body.push_str("Earlier summary:\n");
850 body.push_str(&prev);
851 body.push_str("\n\n");
852 }
853 body.push_str("Conversation excerpt:\n");
854 body.push_str(&transcript);
855
856 let instruction = "You compress conversation history. Summarize the \
857 material below into a concise note that preserves key facts, \
858 decisions, names, and unresolved questions. Reply with only the \
859 summary.";
860 let req = Message::user("summary-req", format!("{instruction}\n\n{body}"));
861 let prompt = template.format(
862 "You summarize conversations faithfully and concisely.",
863 std::slice::from_ref(&req),
864 &[],
865 );
866
867 let sum_params = InferenceParams {
868 max_tokens: SUMMARY_MAX_TOKENS,
869 ..params
870 };
871 let gen = backend
872 .generate(&prompt, &sum_params, abort, Box::new(|_, _, _| {}))
873 .ok()?;
874 let summary = gen.text.trim().to_string();
875 if summary.is_empty() {
876 return None;
877 }
878 Some((remove, summary))
879 })
880 .await;
881
882 let Some((remove, summary)) = outcome.ok().flatten() else {
883 return;
884 };
885
886 self.fold_into_summary(&remove, summary);
887 tx.send(AgentEvent::Warning {
888 message: format!(
889 "Summarized {} earlier message(s) to fit the context window",
890 remove.len()
891 ),
892 })
893 .ok();
894 }
895
896 fn fold_into_summary(&mut self, remove: &[usize], summary: String) {
899 if remove.is_empty() {
900 return;
901 }
902 let insert_at = *remove.iter().min().unwrap();
903 let mut sorted = remove.to_vec();
904 sorted.sort_unstable();
905 for &i in sorted.iter().rev() {
906 if i < self.messages.len() {
907 self.messages.remove(i);
908 }
909 }
910 let summary_msg =
911 Message::user(self.next_id(), format!("{SUMMARY_MARKER}\n{summary}")).pinned();
912 let at = insert_at.min(self.messages.len());
913 self.messages.insert(at, summary_msg);
914 log::info!(
915 "Folded {} messages into a pinned summary at index {at}",
916 remove.len()
917 );
918 }
919}
920
921fn render_transcript(messages: &[Message], indices: &[usize]) -> String {
923 indices
924 .iter()
925 .filter_map(|&i| messages.get(i))
926 .map(|m| {
927 let role = match m.role {
928 Role::User => "User",
929 Role::Assistant | Role::ToolCall => "Assistant",
930 Role::ToolResult => "Tool",
931 Role::System => "System",
932 };
933 format!("{role}: {}", m.content)
934 })
935 .collect::<Vec<_>>()
936 .join("\n")
937}