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}