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