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::{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
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/// Agent configuration.
30#[derive(Debug, Clone)]
31pub struct AgentConfig {
32    /// System prompt prepended to every formatted conversation.
33    pub system_prompt: String,
34    /// Sampling / inference parameters passed to the backend.
35    pub inference_params: InferenceParams,
36    /// Context-window management settings.
37    pub context_config: ContextConfig,
38    /// Maximum LLM↔tool round-trips in a single `prompt()` before the agent
39    /// stops and emits a warning. Guards against tool loops that never produce
40    /// a final answer.
41    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/// Decision returned by an [`ApprovalHook`] for a single tool call.
56#[cfg(feature = "tools")]
57#[derive(Debug, Clone)]
58pub enum ApprovalDecision {
59    /// Run the tool call as normal.
60    Approve,
61    /// Skip execution. The `reason` is fed back to the model as an error tool
62    /// result so it can adapt, and reported via [`AgentEvent::ToolDenied`].
63    Deny {
64        /// Human-readable explanation of why the call was refused.
65        reason: String,
66    },
67}
68
69/// Authorizes tool calls before they run.
70///
71/// When a hook is installed via [`Agent::set_approval_hook`], the agent consults
72/// it once for every parsed tool call - on each tool-loop iteration, for each
73/// call in a multi-call turn - after parsing and immediately before execution.
74/// This is the point at which a host can sandbox, gate, or interactively confirm
75/// tool use (e.g. "the model wants to run `delete_file` - allow?").
76///
77/// The hook receives the parsed call (name + arguments) exactly as the tool
78/// would. It is `async` and may block for as long as it needs - including
79/// awaiting a human decision - so it is never wrapped in an internal timeout.
80///
81/// A [`Deny`](ApprovalDecision::Deny) does not abort the run: the refusal is
82/// appended as an error tool result and the loop continues so the model can
83/// react. To stop the whole run, use [`Agent::abort`].
84///
85/// ```
86/// use std::sync::Arc;
87/// use async_trait::async_trait;
88/// use orion_core::{ApprovalDecision, ApprovalHook, ToolCall};
89///
90/// /// Confirms destructive tools, approves everything else.
91/// struct ConfirmDestructive;
92///
93/// #[async_trait]
94/// impl ApprovalHook for ConfirmDestructive {
95///     async fn review(&self, call: &ToolCall) -> ApprovalDecision {
96///         if call.name.starts_with("delete_") {
97///             ApprovalDecision::Deny {
98///                 reason: format!("{} needs confirmation before it can run", call.name),
99///             }
100///         } else {
101///             ApprovalDecision::Approve
102///         }
103///     }
104/// }
105///
106/// // agent.set_approval_hook(Arc::new(ConfirmDestructive));
107/// # let _hook: Arc<dyn ApprovalHook> = Arc::new(ConfirmDestructive);
108/// ```
109#[cfg(feature = "tools")]
110#[async_trait]
111pub trait ApprovalHook: Send + Sync {
112    /// Review a parsed tool call and decide whether it may execute.
113    async fn review(&self, call: &ToolCall) -> ApprovalDecision;
114}
115
116/// The agent: manages conversation state, context pipeline, and the
117/// prompt → LLM → tool → LLM loop.
118///
119/// ```
120/// use orion_core::{Agent, AgentConfig, ContextConfig, InferenceParams};
121///
122/// let mut agent = Agent::new(AgentConfig {
123///     system_prompt: "You are a coding assistant.".into(),
124///     inference_params: InferenceParams {
125///         max_tokens: 4096,
126///         temperature: 0.4,
127///         context_size: 8192,
128///         n_threads: 6,
129///     },
130///     context_config: ContextConfig {
131///         max_context_tokens: 8192,
132///         max_response_tokens: 4096,
133///         ..Default::default()
134///     },
135///     ..Default::default()
136/// });
137///
138/// // Change settings on the fly.
139/// agent.set_system_prompt("You are a pirate.");
140/// agent.set_inference_params(InferenceParams { temperature: 1.2, ..Default::default() });
141/// agent.clear();
142/// ```
143pub 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    /// Create an agent with the given config and the default ChatML template.
157    pub fn new(config: AgentConfig) -> Self {
158        Self::with_template(config, Arc::new(ChatMLTemplate))
159    }
160
161    /// Create an agent with an explicit chat template.
162    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    /// The current conversation messages, in order.
184    pub fn messages(&self) -> &[Message] {
185        &self.messages
186    }
187
188    /// The agent's current configuration.
189    pub fn config(&self) -> &AgentConfig {
190        &self.config
191    }
192
193    /// The chat template currently in use.
194    pub fn template(&self) -> &dyn ChatTemplate {
195        self.template.as_ref()
196    }
197
198    /// Replace the system prompt used for subsequent prompts.
199    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    /// Replace the inference parameters used for subsequent generations.
206    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    /// Replace the context-management configuration.
218    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    /// Select the strategy used when the conversation overflows the budget.
228    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    /// Pin or unpin a message by id. Pinned messages always survive context
234    /// pruning. Returns whether a message with that id was found.
235    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    /// Swap the chat template at runtime (e.g. after detecting the model family).
250    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    /// Register the tools the agent may invoke during a prompt.
256    ///
257    /// Available only with the `tools` feature (enabled by default).
258    #[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    /// Install a hook consulted before every tool call executes.
265    ///
266    /// See [`ApprovalHook`] for the exact semantics. With no hook installed the
267    /// tool loop behaves exactly as it did before - every call runs immediately.
268    ///
269    /// Available only with the `tools` feature (enabled by default).
270    #[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    /// Clear the conversation history.
277    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    /// Replace the entire conversation (e.g. when restoring a saved session).
284    ///
285    /// Advances the internal id counter past any restored `msg-N` ids so newly
286    /// generated ids don't collide with restored ones.
287    pub fn replace_messages(&mut self, messages: Vec<Message>) {
288        log::debug!("Agent messages replaced: count={}", messages.len());
289        // Advance the id counter past any `msg-N` ids in the loaded set so
290        // newly generated ids don't collide with restored ones (pins and tool
291        // results are addressed by id).
292        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    /// Request cancellation of an in-flight generation.
303    pub fn abort(&self) {
304        log::debug!("Agent abort requested");
305        self.abort.store(true, Ordering::Relaxed);
306    }
307
308    /// Clone of the shared abort flag, for wiring cancellation into a backend.
309    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    /// Without the `tools` feature there are never any tools to advertise.
324    #[cfg(not(feature = "tools"))]
325    fn tool_schemas(&self) -> Vec<ToolSchema> {
326        Vec::new()
327    }
328
329    /// Run a prompt through the agent loop.
330    ///
331    /// Accepts an event sender so the caller can consume events
332    /// concurrently while generation is in progress. This enables
333    /// real-time token streaming to the UI.
334    ///
335    /// Flow:
336    /// 1. Adds the user message.
337    /// 2. Generates an assistant response (prune + template + LLM call),
338    ///    streaming tokens via `tx`.
339    /// 3. If tools are registered and the response contains tool calls, runs
340    ///    each tool, appends a tool-result message, and loops back to the LLM.
341    /// 4. Repeats until the model returns a tool-free answer or the
342    ///    `max_tool_iterations` guard trips.
343    /// 5. Emits lifecycle events for every step and updates conversation state.
344    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        // Under the Summarize strategy, fold overflowing older turns into a
368        // pinned summary before generating (best-effort; no-op otherwise).
369        self.compress_if_needed(&backend, &tx).await;
370
371        // Messages produced this prompt (assistant turns + tool results),
372        // reported in the final `AgentEnd`.
373        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                    // Aborted mid-generation: record an (empty) assistant turn
386                    // so the conversation stays well-formed, then stop.
387                    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                    // Context overflow, backend not ready, generation failure.
408                    log::error!("Agent::prompt: generation error: {e}");
409                    // On the first turn, drop the just-added user message so a
410                    // retry starts clean. On later turns the conversation
411                    // already carries tool context; leave it for re-pruning.
412                    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            // No tool calls → this is the final answer.
466            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            // Tool calls are present - reachable only with the `tools` feature,
480            // since without it `has_tools` is always false (so `tool_calls` is
481            // always empty and we returned above).
482            #[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                // Otherwise loop back: the LLM now sees the tool results.
495            }
496        }
497
498        // Exhausted the iteration budget without a tool-free answer.
499        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    /// Execute every tool call from one assistant turn, appending a tool-result
518    /// message per call and emitting the matching events, then emit `TurnEnd`.
519    /// Returns `true` if an abort was requested while tools were running.
520    #[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            // Consult the approval hook (if installed) before touching the tool.
531            // A denial is turned into an error result the model can see and
532            // react to; it never aborts the loop.
533            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    /// Convenience wrapper over [`prompt`](Agent::prompt) that creates the event
613    /// channel for you.
614    ///
615    /// Returns the event receiver plus a future that drives generation. Poll the
616    /// future (e.g. with `tokio::join!`) while draining the receiver - the two
617    /// run concurrently so tokens stream as they're produced:
618    ///
619    /// ```
620    /// # use std::sync::Arc;
621    /// # use std::sync::atomic::AtomicBool;
622    /// # use orion_core::{Agent, AgentConfig, AgentEvent, CoreResult,
623    /// #     GenerationResult, InferenceParams, LlmBackend, TokenCallback};
624    /// # struct MockBackend;
625    /// # impl LlmBackend for MockBackend {
626    /// #     fn generate(&self, _p: &str, _x: &InferenceParams, _a: Arc<AtomicBool>,
627    /// #         mut on_token: TokenCallback) -> CoreResult<GenerationResult> {
628    /// #         on_token("Hi!", 1, 10.0);
629    /// #         Ok(GenerationResult { text: "Hi!".into(), tokens_generated: 1,
630    /// #             prompt_tokens: 0, tokens_per_sec: 10.0,
631    /// #             time_to_first_token_ms: 1.0, generation_time_ms: 1.0 })
632    /// #     }
633    /// #     fn tokenize_count(&self, t: &str) -> CoreResult<u32> {
634    /// #         Ok(t.split_whitespace().count() as u32) }
635    /// #     fn is_ready(&self) -> bool { true }
636    /// # }
637    /// # fn main() {
638    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
639    /// let mut agent = Agent::new(AgentConfig::default());
640    /// let backend: Arc<dyn LlmBackend> = Arc::new(MockBackend);
641    ///
642    /// let (mut rx, run) = agent.prompt_stream("Hello", backend);
643    /// let (result, reply) = tokio::join!(run, async move {
644    ///     let mut reply = String::new();
645    ///     while let Some(event) = rx.recv().await {
646    ///         if let AgentEvent::MessageDelta { delta, .. } = event {
647    ///             reply.push_str(&delta);
648    ///         }
649    ///     }
650    ///     reply
651    /// });
652    /// result.unwrap();
653    /// assert_eq!(reply, "Hi!");
654    /// # });
655    /// # }
656    /// ```
657    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    /// Run a single LLM generation over the current conversation.
672    ///
673    /// Prepares context (prune + template) and calls the backend on a blocking
674    /// thread, streaming `MessageDelta` tokens and emitting `ContextBudget`.
675    /// Returns the completed [`GenerationResult`], or a `CoreError` (context
676    /// overflow, backend-not-ready, `Aborted`, or a generation failure).
677    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                &params,
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    /// Dispatch one parsed tool call to its registered [`Tool`].
754    ///
755    /// Forwards the tool's streaming progress as `ToolExecUpdate` events.
756    /// Returns `CoreError::Tool` when no tool matches the requested name.
757    #[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    /// Summarize-and-compress: when the conversation overflows under the
785    /// `Summarize` strategy, replace the oldest droppable turns with a single
786    /// pinned summary message so their gist survives instead of being dropped.
787    ///
788    /// Best-effort: any failure (backend not ready, summarizer error, abort)
789    /// logs and returns, leaving the conversation untouched - the normal
790    /// sliding-window pruning in `prepare_context` then applies.
791    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        // Plan + summarize on a blocking thread (tokenize + generate block).
810        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; // everything fits - nothing to summarize
826            }
827
828            // Indices to fold away: the dropped turns plus any prior summary
829            // (pinned, so it never lands in `dropped`) - consolidated into one.
830            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    /// Remove the given message indices and splice a single pinned summary
897    /// message in at the earliest removed position.
898    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
921/// Render selected messages as a plain-text transcript for summarization.
922fn 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}