Skip to main content

yapi_agent/
run.rs

1//! The agent loop: stream a response, run its tool calls, repeat.
2//!
3//! Port of `packages/agent/src/agent-loop.ts` in pi `v1.0.0`.
4
5use std::sync::Arc;
6
7use futures_util::future::join_all;
8use serde_json::Value;
9use tokio_util::sync::CancellationToken;
10use yapi_ai::stream::{EventStream, Request, StreamEvent, StreamOptions};
11use yapi_ai::transcript::{current_tools, tool_state_changes};
12use yapi_ai::validation::validate_tool_arguments;
13use yapi_types::event::{AgentEvent, ToolResult};
14use yapi_types::message::{
15    AssistantMessage, Content, ContentBlock, Message, StopReason, SystemMessage, ThinkingLevel,
16    ToolCall, ToolDeclaration, ToolResultMessage,
17};
18use yapi_types::model::Model;
19
20use crate::hooks::{AfterToolCall, AgentHooks, BeforeToolCall};
21use crate::tool::{ExecutionMode, Tool, UpdateSink};
22
23/// Starts a provider request through the model's wire API. An error
24/// before any request ends the run, as pi's agent ends it when its stream
25/// function throws.
26pub type StreamFn = Arc<dyn Fn(Request) -> Result<EventStream, String> + Send + Sync>;
27
28/// The transcript and the tools the model may call.
29#[derive(Clone, Default)]
30pub struct AgentContext {
31    /// Messages so far. The loop appends to them.
32    pub messages: Vec<Message>,
33    /// Active tools. The loop declares changes to the model in system messages.
34    pub tools: Vec<Arc<dyn Tool>>,
35}
36
37/// How the loop calls the model.
38#[derive(Clone)]
39pub struct LoopConfig {
40    /// The model to call.
41    pub model: Model,
42    /// Thinking level. `Off` disables thinking.
43    pub thinking_level: ThinkingLevel,
44    /// Starts provider requests.
45    pub stream: StreamFn,
46    /// Options for every request. Their `cancel` aborts the run.
47    pub options: StreamOptions,
48    /// Forces sequential tool execution.
49    pub tool_execution: ExecutionMode,
50}
51
52/// Runs the loop for new prompt messages and returns every message it added.
53///
54/// Events go to `hooks.on_event` in order: `agent_start`, then per turn
55/// `turn_start`, the turn's messages and tool executions, `turn_end`, and finally
56/// `agent_end`.
57pub async fn run(
58    prompts: Vec<Message>,
59    context: &mut AgentContext,
60    config: LoopConfig,
61    hooks: &dyn AgentHooks,
62) -> Vec<Message> {
63    let initial = declare_tool_changes(&context.messages, &context.tools, prompts);
64    let mut new_messages = Vec::new();
65
66    hooks.on_event(&AgentEvent::AgentStart).await;
67    hooks.on_event(&AgentEvent::TurnStart).await;
68    for message in initial {
69        let message = emit_message(hooks, message).await;
70        context.messages.push(message.clone());
71        new_messages.push(message);
72    }
73    run_loop(context, &mut new_messages, config, hooks).await;
74    new_messages
75}
76
77/// Continues from the current transcript, which must not end with an assistant
78/// message.
79pub async fn run_continue(
80    context: &mut AgentContext,
81    config: LoopConfig,
82    hooks: &dyn AgentHooks,
83) -> Result<Vec<Message>, String> {
84    match context.messages.last() {
85        None => return Err("Cannot continue: no messages in context".into()),
86        Some(Message::Assistant(_)) => {
87            return Err("Cannot continue from message role: assistant".into());
88        }
89        Some(_) => {}
90    }
91    let mut new_messages = Vec::new();
92    hooks.on_event(&AgentEvent::AgentStart).await;
93    hooks.on_event(&AgentEvent::TurnStart).await;
94    run_loop(context, &mut new_messages, config, hooks).await;
95    Ok(new_messages)
96}
97
98/// Emits `message`'s start and end, and returns the message as the hooks
99/// finished it.
100async fn emit_message(hooks: &dyn AgentHooks, message: Message) -> Message {
101    hooks
102        .on_event(&AgentEvent::MessageStart {
103            message: message.clone(),
104        })
105        .await;
106    end_message(hooks, message).await
107}
108
109/// Lets the hooks finish `message`, then emits its end.
110async fn end_message(hooks: &dyn AgentHooks, message: Message) -> Message {
111    let message = hooks.finish_message(message).await;
112    hooks
113        .on_event(&AgentEvent::MessageEnd {
114            message: message.clone(),
115        })
116        .await;
117    message
118}
119
120async fn run_loop(
121    context: &mut AgentContext,
122    new_messages: &mut Vec<Message>,
123    config: LoopConfig,
124    hooks: &dyn AgentHooks,
125) {
126    let mut first_turn = true;
127    let mut pending = hooks.steering_messages().await;
128    // A turn's request for one more response, which runs with the current
129    // context when no tool results or queued messages lead to one.
130    let mut explicit_continuation = false;
131
132    loop {
133        let mut more_tool_calls = true;
134        while more_tool_calls || !pending.is_empty() {
135            if !first_turn {
136                match hooks.next_turn_messages().await {
137                    Ok(Some(messages)) => context.messages = messages,
138                    Ok(None) => {}
139                    Err(error) => return fail_run(context, &config, hooks, error).await,
140                }
141                if pending.is_empty() {
142                    pending = hooks.steering_messages().await;
143                }
144                hooks.on_event(&AgentEvent::TurnStart).await;
145            }
146            first_turn = false;
147
148            if let Some(tools) = hooks.current_tools().await {
149                context.tools = tools;
150            }
151            for message in declare_tool_changes(
152                &context.messages,
153                &context.tools,
154                std::mem::take(&mut pending),
155            ) {
156                let message = emit_message(hooks, message).await;
157                context.messages.push(message.clone());
158                new_messages.push(message);
159            }
160
161            let message = match stream_response(context, &config, hooks).await {
162                Ok(message) => message,
163                Err(error) => return fail_run(context, &config, hooks, error).await,
164            };
165            new_messages.push(Message::Assistant(Box::new(message.clone())));
166
167            if matches!(message.stop_reason, StopReason::Error | StopReason::Aborted) {
168                hooks.finish_turn(&message, &[]).await;
169                hooks
170                    .on_event(&AgentEvent::TurnEnd {
171                        message: Message::Assistant(Box::new(message)),
172                        tool_results: Vec::new(),
173                    })
174                    .await;
175                end(hooks, new_messages).await;
176                return;
177            }
178
179            let calls: Vec<ToolCall> = message
180                .content
181                .iter()
182                .filter_map(|block| match block {
183                    ContentBlock::ToolCall(call) => Some(call.clone()),
184                    _ => None,
185                })
186                .collect();
187            let mut tool_results = Vec::new();
188            more_tool_calls = false;
189            if !calls.is_empty() {
190                let (results, terminate) = if message.stop_reason == StopReason::Length {
191                    (fail_truncated_calls(&calls, hooks).await, false)
192                } else {
193                    execute_tool_calls(context, &message, &calls, &config, hooks).await
194                };
195                more_tool_calls = !terminate;
196                for result in &results {
197                    context.messages.push(Message::ToolResult(result.clone()));
198                    new_messages.push(Message::ToolResult(result.clone()));
199                }
200                tool_results = results;
201            }
202
203            let decision = hooks.finish_turn(&message, &tool_results).await;
204            hooks
205                .on_event(&AgentEvent::TurnEnd {
206                    message: Message::Assistant(Box::new(message)),
207                    tool_results,
208                })
209                .await;
210            pending = hooks.steering_messages().await;
211            explicit_continuation = decision.continue_run && !more_tool_calls && pending.is_empty();
212        }
213
214        let follow_up = hooks.follow_up_messages().await;
215        if !follow_up.is_empty() {
216            explicit_continuation = false;
217            pending = follow_up;
218            continue;
219        }
220        if std::mem::take(&mut explicit_continuation) {
221            continue;
222        }
223        break;
224    }
225    end(hooks, new_messages).await;
226}
227
228/// pi's `handleRunFailure`: the run ends with a bare error message, the only
229/// message its `agent_end` reports.
230async fn fail_run(
231    context: &mut AgentContext,
232    config: &LoopConfig,
233    hooks: &dyn AgentHooks,
234    error: String,
235) {
236    let mut failure = yapi_ai::stream::new_output(&config.model, yapi_ai::stream::now_ms());
237    failure.content = vec![ContentBlock::text("")];
238    failure.stop_reason = if config.options.cancel.is_cancelled() {
239        StopReason::Aborted
240    } else {
241        StopReason::Error
242    };
243    failure.error_message = Some(error);
244    let message = emit_message(hooks, Message::Assistant(Box::new(failure))).await;
245    context.messages.push(message.clone());
246    hooks
247        .on_event(&AgentEvent::TurnEnd {
248            message: message.clone(),
249            tool_results: Vec::new(),
250        })
251        .await;
252    end(hooks, &[message]).await;
253}
254
255async fn end(hooks: &dyn AgentHooks, new_messages: &[Message]) {
256    hooks
257        .on_event(&AgentEvent::AgentEnd {
258            messages: new_messages.to_vec(),
259            will_retry: false,
260        })
261        .await;
262}
263
264/// Adds the tool changes since the transcript's last declaration to the last
265/// pending system message, or inserts a system message for them before the first
266/// non-system message.
267fn declare_tool_changes(
268    messages: &[Message],
269    tools: &[Arc<dyn Tool>],
270    pending: Vec<Message>,
271) -> Vec<Message> {
272    let system_index = pending
273        .iter()
274        .rposition(|message| matches!(message, Message::System(_)));
275    let mut baseline = pending.clone();
276    let pending_system = system_index.and_then(|index| match &pending[index] {
277        Message::System(system) => Some(system.clone()),
278        _ => None,
279    });
280    if let (Some(index), Some(system)) = (system_index, &pending_system) {
281        baseline[index] = Message::System(with_tool_changes(system, Vec::new(), Vec::new()));
282    }
283    let declared: Vec<Message> = messages.iter().chain(&baseline).cloned().collect();
284    let current: Vec<ToolDeclaration> = tools
285        .iter()
286        .map(|tool| tool.declaration().clone())
287        .collect();
288    let (added, removed) = tool_state_changes(&current_tools(&declared), &current);
289    let unchanged = added.is_empty() && removed.is_empty();
290
291    if let (Some(index), Some(system)) = (system_index, pending_system) {
292        let had_changes = system.tools_added.as_ref().is_some_and(|t| !t.is_empty())
293            || system.tools_removed.as_ref().is_some_and(|t| !t.is_empty());
294        if unchanged && !had_changes {
295            return pending;
296        }
297        baseline[index] = Message::System(with_tool_changes(&system, added, removed));
298        return baseline;
299    }
300    if unchanged {
301        return pending;
302    }
303    let update = SystemMessage {
304        content: Content::Text(String::new()),
305        sections: None,
306        timestamp: yapi_ai::stream::now_ms(),
307        tools_added: None,
308        tools_removed: None,
309    };
310    let update = Message::System(with_tool_changes(&update, added, removed));
311    let index = pending
312        .iter()
313        .position(|message| !matches!(message, Message::System(_)))
314        .unwrap_or(pending.len());
315    let mut result = pending;
316    result.insert(index, update);
317    result
318}
319
320fn with_tool_changes(
321    system: &SystemMessage,
322    added: Vec<ToolDeclaration>,
323    removed: Vec<yapi_types::message::ToolReference>,
324) -> SystemMessage {
325    SystemMessage {
326        tools_added: (!added.is_empty()).then_some(added),
327        tools_removed: (!removed.is_empty()).then_some(removed),
328        ..system.clone()
329    }
330}
331
332/// The model's response to the context, or the stream function's error when
333/// it fails before a request.
334async fn stream_response(
335    context: &mut AgentContext,
336    config: &LoopConfig,
337    hooks: &dyn AgentHooks,
338) -> Result<AssistantMessage, String> {
339    let messages = hooks.transform_context(context.messages.clone()).await;
340    let messages = hooks.convert_to_llm(messages);
341    let auth = hooks.auth(&config.model).await;
342    let reasoning = (config.thinking_level != ThinkingLevel::Off).then_some(config.thinking_level);
343    let mut request = Request {
344        model: config.model.clone(),
345        messages,
346        options: StreamOptions {
347            reasoning,
348            ..config.options.clone()
349        },
350    };
351    let mut stream = match auth.apply(&mut request) {
352        Ok(()) => {
353            yapi_ai::stream::RequestHooks::prepare(&mut request).await;
354            (config.stream)(request)?
355        }
356        Err(message) => {
357            yapi_ai::api::failed_stream(&request.model, &request.options.cancel, message)
358        }
359    };
360    let mut started = false;
361    let finish = |mut message: AssistantMessage| {
362        message.thinking_level = Some(config.thinking_level);
363        message
364    };
365    while let Some(event) = stream.next().await {
366        match event {
367            StreamEvent::Start(partial) => {
368                started = true;
369                hooks
370                    .on_event(&AgentEvent::MessageStart {
371                        message: Message::Assistant(Box::new(partial)),
372                    })
373                    .await;
374            }
375            StreamEvent::Update { event, usage } => {
376                if started {
377                    hooks
378                        .on_event(&AgentEvent::MessageUpdate {
379                            usage,
380                            assistant_message_event: event,
381                        })
382                        .await;
383                }
384            }
385            StreamEvent::Done(message) | StreamEvent::Error(message) => {
386                let message = finish(message);
387                return Ok(complete(context, hooks, message, started).await);
388            }
389        }
390    }
391    // A provider that ends without a final event: report it as an error.
392    let mut message = yapi_ai::stream::new_output(&config.model, yapi_ai::stream::now_ms());
393    message.stop_reason = StopReason::Error;
394    message.error_message = Some("Provider stream ended without a result".into());
395    Ok(complete(context, hooks, finish(message), started).await)
396}
397
398async fn complete(
399    context: &mut AgentContext,
400    hooks: &dyn AgentHooks,
401    message: AssistantMessage,
402    started: bool,
403) -> AssistantMessage {
404    let wrapped = Message::Assistant(Box::new(message.clone()));
405    if !started {
406        hooks
407            .on_event(&AgentEvent::MessageStart {
408                message: wrapped.clone(),
409            })
410            .await;
411    }
412    let finished = end_message(hooks, wrapped).await;
413    context.messages.push(finished.clone());
414    match finished {
415        Message::Assistant(finished) => *finished,
416        _ => message,
417    }
418}
419
420fn error_result(message: &str) -> ToolResult {
421    ToolResult {
422        content: vec![ContentBlock::text(message)],
423        details: Some(Value::Object(Default::default())),
424        ..ToolResult::default()
425    }
426}
427
428/// What a tool call produced.
429#[derive(Clone, Debug)]
430pub struct ToolCallOutcome {
431    /// The call.
432    pub call: ToolCall,
433    /// Its result.
434    pub result: ToolResult,
435    /// Whether the result is an error.
436    pub is_error: bool,
437}
438
439impl ToolCallOutcome {
440    /// `call` failed with `message` before or instead of running.
441    fn error(call: ToolCall, message: &str) -> ToolCallOutcome {
442        ToolCallOutcome {
443            call,
444            result: error_result(message),
445            is_error: true,
446        }
447    }
448}
449
450/// Where a tool call runs: the tools it resolves against, the response and
451/// transcript its hooks see, and its cancellation.
452pub struct ToolCallScope<'a> {
453    /// Tools the call may name.
454    pub tools: &'a [Arc<dyn Tool>],
455    /// The response that made the call, or that made the calling tool's call.
456    pub assistant: &'a AssistantMessage,
457    /// The transcript so far.
458    pub messages: &'a [Message],
459    /// The tool call that made this call, for calls a tool made.
460    pub parent: Option<&'a str>,
461    /// Cancels the call.
462    pub cancel: &'a CancellationToken,
463}
464
465async fn fail_truncated_calls(
466    calls: &[ToolCall],
467    hooks: &dyn AgentHooks,
468) -> Vec<ToolResultMessage> {
469    let mut messages = Vec::new();
470    for call in calls {
471        emit_start(hooks, call).await;
472        let outcome = ToolCallOutcome::error(
473            call.clone(),
474            &format!(
475                "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.",
476                call.name
477            ),
478        );
479        emit_end(hooks, &outcome).await;
480        messages.push(emit_result(&outcome, hooks).await);
481    }
482    messages
483}
484
485async fn emit_start(hooks: &dyn AgentHooks, call: &ToolCall) {
486    hooks
487        .on_event(&AgentEvent::ToolExecutionStart {
488            tool_call_id: call.id.clone(),
489            tool_name: call.name.clone(),
490            args: Value::Object(call.arguments.clone()),
491            parent_tool_call_id: None,
492        })
493        .await;
494}
495
496async fn emit_end(hooks: &dyn AgentHooks, outcome: &ToolCallOutcome) {
497    hooks
498        .on_event(&AgentEvent::ToolExecutionEnd {
499            tool_call_id: outcome.call.id.clone(),
500            tool_name: outcome.call.name.clone(),
501            result: outcome.result.clone(),
502            is_error: outcome.is_error,
503            parent_tool_call_id: None,
504        })
505        .await;
506}
507
508/// The result message of `outcome`, completed by the hooks and emitted.
509async fn emit_result(outcome: &ToolCallOutcome, hooks: &dyn AgentHooks) -> ToolResultMessage {
510    let mut message = ToolResultMessage {
511        tool_call_id: outcome.call.id.clone(),
512        tool_name: outcome.call.name.clone(),
513        content: outcome.result.content.clone(),
514        details: outcome.result.details.clone(),
515        usage: outcome.result.usage.clone(),
516        is_error: outcome.is_error,
517        timestamp: yapi_ai::stream::now_ms(),
518        nested_calls: None,
519    };
520    hooks.complete_tool_result(&mut message);
521    match emit_message(hooks, Message::ToolResult(message.clone())).await {
522        Message::ToolResult(finished) => finished,
523        _ => message,
524    }
525}
526
527/// A call ready to run.
528struct Prepared {
529    call: ToolCall,
530    tool: Arc<dyn Tool>,
531    args: Value,
532}
533
534/// A prepared call, or the outcome when the call cannot run.
535type Preparation = Result<Prepared, Box<ToolCallOutcome>>;
536
537async fn prepare(
538    scope: &ToolCallScope<'_>,
539    call: &ToolCall,
540    hooks: &dyn AgentHooks,
541) -> Preparation {
542    let immediate = |message: &str, terminate: bool| {
543        let mut outcome = ToolCallOutcome::error(call.clone(), message);
544        if terminate {
545            outcome.result.terminate = Some(true);
546        }
547        Err(Box::new(outcome))
548    };
549    let Some(tool) = scope
550        .tools
551        .iter()
552        .find(|tool| tool.declaration().name == call.name)
553    else {
554        return immediate(&format!("Tool {} not found", call.name), false);
555    };
556    let prepared_call = ToolCall {
557        arguments: tool.prepare_arguments(call.arguments.clone()),
558        ..call.clone()
559    };
560    let mut args = match validate_tool_arguments(tool.declaration(), &prepared_call) {
561        Ok(args) => args,
562        Err(message) => return immediate(&message, false),
563    };
564    let block = hooks
565        .before_tool_call(BeforeToolCall {
566            assistant_message: scope.assistant,
567            tool_call: call,
568            args: &mut args,
569            messages: scope.messages,
570            parent_tool_call_id: scope.parent,
571        })
572        .await;
573    if scope.cancel.is_cancelled() {
574        return immediate("Operation aborted", false);
575    }
576    if let Some(block) = block {
577        return immediate(
578            block
579                .reason
580                .as_deref()
581                .unwrap_or("Tool execution was blocked"),
582            block.terminate,
583        );
584    }
585    Ok(Prepared {
586        call: call.clone(),
587        tool: tool.clone(),
588        args,
589    })
590}
591
592async fn execute_prepared(
593    scope: &ToolCallScope<'_>,
594    prepared: Prepared,
595    hooks: &dyn AgentHooks,
596    updates: UpdateSink,
597) -> ToolCallOutcome {
598    let Prepared { call, tool, args } = prepared;
599    let executed = tool
600        .execute(
601            call.id.clone(),
602            args.clone(),
603            scope.cancel.child_token(),
604            updates,
605        )
606        .await;
607    let (mut result, mut is_error) = match executed {
608        Ok(result) => {
609            let is_error = result.is_error == Some(true);
610            (result, is_error)
611        }
612        Err(message) => (error_result(&message), true),
613    };
614    if let Some(patch) = hooks
615        .after_tool_call(AfterToolCall {
616            assistant_message: scope.assistant,
617            tool_call: &call,
618            args: &args,
619            result: &result,
620            is_error,
621            parent_tool_call_id: scope.parent,
622        })
623        .await
624    {
625        if let Some(content) = patch.content {
626            result.content = content;
627            result.structured_content = None;
628        }
629        if let Some(details) = patch.details {
630            result.details = Some(details);
631        }
632        if let Some(structured) = patch.structured_content {
633            result.structured_content = Some(structured);
634        }
635        if let Some(usage) = patch.usage {
636            result.usage = Some(usage);
637        }
638        if let Some(terminate) = patch.terminate {
639            result.terminate = Some(terminate);
640        }
641        if let Some(flag) = patch.is_error {
642            is_error = flag;
643        }
644    }
645    ToolCallOutcome {
646        call,
647        result,
648        is_error,
649    }
650}
651
652/// Runs a prepared call, emitting its progress updates as they arrive. Updates
653/// reported after the tool finished are dropped, as in pi.
654async fn run_with_updates(
655    scope: &ToolCallScope<'_>,
656    prepared: Prepared,
657    hooks: &dyn AgentHooks,
658) -> ToolCallOutcome {
659    let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
660    let (id, name) = (prepared.call.id.clone(), prepared.call.name.clone());
661    let event_args = Value::Object(prepared.call.arguments.clone());
662    let sink: UpdateSink = Arc::new(move |partial: ToolResult| {
663        let _ = sender.send(AgentEvent::ToolExecutionUpdate {
664            tool_call_id: id.clone(),
665            tool_name: name.clone(),
666            args: event_args.clone(),
667            partial_result: partial,
668            parent_tool_call_id: None,
669        });
670    });
671    let execution = execute_prepared(scope, prepared, hooks, sink);
672    tokio::pin!(execution);
673    loop {
674        tokio::select! {
675            biased;
676            Some(event) = receiver.recv() => hooks.on_event(&event).await,
677            outcome = &mut execution => {
678                while let Ok(event) = receiver.try_recv() {
679                    hooks.on_event(&event).await;
680                }
681                return outcome;
682            }
683        }
684    }
685}
686
687async fn execute_tool_calls(
688    context: &AgentContext,
689    assistant: &AssistantMessage,
690    calls: &[ToolCall],
691    config: &LoopConfig,
692    hooks: &dyn AgentHooks,
693) -> (Vec<ToolResultMessage>, bool) {
694    let sequential = config.tool_execution == ExecutionMode::Sequential
695        || calls.iter().any(|call| {
696            context.tools.iter().any(|tool| {
697                tool.declaration().name == call.name
698                    && tool.execution_mode() == ExecutionMode::Sequential
699            })
700        });
701    let cancel = &config.options.cancel;
702    let scope = ToolCallScope {
703        tools: &context.tools,
704        assistant,
705        messages: &context.messages,
706        parent: None,
707        cancel,
708    };
709    let scope = &scope;
710    let mut outcomes: Vec<ToolCallOutcome> = Vec::new();
711    let mut messages = Vec::new();
712
713    if sequential {
714        for call in calls {
715            emit_start(hooks, call).await;
716            let outcome = match prepare(scope, call, hooks).await {
717                Err(outcome) => *outcome,
718                Ok(prepared) => run_with_updates(scope, prepared, hooks).await,
719            };
720            emit_end(hooks, &outcome).await;
721            messages.push(emit_result(&outcome, hooks).await);
722            outcomes.push(outcome);
723            if cancel.is_cancelled() {
724                break;
725            }
726        }
727    } else {
728        let mut entries = Vec::new();
729        for call in calls {
730            emit_start(hooks, call).await;
731            let preparation = prepare(scope, call, hooks).await;
732            if let Err(outcome) = &preparation {
733                emit_end(hooks, outcome).await;
734            }
735            entries.push(preparation);
736            if cancel.is_cancelled() {
737                break;
738            }
739        }
740        let runs = entries.into_iter().map(|entry| async move {
741            match entry {
742                Err(outcome) => *outcome,
743                Ok(prepared) => {
744                    if cancel.is_cancelled() {
745                        let outcome = ToolCallOutcome::error(prepared.call, "Operation aborted");
746                        emit_end(hooks, &outcome).await;
747                        return outcome;
748                    }
749                    let outcome = run_with_updates(scope, prepared, hooks).await;
750                    emit_end(hooks, &outcome).await;
751                    outcome
752                }
753            }
754        });
755        outcomes = join_all(runs).await;
756        for outcome in &outcomes {
757            messages.push(emit_result(outcome, hooks).await);
758        }
759    }
760    let terminate = !outcomes.is_empty()
761        && outcomes
762            .iter()
763            .all(|outcome| outcome.result.terminate == Some(true));
764    (messages, terminate)
765}
766
767/// Runs one call outside the loop's batches, as pi's `runToolCall` does: it
768/// prepares and validates the call, passes it through the hooks and executes
769/// it in `scope`.
770/// Failures come back as error results. Emits no events: `updates` receives
771/// the tool's partial results.
772pub async fn run_tool_call(
773    scope: &ToolCallScope<'_>,
774    call: ToolCall,
775    hooks: &dyn AgentHooks,
776    updates: UpdateSink,
777) -> ToolCallOutcome {
778    match prepare(scope, &call, hooks).await {
779        Err(outcome) => *outcome,
780        Ok(prepared) => execute_prepared(scope, prepared, hooks, updates).await,
781    }
782}