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::BudgetConfig;
5use super::compaction::CompactionConfig;
6use super::hooks::{run_after_completion_hooks, run_before_completion_hooks};
7use super::semantic_memory::{SemanticMemoryHook, SEMANTIC_MEMORY_INPUT_KEY};
8use super::tools::index_tools;
9use super::{
10    AgentError, BaseAgent, CACHE_NS, DEFAULT_MAX_CONCURRENCY, MAX_MAX_ITERATIONS,
11    MIN_MAX_ITERATIONS,
12};
13use crate::approval::{ApprovalDecision, ApprovalHandler};
14use crate::cache::ResponseCache;
15use crate::hooks::{AgentHook, HookError};
16use crate::metrics::AgentMetrics;
17use crate::policy::ToolPolicy;
18use crate::resume::{PendingApproval, ResumeStore};
19use crate::types::{AgentAction, AgentOutput, AgentStep, ToolInput};
20use lc_callbacks::{semconv::GEN_AI_OPERATION_NAME, CallbackManager, RunTree, RunType};
21use lc_core::cost::CostTracker;
22use lc_core::observability::{MetricsSink, ObsEvent};
23use lc_core::runnables::{RunnableConfig, RUN_META_PARENT_RUN_ID, RUN_META_TRACE_ID};
24use lc_core::tools::BaseTool;
25use lc_memory::{BaseMemory, MemoryExtractor, TwoTierMemory};
26use serde_json::json;
27use std::collections::{HashMap, HashSet};
28use std::path::PathBuf;
29use std::sync::atomic::Ordering;
30use std::sync::{Arc, Mutex};
31use std::time::Duration;
32use tokio::sync::Semaphore;
33
34/// A18: builds the per-round [`RunnableConfig`] handed to
35/// [`BaseAgent::plan`]/[`BaseAgent::plan_stream`] for one agent run.
36///
37/// The config carries the effective callback manager plus reserved trace-linkage
38/// metadata (parent = this run's chain root, trace = root trace id), so the
39/// provider-built LLM [`RunTree`] is dispatched under the agent chain tree
40/// instead of becoming a trace root. Returns `None` when no callbacks are
41/// configured, preserving the pre-A18 behavior where providers fire nothing for
42/// observers-less executors (and zero per-round allocation happens).
43pub(super) fn build_plan_config(
44    callbacks: &Option<Arc<CallbackManager>>,
45    root_run: &RunTree,
46) -> Option<RunnableConfig> {
47    let manager = callbacks.as_ref()?;
48    let trace_id = root_run.trace_id.unwrap_or(root_run.id);
49    Some(
50        RunnableConfig::new()
51            .with_callbacks(manager.clone())
52            .with_metadata(RUN_META_PARENT_RUN_ID, json!(root_run.id.to_string()))
53            .with_metadata(RUN_META_TRACE_ID, json!(trace_id.to_string())),
54    )
55}
56
57/// Agent executor.
58///
59/// Responsible for executing the agent's decision loop: Plan -> Act -> Observe.
60pub struct AgentExecutor {
61    /// Agent instance.
62    pub(crate) agent: Arc<dyn BaseAgent>,
63
64    /// Available tools.
65    pub(crate) tools: Vec<Arc<dyn BaseTool>>,
66
67    /// A11: prebuilt name → tool index for O(1) lookups. Kept in sync with `tools`
68    /// (built in `new`, extended in `with_memory_tool`, cloned in the merged-executor
69    /// copy); `index_tools` preserves first-match-wins on name collisions.
70    pub(crate) tools_by_name: HashMap<String, Arc<dyn BaseTool>>,
71
72    /// Max iterations.
73    pub(crate) max_iterations: usize,
74
75    /// Verbose output.
76    pub(crate) verbose: bool,
77
78    /// Memory (optional).
79    pub(crate) memory: Option<Arc<tokio::sync::Mutex<dyn BaseMemory>>>,
80
81    /// Callback manager (optional).
82    pub(crate) callbacks: Option<Arc<CallbackManager>>,
83
84    /// Agent hooks (optional).
85    pub(crate) hooks: Vec<Arc<dyn AgentHook>>,
86
87    /// Tool execution timeout (None = no timeout).
88    pub(crate) tool_timeout: Option<Duration>,
89
90    /// Maximum number of tools executed concurrently.
91    pub(crate) max_concurrency: usize,
92
93    /// Semaphore guarding concurrent tool execution.
94    pub(crate) concurrency_sem: Arc<Semaphore>,
95
96    /// Most recent execution metrics (P1-5). Arc-shared so merged executors
97    /// created by `invoke_with_config` write back to the original executor.
98    pub(crate) metrics_store: Arc<Mutex<Option<AgentMetrics>>>,
99
100    /// LLM result cache (P2-1): `plan()` results hit on `(namespace, inputs, steps)`;
101    /// deterministic prompts are reused directly, skipping the LLM round-trip.
102    /// `None` = no caching.
103    pub(crate) response_cache: Option<Arc<dyn ResponseCache>>,
104    /// This instance's cache namespace (isolates executors sharing the same cache).
105    pub(crate) cache_namespace: String,
106
107    /// Tool permission policy (permission tiering + sandbox gate, P2-9).
108    /// `None` = no checks.
109    pub(crate) tool_policy: Option<ToolPolicy>,
110
111    /// Approval gate (§4.2): async approval before each tool execution. `None` = no
112    /// interception (default off).
113    pub(crate) approval: Option<Arc<dyn ApprovalHandler>>,
114    /// Budget gate (§4.2): hard limits. `None` = unlimited (default off).
115    pub(crate) budget: Option<BudgetConfig>,
116
117    /// Context compaction (0.21.0 S6.1): drops the oldest intermediate steps at
118    /// whole-step boundaries when the trigger fires. `None` = off (default).
119    pub(crate) compaction: Option<CompactionConfig>,
120
121    /// Cross-process resume (§4.2): checkpoint store. When `Some`, `execute_tool`
122    /// persists the pending approval before awaiting approval and clears it once the
123    /// decision lands; a new process can inspect it via `pending_approval()` and
124    /// continue via `resume(decision)`. `None` = off (default).
125    pub(crate) resume_store: Option<Arc<dyn ResumeStore>>,
126
127    /// Observability sink (v0.20.2): exports one `AgentMetrics` event per run
128    /// (invoke / stream / resume). `None` = off (default). Failures are `warn` only.
129    pub(crate) metrics_sink: Option<Arc<dyn MetricsSink>>,
130
131    /// B3 (0.22.4): shared USD spend tracker. Attach the same `Arc<CostTracker>`
132    /// that the tracking LLM (`TokenTrackingLLM::with_cost_tracker`) records into
133    /// and the `max_cost_usd` budget gate reads cumulative spend after each LLM
134    /// call. `None` = off (default); a cost limit without a tracker never trips.
135    pub(crate) cost_tracker: Option<Arc<CostTracker>>,
136
137    /// B4 (0.22.4): two-tier semantic memory hook. When present, each run recalls
138    /// relevant facts into `inputs["semantic_memory"]` before planning, and after a
139    /// successful answer spawns a **detached** extraction task (never blocking the
140    /// answer). `None` = off (default). Distinct from conversation `memory`
141    /// (history injected into the next prompt).
142    pub(crate) semantic_memory: Option<SemanticMemoryHook>,
143
144    /// 0.22.0 C4 fix: what to do when the loop exhausts `max_iterations`
145    /// without a final answer. Default **`Error`** — the previous placeholder
146    /// string was indistinguishable from a real answer and downstream
147    /// consumers (PlanExecute) recorded failed steps as completed.
148    pub(crate) on_max_iterations: MaxIterationsPolicy,
149
150    /// A2 Rule-of-Two (v0.22.1 §S8): when enabled, a tool whose declared risk profile
151    /// arms all three properties (`count_armed() >= 3`) is **blocked before execution**
152    /// and the loop gets a rejection observation. Default `false` (off, zero change) —
153    /// an undeclared tool has an all-false profile and is never intercepted.
154    pub(crate) rule_of_two: bool,
155
156    /// A1 Spotlighting (v0.22.1 §S8): when enabled, tool output observations are wrapped in
157    /// `<untrusted_data>…</untrusted_data>` before entering the intermediate steps, so the
158    /// model reads untrusted tool results as delimited data. Default `false` (off, zero change).
159    pub(crate) spotlight_tool_output: bool,
160}
161
162/// 0.22.0 C4 fix: behavior when the agent loop exhausts its iteration budget.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
164pub enum MaxIterationsPolicy {
165    /// Fail the run with [`AgentError::MaxIterationsReached`] (default —
166    /// "failure disguised as success" is no longer possible).
167    #[default]
168    Error,
169    /// Legacy behavior (≤ 0.21.x): return the stopped-response placeholder
170    /// string. Opt in explicitly when a caller cannot handle errors.
171    Placeholder,
172}
173
174impl AgentExecutor {
175    /// Creates a new AgentExecutor.
176    pub fn new(agent: Arc<dyn BaseAgent>, tools: Vec<Arc<dyn BaseTool>>) -> Self {
177        // A11: build the name index before `tools` is moved into the struct.
178        let tools_by_name = index_tools(&tools);
179        Self {
180            agent,
181            tools,
182            tools_by_name,
183            max_iterations: 10,
184            verbose: false,
185            memory: None,
186            callbacks: None,
187            hooks: Vec::new(),
188            tool_timeout: None,
189            max_concurrency: DEFAULT_MAX_CONCURRENCY,
190            concurrency_sem: Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENCY)),
191            metrics_store: Arc::new(Mutex::new(None)),
192            response_cache: None,
193            cache_namespace: format!("exec-{}", CACHE_NS.fetch_add(1, Ordering::SeqCst)),
194            tool_policy: None,
195            approval: None,
196            budget: None,
197            compaction: None,
198            resume_store: None,
199            metrics_sink: None,
200            cost_tracker: None,
201            semantic_memory: None,
202            on_max_iterations: MaxIterationsPolicy::default(),
203            rule_of_two: false,
204            spotlight_tool_output: false,
205        }
206    }
207
208    /// Sets max iterations, clamped to `[1, 100]`.
209    pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
210        self.max_iterations = max_iterations.clamp(MIN_MAX_ITERATIONS, MAX_MAX_ITERATIONS);
211        if max_iterations > MAX_MAX_ITERATIONS {
212            log::warn!(
213                "max_iterations {} clamped to {}",
214                max_iterations,
215                MAX_MAX_ITERATIONS
216            );
217        }
218        self
219    }
220
221    /// 0.22.0 C4 fix: chooses what happens when the loop exhausts
222    /// `max_iterations` without a final answer.
223    ///
224    /// - `MaxIterationsPolicy::Error` (default): the run fails with
225    ///   [`AgentError::MaxIterationsReached`] — the caller can distinguish
226    ///   "did not converge" from a real answer, and PlanExecute treats the
227    ///   step as failed instead of completed-with-garbage.
228    /// - `MaxIterationsPolicy::Placeholder`: legacy ≤ 0.21.x behavior —
229    ///   return the stopped-response placeholder string.
230    pub fn with_on_max_iterations(mut self, policy: MaxIterationsPolicy) -> Self {
231        self.on_max_iterations = policy;
232        self
233    }
234
235    /// A2 Rule of Two (v0.22.1 §S8): when enabled, a tool whose declared risk profile arms
236    /// all three properties (untrusted-input + sensitive-access + state-changing) is blocked
237    /// before execution and the loop receives a rejection observation. Default off — an
238    /// undeclared tool has an all-false profile and is never intercepted (zero behavior change).
239    pub fn with_rule_of_two(mut self, on: bool) -> Self {
240        self.rule_of_two = on;
241        self
242    }
243
244    /// A1 tool-output spotlighting (v0.22.1 §S8): when enabled, tool observations are wrapped
245    /// in `<untrusted_data>…</untrusted_data>` before entering intermediate steps. Default off —
246    /// tool output passes through unchanged.
247    pub fn with_tool_spotlight(mut self, on: bool) -> Self {
248        self.spotlight_tool_output = on;
249        self
250    }
251
252    /// C1: mounts a file-memory tool (v0.22.1 §S8).
253    ///
254    /// Pushes the [`crate::executor::FileMemoryTool`] adapter over a fresh [`lc_memory::file_memory::FileMemoryStore`]
255    /// rooted at `root` into the executor's tool set, letting the agent explicitly `view` /
256    /// `create` / `write` / `append` / `delete` / `list` named memories during a run. Default
257    /// off — this is explicit opt-in; the tools are only registered when this builder is used.
258    /// Err is returned when the root directory cannot be set up.
259    pub fn with_memory_tool(
260        mut self,
261        root: impl Into<PathBuf>,
262    ) -> Result<Self, lc_memory::file_memory::FileMemoryError> {
263        let tool: Arc<dyn BaseTool> = crate::executor::mount(root)?;
264        // A11: keep the name index consistent with the pushed tool (first-wins).
265        self.tools_by_name
266            .entry(tool.name().to_string())
267            .or_insert_with(|| tool.clone());
268        self.tools.push(tool);
269        Ok(self)
270    }
271
272    /// Sets the tool execution timeout.
273    ///
274    /// A tool that exceeds the timeout returns an error instead of hanging the
275    /// whole agent loop. `None` (the default) disables the timeout.
276    pub fn with_tool_timeout(mut self, timeout: Duration) -> Self {
277        self.tool_timeout = Some(timeout);
278        self
279    }
280
281    /// Sets the maximum number of tools executed concurrently.
282    ///
283    /// Clamped to at least 1. The default is 8.
284    pub fn with_max_concurrency(mut self, max_concurrency: usize) -> Self {
285        let max_concurrency = max_concurrency.max(1);
286        self.max_concurrency = max_concurrency;
287        self.concurrency_sem = Arc::new(Semaphore::new(max_concurrency));
288        self
289    }
290
291    /// Enables the LLM result cache (P2-1).
292    ///
293    /// For deterministic prompts, `plan()` results with the same `(inputs,
294    /// intermediate_steps)` are reused directly, skipping the LLM round-trip — suited to
295    /// cost-sensitive / repeatedly-evaluated deterministic tasks. Tool execution results
296    /// enter the cache key; tools themselves are not cached; the cache applies to the
297    /// non-streaming `invoke` path.
298    ///
299    /// # Example
300    ///
301    /// ```rust,ignore
302    /// let cache = Arc::new(MemoryCache::with_capacity(256));
303    /// let executor = AgentExecutor::new(agent, tools).with_response_cache(cache);
304    /// ```
305    pub fn with_response_cache(mut self, cache: Arc<dyn ResponseCache>) -> Self {
306        self.response_cache = Some(cache);
307        self
308    }
309
310    /// Tool permission policy (permission tiering + sandbox gate, P2-9).
311    ///
312    /// Checked before every tool execution: tools whose risk exceeds `max_permitted`
313    /// are rejected; high-risk tools that are not declared sandboxed
314    /// ([`ToolPolicy::sandboxed`]) are also rejected. Unconfigured = everything allowed.
315    ///
316    /// # Example
317    ///
318    /// ```rust,ignore
319    /// let policy = ToolPolicy::new()
320    ///     .risk("code_interpreter", ToolRisk::Dangerous)
321    ///     .sandboxed("code_interpreter"); // moved into a restricted environment, allowed to run
322    /// let executor = AgentExecutor::new(agent, tools).with_tool_policy(policy);
323    /// ```
324    pub fn with_tool_policy(mut self, policy: ToolPolicy) -> Self {
325        self.tool_policy = Some(policy);
326        self
327    }
328
329    /// Approval gate (§4.2): async approval before each tool execution.
330    ///
331    /// Default `None` = no interception; existing behavior unchanged. Approval
332    /// decisions (implemented by the caller via [`ApprovalHandler`]):
333    /// - [`ApprovalDecision::Allow`](crate::approval::ApprovalDecision::Allow): run as-is;
334    /// - [`ApprovalDecision::Deny`](crate::approval::ApprovalDecision::Deny): skip the tool,
335    ///   feed the reason back as an observation, and re-plan next round;
336    /// - [`ApprovalDecision::Modify`](crate::approval::ApprovalDecision::Modify): run with the
337    ///   new arguments substituted.
338    ///
339    /// # Example
340    ///
341    /// ```rust,ignore
342    /// let executor = AgentExecutor::new(agent, tools)
343    ///     .with_approval(Arc::new(AllowAll));
344    /// ```
345    pub fn with_approval(mut self, handler: Arc<dyn ApprovalHandler>) -> Self {
346        self.approval = Some(handler);
347        self
348    }
349
350    /// Budget gate (§4.2): hard limits, effective on both the `invoke` and `stream`
351    /// paths.
352    ///
353    /// - `invoke`: any limit hit returns [`AgentError::BudgetExceeded`] and stops
354    ///   immediately;
355    /// - `stream`: any limit hit sends `Err(AgentError::BudgetExceeded)` on the channel
356    ///   and stops.
357    ///
358    /// The caller can catch this error to distinguish a "budget stop" from "the model did
359    /// not converge". Default `None` = unlimited.
360    ///
361    /// # Example
362    ///
363    /// ```rust,ignore
364    /// let budget = BudgetConfig {
365    ///     max_tool_calls: Some(3),
366    ///     max_tokens: Some(10_000),
367    ///     max_duration: Some(Duration::from_secs(60)),
368    ///     max_iterations: Some(5),
369    ///     max_cost_usd: Some(1.0),
370    /// };
371    /// let executor = AgentExecutor::new(agent, tools).with_budget(budget);
372    /// ```
373    pub fn with_budget(mut self, budget: BudgetConfig) -> Self {
374        self.budget = Some(budget);
375        self
376    }
377
378    /// B3 (0.22.4): attaches a shared `CostTracker` used by the
379    /// `max_cost_usd` budget gate.
380    ///
381    /// Pass the **same `Arc`** that records the agent's LLM calls — typically
382    /// via `TokenTrackingLLM::with_cost_tracker` (or the equivalent tracked
383    /// model wrapper). After every planning call the executor reads
384    /// [`CostTracker::total_cost_usd`] and hard-stops with
385    /// [`AgentError::BudgetExceeded`] /
386    /// [`super::budget::BudgetExceeded::Cost`] once the configured spend is
387    /// reached. Attaching a tracker without a `max_cost_usd` limit only
388    /// measures; setting a limit without a tracker never trips.
389    pub fn with_cost_tracker(mut self, tracker: Arc<CostTracker>) -> Self {
390        self.cost_tracker = Some(tracker);
391        self
392    }
393
394    /// Context compaction (0.21.0 S6.1): drop the oldest intermediate steps
395    /// (whole steps — action + observation stay paired) when the trigger fires.
396    ///
397    /// Checked before every `plan()` round in both the invoke and stream paths.
398    /// Off by default (`None` = unlimited history, zero behavior change).
399    ///
400    /// # Example
401    ///
402    /// ```rust,ignore
403    /// let config = CompactionConfig::new(
404    ///     CompactionTrigger::TurnCount(20),
405    ///     CompactionStrategy::SlidingWindow { keep_recent_turns: 8 },
406    /// );
407    /// let executor = AgentExecutor::new(agent, tools).with_compaction(config);
408    /// ```
409    pub fn with_compaction(mut self, compaction: CompactionConfig) -> Self {
410        self.compaction = Some(compaction);
411        self
412    }
413
414    /// Cross-process resume (§4.2): checkpoint store.
415    ///
416    /// When enabled, before each tool call enters the approval gate to await approval,
417    /// the framework writes the pending tool + the context needed to resume the agent
418    /// loop ([`PendingApproval`]) into the store; it is cleared once the approval
419    /// decision **lands**. If the process crashes, the checkpoint stays on disk; a new
420    /// process rebuilding an executor with the same configuration calls
421    /// [`pending_approval`](Self::pending_approval) / [`resume`](Self::resume) to
422    /// continue instead of replaying the whole conversation from scratch.
423    ///
424    /// Applies only to the non-streaming `invoke` path (the streaming path has no
425    /// approval gate); only meaningful together with
426    /// [`with_approval`](Self::with_approval). Parallel tool execution (multiple tools
427    /// approved concurrently) does not participate in cross-process persistence — the
428    /// in-process approval still works.
429    ///
430    /// # Example
431    ///
432    /// ```rust,ignore
433    /// let store = Arc::new(FileResumeStore::new("/var/checkpoints/app")?);
434    /// let executor = AgentExecutor::new(agent, tools)
435    ///     .with_resume_store(store)
436    ///     .with_approval(Arc::new(MyHandler));
437    /// ```
438    pub fn with_resume_store(mut self, store: Arc<dyn ResumeStore>) -> Self {
439        self.resume_store = Some(store);
440        self
441    }
442
443    /// Tool registration validation (P2-2).
444    ///
445    /// The Agent declares the tool names it may call via `get_allowed_tools()`; when it
446    /// declares some, every one must be present in this executor's `tools`, otherwise an
447    /// error is returned listing all missing tools. When the Agent declares nothing
448    /// (returns `None`, e.g. a base Agent with no tools), validation is skipped.
449    ///
450    /// Called before each `invoke` / `stream`: startup fail-fast, turning a mid-loop
451    /// `ToolNotFound` into a one-shot, all-configuration-errors-at-once report before
452    /// first execution.
453    pub fn validate_tool_registration(&self) -> Result<(), AgentError> {
454        let Some(allowed) = self.agent.get_allowed_tools() else {
455            return Ok(());
456        };
457        let registered: HashSet<&str> = self.tools.iter().map(|t| t.name()).collect();
458        let missing: Vec<&str> = allowed
459            .into_iter()
460            .filter(|name| !registered.contains(name))
461            .collect();
462        if missing.is_empty() {
463            return Ok(());
464        }
465        Err(AgentError::ToolNotFound(format!(
466            "tools not registered on executor: {}",
467            missing.join(", ")
468        )))
469    }
470
471    /// Sets verbose output.
472    pub fn with_verbose(mut self, verbose: bool) -> Self {
473        self.verbose = verbose;
474        self
475    }
476
477    /// Sets memory.
478    pub fn with_memory(mut self, memory: Arc<tokio::sync::Mutex<dyn BaseMemory>>) -> Self {
479        self.memory = Some(memory);
480        self
481    }
482
483    /// B4 (v0.22.4): mounts two-tier semantic memory.
484    ///
485    /// - `store` — the shared [`TwoTierMemory`] (safe to share across executors;
486    ///   the `namespace` isolates this executor's facts).
487    /// - `namespace` — e.g. a user/session id; recall and extraction never cross it.
488    /// - `extractor` — turn-to-facts extractor, typically
489    ///   [`crate::LlmMemoryExtractor`].
490    ///
491    /// On every run the executor recalls relevant facts and injects them into the
492    /// prompt inputs under `semantic_memory`; after a successful answer it extracts
493    /// durable facts in a **detached background task** and promotes hot/important
494    /// facts from the short tier to the weighted-decay long tier. Off by default.
495    pub fn with_semantic_memory(
496        mut self,
497        store: Arc<TwoTierMemory>,
498        namespace: impl Into<String>,
499        extractor: Arc<dyn MemoryExtractor + Send + Sync>,
500    ) -> Self {
501        self.semantic_memory = Some(SemanticMemoryHook::new(store, namespace, extractor));
502        self
503    }
504
505    /// Sets callback manager.
506    pub fn with_callbacks(mut self, callbacks: Arc<CallbackManager>) -> Self {
507        self.callbacks = Some(callbacks);
508        self
509    }
510
511    /// Sets an observability sink: each run (invoke / stream / resume) exports one
512    /// [`AgentMetrics`] event at the end (v0.20.2). Failures are logged, never
513    /// propagated.
514    pub fn with_metrics_sink(mut self, sink: Arc<dyn MetricsSink>) -> Self {
515        self.metrics_sink = Some(sink);
516        self
517    }
518
519    /// Adds an agent hook.
520    pub fn hook(mut self, hook: impl AgentHook + 'static) -> Self {
521        self.hooks.push(Arc::new(hook));
522        self
523    }
524
525    /// Returns metrics from the most recent invocation, if any.
526    pub fn last_metrics(&self) -> Option<AgentMetrics> {
527        self.metrics_store.lock().ok().and_then(|g| g.clone())
528    }
529
530    /// Exports one run's metrics to the attached sink (v0.20.2). Called at every
531    /// run tail (invoke, resume, stream). Failures are logged as `warn` and never
532    /// propagate into the agent flow.
533    async fn export_metrics(&self, metrics: &AgentMetrics) {
534        if let Some(sink) = &self.metrics_sink {
535            let evt = ObsEvent::AgentMetrics(metrics.clone());
536            if let Err(e) = sink.export(&evt).await {
537                log::warn!(target: "lc_agents::metrics", "agent metrics export failed: {e}");
538            }
539        }
540    }
541
542    /// Reads the currently pending approval checkpoint (cross-process resume).
543    ///
544    /// Returns `Ok(None)` when no [`ResumeStore`] is configured or the store is empty.
545    /// After getting a [`PendingApproval`], the caller shows `tool_name` / `arguments`
546    /// to an operator, collects the approval decision, then calls
547    /// [`resume`](Self::resume) to continue.
548    pub async fn pending_approval(&self) -> Result<Option<PendingApproval>, AgentError> {
549        let Some(store) = &self.resume_store else {
550            return Ok(None);
551        };
552        store
553            .load_pending()
554            .await
555            .map_err(|e| AgentError::Resume(e.to_string()))
556    }
557
558    /// Resumes from a checkpoint (cross-process resume): processes the pending tool with
559    /// the given decision, then continues the agent loop from the suspended iteration and
560    /// returns the final answer.
561    ///
562    /// - No [`ResumeStore`] configured or no checkpoint → `Ok(None)` (no-op).
563    /// - A checkpoint exists → first **claims** it (clears it) to prevent duplicate
564    ///   approval, executes the pending tool, then continues the loop from
565    ///   `iteration + 1`; budgets (tool / token / iteration) keep counting from the
566    ///   checkpoint's accumulated amounts, and `max_duration` restarts its timer at the
567    ///   resume moment (a cross-process monotonic clock is not portable — an honest
568    ///   approximation).
569    ///
570    /// The resuming executor must be constructed identically to the one before the crash
571    /// (same agent / tools / store directory) to resume correctly; the approval decision
572    /// is injected by the caller and [`ApprovalHandler`] is not re-run.
573    pub async fn resume(&self, decision: ApprovalDecision) -> Result<Option<String>, AgentError> {
574        let Some(store) = &self.resume_store else {
575            return Ok(None);
576        };
577        let Some(pending) = store
578            .load_pending()
579            .await
580            .map_err(|e| AgentError::Resume(e.to_string()))?
581        else {
582            return Ok(None);
583        };
584        // Claim the checkpoint: clear it first. If resume crashes midway, approval is
585        // not repeated (at most once).
586        store
587            .clear_pending()
588            .await
589            .map_err(|e| AgentError::Resume(e.to_string()))?;
590
591        let action = AgentAction {
592            tool: pending.tool_name.clone(),
593            tool_input: ToolInput::Object {
594                value: pending.arguments.clone(),
595            },
596            log: String::new(),
597        };
598
599        let mut root_run = RunTree::new(
600            "AgentExecutor",
601            RunType::Chain,
602            json!({"input": pending.inputs.get("input").cloned().unwrap_or_default()}),
603        );
604        // Reuse the original trace_id so the resumed tool child runs keep trace
605        // continuity.
606        if let Some(tid) = &pending.trace_id {
607            if let Ok(id) = uuid::Uuid::parse_str(tid) {
608                root_run.trace_id = Some(id);
609                root_run = root_run.with_metadata("trace_id", json!(tid));
610            }
611        }
612        // T10: root run is a GenAI agent invocation, not a generic chain.
613        root_run = root_run.with_metadata(GEN_AI_OPERATION_NAME, json!("invoke_agent"));
614
615        let started = std::time::Instant::now();
616        let mut metrics = AgentMetrics {
617            trace_id: root_run.trace_id.map(|id| id.to_string()),
618            tool_calls: pending.tool_calls_consumed,
619            total_tokens: pending.tokens_consumed,
620            ..Default::default()
621        };
622
623        // Execute the pending tool (inject the given decision; do not re-run the
624        // approval handler).
625        let observation = self
626            .execute_tool_inner(&action, &root_run, None, Some(decision))
627            .await?;
628        let mut steps = pending.steps;
629        steps.push(AgentStep::new(action, observation));
630
631        // A18: resumed rounds observe the same callbacks / trace linkage.
632        let plan_config = build_plan_config(&self.callbacks, &root_run);
633
634        let result = self
635            .run_agent_loop_from(
636                pending.inputs,
637                steps,
638                pending.iteration + 1,
639                &mut root_run,
640                &mut metrics,
641                plan_config.as_ref(),
642            )
643            .await;
644
645        metrics.duration = started.elapsed();
646        metrics.log_summary();
647        if let Ok(mut guard) = self.metrics_store.lock() {
648            *guard = Some(metrics.clone());
649        }
650        self.export_metrics(&metrics).await;
651        result.map(Some)
652    }
653
654    /// Builds the `plan()` cache key: namespace + inputs + intermediate steps (including
655    /// tool observations).
656    ///
657    /// A deterministic Agent always produces the same `AgentOutput` for the same
658    /// `(inputs, steps)`, so this hash is the "LLM result" fingerprint; observations are
659    /// part of the key, so the cache cannot wrongly hit across different tool results.
660    ///
661    /// The key must be reproducible across runs. `HashMap` iterates in an order seeded
662    /// per-instance (RandomState), so a bare hash of the map's JSON serialization differs
663    /// between two `invoke`s even with identical content — breaking cross-run hits. We
664    /// serialize inputs as *sorted* key/value pairs to make it canonical. (A10)
665    fn cache_key(namespace: &str, inputs: &HashMap<String, String>, steps: &[AgentStep]) -> String {
666        use std::hash::{Hash, Hasher};
667
668        let mut input_keys: Vec<&String> = inputs.keys().collect();
669        input_keys.sort_unstable();
670        let inputs_repr: Vec<String> = input_keys
671            .iter()
672            .map(|k| {
673                // \x1f = unit separator, \x1e = record separator; both are
674                // forbidden in JSON keys, so they can't collide with content.
675                format!("{}\x1f{}\x1e", *k, inputs[*k])
676            })
677            .collect();
678
679        let payload = json!({ "ns": namespace, "inputs": inputs_repr, "steps": steps });
680        let mut hasher = std::collections::hash_map::DefaultHasher::new();
681        payload.to_string().hash(&mut hasher);
682        format!("{:016x}", hasher.finish())
683    }
684
685    /// One-stop lookup / `plan()` / write-back.
686    ///
687    /// On a hit, returns the previous `AgentOutput` directly and records
688    /// `metrics.cache_hits`, without calling the LLM; on a miss, calls `plan()` and
689    /// serializes the result back. Corrupt cache content degrades to re-planning.
690    pub(crate) async fn plan_cached(
691        &self,
692        intermediate_steps: &[AgentStep],
693        inputs: &HashMap<String, String>,
694        metrics: &mut AgentMetrics,
695        config: Option<&RunnableConfig>,
696    ) -> Result<AgentOutput, AgentError> {
697        let cache = self.response_cache.as_ref();
698        let key = cache.map(|_| Self::cache_key(&self.cache_namespace, inputs, intermediate_steps));
699
700        if let (Some(cache), Some(key)) = (cache, &key) {
701            if let Some(cached) = cache.get(key) {
702                match serde_json::from_str::<AgentOutput>(&cached) {
703                    Ok(output) => {
704                        metrics.cache_hits += 1;
705                        log::debug!(target: "lc_agents::cache", "plan cache hit: {}", key);
706                        return Ok(output);
707                    }
708                    Err(_) => {
709                        log::warn!(target: "lc_agents::cache", "plan cache corrupt, re-plan");
710                    }
711                }
712            }
713        }
714
715        metrics.llm_calls += 1;
716        // P2-9: rate-limit / quota check before the LLM call (Reject → abort this round).
717        run_before_completion_hooks(&self.hooks, inputs)?;
718        let output = self.agent.plan(intermediate_steps, inputs, config).await?;
719        let usage = self.agent.last_token_usage();
720        if let Some(usage) = &usage {
721            metrics.add_token_usage(usage);
722        }
723        // P2-9: accumulate the real token usage after the LLM call (for the rate-limit
724        // hook's accounting).
725        run_after_completion_hooks(&self.hooks, &output, usage.as_ref());
726
727        if let (Some(cache), Some(key)) = (cache, &key) {
728            if let Ok(serialized) = serde_json::to_string(&output) {
729                cache.put(key.clone(), serialized);
730            }
731        }
732        Ok(output)
733    }
734
735    /// Executes the agent.
736    pub async fn invoke(&self, input: String) -> Result<String, AgentError> {
737        self.invoke_inner(input, None).await
738    }
739
740    /// Internal execution with optional trace propagation (P1-4).
741    ///
742    /// `trace_id` comes from `RunnableConfig.metadata["trace_id"]` when present;
743    /// `None` for plain `invoke`. The trace_id is stamped onto the root
744    /// `RunTree` so every tool child run inherits it via `create_child`.
745    async fn invoke_inner(
746        &self,
747        input: String,
748        trace_id: Option<String>,
749    ) -> Result<String, AgentError> {
750        // P2-2: startup fail-fast — error out on unregistered tools before any LLM call.
751        self.validate_tool_registration()?;
752
753        let started = std::time::Instant::now();
754
755        // Hooks: on_agent_start (P1-6)
756        for hook in &self.hooks {
757            if let Err(e) = hook.on_agent_start(&input) {
758                log::warn!("Hook on_agent_start error: {}", e);
759            }
760        }
761
762        let mut root_run = RunTree::new(
763            "AgentExecutor",
764            RunType::Chain,
765            json!({"input": input.clone()}),
766        );
767
768        // P1-4: stamp trace_id onto the root run so tool child runs inherit it.
769        if let Some(tid) = trace_id {
770            match uuid::Uuid::parse_str(&tid) {
771                Ok(id) => {
772                    root_run.trace_id = Some(id);
773                    root_run = root_run.with_metadata("trace_id", json!(tid));
774                }
775                Err(_) => log::warn!(target: "lc_agents", "invalid trace_id '{}' ignored", tid),
776            }
777        }
778        // T10: classify the root run as the 2026 `invoke_agent` GenAI operation
779        // before any handler sees on_chain_start, so OtelHandler names the span
780        // `invoke_agent` (unstamped chain runs still produce "chain").
781        root_run = root_run.with_metadata(GEN_AI_OPERATION_NAME, json!("invoke_agent"));
782
783        if let Some(ref callbacks) = self.callbacks {
784            for handler in callbacks.handlers() {
785                handler.on_chain_start(&root_run, &root_run.inputs).await;
786            }
787        }
788
789        let mut inputs = HashMap::new();
790        inputs.insert("input".to_string(), input.clone());
791
792        if let Some(memory) = &self.memory {
793            let memory_guard = memory.lock().await;
794            // P1-7: inject every key from memory_variables() into the prompt rather than
795            // hardcoding "history" — so the Agent can also read it when the memory
796            // component uses a different key (e.g. VectorStore's "memory").
797            let variable_keys: Vec<String> = memory_guard
798                .memory_variables()
799                .into_iter()
800                .map(|k| k.to_string())
801                .collect();
802            let memory_vars = memory_guard
803                .load_memory_variables(&inputs)
804                .await
805                .map_err(|e| AgentError::Other(format!("Failed to load memory: {}", e)))?;
806            drop(memory_guard);
807
808            for key in variable_keys {
809                if let Some(value) = memory_vars.get(&key) {
810                    if let Some(s) = value.as_str() {
811                        inputs.insert(key, s.to_string());
812                    }
813                }
814            }
815        }
816
817        // B4: semantic recall — best-effort, isolated by namespace. Injected as a
818        // delimited facts block under `semantic_memory`; prompt templates surface
819        // it with a {semantic_memory} placeholder.
820        if let Some(hook) = &self.semantic_memory {
821            if let Some(block) = hook.recall(&input).await {
822                inputs.insert(SEMANTIC_MEMORY_INPUT_KEY.to_string(), block);
823            }
824        }
825
826        let intermediate_steps: Vec<AgentStep> = Vec::new();
827
828        let mut metrics = AgentMetrics {
829            trace_id: root_run.trace_id.map(|id| id.to_string()),
830            ..Default::default()
831        };
832
833        // A18: build after trace stamping so the LLM runs inherit the stamped
834        // trace id and become children of this chain root.
835        let plan_config = build_plan_config(&self.callbacks, &root_run);
836
837        let result = self
838            .run_agent_loop(
839                inputs.clone(),
840                intermediate_steps,
841                &mut root_run,
842                &mut metrics,
843                plan_config.as_ref(),
844            )
845            .await;
846
847        // 0.22.0 audit fix (H-A9) reconciled with the F7 error-save contract:
848        // the errored round is still part of the conversation — write the user's
849        // input back to memory so the next round does not lose it. But the raw
850        // error text is never stored as assistant output (H-A9): the assistant
851        // slot is left empty, so framework noise can never be mistaken for
852        // assistant reasoning. A save failure only warns; it never masks the
853        // original agent error.
854        if let Some(memory) = &self.memory {
855            match &result {
856                Ok(output) => {
857                    let mut outputs = HashMap::new();
858                    outputs.insert("output".to_string(), output.clone());
859
860                    memory
861                        .lock()
862                        .await
863                        .save_context(&inputs, &outputs)
864                        .await
865                        .map_err(|e| AgentError::Other(format!("Failed to save memory: {}", e)))?;
866                }
867                Err(e) => {
868                    let mut outputs = HashMap::new();
869                    outputs.insert("output".to_string(), String::new());
870                    if let Err(save_err) = memory.lock().await.save_context(&inputs, &outputs).await
871                    {
872                        log::warn!(
873                            "failed to save errored round to memory: {}, original error: {}",
874                            save_err,
875                            e
876                        );
877                    }
878                }
879            }
880        }
881
882        // B4: successful answer only — detached extraction + consolidation. The
883        // JoinHandle is deliberately dropped: semantic memory never blocks or
884        // fails the run, and errored rounds produce no facts (matching the
885        // conversation-memory contract of leaving the assistant slot empty).
886        if let Some(hook) = &self.semantic_memory {
887            if let Ok(output) = &result {
888                hook.spawn_extraction(input.clone(), output.clone());
889            }
890        }
891
892        match &result {
893            Ok(output) => {
894                root_run.end(json!({"output": output}));
895                if let Some(ref callbacks) = self.callbacks {
896                    if let Some(ref outputs) = root_run.outputs {
897                        for handler in callbacks.handlers() {
898                            handler.on_chain_end(&root_run, outputs).await;
899                        }
900                    }
901                }
902
903                // Hooks: on_agent_end (P1-6)
904                for hook in &self.hooks {
905                    if let Err(e) = hook.on_agent_end(output) {
906                        log::warn!("Hook on_agent_end error: {}", e);
907                    }
908                }
909            }
910            Err(e) => {
911                root_run.end_with_error(e.to_string());
912                if let Some(ref callbacks) = self.callbacks {
913                    for handler in callbacks.handlers() {
914                        handler.on_chain_error(&root_run, &e.to_string()).await;
915                    }
916                }
917
918                // Hooks: on_error (P1-6)
919                for hook in &self.hooks {
920                    hook.on_error(&HookError::Other(e.to_string()));
921                }
922            }
923        }
924
925        // P1-5: finalize and publish metrics.
926        metrics.duration = started.elapsed();
927        metrics.log_summary();
928        if let Ok(mut store) = self.metrics_store.lock() {
929            *store = Some(metrics.clone());
930        }
931        self.export_metrics(&metrics).await;
932
933        result
934    }
935
936    /// Execute the agent with a RunnableConfig, merging config callbacks
937    /// with the executor's own callbacks.
938    ///
939    /// This is the entry point used by `AgentRunnable` (LCEL adapter).
940    /// Config callbacks take precedence over the executor's callbacks.
941    pub async fn invoke_with_config(
942        &self,
943        input: String,
944        config: Option<RunnableConfig>,
945    ) -> Result<String, AgentError> {
946        // If config has callbacks, temporarily use them; otherwise use executor's own
947        let effective_callbacks = config
948            .as_ref()
949            .and_then(|c| c.callbacks.clone())
950            .or_else(|| self.callbacks.clone());
951
952        // P1-4: thread trace_id from config metadata so child runs inherit it.
953        let trace_id = config
954            .as_ref()
955            .and_then(|c| c.metadata.get("trace_id"))
956            .and_then(|v| v.as_str())
957            .map(|s| s.to_string());
958
959        // Create a temporary executor with merged callbacks; metrics_store is
960        // Arc-shared so metrics written here propagate back to this executor.
961        let merged_executor = AgentExecutor {
962            agent: self.agent.clone(),
963            tools_by_name: self.tools_by_name.clone(),
964            tools: self.tools.clone(),
965            max_iterations: self.max_iterations,
966            verbose: self.verbose,
967            memory: self.memory.clone(),
968            callbacks: effective_callbacks,
969            hooks: self.hooks.clone(),
970            tool_timeout: self.tool_timeout,
971            max_concurrency: self.max_concurrency,
972            concurrency_sem: self.concurrency_sem.clone(),
973            metrics_store: self.metrics_store.clone(),
974            response_cache: self.response_cache.clone(),
975            cache_namespace: self.cache_namespace.clone(),
976            tool_policy: self.tool_policy.clone(),
977            approval: self.approval.clone(),
978            budget: self.budget.clone(),
979            compaction: self.compaction.clone(),
980            resume_store: self.resume_store.clone(),
981            metrics_sink: self.metrics_sink.clone(),
982            cost_tracker: self.cost_tracker.clone(),
983            semantic_memory: self.semantic_memory.clone(),
984            on_max_iterations: self.on_max_iterations,
985            rule_of_two: self.rule_of_two,
986            spotlight_tool_output: self.spotlight_tool_output,
987        };
988
989        merged_executor.invoke_inner(input, trace_id).await
990    }
991}
992
993impl std::fmt::Debug for AgentExecutor {
994    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
995        f.debug_struct("AgentExecutor")
996            .field("max_iterations", &self.max_iterations)
997            .field("verbose", &self.verbose)
998            .field("tools_count", &self.tools.len())
999            .field("has_memory", &self.memory.is_some())
1000            .field("tool_timeout", &self.tool_timeout)
1001            .field("max_concurrency", &self.max_concurrency)
1002            .field("has_response_cache", &self.response_cache.is_some())
1003            .field("has_tool_policy", &self.tool_policy.is_some())
1004            .field("has_resume_store", &self.resume_store.is_some())
1005            .field("has_metrics_sink", &self.metrics_sink.is_some())
1006            .field("has_cost_tracker", &self.cost_tracker.is_some())
1007            .field("has_semantic_memory", &self.semantic_memory.is_some())
1008            .field(
1009                "has_metrics",
1010                &self
1011                    .metrics_store
1012                    .lock()
1013                    .ok()
1014                    .map(|guard| guard.is_some())
1015                    .unwrap_or(false),
1016            )
1017            .finish()
1018    }
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023    use super::*;
1024
1025    #[test]
1026    fn cache_key_is_deterministic_across_runs() {
1027        // Two runs with identical content must produce the same key, even
1028        // though a freshly allocated HashMap may iterate in a different order.
1029        let a: HashMap<String, String> = HashMap::from([
1030            ("question".into(), "what is rust".into()),
1031            ("user_id".into(), "42".into()),
1032            ("session".into(), "abc".into()),
1033        ]);
1034        let b: HashMap<String, String> = HashMap::from([
1035            ("session".into(), "abc".into()),
1036            ("question".into(), "what is rust".into()),
1037            ("user_id".into(), "42".into()),
1038        ]);
1039
1040        let key_a = AgentExecutor::cache_key("ns", &a, &[]);
1041        let key_b = AgentExecutor::cache_key("ns", &b, &[]);
1042
1043        assert_eq!(key_a, key_b, "identical inputs must hash to the same key");
1044    }
1045
1046    #[test]
1047    fn cache_key_differs_when_inputs_differ() {
1048        let base: HashMap<String, String> = HashMap::from([("k".into(), "v".into())]);
1049        let changed: HashMap<String, String> = HashMap::from([("k".into(), "other".into())]);
1050
1051        let key_base = AgentExecutor::cache_key("ns", &base, &[]);
1052        let key_changed = AgentExecutor::cache_key("ns", &changed, &[]);
1053
1054        assert_ne!(key_base, key_changed);
1055    }
1056}