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}