Skip to main content

rpi_agent/
agent_loop.rs

1//! Mirrors `packages/agent/src/agent-loop.ts` — the provider-agnostic agent loop.
2//!
3//! Two public free functions, [`run_agent_loop`] (new prompt) and
4//! [`run_agent_loop_continue`] (no new prompt), drive the outer follow-up loop
5//! and inner steering+tool loop. The only LLM boundary is [`StreamFn`]
6//! (sync return → `AssistantMessageEventStream`); everything else talks in
7//! [`AgentMessage`].
8//!
9//! Critical invariants enforced here (plan §5):
10//! - **Tool-execution ordering**: in a parallel batch, `ToolExecutionEnd` fires
11//!   in *completion* order; tool-result `MessageStart`/`MessageEnd` fire later
12//!   in *source/ordinal* order. Implemented by collecting completion signals
13//!   into a queue, then walking the finalized vec by index for the result
14//!   messages.
15//! - **Truncate-fail**: `stop_reason == Length` → every tool call in the
16//!   message fails-in-place with `is_error:true` and is *not* executed.
17//! - **Late-update suppression**: `on_update` after `execute` resolves is a
18//!   no-op via an `accepting_updates: Arc<AtomicBool>` flipped false on settle.
19//!
20//! The loop is fully testable without [`crate::Agent`] — `run_agent_loop` takes
21//! plain `AgentContext`/`AgentLoopConfig` + an `AgentEmitter`.
22
23use crate::agent_tool::AgentTool;
24use crate::error::AgentError;
25use crate::events::{AgentEmitter, AgentEvent};
26use crate::hooks::AgentLoopConfig;
27use crate::message::AgentMessage;
28use crate::stream_fn::StreamFn;
29use crate::types::{
30    AfterToolCallContext, AgentContext, AgentToolResult, BeforeToolCallContext, ToolExecutionMode,
31};
32
33use rpi_ai::types::{
34    AssistantMessage, AssistantMessageEvent, Content, StopReason, ToolCall, ToolCallType,
35    ToolResultMessage, ToolResultRole,
36};
37use rpi_ai::validate_tool_arguments;
38use std::sync::atomic::{AtomicBool, Ordering};
39use std::sync::Arc;
40
41/// The messages a single `run_agent_loop` invocation added (prompt + assistant
42/// + tool results + injected steering/follow-ups). Returned to the caller.
43pub type NewMessages = Vec<AgentMessage>;
44
45/// Why a run ended. `Completed` is the normal exit; `Aborted`/`Failed` are set
46/// when the terminal assistant message carried `Aborted`/`Error`.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum LoopOutcome {
49    Completed,
50    Aborted,
51    Failed,
52}
53
54impl LoopOutcome {
55    fn from_stop(stop: StopReason) -> Self {
56        match stop {
57            StopReason::Aborted => LoopOutcome::Aborted,
58            StopReason::Error => LoopOutcome::Failed,
59            _ => LoopOutcome::Completed,
60        }
61    }
62}
63
64// ----------------------------------------------------------------------------
65// Public entry points
66// ----------------------------------------------------------------------------
67
68/// Run an agent loop starting from a new prompt. Mirrors TS `runAgentLoop`.
69///
70/// Emits `agent_start`, `turn_start`, then `message_start`/`message_end` for
71/// each prompt, then drives [`run_loop`]. Returns the messages produced.
72pub async fn run_agent_loop(
73    prompts: Vec<AgentMessage>,
74    context: AgentContext,
75    config: AgentLoopConfig,
76    emit: Arc<dyn AgentEmitter>,
77    stream_fn: StreamFn,
78) -> Result<NewMessages, AgentError> {
79    let mut new_messages: Vec<AgentMessage> = prompts.clone();
80    let mut current_context = AgentContext {
81        system_prompt: context.system_prompt.clone(),
82        messages: {
83            let mut v = context.messages.clone();
84            v.extend(prompts);
85            v
86        },
87        tools: context.tools.clone(),
88    };
89
90    emit_event(&emit, AgentEvent::AgentStart).await;
91    emit_event(&emit, AgentEvent::TurnStart).await;
92    for prompt in &new_messages {
93        emit_event(
94            &emit,
95            AgentEvent::MessageStart {
96                message: prompt.clone(),
97            },
98        )
99        .await;
100        emit_event(
101            &emit,
102            AgentEvent::MessageEnd {
103                message: prompt.clone(),
104            },
105        )
106        .await;
107    }
108
109    run_loop(
110        &mut current_context,
111        &mut new_messages,
112        &config,
113        &emit,
114        &stream_fn,
115    )
116    .await?;
117    Ok(new_messages)
118}
119
120/// Continue an agent loop from the existing context (no new prompt). Mirrors
121/// TS `runAgentLoopContinue`. Errors if the context is empty or its last
122/// message is an assistant message (the provider would reject that).
123pub async fn run_agent_loop_continue(
124    context: AgentContext,
125    config: AgentLoopConfig,
126    emit: Arc<dyn AgentEmitter>,
127    stream_fn: StreamFn,
128) -> Result<NewMessages, AgentError> {
129    if context.messages.is_empty() {
130        return Err(AgentError::State(
131            "cannot continue: no messages in context".into(),
132        ));
133    }
134    if context.messages.last().unwrap().is_assistant() {
135        return Err(AgentError::State(
136            "cannot continue from message role: assistant".into(),
137        ));
138    }
139
140    let mut new_messages: Vec<AgentMessage> = Vec::new();
141    let mut current_context = context;
142
143    emit_event(&emit, AgentEvent::AgentStart).await;
144    emit_event(&emit, AgentEvent::TurnStart).await;
145
146    run_loop(
147        &mut current_context,
148        &mut new_messages,
149        &config,
150        &emit,
151        &stream_fn,
152    )
153    .await?;
154    Ok(new_messages)
155}
156
157// ----------------------------------------------------------------------------
158// Main loop — mirrors TS runLoop
159// ----------------------------------------------------------------------------
160
161/// A finalized tool call: the raw call, the merged result, and the error flag.
162#[derive(Clone)]
163struct FinalizedToolCall {
164    tool_call: ToolCall,
165    result: AgentToolResult,
166    is_error: bool,
167}
168
169/// A batch of executed tool calls: the per-call `ToolResultMessage`s (in source
170/// order) and the early-terminate hint.
171struct ExecutedToolBatch {
172    messages: Vec<ToolResultMessage>,
173    terminate: bool,
174}
175
176async fn run_loop(
177    current_context: &mut AgentContext,
178    new_messages: &mut Vec<AgentMessage>,
179    config: &AgentLoopConfig,
180    emit: &Arc<dyn AgentEmitter>,
181    stream_fn: &StreamFn,
182) -> Result<LoopOutcome, AgentError> {
183    let mut first_turn = true;
184    // Check for steering messages at start (user may have typed while waiting).
185    let mut pending_messages = drain_steering(config).await;
186
187    // Outer loop: continues when queued follow-up messages arrive after the
188    // agent would otherwise stop.
189    loop {
190        let mut has_more_tool_calls = true;
191
192        // Inner loop: process tool calls and steering messages.
193        while has_more_tool_calls || !pending_messages.is_empty() {
194            if !first_turn {
195                emit_event(emit, AgentEvent::TurnStart).await;
196            } else {
197                first_turn = false;
198            }
199
200            // Inject pending (steering/follow-up) messages before the next LLM call.
201            if !pending_messages.is_empty() {
202                for message in pending_messages.drain(..) {
203                    emit_event(
204                        emit,
205                        AgentEvent::MessageStart {
206                            message: message.clone(),
207                        },
208                    )
209                    .await;
210                    emit_event(
211                        emit,
212                        AgentEvent::MessageEnd {
213                            message: message.clone(),
214                        },
215                    )
216                    .await;
217                    current_context.messages.push(message.clone());
218                    new_messages.push(message);
219                }
220            }
221
222            // Stream the assistant response.
223            let message =
224                stream_assistant_response(current_context, config, emit, stream_fn).await?;
225            new_messages.push(AgentMessage::Assistant(Box::new(message.clone())));
226
227            if matches!(message.stop_reason, StopReason::Error | StopReason::Aborted) {
228                let am = AgentMessage::Assistant(Box::new(message.clone()));
229                emit_event(
230                    emit,
231                    AgentEvent::TurnEnd {
232                        message: am,
233                        tool_results: Vec::new(),
234                    },
235                )
236                .await;
237                emit_event(
238                    emit,
239                    AgentEvent::AgentEnd {
240                        messages: new_messages.clone(),
241                    },
242                )
243                .await;
244                return Ok(LoopOutcome::from_stop(message.stop_reason));
245            }
246
247            // Collect tool calls (in content order = source/ordinal order).
248            let tool_calls: Vec<ToolCall> = message
249                .content
250                .iter()
251                .filter_map(|c| match c {
252                    Content::ToolCall(tc) => Some(tc.clone()),
253                    _ => None,
254                })
255                .collect();
256
257            let mut tool_results: Vec<ToolResultMessage> = Vec::new();
258            has_more_tool_calls = false;
259            if !tool_calls.is_empty() {
260                let batch = if matches!(message.stop_reason, StopReason::Length) {
261                    // Truncate-fail invariant: Length → fail ALL without executing.
262                    fail_tool_calls_from_truncated_message(&tool_calls, emit).await?
263                } else {
264                    execute_tool_calls(current_context, &message, &tool_calls, config, emit).await?
265                };
266                tool_results.extend(batch.messages);
267                has_more_tool_calls = !batch.terminate;
268
269                for result in &tool_results {
270                    let am = AgentMessage::ToolResult(Box::new(result.clone()));
271                    current_context.messages.push(am.clone());
272                    new_messages.push(am);
273                }
274            }
275
276            if !tool_results.is_empty() {
277                if let Some(upd) = after_tool_results(
278                    config,
279                    &message,
280                    &tool_results,
281                    current_context,
282                    new_messages,
283                )
284                .await
285                {
286                    if let Some(ctx) = upd.context {
287                        *current_context = ctx;
288                    }
289                }
290            }
291
292            let am = AgentMessage::Assistant(Box::new(message.clone()));
293            emit_event(
294                emit,
295                AgentEvent::TurnEnd {
296                    message: am,
297                    tool_results: tool_results.clone(),
298                },
299            )
300            .await;
301
302            // prepareNextTurn: replace context if provided. (Model/thinking swaps
303            // are owned by Agent; run_loop borrows config immutably for hook
304            // stability. M2 tests exercise context replacement only.)
305            if let Some(upd) = prepare_next_turn(
306                config,
307                &message,
308                &tool_results,
309                current_context,
310                new_messages,
311            )
312            .await
313            {
314                if let Some(ctx) = upd.context {
315                    *current_context = ctx;
316                }
317            }
318
319            if should_stop_after_turn(
320                config,
321                &message,
322                &tool_results,
323                current_context,
324                new_messages,
325            )
326            .await
327            {
328                emit_event(
329                    emit,
330                    AgentEvent::AgentEnd {
331                        messages: new_messages.clone(),
332                    },
333                )
334                .await;
335                return Ok(LoopOutcome::Completed);
336            }
337
338            if config.signal.is_cancelled() {
339                emit_event(
340                    emit,
341                    AgentEvent::AgentEnd {
342                        messages: new_messages.clone(),
343                    },
344                )
345                .await;
346                return Ok(LoopOutcome::Aborted);
347            }
348
349            pending_messages = drain_steering(config).await;
350        }
351
352        // Agent would stop here. Check for follow-up messages.
353        let follow_ups = drain_follow_up(config).await;
354        if !follow_ups.is_empty() {
355            pending_messages = follow_ups;
356            continue;
357        }
358        break;
359    }
360
361    emit_event(
362        emit,
363        AgentEvent::AgentEnd {
364            messages: new_messages.clone(),
365        },
366    )
367    .await;
368    Ok(LoopOutcome::Completed)
369}
370
371// ----------------------------------------------------------------------------
372// streamAssistantResponse
373// ----------------------------------------------------------------------------
374
375/// Stream one assistant response, folding protocol events into the partial
376/// message and emitting agent `Message*` events. Mirrors TS
377/// `streamAssistantResponse`.
378async fn stream_assistant_response(
379    context: &mut AgentContext,
380    config: &AgentLoopConfig,
381    emit: &Arc<dyn AgentEmitter>,
382    stream_fn: &StreamFn,
383) -> Result<AssistantMessage, AgentError> {
384    // Apply optional transform_context (AgentMessage[] → AgentMessage[]).
385    let messages = if let Some(transform) = &config.transform_context {
386        transform(context.messages.clone(), config.signal.clone()).await
387    } else {
388        context.messages.clone()
389    };
390
391    // convert_to_llm (AgentMessage[] → Message[]).
392    let llm_messages = (config.convert_to_llm)(messages).await;
393
394    let llm_context = rpi_ai::types::Context {
395        system_prompt: if context.system_prompt.is_empty() {
396            None
397        } else {
398            Some(context.system_prompt.clone())
399        },
400        messages: llm_messages,
401        tools: context.tools.iter().map(|t| t.schema().clone()).collect(),
402    };
403
404    // Resolve API key: getApiKey(provider) ?? config.api_key.
405    let resolved_api_key = if let Some(get_key) = &config.get_api_key {
406        get_key(&config.model.provider)
407            .await
408            .or_else(|| config.api_key.clone())
409    } else {
410        config.api_key.clone()
411    };
412
413    let opts = config.to_stream_options(resolved_api_key);
414    let mut response = stream_fn(&config.model, &llm_context, &opts);
415
416    let mut added_partial = false;
417
418    while let Some(event) = response.next().await {
419        match &event {
420            AssistantMessageEvent::Start { partial } => {
421                let am = AgentMessage::Assistant(Box::new((**partial).clone()));
422                context.messages.push(am.clone());
423                added_partial = true;
424                emit_event(emit, AgentEvent::MessageStart { message: am }).await;
425            }
426            AssistantMessageEvent::TextStart { partial, .. }
427            | AssistantMessageEvent::TextDelta { partial, .. }
428            | AssistantMessageEvent::TextEnd { partial, .. }
429            | AssistantMessageEvent::ThinkingStart { partial, .. }
430            | AssistantMessageEvent::ThinkingDelta { partial, .. }
431            | AssistantMessageEvent::ThinkingEnd { partial, .. }
432            | AssistantMessageEvent::ToolCallStart { partial, .. }
433            | AssistantMessageEvent::ToolCallDelta { partial, .. }
434            | AssistantMessageEvent::ToolCallEnd { partial, .. } => {
435                if added_partial {
436                    let am = AgentMessage::Assistant(Box::new((**partial).clone()));
437                    if let Some(last) = context.messages.last_mut() {
438                        *last = am.clone();
439                    }
440                    emit_event(
441                        emit,
442                        AgentEvent::MessageUpdate {
443                            message: am,
444                            assistant_message_event: event.clone(),
445                        },
446                    )
447                    .await;
448                }
449            }
450            AssistantMessageEvent::Done { .. } | AssistantMessageEvent::Error { .. } => {
451                let final_message = response.result().await.map_err(|_| {
452                    AgentError::Provider(
453                        "assistant-message event stream ended without a terminal event".into(),
454                    )
455                })?;
456                let am = AgentMessage::Assistant(Box::new(final_message.clone()));
457                if added_partial {
458                    if let Some(last) = context.messages.last_mut() {
459                        *last = am.clone();
460                    }
461                } else {
462                    context.messages.push(am.clone());
463                    emit_event(
464                        emit,
465                        AgentEvent::MessageStart {
466                            message: am.clone(),
467                        },
468                    )
469                    .await;
470                }
471                emit_event(emit, AgentEvent::MessageEnd { message: am }).await;
472                return Ok(final_message);
473            }
474        }
475    }
476
477    // Stream ended without a terminal event — finalize from result() (TS has the
478    // same fallback).
479    let final_message = response.result().await.map_err(|_| {
480        AgentError::Provider("assistant-message event stream ended without a terminal event".into())
481    })?;
482    let am = AgentMessage::Assistant(Box::new(final_message.clone()));
483    if added_partial {
484        if let Some(last) = context.messages.last_mut() {
485            *last = am.clone();
486        }
487    } else {
488        context.messages.push(am.clone());
489        emit_event(
490            emit,
491            AgentEvent::MessageStart {
492                message: am.clone(),
493            },
494        )
495        .await;
496    }
497    emit_event(emit, AgentEvent::MessageEnd { message: am }).await;
498    Ok(final_message)
499}
500
501// ----------------------------------------------------------------------------
502// Truncate-fail
503// ----------------------------------------------------------------------------
504
505/// Fail every tool call in a truncated message. Mirrors TS
506/// `failToolCallsFromTruncatedMessage`. Each call gets a `tool_execution_start`
507/// + `tool_execution_end` (is_error:true) + tool-result `MessageStart`/`End`,
508/// but is *not* executed.
509async fn fail_tool_calls_from_truncated_message(
510    tool_calls: &[ToolCall],
511    emit: &Arc<dyn AgentEmitter>,
512) -> Result<ExecutedToolBatch, AgentError> {
513    let mut messages: Vec<ToolResultMessage> = Vec::new();
514    for tool_call in tool_calls {
515        emit_event(
516            emit,
517            AgentEvent::ToolExecutionStart {
518                tool_call_id: tool_call.id.clone(),
519                tool_name: tool_call.name.clone(),
520                args: tool_call.arguments.clone(),
521            },
522        )
523        .await;
524        let reason = format!(
525            "Tool call {:?} was not executed: the response hit the output token limit, so its arguments may be truncated. Re-issue the tool call with complete arguments.",
526            tool_call.name
527        );
528        let result = create_error_tool_result(&reason);
529        emit_event(
530            emit,
531            AgentEvent::ToolExecutionEnd {
532                tool_call_id: tool_call.id.clone(),
533                tool_name: tool_call.name.clone(),
534                result: result.clone(),
535                is_error: true,
536            },
537        )
538        .await;
539        let trm = create_tool_result_message(tool_call, &result, true);
540        emit_tool_result_message(emit, &trm).await;
541        messages.push(trm);
542    }
543    Ok(ExecutedToolBatch {
544        messages,
545        terminate: false,
546    })
547}
548
549// ----------------------------------------------------------------------------
550// Tool-call execution
551// ----------------------------------------------------------------------------
552
553/// Execute a batch of tool calls. Sequential if `config.tool_execution ==
554/// Sequential` or any matched tool's `execution_mode()` is `Sequential`;
555/// otherwise parallel. Mirrors TS `executeToolCalls`.
556async fn execute_tool_calls(
557    current_context: &AgentContext,
558    assistant_message: &AssistantMessage,
559    tool_calls: &[ToolCall],
560    config: &AgentLoopConfig,
561    emit: &Arc<dyn AgentEmitter>,
562) -> Result<ExecutedToolBatch, AgentError> {
563    let has_sequential = tool_calls.iter().any(|tc| {
564        current_context
565            .tools
566            .iter()
567            .find(|t| t.schema().name == tc.name)
568            .map(|t| t.execution_mode() == ToolExecutionMode::Sequential)
569            .unwrap_or(false)
570    });
571    if config.tool_execution == ToolExecutionMode::Sequential || has_sequential {
572        execute_tool_calls_sequential(current_context, assistant_message, tool_calls, config, emit)
573            .await
574    } else {
575        execute_tool_calls_parallel(current_context, assistant_message, tool_calls, config, emit)
576            .await
577    }
578}
579
580async fn execute_tool_calls_sequential(
581    current_context: &AgentContext,
582    assistant_message: &AssistantMessage,
583    tool_calls: &[ToolCall],
584    config: &AgentLoopConfig,
585    emit: &Arc<dyn AgentEmitter>,
586) -> Result<ExecutedToolBatch, AgentError> {
587    let mut finalized_calls: Vec<FinalizedToolCall> = Vec::new();
588    let mut messages: Vec<ToolResultMessage> = Vec::new();
589
590    for tool_call in tool_calls {
591        emit_event(
592            emit,
593            AgentEvent::ToolExecutionStart {
594                tool_call_id: tool_call.id.clone(),
595                tool_name: tool_call.name.clone(),
596                args: tool_call.arguments.clone(),
597            },
598        )
599        .await;
600
601        let finalized =
602            run_one_tool_call(current_context, assistant_message, tool_call, config, emit).await?;
603
604        emit_tool_execution_end(emit, &finalized).await;
605        let trm =
606            create_tool_result_message(&finalized.tool_call, &finalized.result, finalized.is_error);
607        emit_tool_result_message(emit, &trm).await;
608        finalized_calls.push(finalized);
609        messages.push(trm);
610
611        if config.signal.is_cancelled() {
612            break;
613        }
614    }
615
616    Ok(ExecutedToolBatch {
617        messages,
618        terminate: should_terminate_tool_batch(&finalized_calls),
619    })
620}
621
622/// Parallel execution. Mirrors TS `executeToolCallsParallel`:
623/// 1. Each call gets `tool_execution_start` + is prepared sequentially
624///    (prepare may mutate args / block).
625/// 2. Immediate outcomes (not-found / blocked / aborted / validation error)
626///    emit `tool_execution_end` immediately.
627/// 3. Ready calls run concurrently. **`tool_execution_end` is emitted in
628///    COMPLETION order** — we drive all prepared futures with `join_set`-style
629///    polling and emit as each resolves.
630/// 4. After all settle, **tool-result `MessageStart`/`MessageEnd` are emitted in
631///    SOURCE/ordinal order** — we walk the finalized vec by index.
632async fn execute_tool_calls_parallel(
633    current_context: &AgentContext,
634    assistant_message: &AssistantMessage,
635    tool_calls: &[ToolCall],
636    config: &AgentLoopConfig,
637    emit: &Arc<dyn AgentEmitter>,
638) -> Result<ExecutedToolBatch, AgentError> {
639    // An entry per tool call: either finalized immediately, or a pending future.
640    enum Entry {
641        Done(FinalizedToolCall),
642        Running(tokio::task::JoinHandle<FinalizedToolCall>),
643    }
644
645    let mut entries: Vec<Entry> = Vec::with_capacity(tool_calls.len());
646
647    for tool_call in tool_calls {
648        emit_event(
649            emit,
650            AgentEvent::ToolExecutionStart {
651                tool_call_id: tool_call.id.clone(),
652                tool_name: tool_call.name.clone(),
653                args: tool_call.arguments.clone(),
654            },
655        )
656        .await;
657
658        match prepare_tool_call(current_context, assistant_message, tool_call, config).await {
659            Prepared::Immediate { result, is_error } => {
660                let finalized = FinalizedToolCall {
661                    tool_call: tool_call.clone(),
662                    result,
663                    is_error,
664                };
665                emit_tool_execution_end(emit, &finalized).await;
666                entries.push(Entry::Done(finalized));
667            }
668            Prepared::Ready { tool, args } => {
669                // Spawn the execute + finalize so it runs concurrently with peers.
670                // The on_update closure captures an `Arc<AtomicBool>` gate so calls
671                // made after `execute` resolves are no-ops (late-update suppression).
672                let tc = tool_call.clone();
673                let am = assistant_message.clone();
674                let ctx = current_context.clone();
675                let cfg = config.clone();
676                let emit2 = Arc::clone(emit);
677                let handle = tokio::spawn(async move {
678                    let executed =
679                        execute_prepared_tool_call(&tc, &tool, &args, &cfg, &emit2).await;
680                    finalize_executed_tool_call(&ctx, &am, &tc, &args, executed, &cfg).await
681                });
682                entries.push(Entry::Running(handle));
683            }
684        }
685        if config.signal.is_cancelled() {
686            break;
687        }
688    }
689
690    // Collect finalized outcomes into a slot per ordinal, emitting
691    // `tool_execution_end` IN COMPLETION ORDER.
692    let mut finalized_by_index: Vec<Option<FinalizedToolCall>> = vec![None; entries.len()];
693    let mut pending: Vec<(usize, tokio::task::JoinHandle<FinalizedToolCall>)> = Vec::new();
694    for (i, e) in entries.into_iter().enumerate() {
695        match e {
696            Entry::Done(f) => {
697                finalized_by_index[i] = Some(f);
698            }
699            Entry::Running(h) => pending.push((i, h)),
700        }
701    }
702
703    while !pending.is_empty() {
704        if pending.len() == 1 {
705            // Last one: just await it directly.
706            let (i, h) = pending.remove(0);
707            let finalized = h.await.unwrap_or_else(|_| FinalizedToolCall {
708                tool_call: panicked_tool_call(),
709                result: create_error_tool_result("tool task panicked"),
710                is_error: true,
711            });
712            emit_tool_execution_end(emit, &finalized).await;
713            finalized_by_index[i] = Some(finalized);
714            break;
715        }
716
717        // Multiple pending: await the *first to complete* by racing them.
718        // We poll each in turn until one is `is_finished()`, then resolve it and
719        // keep the rest for the next loop iteration. `yield_now()` keeps this fair.
720        let mut resolved: Option<(usize, FinalizedToolCall)> = None;
721        let mut still_pending: Vec<(usize, tokio::task::JoinHandle<FinalizedToolCall>)> =
722            Vec::with_capacity(pending.len());
723        // Find any already-finished handle without awaiting.
724        for (i, h) in pending.drain(..) {
725            if resolved.is_none() && h.is_finished() {
726                let finalized = h.await.unwrap_or_else(|_| FinalizedToolCall {
727                    tool_call: panicked_tool_call(),
728                    result: create_error_tool_result("tool task panicked"),
729                    is_error: true,
730                });
731                resolved = Some((i, finalized));
732            } else {
733                still_pending.push((i, h));
734            }
735        }
736        match resolved {
737            Some((i, finalized)) => {
738                emit_tool_execution_end(emit, &finalized).await;
739                finalized_by_index[i] = Some(finalized);
740                pending = still_pending;
741            }
742            None => {
743                // None finished yet: race them with select_all. Build a future that
744                // resolves when any handle completes, then push the rest back.
745                pending = still_pending;
746                race_one_and_collect(emit, &mut pending, &mut finalized_by_index).await;
747            }
748        }
749    }
750
751    // After all settled: emit tool-result MessageStart/MessageEnd IN SOURCE
752    // (ordinal) ORDER.
753    let mut messages: Vec<ToolResultMessage> = Vec::new();
754    let mut finalized_calls: Vec<FinalizedToolCall> = Vec::new();
755    for slot in finalized_by_index.into_iter() {
756        let finalized = slot.expect("every tool call finalized");
757        let trm =
758            create_tool_result_message(&finalized.tool_call, &finalized.result, finalized.is_error);
759        emit_tool_result_message(emit, &trm).await;
760        messages.push(trm);
761        finalized_calls.push(finalized);
762    }
763
764    Ok(ExecutedToolBatch {
765        messages,
766        terminate: should_terminate_tool_batch(&finalized_calls),
767    })
768}
769
770/// Race the pending tool futures and, as each completes, emit its
771/// `tool_execution_end` (completion order) and stash it into
772/// `finalized_by_index`. Loops until `pending` is empty.
773///
774/// This uses `futures::future::select_all` to await the first completion, then
775/// re-runs with the remainder — O(n²) but n is the tool-call count per turn
776/// (typically small), and it preserves exact completion order for the
777/// ordering invariant without a `JoinSet` borrow-dance.
778async fn race_one_and_collect(
779    emit: &Arc<dyn AgentEmitter>,
780    pending: &mut Vec<(usize, tokio::task::JoinHandle<FinalizedToolCall>)>,
781    finalized_by_index: &mut [Option<FinalizedToolCall>],
782) {
783    // Take ownership of the join handles and box them into a uniform future type
784    // so `select_all` can race them. Each future resolves to its ordinal + the
785    // finalized outcome; as each completes we emit `tool_execution_end` (THIS is
786    // where completion order is honored) and stash the result by ordinal.
787    let indexed: Vec<(usize, tokio::task::JoinHandle<FinalizedToolCall>)> = std::mem::take(pending);
788    let mut boxed: Vec<
789        std::pin::Pin<Box<dyn std::future::Future<Output = (usize, FinalizedToolCall)> + Send>>,
790    > = Vec::with_capacity(indexed.len());
791    for (i, h) in indexed {
792        boxed.push(Box::pin(async move {
793            let f = h.await.unwrap_or_else(|_| FinalizedToolCall {
794                tool_call: panicked_tool_call(),
795                result: create_error_tool_result("tool task panicked"),
796                is_error: true,
797            });
798            (i, f)
799        }));
800    }
801
802    while !boxed.is_empty() {
803        // select_all returns (output, index_of_completed, remaining_futures).
804        let (outcome, _idx, rest) = futures::future::select_all(boxed).await;
805        boxed = rest;
806        let (i, finalized) = outcome;
807        emit_tool_execution_end(emit, &finalized).await;
808        finalized_by_index[i] = Some(finalized);
809    }
810}
811
812/// Shared core for the sequential path: prepare → execute → finalize.
813async fn run_one_tool_call(
814    current_context: &AgentContext,
815    assistant_message: &AssistantMessage,
816    tool_call: &ToolCall,
817    config: &AgentLoopConfig,
818    emit: &Arc<dyn AgentEmitter>,
819) -> Result<FinalizedToolCall, AgentError> {
820    match prepare_tool_call(current_context, assistant_message, tool_call, config).await {
821        Prepared::Immediate { result, is_error } => Ok(FinalizedToolCall {
822            tool_call: tool_call.clone(),
823            result,
824            is_error,
825        }),
826        Prepared::Ready { tool, args } => {
827            let executed = execute_prepared_tool_call(tool_call, &tool, &args, config, emit).await;
828            Ok(finalize_executed_tool_call(
829                current_context,
830                assistant_message,
831                tool_call,
832                &args,
833                executed,
834                config,
835            )
836            .await)
837        }
838    }
839}
840
841/// Outcome of [`prepare_tool_call`].
842enum Prepared {
843    /// Resolved without executing (not-found / blocked / aborted / validation error).
844    Immediate {
845        result: AgentToolResult,
846        is_error: bool,
847    },
848    /// Validated and ready to execute.
849    Ready {
850        tool: Arc<dyn AgentTool>,
851        args: serde_json::Value,
852    },
853}
854
855/// Find the tool, call `prepare_arguments`, validate args, run `before_tool_call`.
856/// Mirrors TS `prepareToolCall`.
857async fn prepare_tool_call(
858    current_context: &AgentContext,
859    assistant_message: &AssistantMessage,
860    tool_call: &ToolCall,
861    config: &AgentLoopConfig,
862) -> Prepared {
863    let tool = current_context
864        .tools
865        .iter()
866        .find(|t| t.schema().name == tool_call.name)
867        .cloned();
868    let tool = match tool {
869        Some(t) => t,
870        None => {
871            return Prepared::Immediate {
872                result: create_error_tool_result(&format!("Tool {} not found", tool_call.name)),
873                is_error: true,
874            };
875        }
876    };
877
878    // prepareArguments + schema validation.
879    let prepared_args = match tool.prepare_arguments(tool_call.arguments.clone()) {
880        Ok(v) => v,
881        Err(e) => {
882            return Prepared::Immediate {
883                result: create_error_tool_result(&e.to_string()),
884                is_error: true,
885            };
886        }
887    };
888    let mut prepared_tool_call = tool_call.clone();
889    prepared_tool_call.arguments = prepared_args;
890
891    let validated_args = match validate_tool_arguments(tool.schema(), &prepared_tool_call) {
892        Ok(v) => v,
893        Err(e) => {
894            return Prepared::Immediate {
895                result: create_error_tool_result(&e.to_string()),
896                is_error: true,
897            };
898        }
899    };
900
901    // before_tool_call hook (may block + set terminate, may replace args).
902    // TS hands the callback `args` by reference and lets JS mutate it in place;
903    // Rust hands an immutable borrow, so a rewrite is signalled by
904    // `BeforeToolCallResult::args`. The replacement is applied WITHOUT
905    // re-validation, mirroring TS where the mutation lands after
906    // `validateToolArguments` and is trusted.
907    let mut validated_args = validated_args;
908    if let Some(before) = &config.before_tool_call {
909        let ctx = BeforeToolCallContext {
910            assistant_message,
911            tool_call: &prepared_tool_call,
912            args: &validated_args,
913            context: current_context,
914        };
915        let before_result = before(ctx, config.signal.clone()).await;
916        if config.signal.is_cancelled() {
917            return Prepared::Immediate {
918                result: create_error_tool_result("Operation aborted"),
919                is_error: true,
920            };
921        }
922        if let Some(br) = before_result {
923            if let Some(replacement) = br.args {
924                validated_args = replacement;
925            }
926            if br.block {
927                let mut result = create_error_tool_result(
928                    &br.reason
929                        .unwrap_or_else(|| "Tool execution was blocked".to_string()),
930                );
931                if br.terminate {
932                    result.terminate = true;
933                }
934                return Prepared::Immediate {
935                    result,
936                    is_error: true,
937                };
938            }
939        }
940    }
941
942    if config.signal.is_cancelled() {
943        return Prepared::Immediate {
944            result: create_error_tool_result("Operation aborted"),
945            is_error: true,
946        };
947    }
948
949    Prepared::Ready {
950        tool,
951        args: validated_args,
952    }
953}
954
955/// Run the tool's `execute` with late-update suppression. Mirrors TS
956/// `executePreparedToolCall`.
957async fn execute_prepared_tool_call(
958    tool_call: &ToolCall,
959    tool: &Arc<dyn AgentTool>,
960    args: &serde_json::Value,
961    config: &AgentLoopConfig,
962    emit: &Arc<dyn AgentEmitter>,
963) -> ExecutedToolCallOutcome {
964    // Gate for late updates: flipped false once execute resolves. on_update
965    // checks it and returns early. This is the late-update-suppression invariant.
966    let accepting_updates = Arc::new(AtomicBool::new(true));
967    let tool_call_id = tool_call.id.clone();
968    let tool_name = tool_call.name.clone();
969    let args_clone = args.clone();
970    let emit_clone = Arc::clone(emit);
971
972    let on_update: Arc<dyn Fn(crate::types::ToolResultPartial) + Send + Sync> = {
973        let gate = Arc::clone(&accepting_updates);
974        Arc::new(move |partial: crate::types::ToolResultPartial| {
975            if !gate.load(Ordering::SeqCst) {
976                return;
977            }
978            // Cancellation during a cancelled batch: still emit for non-cancelled
979            // runs; the gate above is the real suppression.
980            let ev = AgentEvent::ToolExecutionUpdate {
981                tool_call_id: tool_call_id.clone(),
982                tool_name: tool_name.clone(),
983                args: args_clone.clone(),
984                partial_result: Arc::new(partial),
985            };
986            // `try_emit` is the non-blocking sync surface, so `on_update` never
987            // awaits (it's an `Arc<dyn Fn>`, not an async). Late-update
988            // suppression + ordering: update events interleave correctly because
989            // they share the collector's mutex / the broadcast's channel.
990            emit_clone.try_emit(ev);
991        })
992    };
993
994    let child_token = config.signal.child_token();
995    match tool
996        .execute(&tool_call.id, args.clone(), child_token, on_update)
997        .await
998    {
999        Ok(result) => {
1000            accepting_updates.store(false, Ordering::SeqCst);
1001            ExecutedToolCallOutcome {
1002                result,
1003                is_error: false,
1004            }
1005        }
1006        Err(e) => {
1007            accepting_updates.store(false, Ordering::SeqCst);
1008            ExecutedToolCallOutcome {
1009                result: create_error_tool_result(&e.to_string()),
1010                is_error: true,
1011            }
1012        }
1013    }
1014}
1015
1016/// Result of `execute` before `after_tool_call` overrides. Mirrors TS
1017/// `ExecutedToolCallOutcome`.
1018struct ExecutedToolCallOutcome {
1019    result: AgentToolResult,
1020    is_error: bool,
1021}
1022
1023/// Apply `after_tool_call` overrides. Mirrors TS `finalizeExecutedToolCall`.
1024async fn finalize_executed_tool_call(
1025    current_context: &AgentContext,
1026    assistant_message: &AssistantMessage,
1027    tool_call: &ToolCall,
1028    args: &serde_json::Value,
1029    executed: ExecutedToolCallOutcome,
1030    config: &AgentLoopConfig,
1031) -> FinalizedToolCall {
1032    let mut result = executed.result;
1033    let mut is_error = executed.is_error;
1034
1035    if let Some(after) = &config.after_tool_call {
1036        let ctx = AfterToolCallContext {
1037            assistant_message,
1038            tool_call,
1039            args,
1040            result: &result,
1041            is_error,
1042            context: current_context,
1043        };
1044        match after(ctx, config.signal.clone()).await {
1045            Some(after_result) => {
1046                if let Some(c) = after_result.content {
1047                    result.content = c;
1048                }
1049                if let Some(d) = after_result.details {
1050                    result.details = d;
1051                }
1052                if let Some(u) = after_result.usage {
1053                    result.usage = Some(u);
1054                }
1055                if let Some(t) = after_result.terminate {
1056                    result.terminate = t;
1057                }
1058                if let Some(ie) = after_result.is_error {
1059                    is_error = ie;
1060                }
1061            }
1062            None => {}
1063        }
1064    }
1065
1066    FinalizedToolCall {
1067        tool_call: tool_call.clone(),
1068        result,
1069        is_error,
1070    }
1071}
1072
1073// ----------------------------------------------------------------------------
1074// Helpers
1075// ----------------------------------------------------------------------------
1076
1077/// Early-terminate iff the batch is non-empty AND every result sets
1078/// `terminate == true`. Mirrors TS `shouldTerminateToolBatch`.
1079fn should_terminate_tool_batch(finalized_calls: &[FinalizedToolCall]) -> bool {
1080    !finalized_calls.is_empty() && finalized_calls.iter().all(|f| f.result.terminate)
1081}
1082
1083/// Build an error `AgentToolResult` — text content, null details.
1084/// Mirrors TS `createErrorToolResult`.
1085fn create_error_tool_result(message: &str) -> AgentToolResult {
1086    AgentToolResult::error_text(message)
1087}
1088
1089/// Build a `ToolResultMessage` from a finalized call. Mirrors TS
1090/// `createToolResultMessage`. Content is normalized to non-null via
1091/// `AgentToolResult::into_content` (TS guards `result.content ?? []`).
1092fn create_tool_result_message(
1093    tool_call: &ToolCall,
1094    result: &AgentToolResult,
1095    is_error: bool,
1096) -> ToolResultMessage {
1097    ToolResultMessage {
1098        role: ToolResultRole,
1099        tool_call_id: tool_call.id.clone(),
1100        tool_name: tool_call.name.clone(),
1101        content: result.clone().into_content(),
1102        details: Some(result.details.clone()),
1103        usage: result.usage.clone(),
1104        added_tool_names: result.added_tool_names.clone(),
1105        is_error,
1106        timestamp: now_ms(),
1107    }
1108}
1109
1110/// Emit `tool_execution_end` for a finalized call.
1111async fn emit_tool_execution_end(emit: &Arc<dyn AgentEmitter>, finalized: &FinalizedToolCall) {
1112    emit_event(
1113        emit,
1114        AgentEvent::ToolExecutionEnd {
1115            tool_call_id: finalized.tool_call.id.clone(),
1116            tool_name: finalized.tool_call.name.clone(),
1117            result: finalized.result.clone(),
1118            is_error: finalized.is_error,
1119        },
1120    )
1121    .await;
1122}
1123
1124/// Emit `message_start` + `message_end` for a tool-result message. Tool-result
1125/// messages fire AFTER all `tool_execution_end`s, in source/ordinal order.
1126async fn emit_tool_result_message(emit: &Arc<dyn AgentEmitter>, trm: &ToolResultMessage) {
1127    let am = AgentMessage::ToolResult(Box::new(trm.clone()));
1128    emit_event(
1129        emit,
1130        AgentEvent::MessageStart {
1131            message: am.clone(),
1132        },
1133    )
1134    .await;
1135    emit_event(emit, AgentEvent::MessageEnd { message: am }).await;
1136}
1137
1138/// Drain steering messages (empty vec if no hook).
1139async fn drain_steering(config: &AgentLoopConfig) -> Vec<AgentMessage> {
1140    if let Some(hook) = &config.get_steering_messages {
1141        hook().await
1142    } else {
1143        Vec::new()
1144    }
1145}
1146
1147/// Drain follow-up messages (empty vec if no hook).
1148async fn drain_follow_up(config: &AgentLoopConfig) -> Vec<AgentMessage> {
1149    if let Some(hook) = &config.get_follow_up_messages {
1150        hook().await
1151    } else {
1152        Vec::new()
1153    }
1154}
1155
1156/// Call `prepare_next_turn` if configured.
1157async fn prepare_next_turn(
1158    config: &AgentLoopConfig,
1159    message: &AssistantMessage,
1160    tool_results: &[ToolResultMessage],
1161    context: &AgentContext,
1162    new_messages: &[AgentMessage],
1163) -> Option<crate::types::AgentLoopTurnUpdate> {
1164    if let Some(hook) = &config.prepare_next_turn {
1165        let ctx = crate::types::ShouldStopAfterTurnContext {
1166            message,
1167            tool_results,
1168            context,
1169            new_messages,
1170        };
1171        hook(ctx).await
1172    } else {
1173        None
1174    }
1175}
1176
1177async fn after_tool_results(
1178    config: &AgentLoopConfig,
1179    message: &AssistantMessage,
1180    tool_results: &[ToolResultMessage],
1181    context: &AgentContext,
1182    new_messages: &[AgentMessage],
1183) -> Option<crate::types::AgentLoopTurnUpdate> {
1184    let hook = config.after_tool_results.as_ref()?;
1185    let ctx = crate::types::ShouldStopAfterTurnContext {
1186        message,
1187        tool_results,
1188        context,
1189        new_messages,
1190    };
1191    hook(ctx).await
1192}
1193
1194/// Call `should_stop_after_turn` if configured.
1195async fn should_stop_after_turn(
1196    config: &AgentLoopConfig,
1197    message: &AssistantMessage,
1198    tool_results: &[ToolResultMessage],
1199    context: &AgentContext,
1200    new_messages: &[AgentMessage],
1201) -> bool {
1202    if let Some(hook) = &config.should_stop_after_turn {
1203        let ctx = crate::types::ShouldStopAfterTurnContext {
1204            message,
1205            tool_results,
1206            context,
1207            new_messages,
1208        };
1209        hook(ctx).await
1210    } else {
1211        false
1212    }
1213}
1214
1215/// Emit one event via the emitter.
1216async fn emit_event(emit: &Arc<dyn AgentEmitter>, event: AgentEvent) {
1217    emit.emit(event).await;
1218}
1219
1220/// Monotonic-ish ms timestamp. The loop only needs ordering + JSONL serializability,
1221/// not wall-clock accuracy. Uses an atomic counter so tests are deterministic.
1222fn now_ms() -> i64 {
1223    use std::sync::atomic::{AtomicI64, Ordering};
1224    static T: AtomicI64 = AtomicI64::new(1);
1225    T.fetch_add(1, Ordering::Relaxed)
1226}
1227
1228/// A placeholder tool call for the panic-recovery path.
1229fn panicked_tool_call() -> ToolCall {
1230    ToolCall {
1231        kind: ToolCallType,
1232        id: "<panic>".to_string(),
1233        name: "<panic>".to_string(),
1234        arguments: serde_json::Value::Null,
1235        thought_signature: None,
1236        namespace: None,
1237    }
1238}