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