Skip to main content

orion_core/
agent.rs

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::{Backend, GenerationResult, InferenceParams, TokenCallback};
10use crate::context::{plan_prune, prepare_context, ContextConfig, PreparedContext, 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
22/// Prefix marking an agent-generated conversation summary (see the `Summarize`
23/// prune strategy). Used to recognise and consolidate prior summaries.
24const SUMMARY_MARKER: &str = "[Summary of earlier conversation]";
25
26/// Token budget for a summarization pass.
27const SUMMARY_MAX_TOKENS: u32 = 320;
28
29/// The frame a summarization pass runs under.
30const SUMMARY_SYSTEM: &str = "You summarize conversations faithfully and concisely.";
31
32/// Agent configuration.
33#[derive(Debug, Clone)]
34pub struct AgentConfig {
35    /// System prompt prepended to every formatted conversation.
36    pub system_prompt: String,
37    /// Sampling / inference parameters passed to the backend.
38    pub inference_params: InferenceParams,
39    /// Context-window management settings.
40    pub context_config: ContextConfig,
41    /// Maximum LLM↔tool round-trips in a single `prompt()` before the agent
42    /// stops and emits a warning. Guards against tool loops that never produce
43    /// a final answer.
44    pub max_tool_iterations: usize,
45}
46
47impl Default for AgentConfig {
48    fn default() -> Self {
49        Self {
50            system_prompt: "You are a helpful assistant.".to_string(),
51            inference_params: InferenceParams::default(),
52            context_config: ContextConfig::default(),
53            max_tool_iterations: 8,
54        }
55    }
56}
57
58/// Decision returned by an [`ApprovalHook`] for a single tool call.
59#[cfg(feature = "tools")]
60#[derive(Debug, Clone)]
61pub enum ApprovalDecision {
62    /// Run the tool call as normal.
63    Approve,
64    /// Skip execution. The `reason` is fed back to the model as an error tool
65    /// result so it can adapt, and reported via [`AgentEvent::ToolDenied`].
66    Deny {
67        /// Human-readable explanation of why the call was refused.
68        reason: String,
69    },
70}
71
72/// Authorizes tool calls before they run.
73///
74/// When a hook is installed via [`Agent::set_approval_hook`], the agent consults
75/// it once for every parsed tool call - on each tool-loop iteration, for each
76/// call in a multi-call turn - after parsing and immediately before execution.
77/// This is the point at which a host can sandbox, gate, or interactively confirm
78/// tool use (e.g. "the model wants to run `delete_file` - allow?").
79///
80/// The hook receives the parsed call (name + arguments) exactly as the tool
81/// would. It is `async` and may block for as long as it needs - including
82/// awaiting a human decision - so it is never wrapped in an internal timeout.
83///
84/// A [`Deny`](ApprovalDecision::Deny) does not abort the run: the refusal is
85/// appended as an error tool result and the loop continues so the model can
86/// react. To stop the whole run, use [`Agent::abort`].
87///
88/// ```
89/// use std::sync::Arc;
90/// use async_trait::async_trait;
91/// use orion_core::{ApprovalDecision, ApprovalHook, ToolCall};
92///
93/// /// Confirms destructive tools, approves everything else.
94/// struct ConfirmDestructive;
95///
96/// #[async_trait]
97/// impl ApprovalHook for ConfirmDestructive {
98///     async fn review(&self, call: &ToolCall) -> ApprovalDecision {
99///         if call.name.starts_with("delete_") {
100///             ApprovalDecision::Deny {
101///                 reason: format!("{} needs confirmation before it can run", call.name),
102///             }
103///         } else {
104///             ApprovalDecision::Approve
105///         }
106///     }
107/// }
108///
109/// // agent.set_approval_hook(Arc::new(ConfirmDestructive));
110/// # let _hook: Arc<dyn ApprovalHook> = Arc::new(ConfirmDestructive);
111/// ```
112#[cfg(feature = "tools")]
113#[async_trait]
114pub trait ApprovalHook: Send + Sync {
115    /// Review a parsed tool call and decide whether it may execute.
116    async fn review(&self, call: &ToolCall) -> ApprovalDecision;
117}
118
119/// The agent: manages conversation state, context pipeline, and the
120/// prompt → LLM → tool → LLM loop.
121///
122/// ```
123/// use orion_core::{Agent, AgentConfig, ContextConfig, InferenceParams};
124///
125/// let mut agent = Agent::new(AgentConfig {
126///     system_prompt: "You are a coding assistant.".into(),
127///     inference_params: InferenceParams {
128///         max_tokens: 4096,
129///         temperature: 0.4,
130///         context_size: 8192,
131///         n_threads: 6,
132///     },
133///     context_config: ContextConfig {
134///         max_context_tokens: 8192,
135///         max_response_tokens: 4096,
136///         ..Default::default()
137///     },
138///     ..Default::default()
139/// });
140///
141/// // Change settings on the fly.
142/// agent.set_system_prompt("You are a pirate.");
143/// agent.set_inference_params(InferenceParams { temperature: 1.2, ..Default::default() });
144/// agent.clear();
145/// ```
146pub struct Agent {
147    config: AgentConfig,
148    messages: Vec<Message>,
149    #[cfg(feature = "tools")]
150    tools: Vec<Box<dyn Tool>>,
151    #[cfg(feature = "tools")]
152    approval_hook: Option<Arc<dyn ApprovalHook>>,
153    template: Arc<dyn ChatTemplate>,
154    abort: Arc<AtomicBool>,
155    msg_counter: u64,
156}
157
158impl Agent {
159    /// Create an agent with the given config and the default ChatML template.
160    pub fn new(config: AgentConfig) -> Self {
161        Self::with_template(config, Arc::new(ChatMLTemplate))
162    }
163
164    /// Create an agent with an explicit chat template.
165    pub fn with_template(config: AgentConfig, template: Arc<dyn ChatTemplate>) -> Self {
166        log::debug!(
167            "Agent created: system_prompt_len={}, max_ctx={}, max_resp={}, template={}",
168            config.system_prompt.len(),
169            config.context_config.max_context_tokens,
170            config.context_config.max_response_tokens,
171            template.name(),
172        );
173        Self {
174            config,
175            messages: Vec::new(),
176            #[cfg(feature = "tools")]
177            tools: Vec::new(),
178            #[cfg(feature = "tools")]
179            approval_hook: None,
180            template,
181            abort: Arc::new(AtomicBool::new(false)),
182            msg_counter: 0,
183        }
184    }
185
186    /// The current conversation messages, in order.
187    pub fn messages(&self) -> &[Message] {
188        &self.messages
189    }
190
191    /// The agent's current configuration.
192    pub fn config(&self) -> &AgentConfig {
193        &self.config
194    }
195
196    /// The chat template currently in use.
197    pub fn template(&self) -> &dyn ChatTemplate {
198        self.template.as_ref()
199    }
200
201    /// Replace the system prompt used for subsequent prompts.
202    pub fn set_system_prompt(&mut self, prompt: impl Into<String>) {
203        let prompt = prompt.into();
204        log::debug!("Agent system prompt updated: len={}", prompt.len());
205        self.config.system_prompt = prompt;
206    }
207
208    /// Replace the inference parameters used for subsequent generations.
209    pub fn set_inference_params(&mut self, params: InferenceParams) {
210        log::debug!(
211            "Agent inference params: max_tokens={}, temp={}, ctx={}, threads={}",
212            params.max_tokens,
213            params.temperature,
214            params.context_size,
215            params.n_threads,
216        );
217        self.config.inference_params = params;
218    }
219
220    /// Replace the context-management configuration.
221    pub fn set_context_config(&mut self, config: ContextConfig) {
222        log::debug!(
223            "Agent context config: max_ctx={}, max_resp={}",
224            config.max_context_tokens,
225            config.max_response_tokens,
226        );
227        self.config.context_config = config;
228    }
229
230    /// Select the strategy used when the conversation overflows the budget.
231    pub fn set_prune_strategy(&mut self, strategy: PruneStrategy) {
232        log::debug!("Agent prune strategy: {strategy:?}");
233        self.config.context_config.prune_strategy = strategy;
234    }
235
236    /// Pin or unpin a message by id. Pinned messages always survive context
237    /// pruning. Returns whether a message with that id was found.
238    pub fn set_pinned(&mut self, message_id: &str, pinned: bool) -> bool {
239        match self.messages.iter_mut().find(|m| m.id == message_id) {
240            Some(msg) => {
241                msg.pinned = pinned;
242                log::debug!("Agent message {message_id} pinned={pinned}");
243                true
244            }
245            None => {
246                log::warn!("set_pinned: message {message_id} not found");
247                false
248            }
249        }
250    }
251
252    /// Swap the chat template at runtime (e.g. after detecting the model family).
253    pub fn set_template(&mut self, template: Arc<dyn ChatTemplate>) {
254        log::debug!("Agent template updated: {}", template.name());
255        self.template = template;
256    }
257
258    /// Register the tools the agent may invoke during a prompt.
259    ///
260    /// Available only with the `tools` feature (enabled by default).
261    #[cfg(feature = "tools")]
262    pub fn set_tools(&mut self, tools: Vec<Box<dyn Tool>>) {
263        log::debug!("Agent tools set: count={}", tools.len());
264        self.tools = tools;
265    }
266
267    /// Install a hook consulted before every tool call executes.
268    ///
269    /// See [`ApprovalHook`] for the exact semantics. With no hook installed the
270    /// tool loop behaves exactly as it did before - every call runs immediately.
271    ///
272    /// Available only with the `tools` feature (enabled by default).
273    #[cfg(feature = "tools")]
274    pub fn set_approval_hook(&mut self, hook: Arc<dyn ApprovalHook>) {
275        log::debug!("Agent approval hook installed");
276        self.approval_hook = Some(hook);
277    }
278
279    /// Clear the conversation history.
280    pub fn clear(&mut self) {
281        let count = self.messages.len();
282        self.messages.clear();
283        log::debug!("Agent conversation cleared: {count} messages removed");
284    }
285
286    /// Replace the entire conversation (e.g. when restoring a saved session).
287    ///
288    /// Advances the internal id counter past any restored `msg-N` ids so newly
289    /// generated ids don't collide with restored ones.
290    pub fn replace_messages(&mut self, messages: Vec<Message>) {
291        log::debug!("Agent messages replaced: count={}", messages.len());
292        // Advance the id counter past any `msg-N` ids in the loaded set so
293        // newly generated ids don't collide with restored ones (pins and tool
294        // results are addressed by id).
295        let max_loaded = messages
296            .iter()
297            .filter_map(|m| m.id.strip_prefix("msg-"))
298            .filter_map(|n| n.parse::<u64>().ok())
299            .max()
300            .unwrap_or(0);
301        self.msg_counter = self.msg_counter.max(max_loaded);
302        self.messages = messages;
303    }
304
305    /// Request cancellation of an in-flight generation.
306    pub fn abort(&self) {
307        log::debug!("Agent abort requested");
308        self.abort.store(true, Ordering::Relaxed);
309    }
310
311    /// Clone of the shared abort flag, for wiring cancellation into a backend.
312    pub fn abort_flag(&self) -> Arc<AtomicBool> {
313        self.abort.clone()
314    }
315
316    fn next_id(&mut self) -> String {
317        self.msg_counter += 1;
318        format!("msg-{}", self.msg_counter)
319    }
320
321    #[cfg(feature = "tools")]
322    fn tool_schemas(&self) -> Vec<ToolSchema> {
323        self.tools.iter().map(|t| t.schema()).collect()
324    }
325
326    /// Without the `tools` feature there are never any tools to advertise.
327    #[cfg(not(feature = "tools"))]
328    fn tool_schemas(&self) -> Vec<ToolSchema> {
329        Vec::new()
330    }
331
332    /// Run a prompt through the agent loop.
333    ///
334    /// Accepts an event sender so the caller can consume events
335    /// concurrently while generation is in progress. This enables
336    /// real-time token streaming to the UI.
337    ///
338    /// Flow:
339    /// 1. Adds the user message.
340    /// 2. Generates an assistant response (prune + template + LLM call),
341    ///    streaming tokens via `tx`.
342    /// 3. If tools are registered and the response contains tool calls, runs
343    ///    each tool, appends a tool-result message, and loops back to the LLM.
344    /// 4. Repeats until the model returns a tool-free answer or the
345    ///    `max_tool_iterations` guard trips.
346    /// 5. Emits lifecycle events for every step and updates conversation state.
347    pub async fn prompt(
348        &mut self,
349        text: impl Into<String>,
350        backend: impl Into<Backend>,
351        tx: mpsc::UnboundedSender<AgentEvent>,
352    ) -> CoreResult<()> {
353        let backend = backend.into();
354        let text = text.into().trim().to_string();
355        if text.is_empty() {
356            return Err(CoreError::Agent("Empty message".into()));
357        }
358
359        self.abort.store(false, Ordering::Relaxed);
360
361        let user_msg = Message::user(self.next_id(), &text);
362        self.messages.push(user_msg.clone());
363
364        tx.send(AgentEvent::AgentStart).ok();
365        tx.send(AgentEvent::MessageStart {
366            message: user_msg.clone(),
367        })
368        .ok();
369        tx.send(AgentEvent::MessageEnd { message: user_msg }).ok();
370
371        // Under the Summarize strategy, fold overflowing older turns into a
372        // pinned summary before generating (best-effort; no-op otherwise).
373        self.compress_if_needed(&backend, &tx).await;
374
375        // Messages produced this prompt (assistant turns + tool results),
376        // reported in the final `AgentEnd`.
377        let mut new_messages: Vec<Message> = Vec::new();
378        #[cfg(feature = "tools")]
379        let has_tools = !self.tools.is_empty();
380        #[cfg(not(feature = "tools"))]
381        let has_tools = false;
382
383        for iteration in 0..self.config.max_tool_iterations {
384            tx.send(AgentEvent::TurnStart).ok();
385
386            let gen = match self.generate_once(&backend, &tx).await {
387                Ok(gen) => gen,
388                Err(CoreError::Aborted) => {
389                    // Aborted mid-generation: record an (empty) assistant turn
390                    // so the conversation stays well-formed, then stop.
391                    log::info!("Agent::prompt: generation aborted by user");
392                    let assistant_msg = Message::assistant(self.next_id(), "");
393                    self.messages.push(assistant_msg.clone());
394                    new_messages.push(assistant_msg.clone());
395                    tx.send(AgentEvent::MessageEnd {
396                        message: assistant_msg.clone(),
397                    })
398                    .ok();
399                    tx.send(AgentEvent::TurnEnd {
400                        message: assistant_msg,
401                        tool_results: vec![],
402                    })
403                    .ok();
404                    tx.send(AgentEvent::AgentEnd {
405                        messages: new_messages,
406                    })
407                    .ok();
408                    return Ok(());
409                }
410                Err(e) => {
411                    // Context overflow, backend not ready, generation failure.
412                    log::error!("Agent::prompt: generation error: {e}");
413                    // On the first turn, drop the just-added user message so a
414                    // retry starts clean. On later turns the conversation
415                    // already carries tool context; leave it for re-pruning.
416                    if iteration == 0 {
417                        self.messages.pop();
418                    }
419                    tx.send(AgentEvent::Error {
420                        message: e.to_string(),
421                    })
422                    .ok();
423                    tx.send(AgentEvent::AgentEnd { messages: vec![] }).ok();
424                    return Ok(());
425                }
426            };
427
428            log::debug!(
429                "Agent::prompt: turn {} → {} tokens, {:.1} t/s, {:.1}ms ttft",
430                iteration,
431                gen.tokens_generated,
432                gen.tokens_per_sec,
433                gen.time_to_first_token_ms,
434            );
435
436            let mut assistant_msg = Message::assistant(self.next_id(), &gen.text);
437            let parsed = if has_tools {
438                parse_tool_calls(&gen.text)
439            } else {
440                Vec::new()
441            };
442            let tool_calls: Vec<ToolCall> = parsed
443                .iter()
444                .enumerate()
445                .map(|(i, p)| ToolCall {
446                    id: format!("{}-call-{}", assistant_msg.id, i + 1),
447                    name: p.name.clone(),
448                    arguments: p.arguments.clone(),
449                })
450                .collect();
451            assistant_msg.tool_calls = tool_calls.clone();
452
453            self.messages.push(assistant_msg.clone());
454            new_messages.push(assistant_msg.clone());
455
456            tx.send(AgentEvent::GenerationStats {
457                tokens_generated: gen.tokens_generated,
458                prompt_tokens: gen.prompt_tokens,
459                tokens_per_sec: gen.tokens_per_sec,
460                time_to_first_token_ms: gen.time_to_first_token_ms,
461                generation_time_ms: gen.generation_time_ms,
462            })
463            .ok();
464            tx.send(AgentEvent::MessageEnd {
465                message: assistant_msg.clone(),
466            })
467            .ok();
468
469            // No tool calls → this is the final answer.
470            if tool_calls.is_empty() {
471                tx.send(AgentEvent::TurnEnd {
472                    message: assistant_msg,
473                    tool_results: vec![],
474                })
475                .ok();
476                tx.send(AgentEvent::AgentEnd {
477                    messages: new_messages,
478                })
479                .ok();
480                return Ok(());
481            }
482
483            // Tool calls are present - reachable only with the `tools` feature,
484            // since without it `has_tools` is always false (so `tool_calls` is
485            // always empty and we returned above).
486            #[cfg(feature = "tools")]
487            {
488                let aborted = self
489                    .run_tool_calls(&tool_calls, assistant_msg, &mut new_messages, &tx)
490                    .await;
491                if aborted {
492                    tx.send(AgentEvent::AgentEnd {
493                        messages: new_messages,
494                    })
495                    .ok();
496                    return Ok(());
497                }
498                // Otherwise loop back: the LLM now sees the tool results.
499            }
500        }
501
502        // Exhausted the iteration budget without a tool-free answer.
503        log::warn!(
504            "Agent::prompt: stopped after {} tool iterations",
505            self.config.max_tool_iterations
506        );
507        tx.send(AgentEvent::Warning {
508            message: format!(
509                "Stopped after {} tool iterations without a final answer",
510                self.config.max_tool_iterations
511            ),
512        })
513        .ok();
514        tx.send(AgentEvent::AgentEnd {
515            messages: new_messages,
516        })
517        .ok();
518        Ok(())
519    }
520
521    /// Execute every tool call from one assistant turn, appending a tool-result
522    /// message per call and emitting the matching events, then emit `TurnEnd`.
523    /// Returns `true` if an abort was requested while tools were running.
524    #[cfg(feature = "tools")]
525    async fn run_tool_calls(
526        &mut self,
527        tool_calls: &[ToolCall],
528        assistant_msg: Message,
529        new_messages: &mut Vec<Message>,
530        tx: &mpsc::UnboundedSender<AgentEvent>,
531    ) -> bool {
532        let mut tool_results: Vec<ToolResult> = Vec::new();
533        for call in tool_calls {
534            // Consult the approval hook (if installed) before touching the tool.
535            // A denial is turned into an error result the model can see and
536            // react to; it never aborts the loop.
537            let decision = match &self.approval_hook {
538                Some(hook) => hook.review(call).await,
539                None => ApprovalDecision::Approve,
540            };
541
542            let (content, is_error) = match decision {
543                ApprovalDecision::Deny { reason } => {
544                    log::info!("Agent::prompt: tool '{}' denied: {reason}", call.name);
545                    tx.send(AgentEvent::ToolDenied {
546                        tool_call_id: call.id.clone(),
547                        tool_name: call.name.clone(),
548                        reason: reason.clone(),
549                    })
550                    .ok();
551                    (reason, true)
552                }
553                ApprovalDecision::Approve => {
554                    tx.send(AgentEvent::ToolExecStart {
555                        tool_call_id: call.id.clone(),
556                        tool_name: call.name.clone(),
557                        args: call.arguments.clone(),
558                    })
559                    .ok();
560
561                    let (content, is_error) = match self.execute_tool(call, tx).await {
562                        Ok(out) => (out.content, false),
563                        Err(e) => {
564                            log::warn!("Agent::prompt: tool '{}' failed: {e}", call.name);
565                            (e.to_string(), true)
566                        }
567                    };
568
569                    tx.send(AgentEvent::ToolExecEnd {
570                        tool_call_id: call.id.clone(),
571                        tool_name: call.name.clone(),
572                        result: ToolResult {
573                            tool_call_id: call.id.clone(),
574                            tool_name: call.name.clone(),
575                            content: content.clone(),
576                            is_error,
577                        },
578                    })
579                    .ok();
580
581                    (content, is_error)
582                }
583            };
584
585            let result = ToolResult {
586                tool_call_id: call.id.clone(),
587                tool_name: call.name.clone(),
588                content: content.clone(),
589                is_error,
590            };
591            let result_msg =
592                Message::tool_result(self.next_id(), &call.id, &call.name, content, is_error);
593            self.messages.push(result_msg.clone());
594            new_messages.push(result_msg.clone());
595            tx.send(AgentEvent::MessageStart {
596                message: result_msg.clone(),
597            })
598            .ok();
599            tx.send(AgentEvent::MessageEnd {
600                message: result_msg,
601            })
602            .ok();
603
604            tool_results.push(result);
605        }
606
607        tx.send(AgentEvent::TurnEnd {
608            message: assistant_msg,
609            tool_results,
610        })
611        .ok();
612
613        self.abort.load(Ordering::Relaxed)
614    }
615
616    /// Convenience wrapper over [`prompt`](Agent::prompt) that creates the event
617    /// channel for you.
618    ///
619    /// Returns the event receiver plus a future that drives generation. Poll the
620    /// future (e.g. with `tokio::join!`) while draining the receiver - the two
621    /// run concurrently so tokens stream as they're produced:
622    ///
623    /// ```
624    /// # use std::sync::Arc;
625    /// # use std::sync::atomic::AtomicBool;
626    /// # use orion_core::{Agent, AgentConfig, AgentEvent, CoreResult,
627    /// #     GenerationResult, InferenceParams, LlmBackend, TokenCallback};
628    /// # struct MockBackend;
629    /// # impl LlmBackend for MockBackend {
630    /// #     fn generate(&self, _p: &str, _x: &InferenceParams, _a: Arc<AtomicBool>,
631    /// #         mut on_token: TokenCallback) -> CoreResult<GenerationResult> {
632    /// #         on_token("Hi!", 1, 10.0);
633    /// #         Ok(GenerationResult { text: "Hi!".into(), tokens_generated: 1,
634    /// #             prompt_tokens: 0, tokens_per_sec: 10.0,
635    /// #             time_to_first_token_ms: 1.0, generation_time_ms: 1.0 })
636    /// #     }
637    /// #     fn tokenize_count(&self, t: &str) -> CoreResult<u32> {
638    /// #         Ok(t.split_whitespace().count() as u32) }
639    /// #     fn is_ready(&self) -> bool { true }
640    /// # }
641    /// # fn main() {
642    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
643    /// let mut agent = Agent::new(AgentConfig::default());
644    /// let backend: Arc<dyn LlmBackend> = Arc::new(MockBackend);
645    ///
646    /// let (mut rx, run) = agent.prompt_stream("Hello", backend);
647    /// let (result, reply) = tokio::join!(run, async move {
648    ///     let mut reply = String::new();
649    ///     while let Some(event) = rx.recv().await {
650    ///         if let AgentEvent::MessageDelta { delta, .. } = event {
651    ///             reply.push_str(&delta);
652    ///         }
653    ///     }
654    ///     reply
655    /// });
656    /// result.unwrap();
657    /// assert_eq!(reply, "Hi!");
658    /// # });
659    /// # }
660    /// ```
661    pub fn prompt_stream(
662        &mut self,
663        text: impl Into<String>,
664        backend: impl Into<Backend>,
665    ) -> (
666        mpsc::UnboundedReceiver<AgentEvent>,
667        impl std::future::Future<Output = CoreResult<()>> + '_,
668    ) {
669        let (tx, rx) = mpsc::unbounded_channel();
670        let text = text.into();
671        let backend = backend.into();
672        let fut = async move { self.prompt(text, backend, tx).await };
673        (rx, fut)
674    }
675
676    /// Run a turn and answer with the reply, for a caller that is not streaming.
677    ///
678    /// [`prompt`](Self::prompt) reports everything through events, which is what streaming
679    /// wants and what a caller that only needs the answer has to unpick for itself. This
680    /// runs the same turn, drains the events and hands back the assistant's final message.
681    ///
682    /// An error the agent reported as an event is returned as `Err` here: a caller with no
683    /// event stream has nowhere else to see it, and answering `Ok` with no reply would be
684    /// a failure that reads like a silence.
685    pub async fn send(
686        &mut self,
687        text: impl Into<String>,
688        backend: impl Into<Backend>,
689    ) -> CoreResult<Message> {
690        let (tx, mut rx) = mpsc::unbounded_channel();
691        self.prompt(text, backend, tx).await?;
692
693        // The channel is unbounded and the turn is over, so everything it produced is
694        // already queued and draining it cannot block.
695        let mut reply = None;
696        let mut failed = None;
697        while let Ok(event) = rx.try_recv() {
698            match event {
699                AgentEvent::TurnEnd { message, .. } => reply = Some(message),
700                AgentEvent::Error { message } => failed = Some(message),
701                _ => {}
702            }
703        }
704
705        if let Some(message) = failed {
706            return Err(CoreError::Agent(message));
707        }
708        reply.ok_or_else(|| CoreError::Agent("The agent produced no reply".into()))
709    }
710
711    /// Run a single LLM generation over the current conversation.
712    ///
713    /// Prepares context (prune + template) and calls the backend on a blocking
714    /// thread, streaming `MessageDelta` tokens and emitting `ContextBudget`.
715    /// Returns the completed [`GenerationResult`], or a `CoreError` (context
716    /// overflow, backend-not-ready, `Aborted`, or a generation failure).
717    async fn generate_once(
718        &self,
719        backend: &Backend,
720        tx: &mpsc::UnboundedSender<AgentEvent>,
721    ) -> CoreResult<GenerationResult> {
722        let messages = self.messages.clone();
723        let system_prompt = self.config.system_prompt.clone();
724        let ctx_config = self.config.context_config.clone();
725        let tool_schemas = self.tool_schemas();
726        let params = self.config.inference_params.clone();
727        let abort = self.abort.clone();
728        let max_ctx = self.config.context_config.max_context_tokens;
729        let template = self.template.clone();
730        let token_tx = tx.clone();
731        let budget_tx = tx.clone();
732
733        let on_token: TokenCallback = Box::new(move |token, count, tps| {
734            token_tx
735                .send(AgentEvent::MessageDelta {
736                    delta: token.to_string(),
737                    tokens_generated: count,
738                    tokens_per_sec: tps,
739                })
740                .ok();
741        });
742
743        let report = move |prepared: &PreparedContext| {
744            log::debug!(
745                "Context prepared: tokens={}, kept={}, pruned={}",
746                prepared.token_count,
747                prepared.messages_included,
748                prepared.messages_pruned,
749            );
750            budget_tx
751                .send(AgentEvent::ContextBudget {
752                    used_tokens: prepared.token_count,
753                    max_tokens: max_ctx,
754                    messages_in_context: prepared.messages_included,
755                    messages_pruned: prepared.messages_pruned,
756                })
757                .ok();
758        };
759
760        match backend {
761            // A local engine tokenizes and generates on the calling thread, so context
762            // preparation goes to a blocking one along with it.
763            Backend::Prompt(backend) => {
764                let backend = backend.clone();
765                log::debug!(
766                    "Agent::generate_once: spawning blocking (max_tokens={}, temp={}, ctx={}, threads={})",
767                    params.max_tokens,
768                    params.temperature,
769                    params.context_size,
770                    params.n_threads,
771                );
772
773                let handle = tokio::task::spawn_blocking(move || {
774                    if !backend.is_ready() {
775                        return Err(CoreError::Backend("No model loaded".into()));
776                    }
777
778                    let prepared = prepare_context(
779                        template.as_ref(),
780                        &system_prompt,
781                        &messages,
782                        &tool_schemas,
783                        &ctx_config,
784                        &|text| backend.tokenize_count(text).unwrap_or(0),
785                    )?;
786                    report(&prepared);
787
788                    backend.generate(&prepared.prompt, &params, abort, on_token)
789                });
790
791                handle.await.map_err(|e| {
792                    log::error!("Agent::generate_once: blocking task panicked: {e}");
793                    CoreError::Agent(format!("Inference task failed: {e}"))
794                })?
795            }
796
797            // A hosted endpoint is I/O, and its token count is an estimate that costs
798            // nothing, so preparation stays on this thread and only the request is awaited.
799            #[cfg(feature = "chat-backend")]
800            Backend::Chat(backend) => {
801                if !backend.is_ready() {
802                    return Err(CoreError::Backend("Backend not ready".into()));
803                }
804
805                let prepared = prepare_context(
806                    template.as_ref(),
807                    &system_prompt,
808                    &messages,
809                    &tool_schemas,
810                    &ctx_config,
811                    &|text| backend.tokenize_count(text),
812                )?;
813                report(&prepared);
814
815                backend
816                    .chat(
817                        &prepared.system,
818                        &prepared.messages,
819                        &params,
820                        abort,
821                        on_token,
822                    )
823                    .await
824            }
825        }
826    }
827
828    /// Dispatch one parsed tool call to its registered [`Tool`].
829    ///
830    /// Forwards the tool's streaming progress as `ToolExecUpdate` events.
831    /// Returns `CoreError::Tool` when no tool matches the requested name.
832    #[cfg(feature = "tools")]
833    async fn execute_tool(
834        &self,
835        call: &ToolCall,
836        tx: &mpsc::UnboundedSender<AgentEvent>,
837    ) -> CoreResult<ToolOutput> {
838        let Some(tool) = self.tools.iter().find(|t| t.name() == call.name) else {
839            return Err(CoreError::Tool(format!("unknown tool: {}", call.name)));
840        };
841
842        let update_tx = tx.clone();
843        let tool_call_id = call.id.clone();
844        let tool_name = call.name.clone();
845        let on_update: ToolUpdateCallback = Box::new(move |partial: &str| {
846            update_tx
847                .send(AgentEvent::ToolExecUpdate {
848                    tool_call_id: tool_call_id.clone(),
849                    tool_name: tool_name.clone(),
850                    partial: partial.to_string(),
851                })
852                .ok();
853        });
854
855        tool.execute(&call.id, call.arguments.clone(), Some(on_update))
856            .await
857    }
858
859    /// Summarize-and-compress: when the conversation overflows under the
860    /// `Summarize` strategy, replace the oldest droppable turns with a single
861    /// pinned summary message so their gist survives instead of being dropped.
862    ///
863    /// Best-effort: any failure (backend not ready, summarizer error, abort)
864    /// logs and returns, leaving the conversation untouched - the normal
865    /// sliding-window pruning in `prepare_context` then applies.
866    async fn compress_if_needed(
867        &mut self,
868        backend: &Backend,
869        tx: &mpsc::UnboundedSender<AgentEvent>,
870    ) {
871        if self.config.context_config.prune_strategy != PruneStrategy::Summarize {
872            return;
873        }
874
875        let messages = self.messages.clone();
876        let system_prompt = self.config.system_prompt.clone();
877        let tools = self.tool_schemas();
878        let ctx_config = self.config.context_config.clone();
879        let template = self.template.clone();
880        let abort = self.abort.clone();
881        let sum_params = InferenceParams {
882            max_tokens: SUMMARY_MAX_TOKENS,
883            ..self.config.inference_params.clone()
884        };
885
886        let outcome: Option<(Vec<usize>, String)> = match backend {
887            // Plan and summarize on a blocking thread: tokenizing and generating both block.
888            Backend::Prompt(backend) => {
889                let backend = backend.clone();
890                tokio::task::spawn_blocking(move || {
891                    if !backend.is_ready() {
892                        return None;
893                    }
894                    let counter = |text: &str| backend.tokenize_count(text).unwrap_or(0);
895                    let (remove, _asked, prompt) = plan_summary(
896                        template.as_ref(),
897                        &system_prompt,
898                        &messages,
899                        &tools,
900                        &ctx_config,
901                        &counter,
902                    )?;
903
904                    let gen = backend
905                        .generate(&prompt, &sum_params, abort, Box::new(|_, _, _| {}))
906                        .ok()?;
907                    let summary = gen.text.trim().to_string();
908                    (!summary.is_empty()).then_some((remove, summary))
909                })
910                .await
911                .ok()
912                .flatten()
913            }
914
915            #[cfg(feature = "chat-backend")]
916            Backend::Chat(backend) => {
917                let counter = |text: &str| backend.tokenize_count(text);
918                let planned = backend.is_ready().then(|| {
919                    plan_summary(
920                        template.as_ref(),
921                        &system_prompt,
922                        &messages,
923                        &tools,
924                        &ctx_config,
925                        &counter,
926                    )
927                });
928
929                match planned.flatten() {
930                    None => None,
931                    Some((remove, asked, _prompt)) => backend
932                        .chat(
933                            SUMMARY_SYSTEM,
934                            std::slice::from_ref(&asked),
935                            &sum_params,
936                            abort,
937                            Box::new(|_, _, _| {}),
938                        )
939                        .await
940                        .ok()
941                        .and_then(|gen| {
942                            let summary = gen.text.trim().to_string();
943                            (!summary.is_empty()).then_some((remove, summary))
944                        }),
945                }
946            }
947        };
948
949        let Some((remove, summary)) = outcome else {
950            return;
951        };
952
953        self.fold_into_summary(&remove, summary);
954        tx.send(AgentEvent::Warning {
955            message: format!(
956                "Summarized {} earlier message(s) to fit the context window",
957                remove.len()
958            ),
959        })
960        .ok();
961    }
962
963    /// Remove the given message indices and splice a single pinned summary
964    /// message in at the earliest removed position.
965    fn fold_into_summary(&mut self, remove: &[usize], summary: String) {
966        if remove.is_empty() {
967            return;
968        }
969        let insert_at = *remove.iter().min().unwrap();
970        let mut sorted = remove.to_vec();
971        sorted.sort_unstable();
972        for &i in sorted.iter().rev() {
973            if i < self.messages.len() {
974                self.messages.remove(i);
975            }
976        }
977        let summary_msg =
978            Message::user(self.next_id(), format!("{SUMMARY_MARKER}\n{summary}")).pinned();
979        let at = insert_at.min(self.messages.len());
980        self.messages.insert(at, summary_msg);
981        log::info!(
982            "Folded {} messages into a pinned summary at index {at}",
983            remove.len()
984        );
985    }
986}
987
988/// Render selected messages as a plain-text transcript for summarization.
989/// What a summarization pass would fold away and what it would ask for, worked out without
990/// touching a backend. `None` when everything fits and there is nothing to fold.
991///
992/// Answers with the message indices to remove, the request as a [`Message`] for a chat
993/// backend, and the same request formatted for a prompt backend.
994fn plan_summary(
995    template: &dyn ChatTemplate,
996    system_prompt: &str,
997    messages: &[Message],
998    tools: &[ToolSchema],
999    ctx_config: &ContextConfig,
1000    counter: &dyn Fn(&str) -> u32,
1001) -> Option<(Vec<usize>, Message, String)> {
1002    let plan = plan_prune(
1003        template,
1004        system_prompt,
1005        messages,
1006        tools,
1007        ctx_config,
1008        counter,
1009    )
1010    .ok()?;
1011    if plan.dropped.is_empty() {
1012        return None; // everything fits - nothing to summarize
1013    }
1014
1015    // Indices to fold away: the dropped turns plus any prior summary (pinned, so it never
1016    // lands in `dropped`) - consolidated into one.
1017    let mut remove: Vec<usize> = plan.dropped.iter().flat_map(|r| r.clone()).collect();
1018    let prior_summary = messages
1019        .iter()
1020        .position(|m| m.pinned && m.content.starts_with(SUMMARY_MARKER));
1021    let prior_body = prior_summary.map(|i| {
1022        remove.push(i);
1023        messages[i]
1024            .content
1025            .strip_prefix(SUMMARY_MARKER)
1026            .unwrap_or(&messages[i].content)
1027            .trim()
1028            .to_string()
1029    });
1030    remove.sort_unstable();
1031    remove.dedup();
1032
1033    let transcript = render_transcript(messages, &remove);
1034    let mut body = String::new();
1035    if let Some(prev) = prior_body.filter(|s| !s.is_empty()) {
1036        body.push_str("Earlier summary:\n");
1037        body.push_str(&prev);
1038        body.push_str("\n\n");
1039    }
1040    body.push_str("Conversation excerpt:\n");
1041    body.push_str(&transcript);
1042
1043    let instruction = "You compress conversation history. Summarize the \
1044         material below into a concise note that preserves key facts, \
1045         decisions, names, and unresolved questions. Reply with only the \
1046         summary.";
1047    let asked = Message::user("summary-req", format!("{instruction}\n\n{body}"));
1048    let prompt = template.format(SUMMARY_SYSTEM, std::slice::from_ref(&asked), &[]);
1049
1050    Some((remove, asked, prompt))
1051}
1052
1053fn render_transcript(messages: &[Message], indices: &[usize]) -> String {
1054    indices
1055        .iter()
1056        .filter_map(|&i| messages.get(i))
1057        .map(|m| {
1058            let role = match m.role {
1059                Role::User => "User",
1060                Role::Assistant | Role::ToolCall => "Assistant",
1061                Role::ToolResult => "Tool",
1062                Role::System => "System",
1063            };
1064            format!("{role}: {}", m.content)
1065        })
1066        .collect::<Vec<_>>()
1067        .join("\n")
1068}