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