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