Skip to main content

lc_agents/executor/
engine.rs

1// lc-agents/src/executor/engine.rs
2//! `AgentExecutor` — the execution loop (plan -> act -> observe).
3
4use super::budget::{budget_iteration_gate, budget_token_gate, budget_tool_gate, BudgetConfig};
5use super::compaction::CompactionConfig;
6use super::hooks::{run_after_completion_hooks, run_before_completion_hooks};
7use super::tools::{
8    execute_tool_for_stream, execute_tools_parallel_for_stream, tool_error_observation,
9};
10use super::{
11    AgentError, BaseAgent, CACHE_NS, DEFAULT_MAX_CONCURRENCY, MAX_MAX_ITERATIONS,
12    MIN_MAX_ITERATIONS,
13};
14use crate::approval::{ApprovalDecision, ApprovalHandler};
15use crate::cache::ResponseCache;
16use crate::hooks::{AgentHook, HookError};
17use crate::metrics::AgentMetrics;
18use crate::policy::ToolPolicy;
19use crate::resume::{PendingApproval, ResumeStore};
20use crate::streaming::state::AgentStreamEvent;
21use crate::types::{AgentAction, AgentOutput, AgentStep, ToolInput};
22use futures_util::Stream;
23use lc_callbacks::{CallbackManager, RunTree, RunType};
24use lc_core::observability::{MetricsSink, ObsEvent};
25use lc_core::runnables::RunnableConfig;
26use lc_core::tools::BaseTool;
27use lc_memory::BaseMemory;
28use serde_json::json;
29use std::collections::{HashMap, HashSet};
30use std::future::Future;
31use std::pin::Pin;
32use std::sync::atomic::Ordering;
33use std::sync::{Arc, Mutex};
34use std::time::{Duration, Instant};
35use tokio::sync::Semaphore;
36
37/// Agent executor.
38///
39/// Responsible for executing the agent's decision loop: Plan -> Act -> Observe.
40pub struct AgentExecutor {
41    /// Agent instance.
42    pub(crate) agent: Arc<dyn BaseAgent>,
43
44    /// Available tools.
45    pub(crate) tools: Vec<Arc<dyn BaseTool>>,
46
47    /// Max iterations.
48    pub(crate) max_iterations: usize,
49
50    /// Verbose output.
51    pub(crate) verbose: bool,
52
53    /// Memory (optional).
54    pub(crate) memory: Option<Arc<tokio::sync::Mutex<dyn BaseMemory>>>,
55
56    /// Callback manager (optional).
57    pub(crate) callbacks: Option<Arc<CallbackManager>>,
58
59    /// Agent hooks (optional).
60    pub(crate) hooks: Vec<Arc<dyn AgentHook>>,
61
62    /// Tool execution timeout (None = no timeout).
63    pub(crate) tool_timeout: Option<Duration>,
64
65    /// Maximum number of tools executed concurrently.
66    pub(crate) max_concurrency: usize,
67
68    /// Semaphore guarding concurrent tool execution.
69    pub(crate) concurrency_sem: Arc<Semaphore>,
70
71    /// Most recent execution metrics (P1-5). Arc-shared so merged executors
72    /// created by `invoke_with_config` write back to the original executor.
73    pub(crate) metrics_store: Arc<Mutex<Option<AgentMetrics>>>,
74
75    /// LLM result cache (P2-1): `plan()` results hit on `(namespace, inputs, steps)`;
76    /// deterministic prompts are reused directly, skipping the LLM round-trip.
77    /// `None` = no caching.
78    pub(crate) response_cache: Option<Arc<dyn ResponseCache>>,
79    /// This instance's cache namespace (isolates executors sharing the same cache).
80    pub(crate) cache_namespace: String,
81
82    /// Tool permission policy (permission tiering + sandbox gate, P2-9).
83    /// `None` = no checks.
84    pub(crate) tool_policy: Option<ToolPolicy>,
85
86    /// Approval gate (§4.2): async approval before each tool execution. `None` = no
87    /// interception (default off).
88    pub(crate) approval: Option<Arc<dyn ApprovalHandler>>,
89    /// Budget gate (§4.2): hard limits. `None` = unlimited (default off).
90    pub(crate) budget: Option<BudgetConfig>,
91
92    /// Context compaction (0.21.0 S6.1): drops the oldest intermediate steps at
93    /// whole-step boundaries when the trigger fires. `None` = off (default).
94    pub(crate) compaction: Option<CompactionConfig>,
95
96    /// Cross-process resume (§4.2): checkpoint store. When `Some`, `execute_tool`
97    /// persists the pending approval before awaiting approval and clears it once the
98    /// decision lands; a new process can inspect it via `pending_approval()` and
99    /// continue via `resume(decision)`. `None` = off (default).
100    pub(crate) resume_store: Option<Arc<dyn ResumeStore>>,
101
102    /// Observability sink (v0.20.2): exports one `AgentMetrics` event per run
103    /// (invoke / stream / resume). `None` = off (default). Failures are `warn` only.
104    pub(crate) metrics_sink: Option<Arc<dyn MetricsSink>>,
105
106    /// 0.22.0 C4 fix: what to do when the loop exhausts `max_iterations`
107    /// without a final answer. Default **`Error`** — the previous placeholder
108    /// string was indistinguishable from a real answer and downstream
109    /// consumers (PlanExecute) recorded failed steps as completed.
110    pub(crate) on_max_iterations: MaxIterationsPolicy,
111}
112
113/// 0.22.0 C4 fix: behavior when the agent loop exhausts its iteration budget.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
115pub enum MaxIterationsPolicy {
116    /// Fail the run with [`AgentError::MaxIterationsReached`] (default —
117    /// "failure disguised as success" is no longer possible).
118    #[default]
119    Error,
120    /// Legacy behavior (≤ 0.21.x): return the stopped-response placeholder
121    /// string. Opt in explicitly when a caller cannot handle errors.
122    Placeholder,
123}
124
125impl AgentExecutor {
126    /// Creates a new AgentExecutor.
127    pub fn new(agent: Arc<dyn BaseAgent>, tools: Vec<Arc<dyn BaseTool>>) -> Self {
128        Self {
129            agent,
130            tools,
131            max_iterations: 10,
132            verbose: false,
133            memory: None,
134            callbacks: None,
135            hooks: Vec::new(),
136            tool_timeout: None,
137            max_concurrency: DEFAULT_MAX_CONCURRENCY,
138            concurrency_sem: Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENCY)),
139            metrics_store: Arc::new(Mutex::new(None)),
140            response_cache: None,
141            cache_namespace: format!("exec-{}", CACHE_NS.fetch_add(1, Ordering::SeqCst)),
142            tool_policy: None,
143            approval: None,
144            budget: None,
145            compaction: None,
146            resume_store: None,
147            metrics_sink: None,
148            on_max_iterations: MaxIterationsPolicy::default(),
149        }
150    }
151
152    /// Sets max iterations, clamped to `[1, 100]`.
153    pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
154        self.max_iterations = max_iterations.clamp(MIN_MAX_ITERATIONS, MAX_MAX_ITERATIONS);
155        if max_iterations > MAX_MAX_ITERATIONS {
156            log::warn!(
157                "max_iterations {} clamped to {}",
158                max_iterations,
159                MAX_MAX_ITERATIONS
160            );
161        }
162        self
163    }
164
165    /// 0.22.0 C4 fix: chooses what happens when the loop exhausts
166    /// `max_iterations` without a final answer.
167    ///
168    /// - `MaxIterationsPolicy::Error` (default): the run fails with
169    ///   [`AgentError::MaxIterationsReached`] — the caller can distinguish
170    ///   "did not converge" from a real answer, and PlanExecute treats the
171    ///   step as failed instead of completed-with-garbage.
172    /// - `MaxIterationsPolicy::Placeholder`: legacy ≤ 0.21.x behavior —
173    ///   return the stopped-response placeholder string.
174    pub fn with_on_max_iterations(mut self, policy: MaxIterationsPolicy) -> Self {
175        self.on_max_iterations = policy;
176        self
177    }
178
179    /// Sets the tool execution timeout.
180    ///
181    /// A tool that exceeds the timeout returns an error instead of hanging the
182    /// whole agent loop. `None` (the default) disables the timeout.
183    pub fn with_tool_timeout(mut self, timeout: Duration) -> Self {
184        self.tool_timeout = Some(timeout);
185        self
186    }
187
188    /// Sets the maximum number of tools executed concurrently.
189    ///
190    /// Clamped to at least 1. The default is 8.
191    pub fn with_max_concurrency(mut self, max_concurrency: usize) -> Self {
192        let max_concurrency = max_concurrency.max(1);
193        self.max_concurrency = max_concurrency;
194        self.concurrency_sem = Arc::new(Semaphore::new(max_concurrency));
195        self
196    }
197
198    /// Enables the LLM result cache (P2-1).
199    ///
200    /// For deterministic prompts, `plan()` results with the same `(inputs,
201    /// intermediate_steps)` are reused directly, skipping the LLM round-trip — suited to
202    /// cost-sensitive / repeatedly-evaluated deterministic tasks. Tool execution results
203    /// enter the cache key; tools themselves are not cached; the cache applies to the
204    /// non-streaming `invoke` path.
205    ///
206    /// # Example
207    ///
208    /// ```rust,ignore
209    /// let cache = Arc::new(MemoryCache::with_capacity(256));
210    /// let executor = AgentExecutor::new(agent, tools).with_response_cache(cache);
211    /// ```
212    pub fn with_response_cache(mut self, cache: Arc<dyn ResponseCache>) -> Self {
213        self.response_cache = Some(cache);
214        self
215    }
216
217    /// Tool permission policy (permission tiering + sandbox gate, P2-9).
218    ///
219    /// Checked before every tool execution: tools whose risk exceeds `max_permitted`
220    /// are rejected; high-risk tools that are not declared sandboxed
221    /// ([`ToolPolicy::sandboxed`]) are also rejected. Unconfigured = everything allowed.
222    ///
223    /// # Example
224    ///
225    /// ```rust,ignore
226    /// let policy = ToolPolicy::new()
227    ///     .risk("code_interpreter", ToolRisk::Dangerous)
228    ///     .sandboxed("code_interpreter"); // moved into a restricted environment, allowed to run
229    /// let executor = AgentExecutor::new(agent, tools).with_tool_policy(policy);
230    /// ```
231    pub fn with_tool_policy(mut self, policy: ToolPolicy) -> Self {
232        self.tool_policy = Some(policy);
233        self
234    }
235
236    /// Approval gate (§4.2): async approval before each tool execution.
237    ///
238    /// Default `None` = no interception; existing behavior unchanged. Approval
239    /// decisions (implemented by the caller via [`ApprovalHandler`]):
240    /// - [`ApprovalDecision::Allow`](crate::approval::ApprovalDecision::Allow): run as-is;
241    /// - [`ApprovalDecision::Deny`](crate::approval::ApprovalDecision::Deny): skip the tool,
242    ///   feed the reason back as an observation, and re-plan next round;
243    /// - [`ApprovalDecision::Modify`](crate::approval::ApprovalDecision::Modify): run with the
244    ///   new arguments substituted.
245    ///
246    /// # Example
247    ///
248    /// ```rust,ignore
249    /// let executor = AgentExecutor::new(agent, tools)
250    ///     .with_approval(Arc::new(AllowAll));
251    /// ```
252    pub fn with_approval(mut self, handler: Arc<dyn ApprovalHandler>) -> Self {
253        self.approval = Some(handler);
254        self
255    }
256
257    /// Budget gate (§4.2): hard limits, effective on both the `invoke` and `stream`
258    /// paths.
259    ///
260    /// - `invoke`: any limit hit returns [`AgentError::BudgetExceeded`] and stops
261    ///   immediately;
262    /// - `stream`: any limit hit sends `Err(AgentError::BudgetExceeded)` on the channel
263    ///   and stops.
264    ///
265    /// The caller can catch this error to distinguish a "budget stop" from "the model did
266    /// not converge". Default `None` = unlimited.
267    ///
268    /// # Example
269    ///
270    /// ```rust,ignore
271    /// let budget = BudgetConfig {
272    ///     max_tool_calls: Some(3),
273    ///     max_tokens: Some(10_000),
274    ///     max_duration: Some(Duration::from_secs(60)),
275    ///     max_iterations: Some(5),
276    /// };
277    /// let executor = AgentExecutor::new(agent, tools).with_budget(budget);
278    /// ```
279    pub fn with_budget(mut self, budget: BudgetConfig) -> Self {
280        self.budget = Some(budget);
281        self
282    }
283
284    /// Context compaction (0.21.0 S6.1): drop the oldest intermediate steps
285    /// (whole steps — action + observation stay paired) when the trigger fires.
286    ///
287    /// Checked before every `plan()` round in both the invoke and stream paths.
288    /// Off by default (`None` = unlimited history, zero behavior change).
289    ///
290    /// # Example
291    ///
292    /// ```rust,ignore
293    /// let config = CompactionConfig::new(
294    ///     CompactionTrigger::TurnCount(20),
295    ///     CompactionStrategy::SlidingWindow { keep_recent_turns: 8 },
296    /// );
297    /// let executor = AgentExecutor::new(agent, tools).with_compaction(config);
298    /// ```
299    pub fn with_compaction(mut self, compaction: CompactionConfig) -> Self {
300        self.compaction = Some(compaction);
301        self
302    }
303
304    /// Cross-process resume (§4.2): checkpoint store.
305    ///
306    /// When enabled, before each tool call enters the approval gate to await approval,
307    /// the framework writes the pending tool + the context needed to resume the agent
308    /// loop ([`PendingApproval`]) into the store; it is cleared once the approval
309    /// decision **lands**. If the process crashes, the checkpoint stays on disk; a new
310    /// process rebuilding an executor with the same configuration calls
311    /// [`pending_approval`](Self::pending_approval) / [`resume`](Self::resume) to
312    /// continue instead of replaying the whole conversation from scratch.
313    ///
314    /// Applies only to the non-streaming `invoke` path (the streaming path has no
315    /// approval gate); only meaningful together with
316    /// [`with_approval`](Self::with_approval). Parallel tool execution (multiple tools
317    /// approved concurrently) does not participate in cross-process persistence — the
318    /// in-process approval still works.
319    ///
320    /// # Example
321    ///
322    /// ```rust,ignore
323    /// let store = Arc::new(FileResumeStore::new("/var/checkpoints/app")?);
324    /// let executor = AgentExecutor::new(agent, tools)
325    ///     .with_resume_store(store)
326    ///     .with_approval(Arc::new(MyHandler));
327    /// ```
328    pub fn with_resume_store(mut self, store: Arc<dyn ResumeStore>) -> Self {
329        self.resume_store = Some(store);
330        self
331    }
332
333    /// Tool registration validation (P2-2).
334    ///
335    /// The Agent declares the tool names it may call via `get_allowed_tools()`; when it
336    /// declares some, every one must be present in this executor's `tools`, otherwise an
337    /// error is returned listing all missing tools. When the Agent declares nothing
338    /// (returns `None`, e.g. a base Agent with no tools), validation is skipped.
339    ///
340    /// Called before each `invoke` / `stream`: startup fail-fast, turning a mid-loop
341    /// `ToolNotFound` into a one-shot, all-configuration-errors-at-once report before
342    /// first execution.
343    pub fn validate_tool_registration(&self) -> Result<(), AgentError> {
344        let Some(allowed) = self.agent.get_allowed_tools() else {
345            return Ok(());
346        };
347        let registered: HashSet<&str> = self.tools.iter().map(|t| t.name()).collect();
348        let missing: Vec<&str> = allowed
349            .into_iter()
350            .filter(|name| !registered.contains(name))
351            .collect();
352        if missing.is_empty() {
353            return Ok(());
354        }
355        Err(AgentError::ToolNotFound(format!(
356            "tools not registered on executor: {}",
357            missing.join(", ")
358        )))
359    }
360
361    /// Sets verbose output.
362    pub fn with_verbose(mut self, verbose: bool) -> Self {
363        self.verbose = verbose;
364        self
365    }
366
367    /// Sets memory.
368    pub fn with_memory(mut self, memory: Arc<tokio::sync::Mutex<dyn BaseMemory>>) -> Self {
369        self.memory = Some(memory);
370        self
371    }
372
373    /// Sets callback manager.
374    pub fn with_callbacks(mut self, callbacks: Arc<CallbackManager>) -> Self {
375        self.callbacks = Some(callbacks);
376        self
377    }
378
379    /// Sets an observability sink: each run (invoke / stream / resume) exports one
380    /// [`AgentMetrics`] event at the end (v0.20.2). Failures are logged, never
381    /// propagated.
382    pub fn with_metrics_sink(mut self, sink: Arc<dyn MetricsSink>) -> Self {
383        self.metrics_sink = Some(sink);
384        self
385    }
386
387    /// Adds an agent hook.
388    pub fn hook(mut self, hook: impl AgentHook + 'static) -> Self {
389        self.hooks.push(Arc::new(hook));
390        self
391    }
392
393    /// Returns metrics from the most recent invocation, if any.
394    pub fn last_metrics(&self) -> Option<AgentMetrics> {
395        self.metrics_store.lock().ok().and_then(|g| g.clone())
396    }
397
398    /// Exports one run's metrics to the attached sink (v0.20.2). Called at every
399    /// run tail (invoke, resume, stream). Failures are logged as `warn` and never
400    /// propagate into the agent flow.
401    async fn export_metrics(&self, metrics: &AgentMetrics) {
402        if let Some(sink) = &self.metrics_sink {
403            let evt = ObsEvent::AgentMetrics(metrics.clone());
404            if let Err(e) = sink.export(&evt).await {
405                log::warn!(target: "lc_agents::metrics", "agent metrics export failed: {e}");
406            }
407        }
408    }
409
410    /// Reads the currently pending approval checkpoint (cross-process resume).
411    ///
412    /// Returns `Ok(None)` when no [`ResumeStore`] is configured or the store is empty.
413    /// After getting a [`PendingApproval`], the caller shows `tool_name` / `arguments`
414    /// to an operator, collects the approval decision, then calls
415    /// [`resume`](Self::resume) to continue.
416    pub async fn pending_approval(&self) -> Result<Option<PendingApproval>, AgentError> {
417        let Some(store) = &self.resume_store else {
418            return Ok(None);
419        };
420        store
421            .load_pending()
422            .await
423            .map_err(|e| AgentError::Resume(e.to_string()))
424    }
425
426    /// Resumes from a checkpoint (cross-process resume): processes the pending tool with
427    /// the given decision, then continues the agent loop from the suspended iteration and
428    /// returns the final answer.
429    ///
430    /// - No [`ResumeStore`] configured or no checkpoint → `Ok(None)` (no-op).
431    /// - A checkpoint exists → first **claims** it (clears it) to prevent duplicate
432    ///   approval, executes the pending tool, then continues the loop from
433    ///   `iteration + 1`; budgets (tool / token / iteration) keep counting from the
434    ///   checkpoint's accumulated amounts, and `max_duration` restarts its timer at the
435    ///   resume moment (a cross-process monotonic clock is not portable — an honest
436    ///   approximation).
437    ///
438    /// The resuming executor must be constructed identically to the one before the crash
439    /// (same agent / tools / store directory) to resume correctly; the approval decision
440    /// is injected by the caller and [`ApprovalHandler`] is not re-run.
441    pub async fn resume(&self, decision: ApprovalDecision) -> Result<Option<String>, AgentError> {
442        let Some(store) = &self.resume_store else {
443            return Ok(None);
444        };
445        let Some(pending) = store
446            .load_pending()
447            .await
448            .map_err(|e| AgentError::Resume(e.to_string()))?
449        else {
450            return Ok(None);
451        };
452        // Claim the checkpoint: clear it first. If resume crashes midway, approval is
453        // not repeated (at most once).
454        store
455            .clear_pending()
456            .await
457            .map_err(|e| AgentError::Resume(e.to_string()))?;
458
459        let action = AgentAction {
460            tool: pending.tool_name.clone(),
461            tool_input: ToolInput::Object {
462                value: pending.arguments.clone(),
463            },
464            log: String::new(),
465        };
466
467        let mut root_run = RunTree::new(
468            "AgentExecutor",
469            RunType::Chain,
470            json!({"input": pending.inputs.get("input").cloned().unwrap_or_default()}),
471        );
472        // Reuse the original trace_id so the resumed tool child runs keep trace
473        // continuity.
474        if let Some(tid) = &pending.trace_id {
475            if let Ok(id) = uuid::Uuid::parse_str(tid) {
476                root_run.trace_id = Some(id);
477                root_run = root_run.with_metadata("trace_id", json!(tid));
478            }
479        }
480
481        let started = std::time::Instant::now();
482        let mut metrics = AgentMetrics {
483            trace_id: root_run.trace_id.map(|id| id.to_string()),
484            tool_calls: pending.tool_calls_consumed,
485            total_tokens: pending.tokens_consumed,
486            ..Default::default()
487        };
488
489        // Execute the pending tool (inject the given decision; do not re-run the
490        // approval handler).
491        let observation = self
492            .execute_tool_inner(&action, &root_run, None, Some(decision))
493            .await?;
494        let mut steps = pending.steps;
495        steps.push(AgentStep::new(action, observation));
496
497        let result = self
498            .run_agent_loop_from(
499                pending.inputs,
500                steps,
501                pending.iteration + 1,
502                &mut root_run,
503                &mut metrics,
504            )
505            .await;
506
507        metrics.duration = started.elapsed();
508        metrics.log_summary();
509        if let Ok(mut guard) = self.metrics_store.lock() {
510            *guard = Some(metrics.clone());
511        }
512        self.export_metrics(&metrics).await;
513        result.map(Some)
514    }
515
516    /// Builds the `plan()` cache key: namespace + inputs + intermediate steps (including
517    /// tool observations).
518    ///
519    /// A deterministic Agent always produces the same `AgentOutput` for the same
520    /// `(inputs, steps)`, so this hash is the "LLM result" fingerprint; observations are
521    /// part of the key, so the cache cannot wrongly hit across different tool results.
522    fn cache_key(namespace: &str, inputs: &HashMap<String, String>, steps: &[AgentStep]) -> String {
523        use std::hash::{Hash, Hasher};
524        let payload = json!({ "ns": namespace, "inputs": inputs, "steps": steps });
525        let mut hasher = std::collections::hash_map::DefaultHasher::new();
526        payload.to_string().hash(&mut hasher);
527        format!("{:016x}", hasher.finish())
528    }
529
530    /// One-stop lookup / `plan()` / write-back.
531    ///
532    /// On a hit, returns the previous `AgentOutput` directly and records
533    /// `metrics.cache_hits`, without calling the LLM; on a miss, calls `plan()` and
534    /// serializes the result back. Corrupt cache content degrades to re-planning.
535    pub(crate) async fn plan_cached(
536        &self,
537        intermediate_steps: &[AgentStep],
538        inputs: &HashMap<String, String>,
539        metrics: &mut AgentMetrics,
540    ) -> Result<AgentOutput, AgentError> {
541        let cache = self.response_cache.as_ref();
542        let key = cache.map(|_| Self::cache_key(&self.cache_namespace, inputs, intermediate_steps));
543
544        if let (Some(cache), Some(key)) = (cache, &key) {
545            if let Some(cached) = cache.get(key) {
546                match serde_json::from_str::<AgentOutput>(&cached) {
547                    Ok(output) => {
548                        metrics.cache_hits += 1;
549                        log::debug!(target: "lc_agents::cache", "plan cache hit: {}", key);
550                        return Ok(output);
551                    }
552                    Err(_) => {
553                        log::warn!(target: "lc_agents::cache", "plan cache corrupt, re-plan");
554                    }
555                }
556            }
557        }
558
559        metrics.llm_calls += 1;
560        // P2-9: rate-limit / quota check before the LLM call (Reject → abort this round).
561        run_before_completion_hooks(&self.hooks, inputs)?;
562        let output = self.agent.plan(intermediate_steps, inputs).await?;
563        let usage = self.agent.last_token_usage();
564        if let Some(usage) = &usage {
565            metrics.add_token_usage(usage);
566        }
567        // P2-9: accumulate the real token usage after the LLM call (for the rate-limit
568        // hook's accounting).
569        run_after_completion_hooks(&self.hooks, &output, usage.as_ref());
570
571        if let (Some(cache), Some(key)) = (cache, &key) {
572            if let Ok(serialized) = serde_json::to_string(&output) {
573                cache.put(key.clone(), serialized);
574            }
575        }
576        Ok(output)
577    }
578
579    /// Executes the agent.
580    pub async fn invoke(&self, input: String) -> Result<String, AgentError> {
581        self.invoke_inner(input, None).await
582    }
583
584    /// Internal execution with optional trace propagation (P1-4).
585    ///
586    /// `trace_id` comes from `RunnableConfig.metadata["trace_id"]` when present;
587    /// `None` for plain `invoke`. The trace_id is stamped onto the root
588    /// `RunTree` so every tool child run inherits it via `create_child`.
589    async fn invoke_inner(
590        &self,
591        input: String,
592        trace_id: Option<String>,
593    ) -> Result<String, AgentError> {
594        // P2-2: startup fail-fast — error out on unregistered tools before any LLM call.
595        self.validate_tool_registration()?;
596
597        let started = std::time::Instant::now();
598
599        // Hooks: on_agent_start (P1-6)
600        for hook in &self.hooks {
601            if let Err(e) = hook.on_agent_start(&input) {
602                log::warn!("Hook on_agent_start error: {}", e);
603            }
604        }
605
606        let mut root_run = RunTree::new(
607            "AgentExecutor",
608            RunType::Chain,
609            json!({"input": input.clone()}),
610        );
611
612        // P1-4: stamp trace_id onto the root run so tool child runs inherit it.
613        if let Some(tid) = trace_id {
614            match uuid::Uuid::parse_str(&tid) {
615                Ok(id) => {
616                    root_run.trace_id = Some(id);
617                    root_run = root_run.with_metadata("trace_id", json!(tid));
618                }
619                Err(_) => log::warn!(target: "lc_agents", "invalid trace_id '{}' ignored", tid),
620            }
621        }
622
623        if let Some(ref callbacks) = self.callbacks {
624            for handler in callbacks.handlers() {
625                handler.on_chain_start(&root_run, &root_run.inputs).await;
626            }
627        }
628
629        let mut inputs = HashMap::new();
630        inputs.insert("input".to_string(), input.clone());
631
632        if let Some(memory) = &self.memory {
633            let memory_guard = memory.lock().await;
634            // P1-7: inject every key from memory_variables() into the prompt rather than
635            // hardcoding "history" — so the Agent can also read it when the memory
636            // component uses a different key (e.g. VectorStore's "memory").
637            let variable_keys: Vec<String> = memory_guard
638                .memory_variables()
639                .into_iter()
640                .map(|k| k.to_string())
641                .collect();
642            let memory_vars = memory_guard
643                .load_memory_variables(&inputs)
644                .await
645                .map_err(|e| AgentError::Other(format!("Failed to load memory: {}", e)))?;
646            drop(memory_guard);
647
648            for key in variable_keys {
649                if let Some(value) = memory_vars.get(&key) {
650                    if let Some(s) = value.as_str() {
651                        inputs.insert(key, s.to_string());
652                    }
653                }
654            }
655        }
656
657        let intermediate_steps: Vec<AgentStep> = Vec::new();
658
659        let mut metrics = AgentMetrics {
660            trace_id: root_run.trace_id.map(|id| id.to_string()),
661            ..Default::default()
662        };
663
664        let result = self
665            .run_agent_loop(
666                inputs.clone(),
667                intermediate_steps,
668                &mut root_run,
669                &mut metrics,
670            )
671            .await;
672
673        // 0.22.0 audit fix (H-A9) reconciled with the F7 error-save contract:
674        // the errored round is still part of the conversation — write the user's
675        // input back to memory so the next round does not lose it. But the raw
676        // error text is never stored as assistant output (H-A9): the assistant
677        // slot is left empty, so framework noise can never be mistaken for
678        // assistant reasoning. A save failure only warns; it never masks the
679        // original agent error.
680        if let Some(memory) = &self.memory {
681            match &result {
682                Ok(output) => {
683                    let mut outputs = HashMap::new();
684                    outputs.insert("output".to_string(), output.clone());
685
686                    memory
687                        .lock()
688                        .await
689                        .save_context(&inputs, &outputs)
690                        .await
691                        .map_err(|e| AgentError::Other(format!("Failed to save memory: {}", e)))?;
692                }
693                Err(e) => {
694                    let mut outputs = HashMap::new();
695                    outputs.insert("output".to_string(), String::new());
696                    if let Err(save_err) =
697                        memory.lock().await.save_context(&inputs, &outputs).await
698                    {
699                        log::warn!(
700                            "failed to save errored round to memory: {}, original error: {}",
701                            save_err,
702                            e
703                        );
704                    }
705                }
706            }
707        }
708
709        match &result {
710            Ok(output) => {
711                root_run.end(json!({"output": output}));
712                if let Some(ref callbacks) = self.callbacks {
713                    if let Some(ref outputs) = root_run.outputs {
714                        for handler in callbacks.handlers() {
715                            handler.on_chain_end(&root_run, outputs).await;
716                        }
717                    }
718                }
719
720                // Hooks: on_agent_end (P1-6)
721                for hook in &self.hooks {
722                    if let Err(e) = hook.on_agent_end(output) {
723                        log::warn!("Hook on_agent_end error: {}", e);
724                    }
725                }
726            }
727            Err(e) => {
728                root_run.end_with_error(e.to_string());
729                if let Some(ref callbacks) = self.callbacks {
730                    for handler in callbacks.handlers() {
731                        handler.on_chain_error(&root_run, &e.to_string()).await;
732                    }
733                }
734
735                // Hooks: on_error (P1-6)
736                for hook in &self.hooks {
737                    hook.on_error(&HookError::Other(e.to_string()));
738                }
739            }
740        }
741
742        // P1-5: finalize and publish metrics.
743        metrics.duration = started.elapsed();
744        metrics.log_summary();
745        if let Ok(mut store) = self.metrics_store.lock() {
746            *store = Some(metrics.clone());
747        }
748        self.export_metrics(&metrics).await;
749
750        result
751    }
752
753    /// Execute the agent with a RunnableConfig, merging config callbacks
754    /// with the executor's own callbacks.
755    ///
756    /// This is the entry point used by `AgentRunnable` (LCEL adapter).
757    /// Config callbacks take precedence over the executor's callbacks.
758    pub async fn invoke_with_config(
759        &self,
760        input: String,
761        config: Option<RunnableConfig>,
762    ) -> Result<String, AgentError> {
763        // If config has callbacks, temporarily use them; otherwise use executor's own
764        let effective_callbacks = config
765            .as_ref()
766            .and_then(|c| c.callbacks.clone())
767            .or_else(|| self.callbacks.clone());
768
769        // P1-4: thread trace_id from config metadata so child runs inherit it.
770        let trace_id = config
771            .as_ref()
772            .and_then(|c| c.metadata.get("trace_id"))
773            .and_then(|v| v.as_str())
774            .map(|s| s.to_string());
775
776        // Create a temporary executor with merged callbacks; metrics_store is
777        // Arc-shared so metrics written here propagate back to this executor.
778        let merged_executor = AgentExecutor {
779            agent: self.agent.clone(),
780            tools: self.tools.clone(),
781            max_iterations: self.max_iterations,
782            verbose: self.verbose,
783            memory: self.memory.clone(),
784            callbacks: effective_callbacks,
785            hooks: self.hooks.clone(),
786            tool_timeout: self.tool_timeout,
787            max_concurrency: self.max_concurrency,
788            concurrency_sem: self.concurrency_sem.clone(),
789            metrics_store: self.metrics_store.clone(),
790            response_cache: self.response_cache.clone(),
791            cache_namespace: self.cache_namespace.clone(),
792            tool_policy: self.tool_policy.clone(),
793            approval: self.approval.clone(),
794            budget: self.budget.clone(),
795            compaction: self.compaction.clone(),
796            resume_store: self.resume_store.clone(),
797            metrics_sink: self.metrics_sink.clone(),
798            on_max_iterations: self.on_max_iterations,
799        };
800
801        merged_executor.invoke_inner(input, trace_id).await
802    }
803
804    /// Stream agent execution as a true async stream of events.
805    ///
806    /// Each step of the agent loop (tool calls, observations, final answer)
807    /// is emitted as an `AgentStreamEvent` as soon as it occurs.
808    ///
809    /// # `Text` event granularity (F3, honest)
810    ///
811    /// `Text` events carry model text, but their granularity depends on the
812    /// agent's [`BaseAgent::plan_stream`] implementation:
813    ///
814    /// * **ReAct and FunctionCalling agents** stream from the model's chat API,
815    ///   so `Text` events arrive **per token** — concat them as they come for a
816    ///   live word-stream. A function-calling step that calls a tool streams
817    ///   back empty model text (tool calls aren't carried in stream chunks);
818    ///   such steps fall back to the non-streaming path internally, so no
819    ///   phantom empty `Text` is emitted.
820    /// * **Other agents** (plan-and-execute without a streaming inner agent, …)
821    ///   use the non-streaming default, so the whole final answer arrives as a
822    ///   single `Text` event immediately before `FinalAnswer`.
823    ///
824    /// `ToolStart`/`ToolEnd` events are always emitted per tool call.
825    ///
826    /// # Example
827    ///
828    /// ```rust,ignore
829    /// let mut stream = executor.stream("What is Rust?".to_string());
830    /// while let Some(event) = stream.next().await {
831    ///     match event {
832    ///         Ok(AgentStreamEvent::ToolStart { name, input }) => { /* show tool call */ }
833    ///         Ok(AgentStreamEvent::ToolEnd { name, output }) => { /* show result */ }
834    ///         Ok(AgentStreamEvent::Text { content }) => { print!("{}", content); } /* model text */
835    ///         Ok(AgentStreamEvent::FinalAnswer { content }) => { /* show answer */ }
836    ///         _ => {}
837    ///     }
838    /// }
839    /// ```
840    pub fn stream(
841        &self,
842        input: String,
843    ) -> Pin<Box<dyn Stream<Item = Result<AgentStreamEvent, AgentError>> + Send>> {
844        let (tx, rx) = tokio::sync::mpsc::channel(32);
845
846        // 0.20.0 A-H2: dropping the returned stream must stop the background agent
847        // loop. Without this, a consumer that stops reading (a client disconnect, an
848        // early UI cancel) left the loop running — consuming tool calls and LLM tokens
849        // for a listener that is gone. The watch channel is the cancel signal: the loop
850        // checks it at iteration / tool boundaries, and the wrapper (`AgentEventStream`)
851        // sends `true` on drop.
852        let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
853
854        // P2-2: the streaming path also fails fast — unregistered tools emit one error
855        // event before ending.
856        if let Err(e) = self.validate_tool_registration() {
857            tokio::spawn(async move {
858                let _ = tx.send(Err(e)).await;
859            });
860            return Box::pin(AgentEventStream {
861                inner: tokio_stream::wrappers::ReceiverStream::new(rx),
862                cancel: cancel_tx,
863            });
864        }
865
866        let agent = self.agent.clone();
867        let tools = self.tools.clone();
868        let max_iterations = self.max_iterations;
869        let verbose = self.verbose;
870        let tool_timeout = self.tool_timeout;
871        let max_concurrency = self.max_concurrency;
872        let hooks = self.hooks.clone();
873        let tool_policy = self.tool_policy.clone();
874        let budget = self.budget.clone();
875        let compaction = self.compaction.clone();
876        let metrics_store = self.metrics_store.clone();
877        let metrics_sink = self.metrics_sink.clone();
878        let on_max_iterations = self.on_max_iterations;
879        // 0.22.0 audit fix (H-A1): the stream path previously dropped the
880        // cross-cutting capabilities invoke has. Clone callbacks + memory into
881        // the spawned task so chain-level callbacks are dispatched, memory
882        // history is loaded before the loop and the final answer is saved.
883        let callbacks = self.callbacks.clone();
884        let memory = self.memory.clone();
885
886        tokio::spawn(async move {
887            let mut intermediate_steps: Vec<AgentStep> = Vec::new();
888            let mut inputs = HashMap::new();
889            inputs.insert("input".to_string(), input.clone());
890
891            // H-A1: chain callbacks / trace parity with invoke — build the root
892            // RunTree, dispatch on_chain_start and on_agent_start hooks, and load
893            // memory variables into the inputs before the loop.
894            //
895            // Remaining known gaps (honest): LLM-level `on_llm_*` callbacks,
896            // tool-level `on_tool_*` callbacks, and RunTree trace_id stamping from
897            // RunnableConfig metadata (stream() takes no config) are still not
898            // dispatched on this path — invoke's tool child-run tracing has no
899            // equivalent here because tool execution goes through
900            // `execute_tool_for_stream` without a RunTree.
901            let mut root_run = RunTree::new(
902                "AgentExecutor",
903                RunType::Chain,
904                json!({"input": inputs.get("input").cloned().unwrap_or_default()}),
905            );
906            if let Some(ref callbacks) = callbacks {
907                for handler in callbacks.handlers() {
908                    handler.on_chain_start(&root_run, &root_run.inputs).await;
909                }
910            }
911            for hook in &hooks {
912                if let Err(e) = hook.on_agent_start(&input) {
913                    log::warn!("Hook on_agent_start error: {}", e);
914                }
915            }
916
917            if let Some(memory) = &memory {
918                let memory_guard = memory.lock().await;
919                let variable_keys: Vec<String> = memory_guard
920                    .memory_variables()
921                    .into_iter()
922                    .map(|k| k.to_string())
923                    .collect();
924                let loaded = match memory_guard.load_memory_variables(&inputs).await {
925                    Ok(vars) => vars,
926                    Err(e) => {
927                        let msg = format!("Failed to load memory: {e}");
928                        stream_chain_error(&callbacks, &mut root_run, &msg).await;
929                        for hook in &hooks {
930                            hook.on_error(&HookError::Other(msg.clone()));
931                        }
932                        let _ = tx.send(Err(AgentError::Other(msg))).await;
933                        return;
934                    }
935                };
936                drop(memory_guard);
937                for key in variable_keys {
938                    if let Some(value) = loaded.get(&key) {
939                        if let Some(s) = value.as_str() {
940                            inputs.insert(key, s.to_string());
941                        }
942                    }
943                }
944            }
945
946            // Budget gate (§4.2): start the stream timer + accumulate metrics (same
947            // semantics as the invoke path).
948            let loop_start = Instant::now();
949            let mut metrics = AgentMetrics::default();
950
951            for iteration in 0..max_iterations {
952                if verbose {
953                    log::info!("=== Stream Iteration {} ===", iteration + 1);
954                }
955
956                // 0.20.0 A-H2: the consumer dropped the stream → stop before the next
957                // plan. Any tool already in flight is allowed to finish (cooperative
958                // cancellation), but no new plan / tool starts.
959                if *cancel_rx.borrow() {
960                    return;
961                }
962
963                // Budget gate: iteration-level (iteration count + wall-clock). Over the
964                // limit → send Err and stop.
965                if let Some(err) =
966                    budget_iteration_gate(budget.as_ref(), max_iterations, iteration, loop_start)
967                {
968                    stream_chain_error(&callbacks, &mut root_run, &err.to_string()).await;
969                    publish_metrics(&metrics, &metrics_store, &metrics_sink, loop_start).await;
970                    let _ = tx.send(Err(err)).await;
971                    return;
972                }
973
974                // P2-9: rate-limit / quota check before the LLM call (also applies on
975                // the streaming path).
976                if let Err(e) = run_before_completion_hooks(&hooks, &inputs) {
977                    let msg = e.to_string();
978                    stream_chain_error(&callbacks, &mut root_run, &msg).await;
979                    publish_metrics(&metrics, &metrics_store, &metrics_sink, loop_start).await;
980                    let _ = tx
981                        .send(Ok(AgentStreamEvent::Error { message: msg }))
982                        .await;
983                    return;
984                }
985                // F3: streaming planning — the agent forwards model text token by token
986                // through on_token as Text events. ReAct / FunctionCalling override
987                // plan_stream to go through `stream_chat` for a real word-by-word stream;
988                // other agents use the default implementation (the whole answer as a
989                // single Text event), matching the old path's behavior.
990                // 0.21.0 S6.1: context compaction before planning — same semantics as
991                // the invoke path (`run_agent_loop_from`), so the two paths cannot diverge.
992                if let Some(config) = &compaction {
993                    let tokens = metrics.total_tokens.unwrap_or(0);
994                    let (kept, dropped) = config.compact(&intermediate_steps, tokens);
995                    if dropped > 0 {
996                        log::info!(
997                            target: "lc_agents::compaction",
998                            "compacted {} of {} steps ({} remain) [stream]",
999                            dropped,
1000                            dropped + kept.len(),
1001                            kept.len()
1002                        );
1003                        intermediate_steps = kept;
1004                        metrics.compactions += 1;
1005                    }
1006                }
1007                let output = {
1008                    // Must not shadow the outer tx: the closure's `move` would carry it
1009                    // away, and the ToolStart/FinalAnswer below would no longer be able
1010                    // to use the outer tx.
1011                    let send_tx = tx.clone();
1012                    // The callback receives its own String (F3): the async block owns
1013                    // the token directly instead of borrowing the argument, so the future
1014                    // is 'static and can be cast to a trait object with `as`.
1015                    let mut on_token = move |token: String| {
1016                        let tx = send_tx.clone();
1017                        Box::pin(async move {
1018                            let _ = tx.send(Ok(AgentStreamEvent::Text { content: token })).await;
1019                        }) as Pin<Box<dyn Future<Output = ()> + Send>>
1020                    };
1021                    match agent
1022                        .plan_stream(&intermediate_steps, &inputs, &mut on_token)
1023                        .await
1024                    {
1025                        Ok(o) => o,
1026                        Err(e) => {
1027                            let msg = e.to_string();
1028                            stream_chain_error(&callbacks, &mut root_run, &msg).await;
1029                            for hook in &hooks {
1030                                hook.on_error(&HookError::Other(msg.clone()));
1031                            }
1032                            publish_metrics(&metrics, &metrics_store, &metrics_sink, loop_start)
1033                                .await;
1034                            let _ = tx
1035                                .send(Ok(AgentStreamEvent::Error { message: msg }))
1036                                .await;
1037                            return;
1038                        }
1039                    }
1040                };
1041                let usage = agent.last_token_usage();
1042                // P2-9: accumulate the real token usage after the LLM call (same semantics
1043                // as plan_cached on the invoke path).
1044                metrics.llm_calls += 1;
1045                if let Some(u) = &usage {
1046                    metrics.add_token_usage(u);
1047                }
1048                run_after_completion_hooks(&hooks, &output, usage.as_ref());
1049                // Budget gate: cumulative tokens after the LLM call. Over the limit →
1050                // send Err and stop.
1051                if let Some(err) = budget_token_gate(budget.as_ref(), &metrics) {
1052                    stream_chain_error(&callbacks, &mut root_run, &err.to_string()).await;
1053                    publish_metrics(&metrics, &metrics_store, &metrics_sink, loop_start).await;
1054                    let _ = tx.send(Err(err)).await;
1055                    return;
1056                }
1057
1058                match output {
1059                    AgentOutput::Finish(finish) => {
1060                        let content = finish.output().unwrap_or("").to_string();
1061                        // H-A1: memory save on the final answer, same as invoke's
1062                        // post-answer save_context. A save failure only warns — it
1063                        // must not mask a successfully finished stream.
1064                        if let Some(memory) = &memory {
1065                            let mut outputs = HashMap::new();
1066                            outputs.insert("output".to_string(), content.clone());
1067                            if let Err(e) =
1068                                memory.lock().await.save_context(&inputs, &outputs).await
1069                            {
1070                                log::warn!("failed to save final answer to memory [stream]: {e}");
1071                            }
1072                        }
1073
1074                        root_run.end(json!({"output": content.clone()}));
1075                        if let Some(ref callbacks) = callbacks {
1076                            if let Some(ref outputs) = root_run.outputs {
1077                                for handler in callbacks.handlers() {
1078                                    handler.on_chain_end(&root_run, outputs).await;
1079                                }
1080                            }
1081                        }
1082                        for hook in &hooks {
1083                            if let Err(e) = hook.on_agent_end(&content) {
1084                                log::warn!("Hook on_agent_end error: {}", e);
1085                            }
1086                        }
1087                        // P1-8 streaming fusion: the model text was already emitted piece
1088                        // by piece by plan_stream through on_token (Text events); here
1089                        // only the FinalAnswer terminal event is sent — the full answer is
1090                        // not repeated.
1091                        publish_metrics(&metrics, &metrics_store, &metrics_sink, loop_start).await;
1092                        let _ = tx.send(Ok(AgentStreamEvent::FinalAnswer { content })).await;
1093                        return;
1094                    }
1095
1096                    AgentOutput::Action(action) => {
1097                        // 0.22.0 audit fix (H-A5): the ReAct parse-repair pseudo-tool
1098                        // is not a real tool — feed its message back as the
1099                        // observation so the model can retry (standard ReAct repair
1100                        // loop), matching the invoke path.
1101                        if action.tool == crate::react::agent::PARSE_ERROR_TOOL {
1102                            let observation = match &action.tool_input {
1103                                ToolInput::String { value } => value.clone(),
1104                                ToolInput::Object { value } => value.to_string(),
1105                            };
1106                            if verbose {
1107                                log::info!("Parse repair observation: {}", observation);
1108                            }
1109                            intermediate_steps.push(AgentStep::new(action, observation));
1110                            continue;
1111                        }
1112                        // P2-9: the streaming path also enforces the tool permission
1113                        // policy.
1114                        if let Some(policy) = &tool_policy {
1115                            if let Err(e) = policy.check(&action.tool) {
1116                                let msg = e.to_string();
1117                                stream_chain_error(&callbacks, &mut root_run, &msg).await;
1118                                publish_metrics(
1119                                    &metrics,
1120                                    &metrics_store,
1121                                    &metrics_sink,
1122                                    loop_start,
1123                                )
1124                                .await;
1125                                let _ = tx
1126                                    .send(Ok(AgentStreamEvent::Error { message: msg }))
1127                                    .await;
1128                                return;
1129                            }
1130                        }
1131                        let tool_name = action.tool.clone();
1132                        let tool_input_str = match &action.tool_input {
1133                            ToolInput::String { value: s } => s.clone(),
1134                            ToolInput::Object { value: v } => {
1135                                serde_json::to_string(v).unwrap_or_default()
1136                            }
1137                        };
1138
1139                        let _ = tx
1140                            .send(Ok(AgentStreamEvent::ToolStart {
1141                                name: tool_name.clone(),
1142                                input: tool_input_str.clone(),
1143                            }))
1144                            .await;
1145
1146                        // Budget gate: check cumulative call count and wall-clock before
1147                        // the tool runs.
1148                        metrics.tool_calls += 1;
1149                        if let Some(err) = budget_tool_gate(budget.as_ref(), &metrics, loop_start) {
1150                            stream_chain_error(&callbacks, &mut root_run, &err.to_string()).await;
1151                            publish_metrics(&metrics, &metrics_store, &metrics_sink, loop_start)
1152                                .await;
1153                            let _ = tx.send(Err(err)).await;
1154                            return;
1155                        }
1156
1157                        // 0.20.0 A-H2: dropped mid-iteration → do not start a new tool.
1158                        if *cancel_rx.borrow() {
1159                            return;
1160                        }
1161
1162                        // Execute the tool. A tool **execution** failure becomes an
1163                        // observation fed back to the loop (S3.1) so the agent can
1164                        // recover. Framework guardrails (A-H1, 0.20.0) —
1165                        // `ControlAbort` handoff/depth guard, `ToolNotFound`, input
1166                        // serialization — reject the call *before* execution; the agent
1167                        // cannot recover from them by re-planning, so they end the
1168                        // stream hard, matching the non-streaming invoke path.
1169                        let observation =
1170                            match execute_tool_for_stream(&tools, &action, tool_timeout).await {
1171                                Ok(obs) => obs,
1172                                Err(e @ AgentError::ToolExecutionError(_)) => {
1173                                    tool_error_observation(&e)
1174                                }
1175                                Err(e) => {
1176                                    let msg = e.to_string();
1177                                    stream_chain_error(&callbacks, &mut root_run, &msg).await;
1178                                    publish_metrics(
1179                                        &metrics,
1180                                        &metrics_store,
1181                                        &metrics_sink,
1182                                        loop_start,
1183                                    )
1184                                    .await;
1185                                    let _ = tx
1186                                        .send(Ok(AgentStreamEvent::Error { message: msg }))
1187                                        .await;
1188                                    return;
1189                                }
1190                            };
1191
1192                        let _ = tx
1193                            .send(Ok(AgentStreamEvent::ToolEnd {
1194                                name: tool_name,
1195                                output: observation.clone(),
1196                            }))
1197                            .await;
1198
1199                        intermediate_steps.push(AgentStep::new(action, observation));
1200                    }
1201
1202                    AgentOutput::Actions(actions) => {
1203                        // P2-9: parallel tools also pass the permission policy first.
1204                        if let Some(policy) = &tool_policy {
1205                            for action in &actions {
1206                                if let Err(e) = policy.check(&action.tool) {
1207                                    let msg = e.to_string();
1208                                    stream_chain_error(&callbacks, &mut root_run, &msg).await;
1209                                    publish_metrics(
1210                                        &metrics,
1211                                        &metrics_store,
1212                                        &metrics_sink,
1213                                        loop_start,
1214                                    )
1215                                    .await;
1216                                    let _ = tx
1217                                        .send(Ok(AgentStreamEvent::Error { message: msg }))
1218                                        .await;
1219                                    return;
1220                                }
1221                            }
1222                        }
1223                        for action in &actions {
1224                            let tool_name = action.tool.clone();
1225                            let tool_input_str = match &action.tool_input {
1226                                ToolInput::String { value: s } => s.clone(),
1227                                ToolInput::Object { value: v } => {
1228                                    serde_json::to_string(v).unwrap_or_default()
1229                                }
1230                            };
1231
1232                            let _ = tx
1233                                .send(Ok(AgentStreamEvent::ToolStart {
1234                                    name: tool_name.clone(),
1235                                    input: tool_input_str,
1236                                }))
1237                                .await;
1238                        }
1239
1240                        // Budget gate: check cumulative call count and wall-clock before
1241                        // the parallel tools run.
1242                        metrics.tool_calls += actions.len();
1243                        if let Some(err) = budget_tool_gate(budget.as_ref(), &metrics, loop_start) {
1244                            stream_chain_error(&callbacks, &mut root_run, &err.to_string()).await;
1245                            publish_metrics(&metrics, &metrics_store, &metrics_sink, loop_start)
1246                                .await;
1247                            let _ = tx.send(Err(err)).await;
1248                            return;
1249                        }
1250
1251                        // 0.20.0 A-H2: dropped mid-iteration → do not start a new batch.
1252                        if *cancel_rx.borrow() {
1253                            return;
1254                        }
1255
1256                        let observations = match execute_tools_parallel_for_stream(
1257                            &tools,
1258                            &actions,
1259                            tool_timeout,
1260                            max_concurrency,
1261                        )
1262                        .await
1263                        {
1264                            Ok(obs) => obs,
1265                            // A-H1 (0.20.0): a framework guardrail in any one tool
1266                            // of the batch ends the stream hard, matching the
1267                            // invoke-parallel path. Execution errors were already
1268                            // converted to observations inside the helper.
1269                            Err(e) => {
1270                                let msg = e.to_string();
1271                                stream_chain_error(&callbacks, &mut root_run, &msg).await;
1272                                publish_metrics(
1273                                    &metrics,
1274                                    &metrics_store,
1275                                    &metrics_sink,
1276                                    loop_start,
1277                                )
1278                                .await;
1279                                let _ = tx
1280                                    .send(Ok(AgentStreamEvent::Error { message: msg }))
1281                                    .await;
1282                                return;
1283                            }
1284                        };
1285
1286                        for (action, observation) in
1287                            actions.into_iter().zip(observations.into_iter())
1288                        {
1289                            let _ = tx
1290                                .send(Ok(AgentStreamEvent::ToolEnd {
1291                                    name: action.tool.clone(),
1292                                    output: observation.clone(),
1293                                }))
1294                                .await;
1295
1296                            intermediate_steps.push(AgentStep::new(action, observation));
1297                        }
1298                    }
1299                }
1300            }
1301
1302            // 0.22.0 C4 fix: the iteration cap is a failure by default —
1303            // surface `MaxIterationsReached` on the stream instead of streaming
1304            // a placeholder that looks like a real answer.
1305            log::warn!(
1306                "agent reached max iterations; policy: {:?}",
1307                on_max_iterations
1308            );
1309            if on_max_iterations == MaxIterationsPolicy::Error {
1310                stream_chain_error(&callbacks, &mut root_run, "max iterations reached").await;
1311                publish_metrics(&metrics, &metrics_store, &metrics_sink, loop_start).await;
1312                let _ = tx
1313                    .send(Err(AgentError::MaxIterationsReached))
1314                    .await;
1315                return;
1316            }
1317            // Legacy placeholder policy: the stopped response is treated as a final
1318            // answer — run the H-A1 terminal path (memory save + chain end) too.
1319            let finish = agent.return_stopped_response(&intermediate_steps);
1320            let content = finish.output().unwrap_or("").to_string();
1321            if let Some(memory) = &memory {
1322                let mut outputs = HashMap::new();
1323                outputs.insert("output".to_string(), content.clone());
1324                if let Err(e) = memory.lock().await.save_context(&inputs, &outputs).await {
1325                    log::warn!("failed to save final answer to memory [stream]: {e}");
1326                }
1327            }
1328            root_run.end(json!({"output": content.clone()}));
1329            if let Some(ref callbacks) = callbacks {
1330                if let Some(ref outputs) = root_run.outputs {
1331                    for handler in callbacks.handlers() {
1332                        handler.on_chain_end(&root_run, outputs).await;
1333                    }
1334                }
1335            }
1336            for hook in &hooks {
1337                if let Err(e) = hook.on_agent_end(&content) {
1338                    log::warn!("Hook on_agent_end error: {}", e);
1339                }
1340            }
1341            publish_metrics(&metrics, &metrics_store, &metrics_sink, loop_start).await;
1342            let _ = tx.send(Ok(AgentStreamEvent::FinalAnswer { content })).await;
1343        });
1344
1345        Box::pin(AgentEventStream {
1346            inner: tokio_stream::wrappers::ReceiverStream::new(rx),
1347            cancel: cancel_tx,
1348        })
1349    }
1350}
1351
1352impl std::fmt::Debug for AgentExecutor {
1353    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1354        f.debug_struct("AgentExecutor")
1355            .field("max_iterations", &self.max_iterations)
1356            .field("verbose", &self.verbose)
1357            .field("tools_count", &self.tools.len())
1358            .field("has_memory", &self.memory.is_some())
1359            .field("tool_timeout", &self.tool_timeout)
1360            .field("max_concurrency", &self.max_concurrency)
1361            .field("has_response_cache", &self.response_cache.is_some())
1362            .field("has_tool_policy", &self.tool_policy.is_some())
1363            .field("has_resume_store", &self.resume_store.is_some())
1364            .field("has_metrics_sink", &self.metrics_sink.is_some())
1365            .field(
1366                "has_metrics",
1367                &self
1368                    .metrics_store
1369                    .lock()
1370                    .ok()
1371                    .map(|guard| guard.is_some())
1372                    .unwrap_or(false),
1373            )
1374            .finish()
1375    }
1376}
1377
1378/// Stream wrapper returned by [`AgentExecutor::stream`]: cancels the background agent
1379/// loop when the consumer drops the stream (0.20.0 A-H2). A dropped stream means the
1380/// listener is gone — the loop must stop burning tool calls and LLM tokens instead of
1381/// running the remaining iterations invisibly.
1382struct AgentEventStream {
1383    /// The live event channel.
1384    inner: tokio_stream::wrappers::ReceiverStream<Result<AgentStreamEvent, AgentError>>,
1385    /// Set to `true` on drop; the loop observes it via `cancel_rx` at iteration / tool
1386    /// boundaries and stops cooperatively (letting any in-flight tool finish).
1387    cancel: tokio::sync::watch::Sender<bool>,
1388}
1389
1390impl Stream for AgentEventStream {
1391    type Item = Result<AgentStreamEvent, AgentError>;
1392
1393    fn poll_next(
1394        mut self: Pin<&mut Self>,
1395        cx: &mut std::task::Context<'_>,
1396    ) -> std::task::Poll<Option<Self::Item>> {
1397        Pin::new(&mut self.inner).poll_next(cx)
1398    }
1399}
1400
1401impl Drop for AgentEventStream {
1402    fn drop(&mut self) {
1403        let _ = self.cancel.send(true);
1404    }
1405}
1406
1407/// 0.22.0 audit fix (H-A1): dispatches the chain-error callbacks on the stream
1408/// path, mirroring invoke's `on_chain_error` handling. Best-effort — never
1409/// fails, only marks the root run as errored first.
1410async fn stream_chain_error(
1411    callbacks: &Option<Arc<CallbackManager>>,
1412    root_run: &mut RunTree,
1413    message: &str,
1414) {
1415    root_run.end_with_error(message.to_string());
1416    if let Some(callbacks) = callbacks {
1417        for handler in callbacks.handlers() {
1418            handler.on_chain_error(root_run, message).await;
1419        }
1420    }
1421}
1422
1423/// Publishes `AgentMetrics` at the end of a stream (aligned with the invoke path):
1424/// clone → fill duration → audit log → write `metrics_store`.
1425///
1426/// **Ordering constraint (race)**: the stream closure runs in `tokio::spawn`, so every
1427/// termination path must **`publish_metrics` before `tx.send(terminal event)`** —
1428/// otherwise a consumer that checks `last_metrics()` immediately after draining the
1429/// stream may read `None` (the event arrived but the write has not happened yet).
1430async fn publish_metrics(
1431    metrics: &AgentMetrics,
1432    metrics_store: &Arc<Mutex<Option<AgentMetrics>>>,
1433    metrics_sink: &Option<Arc<dyn MetricsSink>>,
1434    started: Instant,
1435) {
1436    let mut m = metrics.clone();
1437    m.duration = started.elapsed();
1438    m.log_summary();
1439    if let Ok(mut guard) = metrics_store.lock() {
1440        *guard = Some(m.clone());
1441    }
1442    if let Some(sink) = metrics_sink {
1443        let evt = ObsEvent::AgentMetrics(m);
1444        if let Err(e) = sink.export(&evt).await {
1445            log::warn!(target: "lc_agents::metrics", "agent metrics export failed: {e}");
1446        }
1447    }
1448}