Skip to main content

lc_agents/executor/
stream.rs

1// lc-agents/src/executor/stream.rs
2//! Streaming execution for [`AgentExecutor`](super::engine::AgentExecutor) — the
3//! background event loop behind [`AgentExecutor::stream`].
4//!
5//! Extracted verbatim from `engine.rs` in v0.23 (T9, zero behavior change): the
6//! streaming path runs in a detached `tokio::spawn` task and pushes
7//! [`AgentStreamEvent`]s over an mpsc channel; [`AgentEventStream`] cancels the loop
8//! cooperatively when the consumer drops the stream. The non-streaming `invoke` path
9//! stays in `engine.rs`. **Do not let the two paths diverge** — the gates, hooks and
10//! terminal handling here intentionally mirror `run_agent_loop`.
11
12use super::budget::{budget_cost_gate, budget_iteration_gate, budget_token_gate, budget_tool_gate};
13use super::engine::{build_plan_config, AgentExecutor, MaxIterationsPolicy};
14use super::hooks::{run_after_completion_hooks, run_before_completion_hooks};
15use super::semantic_memory::SEMANTIC_MEMORY_INPUT_KEY;
16use super::tools::{
17    execute_tool_for_stream, execute_tools_parallel_for_stream, tool_error_observation,
18};
19use super::AgentError;
20use crate::hooks::HookError;
21use crate::metrics::AgentMetrics;
22use crate::streaming::state::AgentStreamEvent;
23use crate::types::{AgentOutput, AgentStep, ToolInput};
24use futures_util::Stream;
25use lc_callbacks::{semconv::GEN_AI_OPERATION_NAME, CallbackManager, RunTree, RunType};
26use lc_core::observability::{MetricsSink, ObsEvent};
27use serde_json::json;
28use std::collections::HashMap;
29use std::future::Future;
30use std::pin::Pin;
31use std::sync::{Arc, Mutex};
32use std::time::Instant;
33
34impl AgentExecutor {
35    /// Stream agent execution as a true async stream of events.
36    ///
37    /// Each step of the agent loop (tool calls, observations, final answer)
38    /// is emitted as an `AgentStreamEvent` as soon as it occurs.
39    ///
40    /// # Error semantics (A9, unified)
41    /// The stream item is `Result<AgentStreamEvent, AgentError>`. A terminal
42    /// failure — a permission-policy rejection, a tool timeout, a guarded-tool
43    /// abort, or budget exhaustion (A-S2 / A-H1) — is delivered as an
44    /// `Err(AgentError)`, which terminates the stream. There is no successful
45    /// `Ok(AgentStreamEvent::Error { .. })`; that variant exists for infallible
46    /// streams (e.g. [`crate::StreamingFunctionCallingAgent`]) and in-band errors.
47    ///
48    /// # `Text` event granularity (F3, honest)
49    ///
50    /// `Text` events carry model text, but their granularity depends on the
51    /// agent's [`crate::executor::BaseAgent::plan_stream`] implementation:
52    ///
53    /// * **ReAct and FunctionCalling agents** stream from the model's chat API,
54    ///   so `Text` events arrive **per token** — concat them as they come for a
55    ///   live word-stream. A function-calling step that calls a tool streams
56    ///   back empty model text (tool calls aren't carried in stream chunks);
57    ///   such steps fall back to the non-streaming path internally, so no
58    ///   phantom empty `Text` is emitted.
59    /// * **Other agents** (plan-and-execute without a streaming inner agent, …)
60    ///   use the non-streaming default, so the whole final answer arrives as a
61    ///   single `Text` event immediately before `FinalAnswer`.
62    ///
63    /// `ToolStart`/`ToolEnd` events are always emitted per tool call.
64    ///
65    /// # Example
66    ///
67    /// ```rust,ignore
68    /// let mut stream = executor.stream("What is Rust?".to_string());
69    /// while let Some(event) = stream.next().await {
70    ///     match event {
71    ///         Ok(AgentStreamEvent::ToolStart { name, input }) => { /* show tool call */ }
72    ///         Ok(AgentStreamEvent::ToolEnd { name, output }) => { /* show result */ }
73    ///         Ok(AgentStreamEvent::Text { content }) => { print!("{}", content); } /* model text */
74    ///         Ok(AgentStreamEvent::FinalAnswer { content }) => { /* show answer */ }
75    ///         Err(e) => { /* terminal failure — the loop has ended */ }
76    ///         _ => {}
77    ///     }
78    /// }
79    /// ```
80    pub fn stream(
81        &self,
82        input: String,
83    ) -> Pin<Box<dyn Stream<Item = Result<AgentStreamEvent, AgentError>> + Send>> {
84        let (tx, rx) = tokio::sync::mpsc::channel(32);
85
86        // 0.20.0 A-H2: dropping the returned stream must stop the background agent
87        // loop. Without this, a consumer that stops reading (a client disconnect, an
88        // early UI cancel) left the loop running — consuming tool calls and LLM tokens
89        // for a listener that is gone. The watch channel is the cancel signal: the loop
90        // checks it at iteration / tool boundaries, and the wrapper (`AgentEventStream`)
91        // sends `true` on drop.
92        let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
93
94        // P2-2: the streaming path also fails fast — unregistered tools emit one error
95        // event before ending.
96        if let Err(e) = self.validate_tool_registration() {
97            tokio::spawn(async move {
98                let _ = tx.send(Err(e)).await;
99            });
100            return Box::pin(AgentEventStream {
101                inner: tokio_stream::wrappers::ReceiverStream::new(rx),
102                cancel: cancel_tx,
103            });
104        }
105
106        let agent = self.agent.clone();
107        // A11: the stream loop looks tools up by name via the prebuilt index.
108        let tools_by_name = self.tools_by_name.clone();
109        let max_iterations = self.max_iterations;
110        let verbose = self.verbose;
111        let tool_timeout = self.tool_timeout;
112        let max_concurrency = self.max_concurrency;
113        let hooks = self.hooks.clone();
114        let tool_policy = self.tool_policy.clone();
115        let budget = self.budget.clone();
116        let compaction = self.compaction.clone();
117        let metrics_store = self.metrics_store.clone();
118        let metrics_sink = self.metrics_sink.clone();
119        let cost_tracker = self.cost_tracker.clone();
120        let on_max_iterations = self.on_max_iterations;
121        // 0.22.0 audit fix (H-A1): the stream path previously dropped the
122        // cross-cutting capabilities invoke has. Clone callbacks + memory into
123        // the spawned task so chain-level callbacks are dispatched, memory
124        // history is loaded before the loop and the final answer is saved.
125        let callbacks = self.callbacks.clone();
126        let memory = self.memory.clone();
127        // B4: cloned into the 'static stream task like `memory`; recall runs
128        // before the loop, extraction spawns detached from the final answer.
129        let semantic_memory = self.semantic_memory.clone();
130        // v0.22.1 §S8: copy the A1/A2 toggles so the spawned stream loop reads locals,
131        // not `&self` (disjoint capture holds here; referencing `self.` would borrow the
132        // whole executor into the `'static` task because `Mutex<dyn BaseMemory>` is invariant).
133        let rule_of_two = self.rule_of_two;
134        let spotlight_tool_output = self.spotlight_tool_output;
135
136        tokio::spawn(async move {
137            let mut intermediate_steps: Vec<AgentStep> = Vec::new();
138            let mut inputs = HashMap::new();
139            inputs.insert("input".to_string(), input.clone());
140
141            // H-A1: chain callbacks / trace parity with invoke — build the root
142            // RunTree, dispatch on_chain_start and on_agent_start hooks, and load
143            // memory variables into the inputs before the loop.
144            //
145            // A18: planning-round `on_llm_*` callbacks are now dispatched on this
146            // path too — `plan_config` carries the same callbacks + trace linkage
147            // (`__lc_parent_run_id` / `__lc_trace_id`) the invoke path stamps, so
148            // the provider-built LLM runs are children of this chain root.
149            //
150            // Remaining known gaps (honest): tool-level `on_tool_*` callbacks and
151            // RunTree trace_id stamping from RunnableConfig metadata (stream()
152            // takes no config) are still not dispatched on this path — invoke's
153            // tool child-run tracing has no equivalent here because tool execution
154            // goes through `execute_tool_for_stream` without a RunTree.
155            let mut root_run = RunTree::new(
156                "AgentExecutor",
157                RunType::Chain,
158                json!({"input": inputs.get("input").cloned().unwrap_or_default()}),
159            );
160            // T10: same `invoke_agent` classification as the non-streaming path.
161            root_run = root_run.with_metadata(GEN_AI_OPERATION_NAME, json!("invoke_agent"));
162            if let Some(ref callbacks) = callbacks {
163                for handler in callbacks.handlers() {
164                    handler.on_chain_start(&root_run, &root_run.inputs).await;
165                }
166            }
167            let plan_config = build_plan_config(&callbacks, &root_run);
168            for hook in &hooks {
169                if let Err(e) = hook.on_agent_start(&input) {
170                    log::warn!("Hook on_agent_start error: {}", e);
171                }
172            }
173
174            if let Some(memory) = &memory {
175                let memory_guard = memory.lock().await;
176                let variable_keys: Vec<String> = memory_guard
177                    .memory_variables()
178                    .into_iter()
179                    .map(|k| k.to_string())
180                    .collect();
181                let loaded = match memory_guard.load_memory_variables(&inputs).await {
182                    Ok(vars) => vars,
183                    Err(e) => {
184                        let msg = format!("Failed to load memory: {e}");
185                        stream_chain_error(&callbacks, &mut root_run, &msg).await;
186                        for hook in &hooks {
187                            hook.on_error(&HookError::Other(msg.clone()));
188                        }
189                        let _ = tx.send(Err(AgentError::Other(msg))).await;
190                        return;
191                    }
192                };
193                drop(memory_guard);
194                for key in variable_keys {
195                    if let Some(value) = loaded.get(&key) {
196                        if let Some(s) = value.as_str() {
197                            inputs.insert(key, s.to_string());
198                        }
199                    }
200                }
201            }
202
203            // B4: semantic recall, same best-effort semantics as invoke.
204            if let Some(hook) = &semantic_memory {
205                if let Some(block) = hook.recall(&input).await {
206                    inputs.insert(SEMANTIC_MEMORY_INPUT_KEY.to_string(), block);
207                }
208            }
209
210            // Budget gate (§4.2): start the stream timer + accumulate metrics (same
211            // semantics as the invoke path).
212            let loop_start = Instant::now();
213            let mut metrics = AgentMetrics::default();
214
215            for iteration in 0..max_iterations {
216                if verbose {
217                    log::info!("=== Stream Iteration {} ===", iteration + 1);
218                }
219
220                // 0.20.0 A-H2: the consumer dropped the stream → stop before the next
221                // plan. Any tool already in flight is allowed to finish (cooperative
222                // cancellation), but no new plan / tool starts.
223                if *cancel_rx.borrow() {
224                    return;
225                }
226
227                // Budget gate: iteration-level (iteration count + wall-clock). Over the
228                // limit → send Err and stop.
229                if let Some(err) =
230                    budget_iteration_gate(budget.as_ref(), max_iterations, iteration, loop_start)
231                {
232                    stream_chain_error(&callbacks, &mut root_run, &err.to_string()).await;
233                    publish_metrics(&metrics, &metrics_store, &metrics_sink, loop_start).await;
234                    let _ = tx.send(Err(err)).await;
235                    return;
236                }
237
238                // P2-9: rate-limit / quota check before the LLM call (also applies on
239                // the streaming path).
240                if let Err(e) = run_before_completion_hooks(&hooks, &inputs) {
241                    let msg = e.to_string();
242                    stream_chain_error(&callbacks, &mut root_run, &msg).await;
243                    publish_metrics(&metrics, &metrics_store, &metrics_sink, loop_start).await;
244                    let _ = tx.send(Err(AgentError::Other(msg))).await;
245                    return;
246                }
247                // F3: streaming planning — the agent forwards model text token by token
248                // through on_token as Text events. ReAct / FunctionCalling override
249                // plan_stream to go through `stream_chat` for a real word-by-word stream;
250                // other agents use the default implementation (the whole answer as a
251                // single Text event), matching the old path's behavior.
252                // 0.21.0 S6.1: context compaction before planning — same semantics as
253                // the invoke path (`run_agent_loop_from`), so the two paths cannot diverge.
254                if let Some(config) = &compaction {
255                    let tokens = metrics.total_tokens.unwrap_or(0);
256                    let (kept, dropped) = config.compact(&intermediate_steps, tokens);
257                    if dropped > 0 {
258                        log::info!(
259                            target: "lc_agents::compaction",
260                            "compacted {} of {} steps ({} remain) [stream]",
261                            dropped,
262                            dropped + kept.len(),
263                            kept.len()
264                        );
265                        intermediate_steps = kept;
266                        metrics.compactions += 1;
267                    }
268                }
269                let output = {
270                    // Must not shadow the outer tx: the closure's `move` would carry it
271                    // away, and the ToolStart/FinalAnswer below would no longer be able
272                    // to use the outer tx.
273                    let send_tx = tx.clone();
274                    // The callback receives its own String (F3): the async block owns
275                    // the token directly instead of borrowing the argument, so the future
276                    // is 'static and can be cast to a trait object with `as`.
277                    let mut on_token = move |token: String| {
278                        let tx = send_tx.clone();
279                        Box::pin(async move {
280                            let _ = tx.send(Ok(AgentStreamEvent::Text { content: token })).await;
281                        }) as Pin<Box<dyn Future<Output = ()> + Send>>
282                    };
283                    match agent
284                        .plan_stream(
285                            &intermediate_steps,
286                            &inputs,
287                            &mut on_token,
288                            plan_config.as_ref(),
289                        )
290                        .await
291                    {
292                        Ok(o) => o,
293                        Err(e) => {
294                            let msg = e.to_string();
295                            stream_chain_error(&callbacks, &mut root_run, &msg).await;
296                            for hook in &hooks {
297                                hook.on_error(&HookError::Other(msg.clone()));
298                            }
299                            publish_metrics(&metrics, &metrics_store, &metrics_sink, loop_start)
300                                .await;
301                            let _ = tx.send(Err(AgentError::Other(msg))).await;
302                            return;
303                        }
304                    }
305                };
306                let usage = agent.last_token_usage();
307                // P2-9: accumulate the real token usage after the LLM call (same semantics
308                // as plan_cached on the invoke path).
309                metrics.llm_calls += 1;
310                if let Some(u) = &usage {
311                    metrics.add_token_usage(u);
312                }
313                run_after_completion_hooks(&hooks, &output, usage.as_ref());
314                // Budget gate: cumulative tokens after the LLM call. Over the limit →
315                // send Err and stop.
316                if let Some(err) = budget_token_gate(budget.as_ref(), &metrics) {
317                    stream_chain_error(&callbacks, &mut root_run, &err.to_string()).await;
318                    publish_metrics(&metrics, &metrics_store, &metrics_sink, loop_start).await;
319                    let _ = tx.send(Err(err)).await;
320                    return;
321                }
322                // B3 (0.22.4): cumulative USD spend gate, same semantics as invoke.
323                if let Some(tracker) = &cost_tracker {
324                    let spent = tracker.total_cost_usd().await;
325                    if let Some(err) = budget_cost_gate(budget.as_ref(), spent) {
326                        stream_chain_error(&callbacks, &mut root_run, &err.to_string()).await;
327                        publish_metrics(&metrics, &metrics_store, &metrics_sink, loop_start).await;
328                        let _ = tx.send(Err(err)).await;
329                        return;
330                    }
331                }
332
333                match output {
334                    AgentOutput::Finish(finish) => {
335                        let content = finish.output().unwrap_or("").to_string();
336                        // H-A1: memory save on the final answer, same as invoke's
337                        // post-answer save_context. A save failure only warns — it
338                        // must not mask a successfully finished stream.
339                        if let Some(memory) = &memory {
340                            let mut outputs = HashMap::new();
341                            outputs.insert("output".to_string(), content.clone());
342                            if let Err(e) =
343                                memory.lock().await.save_context(&inputs, &outputs).await
344                            {
345                                log::warn!("failed to save final answer to memory [stream]: {e}");
346                            }
347                        }
348                        // B4: detached fact extraction, same as invoke.
349                        if let Some(hook) = &semantic_memory {
350                            hook.spawn_extraction(input.clone(), content.clone());
351                        }
352
353                        root_run.end(json!({"output": content.clone()}));
354                        if let Some(ref callbacks) = callbacks {
355                            if let Some(ref outputs) = root_run.outputs {
356                                for handler in callbacks.handlers() {
357                                    handler.on_chain_end(&root_run, outputs).await;
358                                }
359                            }
360                        }
361                        for hook in &hooks {
362                            if let Err(e) = hook.on_agent_end(&content) {
363                                log::warn!("Hook on_agent_end error: {}", e);
364                            }
365                        }
366                        // P1-8 streaming fusion: the model text was already emitted piece
367                        // by piece by plan_stream through on_token (Text events); here
368                        // only the FinalAnswer terminal event is sent — the full answer is
369                        // not repeated.
370                        publish_metrics(&metrics, &metrics_store, &metrics_sink, loop_start).await;
371                        let _ = tx.send(Ok(AgentStreamEvent::FinalAnswer { content })).await;
372                        return;
373                    }
374
375                    AgentOutput::Action(action) => {
376                        // 0.22.0 audit fix (H-A5): the ReAct parse-repair pseudo-tool
377                        // is not a real tool — feed its message back as the
378                        // observation so the model can retry (standard ReAct repair
379                        // loop), matching the invoke path.
380                        if action.tool == crate::react::agent::PARSE_ERROR_TOOL {
381                            let observation = match &action.tool_input {
382                                ToolInput::String { value } => value.clone(),
383                                ToolInput::Object { value } => value.to_string(),
384                            };
385                            if verbose {
386                                log::info!("Parse repair observation: {}", observation);
387                            }
388                            intermediate_steps.push(AgentStep::new(action, observation));
389                            continue;
390                        }
391                        // P2-9: the streaming path also enforces the tool permission
392                        // policy.
393                        if let Some(policy) = &tool_policy {
394                            if let Err(e) = policy.check(&action.tool) {
395                                let msg = e.to_string();
396                                stream_chain_error(&callbacks, &mut root_run, &msg).await;
397                                publish_metrics(
398                                    &metrics,
399                                    &metrics_store,
400                                    &metrics_sink,
401                                    loop_start,
402                                )
403                                .await;
404                                let _ = tx.send(Err(AgentError::Other(msg))).await;
405                                return;
406                            }
407                        }
408                        let tool_name = action.tool.clone();
409                        let tool_input_str = match &action.tool_input {
410                            ToolInput::String { value: s } => s.clone(),
411                            ToolInput::Object { value: v } => {
412                                serde_json::to_string(v).unwrap_or_default()
413                            }
414                        };
415
416                        // A11: the budget gate runs **before** `ToolStart` is emitted.
417                        // Previously the gate ran after, so a rejection left an orphan
418                        // `ToolStart` with no matching `ToolEnd`/error. Order now mirrors
419                        // the invoke path: reject first, emit the start event only when
420                        // the call is actually allowed.
421                        metrics.tool_calls += 1;
422                        if let Some(err) = budget_tool_gate(budget.as_ref(), &metrics, loop_start) {
423                            stream_chain_error(&callbacks, &mut root_run, &err.to_string()).await;
424                            publish_metrics(&metrics, &metrics_store, &metrics_sink, loop_start)
425                                .await;
426                            let _ = tx.send(Err(err)).await;
427                            return;
428                        }
429
430                        let _ = tx
431                            .send(Ok(AgentStreamEvent::ToolStart {
432                                name: tool_name.clone(),
433                                input: tool_input_str.clone(),
434                            }))
435                            .await;
436
437                        // 0.20.0 A-H2: dropped mid-iteration → do not start a new tool.
438                        if *cancel_rx.borrow() {
439                            return;
440                        }
441
442                        // Execute the tool. A tool **execution** failure becomes an
443                        // observation fed back to the loop (S3.1) so the agent can
444                        // recover. Framework guardrails (A-H1, 0.20.0) —
445                        // `ControlAbort` handoff/depth guard, `ToolNotFound`, input
446                        // serialization — reject the call *before* execution; the agent
447                        // cannot recover from them by re-planning, so they end the
448                        // stream hard, matching the non-streaming invoke path.
449                        let observation = match execute_tool_for_stream(
450                            &tools_by_name,
451                            &action,
452                            tool_timeout,
453                            spotlight_tool_output,
454                            rule_of_two,
455                        )
456                        .await
457                        {
458                            Ok(obs) => obs,
459                            Err(e @ AgentError::ToolExecutionError(_)) => {
460                                tool_error_observation(&e)
461                            }
462                            Err(e) => {
463                                let msg = e.to_string();
464                                stream_chain_error(&callbacks, &mut root_run, &msg).await;
465                                publish_metrics(
466                                    &metrics,
467                                    &metrics_store,
468                                    &metrics_sink,
469                                    loop_start,
470                                )
471                                .await;
472                                let _ = tx.send(Err(AgentError::Other(msg))).await;
473                                return;
474                            }
475                        };
476
477                        let _ = tx
478                            .send(Ok(AgentStreamEvent::ToolEnd {
479                                name: tool_name,
480                                output: observation.clone(),
481                            }))
482                            .await;
483
484                        intermediate_steps.push(AgentStep::new(action, observation));
485                    }
486
487                    AgentOutput::Actions(actions) => {
488                        // P2-9: parallel tools also pass the permission policy first.
489                        if let Some(policy) = &tool_policy {
490                            for action in &actions {
491                                if let Err(e) = policy.check(&action.tool) {
492                                    let msg = e.to_string();
493                                    stream_chain_error(&callbacks, &mut root_run, &msg).await;
494                                    publish_metrics(
495                                        &metrics,
496                                        &metrics_store,
497                                        &metrics_sink,
498                                        loop_start,
499                                    )
500                                    .await;
501                                    let _ = tx.send(Err(AgentError::Other(msg))).await;
502                                    return;
503                                }
504                            }
505                        }
506                        // A11: budget gate runs **before** any `ToolStart` is emitted for the
507                        // batch, so a rejection leaves no orphan start events.
508                        metrics.tool_calls += actions.len();
509                        if let Some(err) = budget_tool_gate(budget.as_ref(), &metrics, loop_start) {
510                            stream_chain_error(&callbacks, &mut root_run, &err.to_string()).await;
511                            publish_metrics(&metrics, &metrics_store, &metrics_sink, loop_start)
512                                .await;
513                            let _ = tx.send(Err(err)).await;
514                            return;
515                        }
516
517                        for action in &actions {
518                            let tool_name = action.tool.clone();
519                            let tool_input_str = match &action.tool_input {
520                                ToolInput::String { value: s } => s.clone(),
521                                ToolInput::Object { value: v } => {
522                                    serde_json::to_string(v).unwrap_or_default()
523                                }
524                            };
525
526                            let _ = tx
527                                .send(Ok(AgentStreamEvent::ToolStart {
528                                    name: tool_name.clone(),
529                                    input: tool_input_str,
530                                }))
531                                .await;
532                        }
533
534                        // 0.20.0 A-H2: dropped mid-iteration → do not start a new batch.
535                        if *cancel_rx.borrow() {
536                            return;
537                        }
538
539                        let observations = match execute_tools_parallel_for_stream(
540                            &tools_by_name,
541                            &actions,
542                            tool_timeout,
543                            max_concurrency,
544                            spotlight_tool_output,
545                            rule_of_two,
546                        )
547                        .await
548                        {
549                            Ok(obs) => obs,
550                            // A-H1 (0.20.0): a framework guardrail in any one tool
551                            // of the batch ends the stream hard, matching the
552                            // invoke-parallel path. Execution errors were already
553                            // converted to observations inside the helper.
554                            Err(e) => {
555                                let msg = e.to_string();
556                                stream_chain_error(&callbacks, &mut root_run, &msg).await;
557                                publish_metrics(
558                                    &metrics,
559                                    &metrics_store,
560                                    &metrics_sink,
561                                    loop_start,
562                                )
563                                .await;
564                                let _ = tx.send(Err(AgentError::Other(msg))).await;
565                                return;
566                            }
567                        };
568
569                        for (action, observation) in
570                            actions.into_iter().zip(observations.into_iter())
571                        {
572                            let _ = tx
573                                .send(Ok(AgentStreamEvent::ToolEnd {
574                                    name: action.tool.clone(),
575                                    output: observation.clone(),
576                                }))
577                                .await;
578
579                            intermediate_steps.push(AgentStep::new(action, observation));
580                        }
581                    }
582                }
583            }
584
585            // 0.22.0 C4 fix: the iteration cap is a failure by default —
586            // surface `MaxIterationsReached` on the stream instead of streaming
587            // a placeholder that looks like a real answer.
588            log::warn!(
589                "agent reached max iterations; policy: {:?}",
590                on_max_iterations
591            );
592            if on_max_iterations == MaxIterationsPolicy::Error {
593                stream_chain_error(&callbacks, &mut root_run, "max iterations reached").await;
594                publish_metrics(&metrics, &metrics_store, &metrics_sink, loop_start).await;
595                let _ = tx.send(Err(AgentError::MaxIterationsReached)).await;
596                return;
597            }
598            // Legacy placeholder policy: the stopped response is treated as a final
599            // answer — run the H-A1 terminal path (memory save + chain end) too.
600            let finish = agent.return_stopped_response(&intermediate_steps);
601            let content = finish.output().unwrap_or("").to_string();
602            if let Some(memory) = &memory {
603                let mut outputs = HashMap::new();
604                outputs.insert("output".to_string(), content.clone());
605                if let Err(e) = memory.lock().await.save_context(&inputs, &outputs).await {
606                    log::warn!("failed to save final answer to memory [stream]: {e}");
607                }
608            }
609            // B4: detached fact extraction, same as invoke.
610            if let Some(hook) = &semantic_memory {
611                hook.spawn_extraction(input.clone(), content.clone());
612            }
613            root_run.end(json!({"output": content.clone()}));
614            if let Some(ref callbacks) = callbacks {
615                if let Some(ref outputs) = root_run.outputs {
616                    for handler in callbacks.handlers() {
617                        handler.on_chain_end(&root_run, outputs).await;
618                    }
619                }
620            }
621            for hook in &hooks {
622                if let Err(e) = hook.on_agent_end(&content) {
623                    log::warn!("Hook on_agent_end error: {}", e);
624                }
625            }
626            publish_metrics(&metrics, &metrics_store, &metrics_sink, loop_start).await;
627            let _ = tx.send(Ok(AgentStreamEvent::FinalAnswer { content })).await;
628        });
629
630        Box::pin(AgentEventStream {
631            inner: tokio_stream::wrappers::ReceiverStream::new(rx),
632            cancel: cancel_tx,
633        })
634    }
635}
636
637/// Stream wrapper returned by [`AgentExecutor::stream`]: cancels the background agent
638/// loop when the consumer drops the stream (0.20.0 A-H2). A dropped stream means the
639/// listener is gone — the loop must stop burning tool calls and LLM tokens instead of
640/// running the remaining iterations invisibly.
641struct AgentEventStream {
642    /// The live event channel.
643    inner: tokio_stream::wrappers::ReceiverStream<Result<AgentStreamEvent, AgentError>>,
644    /// Set to `true` on drop; the loop observes it via `cancel_rx` at iteration / tool
645    /// boundaries and stops cooperatively (letting any in-flight tool finish).
646    cancel: tokio::sync::watch::Sender<bool>,
647}
648
649impl Stream for AgentEventStream {
650    type Item = Result<AgentStreamEvent, AgentError>;
651
652    fn poll_next(
653        mut self: Pin<&mut Self>,
654        cx: &mut std::task::Context<'_>,
655    ) -> std::task::Poll<Option<Self::Item>> {
656        Pin::new(&mut self.inner).poll_next(cx)
657    }
658}
659
660impl Drop for AgentEventStream {
661    fn drop(&mut self) {
662        let _ = self.cancel.send(true);
663    }
664}
665
666/// 0.22.0 audit fix (H-A1): dispatches the chain-error callbacks on the stream
667/// path, mirroring invoke's `on_chain_error` handling. Best-effort — never
668/// fails, only marks the root run as errored first.
669async fn stream_chain_error(
670    callbacks: &Option<Arc<CallbackManager>>,
671    root_run: &mut RunTree,
672    message: &str,
673) {
674    root_run.end_with_error(message.to_string());
675    if let Some(callbacks) = callbacks {
676        for handler in callbacks.handlers() {
677            handler.on_chain_error(root_run, message).await;
678        }
679    }
680}
681
682/// Publishes `AgentMetrics` at the end of a stream (aligned with the invoke path):
683/// clone → fill duration → audit log → write `metrics_store`.
684///
685/// **Ordering constraint (race)**: the stream closure runs in `tokio::spawn`, so every
686/// termination path must **`publish_metrics` before `tx.send(terminal event)`** —
687/// otherwise a consumer that checks `last_metrics()` immediately after draining the
688/// stream may read `None` (the event arrived but the write has not happened yet).
689async fn publish_metrics(
690    metrics: &AgentMetrics,
691    metrics_store: &Arc<Mutex<Option<AgentMetrics>>>,
692    metrics_sink: &Option<Arc<dyn MetricsSink>>,
693    started: Instant,
694) {
695    let mut m = metrics.clone();
696    m.duration = started.elapsed();
697    m.log_summary();
698    if let Ok(mut guard) = metrics_store.lock() {
699        *guard = Some(m.clone());
700    }
701    if let Some(sink) = metrics_sink {
702        let evt = ObsEvent::AgentMetrics(m);
703        if let Err(e) = sink.export(&evt).await {
704            log::warn!(target: "lc_agents::metrics", "agent metrics export failed: {e}");
705        }
706    }
707}