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}