Skip to main content

rig_ecs/systems/
mod.rs

1//! Agent run lifecycle and request-processing systems in [`RigSchedule`].
2//!
3//! Hosts edit the graph before [`RigSet::Assemble`], folded effects in
4//! [`RigSet::Patch`], and outputs in [`RigSet::Judge`].
5//!
6//! ```
7//! use bevy_ecs::schedule::IntoScheduleConfigs;
8//! use rig_ecs::{RigPlugin, bus::RigSchedule, systems::RigSet};
9//! let mut app = bevy_app::App::new();
10//! app.add_plugins(RigPlugin::default());
11//! app.add_systems(RigSchedule, (|| {}).in_set(RigSet::Checkpoint));
12//! ```
13
14use crate::agent::checkpoint::{
15    ToolTurnCommit, ToolTurnCommitted, ToolTurnHolds, TurnAssistant, TurnResults,
16};
17use crate::agent::content::{
18    binary::BinaryAssets,
19    parts::{
20        ContentError, ContentGraph, ToolResultLimit, ToolResultStatus, replace_deferred,
21        spawn_deferred, spawn_deferred_with, write_message,
22    },
23};
24use bevy_reflect::Reflect;
25
26use crate::agent::content::parts::{AssistantHead, EditTarget, RequestPartEdit};
27use bevy_ecs::{
28    prelude::*,
29    query::{QueryData, QueryFilter},
30};
31use rig_core::{
32    completion::message::{
33        AssistantContent, AssistantMessage, ToolChoice, ToolResultContent, UserContent,
34    },
35    effect::{EffectKind, FamilyDescriptor, Outcome},
36    error::ErrorKind,
37    structured_output::{
38        missing_required_fields, output_tool_callable, output_tool_name, reprompt_missing_fields,
39        reprompt_text_answer, text_satisfies_schema,
40    },
41    transcript::{assistant_text_from_choice, invalid_call_feedback, is_empty_assistant_turn},
42};
43
44use crate::{
45    agent::{
46        AcceptUnknownFinishReasons, AdditionalParams, Advert, Attachment, Batch, Cancelled,
47        Context, Conversation, Cursor, DEFAULT_PROVIDER_RETRIES, DocumentId, DocumentProps,
48        DocumentText, Failed, Failure, Grant, InvalidCall, InvalidCalls, InvalidRetries, MaxTokens,
49        MaxTurns, MemoryAppendScheduled, MessageParts, Options, Output, OutputKind, OutputRetries,
50        OutputToolConfig, OutputToolName, Outputs, Preamble, Prompt, ProviderRetried,
51        ProviderRetries, ProviderRetrying, Remembered, Remembering, Remembers, Reprompt,
52        RequestPatch, Resolution, Retrievable, Retrieval, RetrievalKind, Retrieves, Retrieving,
53        Retry, Run, RunCounter, RunOf, RunPhase, RunResult, RunSeq, Settled, StreamRequested,
54        Temperature, ToolAccess, ToolCallSlot, ToolChoiceSpec, ToolContextSpec, ToolPolicy, Turn,
55        Unhandled, Usage, UsesModel, Utterance,
56    },
57    bus::{
58        Bound, BusSet, EffectOutcome, Issued, PendingEffect, RigSchedule, ServedBy,
59        Streamed as BusStreamed, ToolInputs,
60    },
61    policy::{self, RequestGraph},
62};
63
64pub mod backoff;
65pub mod diagnostics;
66mod stream_invalid;
67pub mod witness;
68pub use stream_invalid::discover_streamed_invalid_calls;
69
70/// The agent's sets, in order, around the bus module's.
71#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
72pub enum RigSet {
73    /// A run that wants a turn gets one, or fails its budget.
74    Advance,
75    /// A run without a model of its own takes the agent's.
76    Select,
77    /// The fold: the turn's graph becomes the turn's effect.
78    Assemble,
79    /// The second steering slot: the folded effect, before the bus.
80    Patch,
81    /// A turn's tool batch is released up to its concurrency.
82    Release,
83    /// The turn's outputs from the effect's stream or answer.
84    Fold,
85    /// The agent's judge: the turn's outputs, before they are read.
86    Judge,
87    /// The turn is read into the graph.
88    Materialise,
89    /// Committed graph writes are visible; hosts may inspect or save before advancement.
90    Checkpoint,
91    /// A run settled or failed.
92    Settle,
93}
94
95/// A run that may want a turn: `Ready`, phased, not failed (`Assembling`
96/// is read off the phase).
97#[derive(QueryFilter)]
98pub struct Wanting {
99    phased: With<RunPhase>,
100    ready: With<crate::agent::Ready>,
101    live: Without<Failed>,
102}
103/// A `Ready` run that has neither a phase nor an ending: `open_runs` opens it.
104#[derive(QueryFilter)]
105pub struct Unopened {
106    run: With<Run>,
107    ready: With<crate::agent::Ready>,
108    not_failed: Without<Failed>,
109    not_settled: Without<Settled>,
110    phaseless: Without<RunPhase>,
111}
112/// A run with no model of its own yet.
113#[derive(QueryFilter)]
114pub struct Unselected {
115    run: With<Run>,
116    unselected: Without<UsesModel>,
117}
118/// A run that has not failed.
119#[derive(QueryFilter)]
120pub struct LiveRun {
121    run: With<Run>,
122    live: Without<Failed>,
123}
124/// What `Fold` reads of an effect: its turn, its stream so far, its
125/// outcome once landed.
126#[derive(QueryData)]
127pub struct EffectView {
128    /// The turn the effect is `ChildOf`.
129    pub turn_of: &'static ChildOf,
130    /// The stream so far, for a streamed effect.
131    pub streamed: Option<&'static BusStreamed>,
132    /// The outcome, once landed.
133    pub outcome: Option<&'static EffectOutcome>,
134}
135/// What `Materialise` reads of a landed completion effect: its turn, its
136/// outcome, its stream if it streamed.
137#[derive(QueryData)]
138pub struct LandedEffect {
139    /// The turn the effect is `ChildOf`.
140    pub turn_of: &'static ChildOf,
141    /// The outcome.
142    pub outcome: &'static EffectOutcome,
143    /// The stream, for a streamed effect.
144    pub streamed: Option<&'static BusStreamed>,
145}
146/// An invalid call nothing resolved.
147#[derive(QueryFilter)]
148pub struct Unresolved {
149    invalid: With<InvalidCall>,
150    unresolved: Without<Resolution>,
151}
152/// A turn `Materialise` has not read.
153#[derive(QueryFilter)]
154pub struct Unread {
155    turn: With<Turn>,
156    unread: Without<Materialised>,
157}
158/// What `fold_turn` reads of a run.
159#[derive(QueryData)]
160pub struct AssemblingRun {
161    /// The agent.
162    pub run_of: &'static RunOf,
163    /// The run's order among runs.
164    pub seq: &'static RunSeq,
165    /// Whether the run streams.
166    pub stream: &'static StreamRequested,
167    /// The run's model, once selected.
168    pub model: Option<&'static UsesModel>,
169    /// The output tool's name, once minted.
170    pub minted: &'static OutputToolName,
171}
172/// The request settings `fold_turn` resolves, the run's over the agent's.
173#[derive(bevy_ecs::system::SystemParam)]
174pub struct Settings<'w, 's> {
175    /// The preamble.
176    pub preambles: Query<'w, 's, &'static Preamble>,
177    /// The temperature.
178    pub temperatures: Query<'w, 's, &'static Temperature>,
179    /// The token budget.
180    pub max_tokens: Query<'w, 's, &'static MaxTokens>,
181    /// Whether unknown finish reasons stop normally.
182    pub unknown_finishes: Query<'w, 's, &'static AcceptUnknownFinishReasons>,
183    /// The provider's extra parameters.
184    pub params: Query<'w, 's, &'static AdditionalParams>,
185    /// The portable generation options.
186    pub options: Query<'w, 's, &'static Options>,
187    /// The typed per-provider options.
188    pub provider_options: Query<'w, 's, &'static crate::agent::ProviderOptions>,
189    /// The tool choice.
190    pub choices: Query<'w, 's, &'static ToolChoiceSpec>,
191    /// The output mode.
192    pub outputs: Query<'w, 's, &'static Output>,
193    /// The output tool's reserved name, description and preamble behavior.
194    pub output_tools: Query<'w, 's, &'static OutputToolConfig>,
195    /// Execution bindings and permissions, separate from advertisements.
196    pub tool_access: Query<'w, 's, &'static ToolAccess>,
197    /// The request-time size policy for tool-result text.
198    pub tool_result_limits: Query<'w, 's, &'static ToolResultLimit>,
199}
200/// What `fold_turn` reads of a fresh turn: its run, its patch, whether
201/// it is retrieving.
202#[derive(QueryData)]
203pub struct FreshTurn {
204    /// The turn.
205    pub entity: Entity,
206    /// The run the turn is `ChildOf`.
207    pub turn_of: &'static ChildOf,
208    /// The turn's request patch, if a system wrote one.
209    pub patch: Option<&'static RequestPatch>,
210    /// Whether the turn retrieves before it folds.
211    pub retrieving: Has<Retrieving>,
212}
213/// A fresh turn whose retrievals are out.
214#[derive(QueryFilter)]
215pub struct RetrievingTurn {
216    fresh: With<Fresh>,
217    retrieving: With<Retrieving>,
218}
219/// A remembering run whose persisted finalization has not scheduled an append.
220#[derive(QueryFilter)]
221pub struct NeedsMemoryAppend {
222    settled: With<Settled>,
223    remembering: With<Remembering>,
224    unscheduled: Without<MemoryAppendScheduled>,
225}
226/// A completion effect: any effect of a turn that is not a retrieval.
227#[derive(QueryFilter)]
228pub struct NotRetrieval {
229    effect: With<PendingEffect>,
230    completion: Without<Retrieval>,
231}
232/// What the cancel observer reads of a run: its phase, whether ended.
233#[derive(QueryData)]
234pub struct RunState {
235    /// The phase, while the run has one.
236    pub phase: Option<&'static RunPhase>,
237    /// Whether the run failed.
238    pub failed: Has<Failed>,
239}
240/// What the cancel observer reads of a turn: its run, whether it was
241/// read, whether its batch is out.
242#[derive(QueryData)]
243pub struct TurnState {
244    /// The run the turn is `ChildOf`.
245    pub run_of: &'static ChildOf,
246    /// Whether `Materialise` read the turn.
247    pub materialised: Has<Materialised>,
248    /// Whether the turn's batch is out.
249    pub batched: Has<Batch>,
250}
251
252/// A fresh turn: spawned by `Advance`, not yet folded by `Assemble`.
253#[derive(Component, Debug, Clone, Copy, Default, Reflect)]
254#[reflect(Component)]
255pub struct Fresh;
256
257/// The output mode the turn was folded under, pinned.
258#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Reflect)]
259#[reflect(Component)]
260pub struct Folded(pub OutputKind);
261
262/// A turn `Materialise` has read.
263#[derive(Component, Debug, Clone, Copy, Default, Reflect)]
264#[reflect(Component)]
265pub struct Materialised;
266
267/// A tool a turn may call: its name, its handler key, and its handler
268/// entity; `None` when the run's `ToolAccess.executable` named it rather
269/// than an advert (the bus routes the key).
270#[derive(Debug, Clone)]
271pub struct GrantedTool {
272    /// The tool's name, as the model calls it.
273    pub name: String,
274    /// The handler key the call dispatches to.
275    pub key: rig_core::effect::HandlerKey,
276    /// The advertised handler entity, when an advert granted the tool.
277    pub handler: Option<Entity>,
278}
279
280/// Complete turn content after judgement and invalid-call edits, with executable
281/// tools and the materialised assistant entity. Transient within one
282/// [`RigSet::Materialise`] pass; removed when the turn finishes processing.
283#[derive(Component, Debug, Clone)]
284pub struct TurnRead {
285    /// The turn's origin, stop and provider message.
286    pub head: AssistantHead,
287    /// The turn's parts as read.
288    pub content: Vec<AssistantContent>,
289    /// The tools the turn may call, in advertisement order.
290    pub granted: Vec<GrantedTool>,
291    /// The assistant utterance, once spawned.
292    pub assistant: Option<Entity>,
293}
294
295impl TurnRead {
296    /// The turn's tool calls, in order.
297    fn calls(&self) -> impl Iterator<Item = &rig_core::completion::message::ToolCall> {
298        self.content.iter().filter_map(|part| match part {
299            AssistantContent::ToolCall(call) => Some(call),
300            _ => None,
301        })
302    }
303}
304
305/// The agent runtime: the sets, the counters, the observers and the systems,
306/// in the bus's [`RigSchedule`]. Requires [`crate::bus::BusPlugin`] first.
307#[derive(Debug, Default, Clone, Copy)]
308pub struct AgentPlugin;
309
310impl bevy_app::Plugin for AgentPlugin {
311    fn build(&self, app: &mut bevy_app::App) {
312        Self::install(app.world_mut());
313        if app
314            .world()
315            .contains_resource::<bevy_diagnostic::DiagnosticsStore>()
316        {
317            diagnostics::register(app);
318            app.add_systems(RigSchedule, diagnostics::measure.in_set(RigSet::Settle));
319        }
320    }
321}
322
323impl AgentPlugin {
324    /// Install agent systems, counters, and observers for a host-driven schedule.
325    /// Panics unless the bus policy and schedules have already been installed.
326    pub fn install(world: &mut World) {
327        assert!(
328            world.contains_resource::<crate::bus::Policy>(),
329            "AgentPlugin needs BusPlugin first: it runs in the bus's RigSchedule"
330        );
331        world.init_resource::<BinaryAssets>();
332        world.init_resource::<RunCounter>();
333        world.add_observer(effect_cancelled);
334        world.add_observer(run_cancelled);
335        world.add_observer(batch_marker_follows_the_hold);
336        witness::install(world);
337        let mut schedules = world.resource_mut::<Schedules>();
338        let Some(schedule) = schedules.get_mut(RigSchedule) else {
339            return;
340        };
341        schedule.configure_sets(
342            (
343                RigSet::Advance,
344                RigSet::Select,
345                RigSet::Assemble,
346                RigSet::Patch,
347                RigSet::Release,
348            )
349                .chain()
350                .before(BusSet::Gate),
351        );
352        schedule.configure_sets(
353            (
354                RigSet::Fold,
355                RigSet::Judge,
356                RigSet::Materialise,
357                RigSet::Checkpoint,
358                RigSet::Settle,
359            )
360                .chain()
361                .after(BusSet::Judge),
362        );
363        schedule.add_systems((
364            (open_runs, advance).chain().in_set(RigSet::Advance),
365            attach_retrieved
366                .after(RigSet::Advance)
367                .before(RigSet::Select),
368            select.in_set(RigSet::Select),
369            fold_turn.in_set(RigSet::Assemble),
370            backoff::hold_retries
371                .after(RigSet::Patch)
372                .before(RigSet::Release),
373            release_batch.in_set(RigSet::Release),
374            (fold, discover_streamed_invalid_calls)
375                .chain()
376                .in_set(RigSet::Fold),
377            (
378                land_memory,
379                resolve_invalid_defaults,
380                land_batch,
381                record_usage,
382                judge_invalid_calls,
383                read_turn,
384                materialise_assistant,
385                materialise_batch,
386                materialise_reprompt,
387                materialise_answer,
388            )
389                .chain()
390                .in_set(RigSet::Materialise),
391            append_memory.in_set(RigSet::Settle),
392        ));
393    }
394}
395
396/// The run's phase changes: the next phase, or an ending in its place.
397trait PhaseCommands {
398    fn phase(&mut self, next: RunPhase) -> &mut Self;
399    fn end(&mut self, ending: impl Bundle) -> &mut Self;
400}
401
402impl PhaseCommands for bevy_ecs::system::EntityCommands<'_> {
403    fn phase(&mut self, next: RunPhase) -> &mut Self {
404        self.insert(next)
405    }
406    fn end(&mut self, ending: impl Bundle) -> &mut Self {
407        self.remove::<RunPhase>().insert(ending)
408    }
409}
410
411/// Fail only the affected run with a content error, removing any settled marker.
412fn fail_content(commands: &mut Commands, run: Entity, error: ContentError) {
413    commands
414        .entity(run)
415        .remove::<(RunPhase, Settled)>()
416        .insert(Failed(Failure::Content(error)));
417}
418
419/// Why a despawn left a run in the world.
420#[derive(Debug, Clone, Copy, PartialEq, Eq)]
421pub enum RunBusy {
422    /// The entity is not a run.
423    NotARun,
424    /// The run has not ended: it has no [`Settled`] and no [`Failed`].
425    Unsettled,
426    /// An effect of the run is still in flight or waiting to dispatch.
427    /// Cancel the run and let it drain first.
428    InFlight,
429}
430
431/// A queued `despawn_run` ([`RunCommands`] on `Commands`) refused the run:
432/// triggered on the run entity when the command applies, with the reason
433/// the exclusive form would have returned. The run is left as it was.
434#[derive(EntityEvent, Debug, Clone, Copy, PartialEq, Eq)]
435pub struct RunDespawnRefused {
436    /// The run that stays.
437    pub entity: Entity,
438    /// Why.
439    pub reason: RunBusy,
440}
441
442/// Spawn, cancel, and remove runs through a world or deferred commands.
443/// World operations apply immediately; commands reserve spawn IDs immediately
444/// but populate runs only when flushed. Ordered commands preserve dependencies
445/// between spawning, cancelling, and removing a run.
446pub trait RunCommands {
447    /// What `despawn_run` reports: the refusal, on `World`; nothing on
448    /// `Commands`, which triggers [`RunDespawnRefused`] on the run instead.
449    type Despawned;
450
451    /// Spawn a run of `agent` with `prompt` as its first utterance, after
452    /// `history`: the host's one entry point. The prompt is a user
453    /// message's parts (`&str` text, or text and images kept in their
454    /// given order, [`Prompt`]). Returns the run entity. On `Commands`
455    /// the entity is reserved at once and populated when the command
456    /// applies; a run despawned before or while it is populated (a host
457    /// `Add<Run>` observer that refuses it, say) is simply gone.
458    fn spawn_run(
459        &mut self,
460        agent: Entity,
461        history: &[MessageParts],
462        prompt: impl Into<Prompt>,
463        streamed: bool,
464        max_turns: Option<usize>,
465    ) -> Entity;
466
467    /// Stop `run` with `reason` (CONTRACT ยง9.1): `Cancelled(reason)` on the
468    /// run. A run that ended keeps its ending; an entity that is not a run
469    /// is left alone. A run cancelled before it opened (`Ready` written by
470    /// hand, not yet seen by `Advance`) fails with its unread [`Prompt`]
471    /// still on it, and a scene saves the prompt with the failed run.
472    fn cancel_run(&mut self, run: Entity, reason: impl Into<String>);
473
474    /// Despawn an ended run and its linked descendants, including stream folds.
475    /// Refuses without mutation if the entity is not a run, has not ended, or
476    /// has pending or in-flight effects. Hosts must remove finished runs to
477    /// reclaim their graphs; the runtime does not remove them automatically.
478    fn despawn_run(&mut self, run: Entity) -> Self::Despawned;
479}
480
481impl RunCommands for World {
482    type Despawned = Result<(), RunBusy>;
483
484    fn spawn_run(
485        &mut self,
486        agent: Entity,
487        history: &[MessageParts],
488        prompt: impl Into<Prompt>,
489        streamed: bool,
490        max_turns: Option<usize>,
491    ) -> Entity {
492        let run = self.spawn_empty().id();
493        spawn_run_at(
494            self,
495            run,
496            agent,
497            history,
498            prompt.into(),
499            streamed,
500            max_turns,
501        );
502        run
503    }
504
505    fn cancel_run(&mut self, run: Entity, reason: impl Into<String>) {
506        cancel_run_in(self, run, reason.into());
507    }
508
509    fn despawn_run(&mut self, run: Entity) -> Result<(), RunBusy> {
510        despawn_run_in(self, run)
511    }
512}
513
514impl RunCommands for Commands<'_, '_> {
515    type Despawned = ();
516
517    fn spawn_run(
518        &mut self,
519        agent: Entity,
520        history: &[MessageParts],
521        prompt: impl Into<Prompt>,
522        streamed: bool,
523        max_turns: Option<usize>,
524    ) -> Entity {
525        let run = self.spawn_empty().id();
526        let history = history.to_vec();
527        let prompt = prompt.into();
528        self.queue(move |world: &mut World| {
529            spawn_run_at(world, run, agent, &history, prompt, streamed, max_turns);
530        });
531        run
532    }
533
534    fn cancel_run(&mut self, run: Entity, reason: impl Into<String>) {
535        let reason = reason.into();
536        self.queue(move |world: &mut World| cancel_run_in(world, run, reason));
537    }
538
539    fn despawn_run(&mut self, run: Entity) {
540        self.queue(move |world: &mut World| {
541            if let Err(reason) = despawn_run_in(world, run) {
542                world.trigger(RunDespawnRefused {
543                    entity: run,
544                    reason,
545                });
546            }
547        });
548    }
549}
550
551/// Populate and open a reserved run with its history and prompt. Stop if a host
552/// observer removes the run during insertion; content errors mark the run failed.
553fn spawn_run_at(
554    world: &mut World,
555    run: Entity,
556    agent: Entity,
557    history: &[MessageParts],
558    prompt: Prompt,
559    streamed: bool,
560    max_turns: Option<usize>,
561) {
562    if world.get_entity(run).is_err() {
563        return;
564    }
565    let Ok(mut entity) = world.get_entity_mut(run) else {
566        return;
567    };
568    entity.insert((Run, RunOf(agent), StreamRequested(streamed), prompt));
569    if let Some(limit) = max_turns {
570        let Ok(mut entity) = world.get_entity_mut(run) else {
571            return;
572        };
573        entity.insert(MaxTurns(limit));
574    }
575    for parts in history.iter().cloned() {
576        if world.get_entity(run).is_err() {
577            return;
578        }
579        if let Err(error) = spawn_utterance(world, run, parts) {
580            if let Ok(mut entity) = world.get_entity_mut(run) {
581                entity
582                    .remove::<Prompt>()
583                    .insert(Failed(Failure::Content(error)));
584            }
585            return;
586        }
587    }
588    let Ok(mut entity) = world.get_entity_mut(run) else {
589        return;
590    };
591    entity.insert(crate::agent::Ready);
592    open_run(world, run);
593}
594
595fn cancel_run_in(world: &mut World, run: Entity, reason: String) {
596    if let Ok(mut entity) = world.get_entity_mut(run)
597        && entity.contains::<Run>()
598    {
599        entity.insert(Cancelled(reason));
600    }
601}
602
603fn despawn_run_in(world: &mut World, run: Entity) -> Result<(), RunBusy> {
604    if world.get::<Run>(run).is_none() {
605        return Err(RunBusy::NotARun);
606    }
607    if world.get::<Settled>(run).is_none() && world.get::<Failed>(run).is_none() {
608        return Err(RunBusy::Unsettled);
609    }
610    let mut stack = vec![run];
611    while let Some(entity) = stack.pop() {
612        if world.get::<crate::bus::InFlight>(entity).is_some()
613            || (world.get::<PendingEffect>(entity).is_some()
614                && world.get::<EffectOutcome>(entity).is_none())
615        {
616            return Err(RunBusy::InFlight);
617        }
618        if let Some(children) = world.get::<Children>(entity) {
619            stack.extend(children.iter());
620        }
621    }
622    world.entity_mut(run).despawn();
623    Ok(())
624}
625
626/// Open ready, phaseless, nonterminal runs before advancement. Consumes their
627/// prompts into history and starts assembly, or loads memory first when the agent
628/// remembers and no history was supplied. Content errors fail the affected run.
629pub fn open_runs(mut commands: Commands, runs: Query<Entity, Unopened>) {
630    // Queued: the opening writes the graph, and the chain's sync point
631    // applies it before `advance` reads.
632    for run in &runs {
633        commands.queue(move |world: &mut World| open_run(world, run));
634    }
635}
636
637/// Open `run` (see [`open_runs`]): a no-op unless the run is `Ready`,
638/// phaseless and not ended.
639fn open_run(world: &mut World, run: Entity) {
640    let Some(entity) = world.get_entity(run).ok() else {
641        return;
642    };
643    if !entity.contains::<Run>()
644        || !entity.contains::<crate::agent::Ready>()
645        || entity.contains::<Failed>()
646        || entity.contains::<Settled>()
647        || entity.contains::<RunPhase>()
648    {
649        return;
650    }
651    let agent = entity.get::<RunOf>().map(|run_of| run_of.0);
652    let had_history = entity.get::<Children>().is_some_and(|children| {
653        children
654            .iter()
655            .any(|child| world.get::<Utterance>(child).is_some())
656    });
657    if let Some(Prompt(content)) = world.entity_mut(run).take::<Prompt>()
658        && let Err(error) =
659            MessageParts::user(content).and_then(|prompt| spawn_utterance(world, run, prompt))
660    {
661        world
662            .entity_mut(run)
663            .insert(Failed(Failure::Content(error)));
664        return;
665    }
666    let memory = (!had_history)
667        .then(|| {
668            let agent = agent?;
669            let handler = world.get::<Remembers>(agent).map(|remembers| remembers.0)?;
670            let conversation = world
671                .get::<Conversation>(agent)
672                .map(|conversation| conversation.0.clone())?;
673            let key = world.get::<Bound>(handler).map(|bound| bound.key.clone())?;
674            Some((key, conversation))
675        })
676        .flatten();
677    match memory {
678        Some((key, conversation)) => {
679            world.entity_mut(run).insert((
680                RunPhase::LoadingMemory,
681                Remembering,
682                Conversation(conversation.clone()),
683            ));
684            world.spawn((
685                PendingEffect::new(
686                    key,
687                    EffectKind::Memory {
688                        op: rig_core::effect::MemoryOp::Load {
689                            conversation: rig_core::id::ConversationId::from(conversation.as_str()),
690                        },
691                    },
692                ),
693                ChildOf(run),
694            ));
695        }
696        None => {
697            world.entity_mut(run).insert(RunPhase::Assembling);
698        }
699    }
700}
701
702/// Spawn one utterance `ChildOf` `run`, last among its siblings.
703/// Returns content conversion errors and removes the new utterance on failure.
704pub fn spawn_utterance(
705    world: &mut World,
706    run: Entity,
707    parts: MessageParts,
708) -> Result<Entity, ContentError> {
709    let entity = world.spawn((Utterance, ChildOf(run))).id();
710    if let Err(error) = write_message(world, entity, parts) {
711        world.despawn(entity);
712        return Err(error);
713    }
714    Ok(entity)
715}
716
717/// A run's effective setting: its own component, else its agent's.
718fn setting<'a, C: Component>(run: Entity, agent: Entity, query: &'a Query<&C>) -> Option<&'a C> {
719    query.get(run).ok().or_else(|| query.get(agent).ok())
720}
721
722/// The links of one kind under `owner`, in sibling (`Children`) order.
723fn links_in_order<'a, L: Component, F: bevy_ecs::query::QueryFilter>(
724    owner: Entity,
725    children: &Query<&Children>,
726    links: &'a Query<&L, F>,
727) -> Vec<&'a L> {
728    children
729        .get(owner)
730        .map(|children| {
731            children
732                .iter()
733                .filter_map(|child| links.get(child).ok())
734                .collect()
735        })
736        .unwrap_or_default()
737}
738
739/// Advance ready, assembling runs in sequence order, failing exhausted turn budgets.
740/// Creates a fresh turn with ordered grants and context, or a retrieving turn
741/// whose links are populated after retrieval. Commit holds postpone advancement;
742/// provider retries do not consume the turn budget.
743pub fn advance(
744    mut commands: Commands,
745    runs: Query<(Entity, &RunOf, &Cursor, &RunSeq, &RunPhase), Wanting>,
746    fresh: Query<&ChildOf, With<Fresh>>,
747    children: Query<&Children>,
748    grants: Query<&Grant, Without<Retrievable>>,
749    contexts: Query<&Context>,
750    retrievals: Query<(), With<Retrieves>>,
751    max_turns: Query<&MaxTurns>,
752    retrying: Query<(), With<ProviderRetrying>>,
753    provider_retried: Query<&ProviderRetried>,
754    holds: Query<&ToolTurnHolds>,
755    commits: Query<(&ChildOf, &ToolTurnCommit)>,
756) {
757    let mut runs: Vec<_> = runs
758        .iter()
759        .filter(|(_, _, _, _, phase)| **phase == RunPhase::Assembling)
760        .collect();
761    runs.sort_by_key(|(_, _, _, seq, _)| **seq);
762    for (run, RunOf(agent), cursor, _, _) in runs {
763        if fresh.iter().any(|child_of| child_of.parent() == run) {
764            continue;
765        }
766        // A retried attempt re-issues a turn the cursor already counted
767        // (CONTRACT ยง5): it neither checks nor spends the model-call budget.
768        let retrying = retrying.get(run).is_ok();
769        let limit = setting(run, *agent, &max_turns).map_or(1, |limit| limit.0);
770        if !retrying && cursor.turn >= limit {
771            commands
772                .entity(run)
773                .end(Failed(Failure::MaxTurns { limit }));
774            continue;
775        }
776        if holds.get(run).is_ok_and(|holds| {
777            commits
778                .iter()
779                .any(|(parent, commit)| parent.parent() == run && holds.blocks(commit.turn))
780        }) {
781            continue;
782        }
783        let turn = commands.spawn((Turn, Fresh, ChildOf(run))).id();
784        let retrieves = children
785            .get(*agent)
786            .map(|children| children.iter().any(|child| retrievals.get(child).is_ok()))
787            .unwrap_or(false);
788        if retrieves {
789            commands.entity(turn).insert(Retrieving);
790        } else {
791            for Grant(tool) in links_in_order(*agent, &children, &grants) {
792                commands.spawn((Advert(*tool), ChildOf(turn)));
793            }
794            for Context(document) in links_in_order(*agent, &children, &contexts) {
795                commands.spawn((Attachment(*document), ChildOf(turn)));
796            }
797        }
798        if retrying {
799            commands.entity(run).remove::<ProviderRetrying>();
800            commands.entity(turn).insert(backoff::RetryAttempt(
801                provider_retried.get(run).map_or(1, |n| n.0),
802            ));
803        } else {
804            commands.entity(run).insert(Cursor {
805                turn: cursor.turn + 1,
806            });
807        }
808    }
809}
810
811/// A fresh turn whose retrievals landed gets its adverts and attachments
812/// (CONTRACT ยง12): the retrieved tools first, in result order, then the
813/// static grants; the static attachments, then one document entity per
814/// result (an existing entity with that id reused). Runs after `Advance`
815/// and before `Select`; `fold_turn` waits for it.
816pub fn attach_retrieved(
817    mut commands: Commands,
818    turns: Query<(Entity, &ChildOf), RetrievingTurn>,
819    runs: Query<&RunOf>,
820    children: Query<&Children>,
821    retrievals: Query<(&PendingEffect, &Retrieval, Option<&EffectOutcome>)>,
822    grants: Query<(&Grant, Has<Retrievable>)>,
823    contexts: Query<&Context>,
824    bound: Query<&Bound>,
825    indexes: Query<&Retrieves, With<Retrieval>>,
826    documents: Query<(Entity, &DocumentId)>,
827) {
828    for (turn, turn_of) in &turns {
829        let run = turn_of.parent();
830        let Ok(RunOf(agent)) = runs.get(run) else {
831            continue;
832        };
833        let effects: Vec<(&Retrieval, Option<&EffectOutcome>)> = children
834            .get(turn)
835            .map(|children| {
836                children
837                    .iter()
838                    .filter_map(|child| retrievals.get(child).ok())
839                    .map(|(_, retrieval, outcome)| (retrieval, outcome))
840                    .collect()
841            })
842            .unwrap_or_default();
843        // Wait for an available index's effects to be spawned later this
844        // tick. With no available index, still attach the static links here.
845        let available_index = children.get(*agent).is_ok_and(|children| {
846            children.iter().any(|child| {
847                indexes
848                    .get(child)
849                    .is_ok_and(|Retrieves(index)| bound.get(*index).is_ok())
850            })
851        });
852        if (effects.is_empty() && available_index)
853            || effects.iter().any(|(_, outcome)| outcome.is_none())
854        {
855            continue;
856        }
857        let mut retrieved_tools: Vec<String> = Vec::new();
858        let mut retrieved_documents: Vec<(String, String)> = Vec::new();
859        for (retrieval, outcome) in &effects {
860            let Some(EffectOutcome(Ok(Outcome::Documents(results)))) = outcome else {
861                continue;
862            };
863            match (retrieval.what, results) {
864                (RetrievalKind::Tools, rig_core::effect::RetrievedDocuments::Ids(ids)) => {
865                    retrieved_tools.extend(ids.iter().map(|(_, id)| id.clone()));
866                }
867                (
868                    RetrievalKind::Documents,
869                    rig_core::effect::RetrievedDocuments::Scored(scored),
870                ) => {
871                    retrieved_documents.extend(scored.iter().map(|(_, id, value)| {
872                        (
873                            id.clone(),
874                            serde_json::to_string_pretty(value)
875                                .unwrap_or_else(|_| value.to_string()),
876                        )
877                    }));
878                }
879                (RetrievalKind::Tools, rig_core::effect::RetrievedDocuments::Scored(_))
880                | (RetrievalKind::Documents, rig_core::effect::RetrievedDocuments::Ids(_)) => {}
881            }
882        }
883        let links: Vec<(&Grant, bool)> = children
884            .get(*agent)
885            .map(|children| {
886                children
887                    .iter()
888                    .filter_map(|child| grants.get(child).ok())
889                    .collect()
890            })
891            .unwrap_or_default();
892        let tool_named = |name: &str| -> Option<Entity> {
893            links.iter().find_map(|(Grant(tool), _)| {
894                bound
895                    .get(*tool)
896                    .is_ok_and(|bound| policy::tool_name(&bound.descriptor) == Some(name))
897                    .then_some(*tool)
898            })
899        };
900        for name in &retrieved_tools {
901            if let Some(tool) = tool_named(name) {
902                commands.spawn((Advert(tool), ChildOf(turn)));
903            }
904        }
905        for (Grant(tool), retrievable) in &links {
906            if !retrievable {
907                commands.spawn((Advert(*tool), ChildOf(turn)));
908            }
909        }
910        for Context(document) in links_in_order(*agent, &children, &contexts) {
911            commands.spawn((Attachment(*document), ChildOf(turn)));
912        }
913        for (id, text) in retrieved_documents {
914            let document = documents
915                .iter()
916                .find(|(_, existing)| existing.0 == id)
917                .map(|(entity, _)| entity)
918                .unwrap_or_else(|| commands.spawn((DocumentId(id), DocumentText(text))).id());
919            commands.spawn((Attachment(document), ChildOf(turn)));
920        }
921        commands.entity(turn).remove::<Retrieving>();
922    }
923}
924
925/// Materialise loaded memory before the prompt, marking imported utterances
926/// remembered and starting assembly. Failed loads, wrong outcome families, and
927/// content conversion errors fail the affected run.
928pub fn land_memory(
929    mut commands: Commands,
930    mut assets: ResMut<BinaryAssets>,
931    runs: Query<(Entity, &RunPhase), Without<Failed>>,
932    children: Query<&Children>,
933    loads: Query<(&PendingEffect, &EffectOutcome)>,
934) {
935    for (run, _) in runs
936        .iter()
937        .filter(|(_, phase)| **phase == RunPhase::LoadingMemory)
938    {
939        let Some(outcome) = children.get(run).ok().and_then(|children| {
940            children.iter().find_map(|child| {
941                loads
942                    .get(child)
943                    .ok()
944                    .and_then(|(effect, outcome)| match &effect.kind {
945                        EffectKind::Memory {
946                            op: rig_core::effect::MemoryOp::Load { .. },
947                        } => Some(&outcome.0),
948                        EffectKind::Memory { .. }
949                        | EffectKind::Completion { .. }
950                        | EffectKind::ToolCall { .. }
951                        | EffectKind::Embed { .. }
952                        | EffectKind::Rerank { .. }
953                        | EffectKind::Retrieve { .. }
954                        | EffectKind::Custom { .. } => None,
955                    })
956            })
957        }) else {
958            continue;
959        };
960        match outcome {
961            Ok(Outcome::Memory(rig_core::effect::MemoryOutcome::Loaded { messages })) => {
962                if let Err(error) = land_loaded(&mut commands, &mut assets, run, messages) {
963                    fail_content(&mut commands, run, error);
964                }
965            }
966            Ok(other) => {
967                commands.entity(run).end(Failed(Failure::Memory(
968                    rig_core::error::ErrorReport::new(
969                        ErrorKind::Internal,
970                        format!(
971                            "the memory handler answered a load with a {} outcome",
972                            other.family()
973                        ),
974                    ),
975                )));
976            }
977            Err(report) => {
978                commands
979                    .entity(run)
980                    .end(Failed(Failure::Memory(report.clone())));
981            }
982        }
983    }
984}
985
986/// The loaded messages become `run`'s first utterances, each `Remembered`,
987/// and the run is `Assembling`.
988fn land_loaded(
989    commands: &mut Commands,
990    assets: &mut BinaryAssets,
991    run: Entity,
992    messages: &[rig_core::completion::Message],
993) -> Result<(), ContentError> {
994    let mut loaded = Vec::with_capacity(messages.len());
995    for message in messages {
996        if let Some(parts) = MessageParts::from_message(message) {
997            let utterance = spawn_deferred(commands, assets, run, parts)?;
998            commands.entity(utterance).insert(Remembered);
999            loaded.push(utterance);
1000        }
1001    }
1002    if !loaded.is_empty() {
1003        commands.entity(run).insert_children(0, &loaded);
1004    }
1005    commands.entity(run).phase(RunPhase::Assembling);
1006    Ok(())
1007}
1008
1009/// Schedule a settled remembering run's ordered, nonremembered utterances for
1010/// memory append. Content errors fail the run. A persisted marker prevents
1011/// scheduling a second append after checkpoint restoration.
1012pub fn append_memory(
1013    mut commands: Commands,
1014    settled: Query<(Entity, &RunOf, &Conversation), NeedsMemoryAppend>,
1015    memories: Query<&Remembers>,
1016    bound: Query<&Bound>,
1017    children: Query<&Children>,
1018    utterances: Query<(Entity, Has<Remembered>), With<Utterance>>,
1019    content: ContentGraph,
1020) {
1021    for (run, RunOf(agent), Conversation(conversation)) in &settled {
1022        let Some(key) = memories
1023            .get(*agent)
1024            .ok()
1025            .and_then(|Remembers(memory)| bound.get(*memory).ok())
1026            .map(|bound| bound.key.clone())
1027        else {
1028            continue;
1029        };
1030        let said: Result<Vec<MessageParts>, ContentError> = children
1031            .get(run)
1032            .map(|children| {
1033                children
1034                    .iter()
1035                    .filter_map(|child| utterances.get(child).ok())
1036                    .filter(|(_, remembered)| !*remembered)
1037                    .map(|(entity, _)| content.message(entity))
1038                    .collect()
1039            })
1040            .unwrap_or_else(|_| Ok(Vec::new()));
1041        let said = match said {
1042            Ok(said) => said,
1043            Err(error) => {
1044                fail_content(&mut commands, run, error);
1045                continue;
1046            }
1047        };
1048        commands.spawn((
1049            PendingEffect::new(
1050                key,
1051                EffectKind::Memory {
1052                    op: rig_core::effect::MemoryOp::Append {
1053                        conversation: rig_core::id::ConversationId::from(conversation.as_str()),
1054                        messages: said.into_iter().map(|parts| parts.to_message()).collect(),
1055                    },
1056                },
1057            ),
1058            ChildOf(run),
1059        ));
1060        commands.entity(run).insert(MemoryAppendScheduled);
1061    }
1062}
1063
1064/// `RigSet::Select`: a run without a model of its own takes its agent's.
1065/// A routing system before this one gives the run another.
1066pub fn select(
1067    mut commands: Commands,
1068    runs: Query<(Entity, &RunOf), Unselected>,
1069    models: Query<&UsesModel>,
1070) {
1071    for (run, RunOf(agent)) in &runs {
1072        if let Ok(UsesModel(model)) = models.get(*agent) {
1073            commands.entity(run).insert(UsesModel(*model));
1074        }
1075    }
1076}
1077
1078/// A request edit link, including a malformed link for explicit validation;
1079/// the links apply in the turn's sibling (`Children`) order.
1080#[derive(QueryData)]
1081pub struct PartEdit {
1082    /// The link entity.
1083    pub link: Entity,
1084    /// The part the edit targets; a link without one is malformed.
1085    pub target: Option<&'static EditTarget>,
1086    /// The edit.
1087    pub edit: &'static RequestPartEdit,
1088}
1089
1090/// A turn's request part edits, gathered: by target, the links consumed,
1091/// the utterances they touch.
1092struct PartEdits {
1093    edits: std::collections::BTreeMap<Entity, RequestPartEdit>,
1094    consumed: Vec<Entity>,
1095    edited: std::collections::HashSet<Entity>,
1096}
1097
1098/// The part edits linked to `turn`, in link order. A link without a
1099/// target is `Missing`; a target outside `run`, or any edit beside a
1100/// patched history (a replacement history has no stable entity identity:
1101/// conflicting operations are refused, never silently dropped), is `Shape`.
1102fn part_edits_of(
1103    turn: Entity,
1104    run: Entity,
1105    patch: Option<&RequestPatch>,
1106    children: &Query<&Children>,
1107    part_edits: &Query<PartEdit>,
1108    content: &ContentGraph,
1109) -> Result<PartEdits, ContentError> {
1110    let mut gathered = PartEdits {
1111        edits: std::collections::BTreeMap::new(),
1112        consumed: Vec::new(),
1113        edited: std::collections::HashSet::new(),
1114    };
1115    let links = children
1116        .get(turn)
1117        .into_iter()
1118        .flat_map(|children| children.iter())
1119        .filter_map(|link| part_edits.get(link).ok());
1120    for PartEditItem { link, target, edit } in links {
1121        let target = target.ok_or(ContentError::Missing)?;
1122        let utterance = content.target_utterance(target.0)?;
1123        gathered.edited.insert(utterance);
1124        if !children
1125            .get(run)
1126            .is_ok_and(|owned| owned.contains(&utterance))
1127            || patch.is_some_and(|patch| patch.history.is_some())
1128        {
1129            return Err(ContentError::Shape);
1130        }
1131        gathered.edits.insert(target.0, edit.clone());
1132        gathered.consumed.push(link);
1133    }
1134    Ok(gathered)
1135}
1136
1137/// `run`'s utterances in order, as DTOs, under the part edits linked to
1138/// `turn` ([`part_edits_of`]): an edited one rendered with its edit, any
1139/// other rendered plain. With them, the edit links consumed.
1140fn render_history(
1141    turn: Entity,
1142    run: Entity,
1143    patch: Option<&RequestPatch>,
1144    children: &Query<&Children>,
1145    utterances: &Query<Entity, With<Utterance>>,
1146    part_edits: &Query<PartEdit>,
1147    content: &ContentGraph,
1148) -> Result<(Vec<MessageParts>, Vec<Entity>), ContentError> {
1149    let edits = part_edits_of(turn, run, patch, children, part_edits, content)?;
1150    let history = children
1151        .get(run)
1152        .into_iter()
1153        .flat_map(|children| children.iter())
1154        .filter_map(|child| utterances.get(child).ok())
1155        .map(|entity| {
1156            if edits.edited.contains(&entity) {
1157                content.message_with(entity, &edits.edits)
1158            } else {
1159                content.message(entity)
1160            }
1161        })
1162        .collect::<Result<_, _>>()?;
1163    Ok((history, edits.consumed))
1164}
1165
1166/// The first pass over a retrieving turn (CONTRACT ยง12): one `Retrieve`
1167/// effect per index of the agent (`indexes`, in link order), `ChildOf`
1168/// the turn; the fold waits for `attach_retrieved`.
1169fn spawn_retrievals<'a>(
1170    commands: &mut Commands,
1171    turn: Entity,
1172    history: &[MessageParts],
1173    indexes: impl Iterator<Item = (&'a Retrieves, &'a Retrieval)>,
1174    bound: &Query<&Bound>,
1175) {
1176    let query = policy::retrieval_query(history);
1177    for (Retrieves(index), retrieval) in indexes {
1178        let Ok(index) = bound.get(*index) else {
1179            continue;
1180        };
1181        let request = rig_core::vector_store::request::VectorSearchRequest::builder()
1182            .query(query.clone())
1183            .samples(retrieval.samples)
1184            .build()
1185            .map_filter(rig_core::vector_store::request::Filter::interpret);
1186        let query = match retrieval.what {
1187            RetrievalKind::Documents => rig_core::effect::RetrieveQuery::TopN { req: request },
1188            RetrievalKind::Tools => rig_core::effect::RetrieveQuery::TopNIds { req: request },
1189        };
1190        commands.spawn((
1191            PendingEffect::new(index.key.clone(), EffectKind::Retrieve { query }),
1192            *retrieval,
1193            ChildOf(turn),
1194        ));
1195    }
1196}
1197
1198/// The tools `turn` advertises that the patch allows, in advert order, as
1199/// handler entities with their bindings. An advert the patch's
1200/// `active_tools` excludes is despawned: the request and the executable
1201/// grant set must agree, so materialisation, invalid-call repair and scene
1202/// continuation see the same surface.
1203fn allowed_tools<'a>(
1204    commands: &mut Commands,
1205    turn: Entity,
1206    patch: Option<&RequestPatch>,
1207    children: &Query<&Children>,
1208    adverts: &Query<&Advert>,
1209    bound: &'a Query<&Bound>,
1210) -> Vec<(Entity, &'a Bound)> {
1211    children
1212        .get(turn)
1213        .into_iter()
1214        .flat_map(|children| children.iter())
1215        .filter_map(|link| {
1216            let Advert(tool) = adverts.get(link).ok()?;
1217            Some((link, *tool, bound.get(*tool).ok()?))
1218        })
1219        .filter_map(|(link, tool, bound)| {
1220            let allowed = match (
1221                patch.and_then(|p| p.active_tools.as_ref()),
1222                policy::tool_name(&bound.descriptor),
1223            ) {
1224                (Some(allowed), Some(name)) => allowed.iter().any(|allowed| allowed == name),
1225                (Some(_), None) | (None, _) => true,
1226            };
1227            if allowed {
1228                Some((tool, bound))
1229            } else {
1230                commands.entity(link).despawn();
1231                None
1232            }
1233        })
1234        .collect()
1235}
1236
1237/// The run's bound completion model, or why the run cannot fold: no
1238/// selected model, no binding, or a binding that does not serve
1239/// completions (a provider `HandlerUnavailable` report: the run never
1240/// silently waits).
1241fn completion_model<'a>(
1242    model: Option<&UsesModel>,
1243    bound: &'a Query<&Bound>,
1244) -> Result<(Entity, &'a Bound, bool), Failure> {
1245    let unavailable = |message: String| {
1246        Failure::Provider(rig_core::error::ErrorReport::new(
1247            rig_core::error::ErrorKind::HandlerUnavailable,
1248            message,
1249        ))
1250    };
1251    let Some((model, model_bound)) =
1252        model.and_then(|UsesModel(model)| bound.get(*model).ok().map(|bound| (*model, bound)))
1253    else {
1254        return Err(unavailable(
1255            "the run has no bound completion model; its selected model or its agent's binding was removed".to_owned(),
1256        ));
1257    };
1258    match &model_bound.descriptor.family {
1259        FamilyDescriptor::Completion { capabilities, .. } => Ok((
1260            model,
1261            model_bound,
1262            capabilities.composes_native_output_with_tools,
1263        )),
1264        FamilyDescriptor::Tool { .. }
1265        | FamilyDescriptor::Embed { .. }
1266        | FamilyDescriptor::Rerank { .. }
1267        | FamilyDescriptor::Memory { .. }
1268        | FamilyDescriptor::Retrieve { .. }
1269        | FamilyDescriptor::Custom { .. } => Err(unavailable(format!(
1270            "selected model `{}` does not serve completions",
1271            model_bound.key
1272        ))),
1273    }
1274}
1275
1276/// The output mode of a turn, resolved, with the output tool's name: a
1277/// committed name (minted on an earlier turn) stays the mode whatever this
1278/// turn's choice says (CONTRACT ยง9.3); a reserved name with a schema is
1279/// `Tool`; else the policy decides from the mode, the schema, the tools,
1280/// the choice and the model's capabilities. `Err(name)` is a collision:
1281/// the output tool's name is also a granted tool's.
1282fn resolve_output_tool(
1283    minted: &OutputToolName,
1284    reserved_name: Option<&str>,
1285    output: &Output,
1286    tool_choice: Option<&ToolChoice>,
1287    tools: &[(Entity, &Bound)],
1288    access: &ToolAccess,
1289    composes: bool,
1290) -> Result<(OutputKind, String), String> {
1291    let granted_names: Vec<&str> = tools
1292        .iter()
1293        .filter_map(|(_, bound)| policy::tool_name(&bound.descriptor))
1294        .collect();
1295    let occupied_names: Vec<&str> = granted_names
1296        .iter()
1297        .copied()
1298        .chain(
1299            access
1300                .executable
1301                .iter()
1302                .flat_map(|executable| executable.keys().map(String::as_str)),
1303        )
1304        .collect();
1305    let output_tool = minted
1306        .0
1307        .clone()
1308        .or_else(|| reserved_name.map(str::to_owned))
1309        .unwrap_or_else(|| output_tool_name(|name| occupied_names.contains(&name)));
1310    let callable = output_tool_callable(tool_choice, &output_tool);
1311    let resolved = if minted.0.is_some() || (reserved_name.is_some() && output.schema.is_some()) {
1312        OutputKind::Tool
1313    } else {
1314        policy::resolve_output(
1315            output.mode,
1316            output.schema.is_some(),
1317            granted_names.len(),
1318            callable,
1319            composes,
1320        )
1321    };
1322    if resolved == OutputKind::Tool && occupied_names.contains(&output_tool.as_str()) {
1323        // A reserved or already minted name must not also advertise a
1324        // granted tool: refuse the ambiguous request before dispatch.
1325        return Err(output_tool);
1326    }
1327    Ok((resolved, output_tool))
1328}
1329
1330/// The documents attached to `turn`, in link order, then the patch's
1331/// extra context.
1332fn attached_documents(
1333    turn: Entity,
1334    patch: Option<&RequestPatch>,
1335    children: &Query<&Children>,
1336    attachments: &Query<&Attachment>,
1337    documents: &Query<(&DocumentId, &DocumentText, Option<&DocumentProps>)>,
1338) -> Vec<rig_core::completion::Document> {
1339    links_in_order(turn, children, attachments)
1340        .into_iter()
1341        .filter_map(|Attachment(document)| documents.get(*document).ok())
1342        .map(|(id, text, props)| rig_core::completion::Document {
1343            id: id.0.clone(),
1344            text: text.0.clone(),
1345            additional_props: props.map(|props| props.0.clone()).unwrap_or_default(),
1346        })
1347        .chain(
1348            patch
1349                .into_iter()
1350                .flat_map(|patch| patch.extra_context.iter().cloned()),
1351        )
1352        .collect()
1353}
1354
1355/// The run's `ToolAccess` (else the agent's, else none) completed for the
1356/// turn: `executable` defaults to the advertised tools by name, `allowed`
1357/// to every executable name.
1358fn tool_access_for(
1359    run: Entity,
1360    agent: Entity,
1361    tools: &[(Entity, &Bound)],
1362    tool_access: &Query<&ToolAccess>,
1363) -> ToolAccess {
1364    let mut access = setting(run, agent, tool_access)
1365        .cloned()
1366        .unwrap_or_default();
1367    let executable = access.executable.get_or_insert_with(|| {
1368        tools
1369            .iter()
1370            .filter_map(|(_, bound)| {
1371                policy::tool_name(&bound.descriptor)
1372                    .map(|name| (name.to_owned(), bound.key.clone()))
1373            })
1374            .collect()
1375    });
1376    if access.allowed.is_none() {
1377        access.allowed = Some(executable.keys().cloned().collect());
1378    }
1379    access
1380}
1381
1382/// The request settings of one turn, resolved: the patch's over the
1383/// run's over the agent's.
1384struct Resolved {
1385    preamble: Option<String>,
1386    temperature: Option<f64>,
1387    max_tokens: Option<u64>,
1388    accept_unknown_finish_reasons: bool,
1389    additional_params: Option<serde_json::Value>,
1390    options: rig_core::completion::GenerationOptions,
1391    provider_options: rig_core::completion::ProviderOptions,
1392    tool_choice: Option<ToolChoice>,
1393    output: Output,
1394    output_tool_config: Option<OutputToolConfig>,
1395}
1396
1397impl Settings<'_, '_> {
1398    /// The settings for `run`'s turn under `patch` (CONTRACT ยง9.3): the
1399    /// patch's field where it has one (an object `additional_params`
1400    /// merges over the setting's), else the run's, else the agent's.
1401    fn resolve(&self, run: Entity, agent: Entity, patch: Option<&RequestPatch>) -> Resolved {
1402        let additional_params = match (
1403            setting(run, agent, &self.params).and_then(|p| p.0.clone()),
1404            patch.and_then(|p| p.additional_params.clone()),
1405        ) {
1406            (Some(base), Some(patched)) if base.is_object() && patched.is_object() => {
1407                Some(rig_core::json_utils::merge(base, patched))
1408            }
1409            (base, patched) => patched.or(base),
1410        };
1411        Resolved {
1412            preamble: patch
1413                .and_then(|p| p.preamble.clone())
1414                .or_else(|| setting(run, agent, &self.preambles).and_then(|p| p.0.clone())),
1415            temperature: patch
1416                .and_then(|p| p.temperature)
1417                .or_else(|| setting(run, agent, &self.temperatures).and_then(|t| t.0)),
1418            max_tokens: patch
1419                .and_then(|p| p.max_tokens)
1420                .or_else(|| setting(run, agent, &self.max_tokens).and_then(|m| m.0)),
1421            accept_unknown_finish_reasons: setting(run, agent, &self.unknown_finishes)
1422                .copied()
1423                .unwrap_or_default()
1424                .0,
1425            additional_params,
1426            options: {
1427                let base = self
1428                    .options
1429                    .get(agent)
1430                    .map(|options| options.0.clone())
1431                    .unwrap_or_default();
1432                match self.options.get(run) {
1433                    Ok(over) if run != agent => base.overlay(&over.0),
1434                    _ => base,
1435                }
1436            },
1437            provider_options: {
1438                let base = self
1439                    .provider_options
1440                    .get(agent)
1441                    .map(|options| options.0.clone())
1442                    .unwrap_or_default();
1443                // When the run is its own agent, overlaying its entries on
1444                // themselves changes nothing.
1445                match self.provider_options.get(run) {
1446                    Ok(over) => base.overlay(&over.0),
1447                    Err(_) => base,
1448                }
1449            },
1450            tool_choice: patch
1451                .and_then(|p| p.tool_choice.clone())
1452                .or_else(|| setting(run, agent, &self.choices).and_then(|c| c.0.clone())),
1453            output: setting(run, agent, &self.outputs)
1454                .cloned()
1455                .unwrap_or_default(),
1456            output_tool_config: setting(run, agent, &self.output_tools).cloned(),
1457        }
1458    }
1459}
1460
1461/// Fold fresh turns in run order into completion effects owned by their turns,
1462/// with retained tool access snapshots. Applies request edits, size limits,
1463/// settings, and output policy, consumes the turn's patch, initializes its
1464/// outputs, and moves the run to `AwaitingModel`; retrieving turns instead
1465/// dispatch their initial retrievals. Missing or incompatible models,
1466/// output-tool collisions, and content errors fail the affected run.
1467pub fn fold_turn(
1468    mut commands: Commands,
1469    fresh: Query<FreshTurn, With<Fresh>>,
1470    runs: Query<AssemblingRun, LiveRun>,
1471    children: Query<&Children>,
1472    utterances: Query<Entity, With<Utterance>>,
1473    content: ContentGraph,
1474    part_edits: Query<PartEdit>,
1475    retrievals: Query<(&Retrieves, &Retrieval)>,
1476    retrieving: Query<(), With<Retrieval>>,
1477    adverts: Query<&Advert>,
1478    attachments: Query<&Attachment>,
1479    documents: Query<(&DocumentId, &DocumentText, Option<&DocumentProps>)>,
1480    bound: Query<&Bound>,
1481    settings: Settings,
1482) {
1483    let mut turns: Vec<_> = fresh
1484        .iter()
1485        .filter_map(|turn| {
1486            let run = turn.turn_of.parent();
1487            runs.get(run).ok().map(|view| (view.seq.0, turn, run))
1488        })
1489        .collect();
1490    turns.sort_by_key(|(seq, _, _)| *seq);
1491    let mut folded = Vec::new();
1492    for (_, turn, run) in turns {
1493        let Ok(view) = runs.get(run) else {
1494            continue;
1495        };
1496        let agent = view.run_of.0;
1497        let (model, model_bound, composes) = match completion_model(view.model, &bound) {
1498            Ok(model) => model,
1499            Err(failure) => {
1500                commands.entity(run).end(Failed(failure));
1501                commands.entity(turn.entity).remove::<Fresh>();
1502                continue;
1503            }
1504        };
1505        let patch = turn.patch;
1506        let rendered = render_history(
1507            turn.entity,
1508            run,
1509            patch,
1510            &children,
1511            &utterances,
1512            &part_edits,
1513            &content,
1514        );
1515        let (mut history, consumed_edits) = match rendered {
1516            Ok(rendered) => rendered,
1517            Err(error) => {
1518                fail_content(&mut commands, run, error);
1519                continue;
1520            }
1521        };
1522        if turn.retrieving {
1523            let spawned = children
1524                .get(turn.entity)
1525                .is_ok_and(|children| children.iter().any(|child| retrieving.get(child).is_ok()));
1526            if !spawned {
1527                let indexes = children
1528                    .get(agent)
1529                    .into_iter()
1530                    .flat_map(|children| children.iter())
1531                    .filter_map(|child| retrievals.get(child).ok());
1532                spawn_retrievals(&mut commands, turn.entity, &history, indexes, &bound);
1533            }
1534            continue;
1535        }
1536        // Limit only rendered request text so persisted history remains lossless.
1537        if let Some(limit) = setting(run, agent, &settings.tool_result_limits) {
1538            for parts in &mut history {
1539                if policy::tool_results_exceed(parts, limit) {
1540                    policy::limit_tool_results(parts, limit);
1541                }
1542            }
1543        }
1544        let tools = allowed_tools(
1545            &mut commands,
1546            turn.entity,
1547            patch,
1548            &children,
1549            &adverts,
1550            &bound,
1551        );
1552        let attached = attached_documents(turn.entity, patch, &children, &attachments, &documents);
1553        // History replacement must retain the final utterance as the current prompt.
1554        if let Some(patched) = patch.and_then(|p| p.history.as_ref()) {
1555            let prompt = history.pop();
1556            history = patched.iter().cloned().chain(prompt).collect();
1557        }
1558        let resolved = settings.resolve(run, agent, patch);
1559        let reserved_name = resolved
1560            .output_tool_config
1561            .as_ref()
1562            .and_then(|config| config.name.as_deref());
1563        let access = tool_access_for(run, agent, &tools, &settings.tool_access);
1564        let (mode, output_tool) = match resolve_output_tool(
1565            view.minted,
1566            reserved_name,
1567            &resolved.output,
1568            resolved.tool_choice.as_ref(),
1569            &tools,
1570            &access,
1571            composes,
1572        ) {
1573            Ok(mode) => mode,
1574            Err(name) => {
1575                commands.entity(turn.entity).remove::<Fresh>();
1576                commands
1577                    .entity(run)
1578                    .end(Failed(Failure::OutputToolCollision { name }));
1579                continue;
1580            }
1581        };
1582        if mode == OutputKind::Tool && view.minted.0.is_none() {
1583            commands
1584                .entity(run)
1585                .insert(OutputToolName(Some(output_tool.clone())));
1586        }
1587        for link in consumed_edits {
1588            commands.entity(link).despawn();
1589        }
1590        commands.entity(turn.entity).insert(access);
1591        let graph = RequestGraph {
1592            preamble: resolved.preamble.as_deref(),
1593            utterances: history.iter().collect(),
1594            documents: attached,
1595            tools: tools.iter().map(|(_, bound)| &bound.descriptor).collect(),
1596            temperature: resolved.temperature,
1597            max_tokens: resolved.max_tokens,
1598            accept_unknown_finish_reasons: resolved.accept_unknown_finish_reasons,
1599            additional_params: resolved.additional_params.as_ref(),
1600            options: &resolved.options,
1601            provider_options: &resolved.provider_options,
1602            tool_choice: resolved.tool_choice.as_ref(),
1603            output: mode,
1604            schema: resolved.output.schema.as_ref(),
1605            output_tool: (mode == OutputKind::Tool).then_some(output_tool.as_str()),
1606            output_tool_config: resolved.output_tool_config.as_ref(),
1607        };
1608        match policy::fold_request(&graph) {
1609            Ok(request) => {
1610                let kind = EffectKind::Completion {
1611                    request,
1612                    stream: view.stream.0,
1613                };
1614                folded.push((turn.entity, run, model, model_bound.key.clone(), kind, mode));
1615            }
1616            Err(error) => fail_content(&mut commands, run, error),
1617        }
1618    }
1619    // Completions follow this pass's retrievals, keeping their dispatch order.
1620    for (turn, run, model, key, kind, mode) in folded {
1621        commands.spawn((
1622            PendingEffect::new(key, kind),
1623            ServedBy(model),
1624            ChildOf(turn),
1625        ));
1626        commands
1627            .entity(turn)
1628            .remove::<(Fresh, RequestPatch)>()
1629            .insert((Folded(mode), Outputs::default()));
1630        commands.entity(run).phase(RunPhase::AwaitingModel);
1631    }
1632}
1633
1634/// Update turn outputs from streamed text or final outcomes. Changed outputs
1635/// signal progress; a completed response's content keeps the provider's order.
1636pub fn fold(effects: Query<EffectView, NotRetrieval>, mut turns: Query<&mut Outputs, With<Turn>>) {
1637    for EffectViewItem {
1638        turn_of,
1639        streamed,
1640        outcome,
1641    } in &effects
1642    {
1643        let Ok(mut outputs) = turns.get_mut(turn_of.parent()) else {
1644            continue;
1645        };
1646        if outputs.done {
1647            continue;
1648        }
1649        match outcome {
1650            Some(EffectOutcome(Ok(Outcome::Completion(response)))) => {
1651                outputs.content = response.choice.clone();
1652                outputs.head = AssistantHead::of(&response.head());
1653                outputs.done = true;
1654            }
1655            Some(EffectOutcome(Ok(_))) | Some(EffectOutcome(Err(_))) => {
1656                outputs.done = true;
1657            }
1658            None => {
1659                if let Some(streamed) = streamed
1660                    && !streamed.text.is_empty()
1661                {
1662                    let current = assistant_text_from_choice(&outputs.content);
1663                    if current != streamed.text {
1664                        outputs.content = vec![AssistantContent::text(&streamed.text)];
1665                    }
1666                }
1667            }
1668        }
1669    }
1670}
1671
1672/// The default policy for an invalid call nothing resolved: the run's
1673/// `InvalidCalls.unhandled`, written at the head of `Materialise`'s chain
1674/// (after `land_memory`), so a user system before the set wins.
1675pub fn resolve_invalid_defaults(
1676    mut commands: Commands,
1677    calls: Query<(Entity, &ChildOf), Unresolved>,
1678    turns: Query<&ChildOf, With<Turn>>,
1679    runs: Query<&RunOf>,
1680    policies: Query<&InvalidCalls>,
1681) {
1682    for (call, turn_of) in &calls {
1683        let Ok(run_of) = turns.get(turn_of.parent()) else {
1684            continue;
1685        };
1686        let run = run_of.parent();
1687        let Ok(RunOf(agent)) = runs.get(run) else {
1688            continue;
1689        };
1690        let unhandled = setting(run, *agent, &policies).map_or(Unhandled::Fail, |p| p.unhandled);
1691        commands.entity(call).insert(match unhandled {
1692            Unhandled::Fail => Resolution::Fail,
1693            Unhandled::Ignore => Resolution::Ignore,
1694        });
1695    }
1696}
1697
1698/// What the batch's systems read of a tool child of a turn: which call it
1699/// is, whether it was issued, its outcome, whether the batch's own hold
1700/// is on it.
1701#[derive(QueryData)]
1702pub struct ToolChild {
1703    /// The effect entity.
1704    pub entity: Entity,
1705    /// Which call of the batch it is.
1706    pub slot: &'static ToolCallSlot,
1707    /// Whether the bus issued it.
1708    pub issued: Has<Issued>,
1709    /// The outcome, once landed.
1710    pub outcome: Option<&'static EffectOutcome>,
1711    /// Whether the batch's own hold is on it.
1712    pub batch_held: Has<BatchHeld>,
1713}
1714
1715/// The runtime's own hold on a tool child beyond the run's concurrency,
1716/// placed beside `Held` at spawn and lifted by `release_batch` in call
1717/// order as earlier calls land. The marker says whose hold it is: a
1718/// `Gate` policy's `Held` is not the runtime's to lift, so a call a policy
1719/// holds stays held until that policy releases it, and a call the batch
1720/// holds is released by the batch alone.
1721#[derive(Component, Debug, Default, Clone, Copy, Reflect)]
1722#[reflect(Component)]
1723pub struct BatchHeld;
1724
1725/// Remove batch ownership and its accounting marker when a hold is removed.
1726/// Hosts removing `Held` bypass all owners; clearing the marker keeps slot
1727/// accounting and checkpoint state consistent with that release.
1728pub fn batch_marker_follows_the_hold(
1729    released: On<Remove, crate::bus::Held>,
1730    mut commands: Commands,
1731) {
1732    let entity = released.event().entity;
1733    commands.queue(move |world: &mut World| {
1734        let Ok(mut effect) = world.get_entity_mut(entity) else {
1735            return;
1736        };
1737        effect.remove::<BatchHeld>();
1738        crate::bus::hold::forget_owner(world, entity, "rig-ecs/batch");
1739    });
1740}
1741
1742/// The tool children of `turn`, by call index.
1743fn batch_children<'a>(
1744    turn: Entity,
1745    children: &Query<&Children>,
1746    tools: &'a Query<ToolChild>,
1747) -> Vec<ToolChildItem<'a, 'a>> {
1748    let mut found: Vec<_> = children
1749        .get(turn)
1750        .into_iter()
1751        .flat_map(|children| children.iter())
1752        .filter_map(|child| tools.get(child).ok())
1753        .collect();
1754    found.sort_by_key(|child| child.slot.index);
1755    found
1756}
1757
1758/// Release batch-owned holds in call order up to the run's tool concurrency.
1759/// Other policies' holds remain intact and consume slots. A terminal tool
1760/// failure prevents further releases while already-started calls drain.
1761pub fn release_batch(
1762    mut commands: Commands,
1763    turns: Query<(Entity, &ChildOf), With<Batch>>,
1764    runs: Query<&RunOf>,
1765    policies: Query<&ToolPolicy>,
1766    children: Query<&Children>,
1767    tools: Query<ToolChild>,
1768) {
1769    for (turn, turn_of) in &turns {
1770        let run = turn_of.parent();
1771        let Ok(RunOf(agent)) = runs.get(run) else {
1772            continue;
1773        };
1774        let concurrency = setting(run, *agent, &policies)
1775            .map_or(1, |policy| policy.concurrency)
1776            .max(1);
1777        let batch = batch_children(turn, &children, &tools);
1778        if batch.iter().any(|child| {
1779            child
1780                .outcome
1781                .is_some_and(|o| policy::tool_failure(&o.0).is_some())
1782        }) {
1783            continue;
1784        }
1785        // Other policies' holds still occupy slots once the batch releases its hold.
1786        let active = batch
1787            .iter()
1788            .filter(|child| !child.batch_held && child.outcome.is_none())
1789            .count();
1790        let mut free = concurrency.saturating_sub(active);
1791        for child in &batch {
1792            if free == 0 {
1793                break;
1794            }
1795            if child.batch_held && !child.issued {
1796                let entity = child.entity;
1797                commands.queue(move |world: &mut World| {
1798                    if let Ok(mut effect) = world.get_entity_mut(entity) {
1799                        effect.remove::<BatchHeld>();
1800                    }
1801                    // A hold the batch never took (the effect was denied or
1802                    // despawned meanwhile) has nothing to release.
1803                    let _ = crate::bus::release_hold(world, entity, "rig-ecs/batch");
1804                });
1805                free -= 1;
1806            }
1807        }
1808    }
1809}
1810
1811/// Commit a completed tool batch as one user utterance in call order, then
1812/// resume assembly or settle an accompanying output-tool call with its arguments.
1813/// Terminal failures wait for started calls to drain, fail the run, and despawn
1814/// unstarted calls without dispatch or records. Content errors fail the run.
1815pub fn land_batch(
1816    mut commands: Commands,
1817    mut assets: ResMut<BinaryAssets>,
1818    turns: Query<(Entity, &ChildOf, &Batch, &Outputs)>,
1819    runs: Query<(&OutputToolName, &RunSeq, &Cursor, &RunPhase)>,
1820    children: Query<&Children>,
1821    tools: Query<ToolChild>,
1822) {
1823    let mut turns: Vec<_> = turns.iter().collect();
1824    turns.sort_by_key(|(_, turn_of, _, _)| {
1825        runs.get(turn_of.parent()).map(|(_, seq, _, _)| *seq).ok()
1826    });
1827    for (turn, turn_of, batch, outs) in turns {
1828        let run = turn_of.parent();
1829        let Ok((minted, _, cursor, &RunPhase::ResolvingTools)) = runs.get(run) else {
1830            continue;
1831        };
1832        let calls = batch_children(turn, &children, &tools);
1833        let failure = calls
1834            .iter()
1835            .find_map(|child| child.outcome.and_then(|o| policy::tool_failure(&o.0)));
1836        let started_landed = calls
1837            .iter()
1838            .all(|child| !child.issued || child.outcome.is_some());
1839        if let Some(failure) = failure {
1840            if !started_landed {
1841                continue;
1842            }
1843            // The ending first, so the despawns' observer finds the run
1844            // ended with this failure and leaves it.
1845            commands.entity(turn).remove::<Batch>();
1846            commands.entity(run).end(Failed(failure));
1847            for child in &calls {
1848                if !child.issued && child.outcome.is_none() {
1849                    commands.entity(child.entity).despawn();
1850                }
1851            }
1852            continue;
1853        }
1854        if calls.len() < batch.calls || calls.iter().any(|child| child.outcome.is_none()) {
1855            continue;
1856        }
1857        let mut parts = Vec::with_capacity(calls.len());
1858        let mut statuses = Vec::with_capacity(calls.len());
1859        let mut failed = None;
1860        for child in &calls {
1861            let Some(EffectOutcome(outcome)) = child.outcome else {
1862                continue;
1863            };
1864            let slot = child.slot;
1865            match policy::tool_result_part(slot.id.clone(), slot.name.clone(), outcome) {
1866                Ok((part, status)) => {
1867                    parts.push(part);
1868                    statuses.push(status);
1869                }
1870                Err(failure) => {
1871                    failed = Some(failure);
1872                    break;
1873                }
1874            }
1875        }
1876        commands.entity(turn).remove::<Batch>();
1877        if let Some(failure) = failed {
1878            commands.entity(run).end(Failed(failure));
1879            continue;
1880        }
1881        let results_entity = match MessageParts::user(parts).and_then(|results| {
1882            spawn_deferred_with(&mut commands, &mut assets, run, results, statuses)
1883        }) {
1884            Ok(results) => results,
1885            Err(error) => {
1886                fail_content(&mut commands, run, error);
1887                continue;
1888            }
1889        };
1890        commands.entity(turn).insert((
1891            ToolTurnCommit { turn: cursor.turn },
1892            TurnResults(results_entity),
1893        ));
1894        let output_call = minted.0.as_deref().and_then(|name| {
1895            outs.content.iter().find_map(|part| match part {
1896                AssistantContent::ToolCall(call) if call.function.name == name => {
1897                    Some(call.function.arguments_value().to_string())
1898                }
1899                _ => None,
1900            })
1901        });
1902        match output_call {
1903            Some(arguments) => {
1904                commands.entity(run).end((RunResult(arguments), Settled));
1905            }
1906            None => {
1907                commands.entity(run).phase(RunPhase::Assembling);
1908            }
1909        }
1910        commands.queue(move |world: &mut World| {
1911            // A terminal observer may already have removed the run. Do not
1912            // publish a live notification pointing at a graph it deleted.
1913            if world.get::<Run>(run).is_some()
1914                && world.get::<ToolTurnCommit>(turn).is_some()
1915                && world
1916                    .get::<ChildOf>(turn)
1917                    .is_some_and(|parent| parent.parent() == run)
1918            {
1919                world.trigger(ToolTurnCommitted { run, turn });
1920            }
1921        });
1922    }
1923}
1924
1925/// What the pending invalid calls of a turn amount to, in precedence:
1926/// a `Fail` fails the run; else a `Retry` retries the turn; else a `Skip`
1927/// answers the call and skips the turn; else repairs and ignores edit the
1928/// turn's content and the turn goes on.
1929enum InvalidVerdict {
1930    Fail(InvalidCall),
1931    Retry(InvalidCall, String),
1932    Skip(InvalidCall, String),
1933    Edit,
1934}
1935
1936fn invalid_verdict(pending: &[(Entity, InvalidCall, Resolution)]) -> InvalidVerdict {
1937    if let Some((_, call, _)) = pending
1938        .iter()
1939        .find(|(_, _, resolution)| matches!(resolution, Resolution::Fail))
1940    {
1941        return InvalidVerdict::Fail(call.clone());
1942    }
1943    if let Some((_, call, Resolution::Retry { feedback })) = pending
1944        .iter()
1945        .find(|(_, _, resolution)| matches!(resolution, Resolution::Retry { .. }))
1946    {
1947        return InvalidVerdict::Retry(call.clone(), feedback.clone());
1948    }
1949    if let Some((_, call, Resolution::Skip { reason })) = pending
1950        .iter()
1951        .find(|(_, _, resolution)| matches!(resolution, Resolution::Skip { .. }))
1952    {
1953        return InvalidVerdict::Skip(call.clone(), reason.clone());
1954    }
1955    InvalidVerdict::Edit
1956}
1957
1958/// The landed completion effect of `turn`, if any.
1959fn landed_effect<'a>(
1960    turn: Entity,
1961    effects: &'a Query<LandedEffect, NotRetrieval>,
1962) -> Option<LandedEffectItem<'a, 'a>> {
1963    effects
1964        .iter()
1965        .find(|effect| effect.turn_of.parent() == turn)
1966}
1967
1968/// The tools `turn` may call: the run's `ToolAccess.executable` when the
1969/// turn was folded under one, else the tools it advertised, by name, with
1970/// their keys and their handler entities.
1971fn granted_tools(
1972    turn: Entity,
1973    children: &Query<&Children>,
1974    adverts: &Query<&Advert>,
1975    bound: &Query<&Bound>,
1976    access: Option<&ToolAccess>,
1977) -> Vec<GrantedTool> {
1978    if let Some(executable) = access.and_then(|access| access.executable.as_ref()) {
1979        return executable
1980            .iter()
1981            .map(|(name, key)| GrantedTool {
1982                name: name.clone(),
1983                key: key.clone(),
1984                handler: None,
1985            })
1986            .collect();
1987    }
1988    links_in_order(turn, children, adverts)
1989        .into_iter()
1990        .filter_map(|Advert(tool)| bound.get(*tool).ok().map(|bound| (*tool, bound)))
1991        .filter_map(|(tool, bound)| {
1992            policy::tool_name(&bound.descriptor).map(|name| GrantedTool {
1993                name: name.to_owned(),
1994                key: bound.key.clone(),
1995                handler: Some(tool),
1996            })
1997        })
1998        .collect()
1999}
2000
2001/// Count a landed completion's usage once before deferred invalid-call decisions
2002/// consume the turn, including responses completed after an early stream decision.
2003pub fn record_usage(
2004    mut commands: Commands,
2005    mut turns: Query<(Entity, &ChildOf, &mut Outputs), Unread>,
2006    effects: Query<LandedEffect, NotRetrieval>,
2007    runs: Query<(&Usage, &RunPhase)>,
2008) {
2009    for (turn, turn_of, mut outs) in &mut turns {
2010        if outs.usage_recorded {
2011            continue;
2012        }
2013        let run = turn_of.parent();
2014        let Ok((usage, &RunPhase::AwaitingModel)) = runs.get(run) else {
2015            continue;
2016        };
2017        let Some(EffectOutcome(Ok(Outcome::Completion(response)))) =
2018            landed_effect(turn, &effects).map(|effect| effect.outcome)
2019        else {
2020            continue;
2021        };
2022        commands.entity(run).insert(Usage(usage.0 + response.usage));
2023        outs.usage_recorded = true;
2024    }
2025}
2026
2027/// The pending invalid calls of `turn`, each with its resolution.
2028fn pending_invalid_calls(
2029    turn: Entity,
2030    invalid_calls: &Query<(Entity, &ChildOf, &InvalidCall, &Resolution)>,
2031) -> Vec<(Entity, InvalidCall, Resolution)> {
2032    invalid_calls
2033        .iter()
2034        .filter(|(_, child_of, _, _)| child_of.parent() == turn)
2035        .map(|(entity, _, call, resolution)| (entity, call.clone(), resolution.clone()))
2036        .collect()
2037}
2038
2039/// The turn's content after its invalid calls' edits: repairs rename their
2040/// call; ignores drop theirs. What is left is the turn.
2041fn edited_content(
2042    content: &[AssistantContent],
2043    pending: &[(Entity, InvalidCall, Resolution)],
2044) -> Vec<AssistantContent> {
2045    let mut content = content.to_vec();
2046    for (_, call, resolution) in pending {
2047        match resolution {
2048            Resolution::Repair { to } => {
2049                for part in &mut content {
2050                    if let AssistantContent::ToolCall(tool_call) = part
2051                        && tool_call.id == call.id
2052                        && let Ok(to) = rig_core::message::ToolName::new(to.clone())
2053                    {
2054                        tool_call.function.name = to;
2055                    }
2056                }
2057            }
2058            Resolution::Ignore => {
2059                content.retain(|part| match part {
2060                    AssistantContent::ToolCall(tool_call) => tool_call.id != call.id,
2061                    _ => true,
2062                });
2063            }
2064            Resolution::Fail | Resolution::Retry { .. } | Resolution::Skip { .. } => {}
2065        }
2066    }
2067    content
2068}
2069
2070/// An invalid call's `Fail`: the delivered prefix (if any) becomes history,
2071/// the turn is read, and the run fails `UnknownToolCall`.
2072fn fail_unknown_call(
2073    commands: &mut Commands,
2074    assets: &mut BinaryAssets,
2075    run: Entity,
2076    turn: Entity,
2077    call: InvalidCall,
2078) -> Result<(), ContentError> {
2079    commands.entity(turn).insert(Materialised);
2080    // The run fails on the call: the delivered prefix is history, a turn
2081    // that never ended and is never replayed.
2082    let prefix = AssistantMessage::aborted(
2083        call.origin.clone(),
2084        call.prefix.clone(),
2085        format!("the run failed on the unknown tool call `{}`", call.name),
2086    );
2087    if let Ok(assistant) = MessageParts::assistant(prefix) {
2088        spawn_deferred(commands, assets, run, assistant)?;
2089    }
2090    commands
2091        .entity(run)
2092        .end(Failed(Failure::UnknownToolCall { name: call.name }));
2093    Ok(())
2094}
2095
2096/// An invalid call's `Retry` or `Skip`: the turn up to the call becomes
2097/// history (a streamed turn is abandoned where the call surfaced), then
2098/// one user utterance answering the call and its peers with the feedback,
2099/// every result `Skipped`; the turn is read and the run wants another
2100/// (a retry spends one of `InvalidRetries`).
2101fn abandon_turn(
2102    commands: &mut Commands,
2103    assets: &mut BinaryAssets,
2104    run: Entity,
2105    turn: Entity,
2106    outs: &Outputs,
2107    call: &InvalidCall,
2108    feedback: &str,
2109    retries: Option<InvalidRetries>,
2110) -> Result<(), ContentError> {
2111    commands.entity(turn).insert(Materialised);
2112    // A streamed call's turn is what was delivered before it; a whole
2113    // turn is all of it.
2114    let content = if call.prefix.is_empty() {
2115        outs.content.clone()
2116    } else {
2117        call.prefix.clone()
2118    };
2119    let diagnostic_id = &call.id;
2120    // A streamed prefix keeps the stream's origin but no provider item; the
2121    // run answers its calls, so it stops to use tools.
2122    let message = if call.prefix.is_empty() {
2123        outs.head.message(content.clone())
2124    } else {
2125        AssistantMessage::rolled_back(call.origin.clone(), content.clone())
2126    };
2127    let assistant = MessageParts::assistant(message)?;
2128    spawn_deferred(commands, assets, run, assistant)?;
2129    let results = MessageParts::user(invalid_call_feedback(&content, diagnostic_id, feedback))
2130        .map_err(|_| ContentError::Shape)?;
2131    let skipped = match &results {
2132        MessageParts::User { content } => vec![ToolResultStatus::Skipped; content.len()],
2133        MessageParts::Assistant(_) => Vec::new(),
2134    };
2135    spawn_deferred_with(commands, assets, run, results, skipped)?;
2136    let mut run_commands = commands.entity(run);
2137    run_commands.insert(RunPhase::Assembling);
2138    if let Some(retries) = retries {
2139        run_commands.insert(retries);
2140    }
2141    Ok(())
2142}
2143
2144/// Apply invalid-call resolutions after usage accounting, in fail/retry/skip/edit
2145/// precedence. Failure, exhausted retries, and skips under `ToolChoice::None`
2146/// fail immediately, including mid-stream. Other decisions wait for completion;
2147/// edits also wait for all delivered names to be validated. Retries and skips
2148/// preserve the rejected prefix as history. Consumed call entities are despawned.
2149pub fn judge_invalid_calls(
2150    mut commands: Commands,
2151    mut assets: ResMut<BinaryAssets>,
2152    mut turns: Query<(Entity, &ChildOf, &mut Outputs), Unread>,
2153    effects: Query<LandedEffect, NotRetrieval>,
2154    runs: Query<(&RunOf, &RunSeq, &InvalidRetries, &OutputToolName, &RunPhase)>,
2155    invalid_calls: Query<(Entity, &ChildOf, &InvalidCall, &Resolution)>,
2156    choices: Query<&ToolChoiceSpec>,
2157    policies: Query<&InvalidCalls>,
2158) {
2159    let mut turns: Vec<_> = turns
2160        .iter_mut()
2161        .filter_map(|(turn, turn_of, outs)| {
2162            let run = turn_of.parent();
2163            runs.get(run)
2164                .ok()
2165                .map(|(_, seq, ..)| (*seq, turn, run, outs))
2166        })
2167        .collect();
2168    turns.sort_by_key(|(seq, ..)| *seq);
2169    for (_, turn, run, mut outs) in turns {
2170        let Ok((RunOf(agent), _, invalid_retries, _, &RunPhase::AwaitingModel)) = runs.get(run)
2171        else {
2172            continue;
2173        };
2174        let agent = *agent;
2175        let stream = landed_effect(turn, &effects).and_then(|effect| effect.streamed);
2176        let pending = pending_invalid_calls(turn, &invalid_calls);
2177        if pending.is_empty() {
2178            continue;
2179        }
2180        let budget = setting(run, agent, &policies).map_or(0, |p| p.retries);
2181        let tool_choice = setting(run, agent, &choices).and_then(|c| c.0.as_ref());
2182        let verdict = match invalid_verdict(&pending) {
2183            InvalidVerdict::Retry(call, _) if invalid_retries.0 >= budget => {
2184                InvalidVerdict::Fail(call)
2185            }
2186            InvalidVerdict::Skip(call, _) if matches!(tool_choice, Some(ToolChoice::None)) => {
2187                InvalidVerdict::Fail(call)
2188            }
2189            verdict => verdict,
2190        };
2191        // Effective failure (including an exhausted retry) is immediate.
2192        // Keep edits until final folding and real usage arrive.
2193        if !outs.done && !matches!(verdict, InvalidVerdict::Fail(_)) {
2194            continue;
2195        }
2196        // EOF can arrive with more name events than this pass judged.
2197        // Retain earlier edits while discovery visits that delivered tail,
2198        // so the next decision sees the repaired/ignored prefix in order.
2199        if matches!(verdict, InvalidVerdict::Edit)
2200            && stream.is_some_and(|stream| {
2201                outs.stream_validated < stream_invalid::validation_len(stream)
2202            })
2203        {
2204            continue;
2205        }
2206        for (entity, _, _) in &pending {
2207            commands.entity(*entity).despawn();
2208        }
2209        let judged = match verdict {
2210            InvalidVerdict::Fail(call) => {
2211                fail_unknown_call(&mut commands, &mut assets, run, turn, call)
2212            }
2213            InvalidVerdict::Retry(call, feedback) | InvalidVerdict::Skip(call, feedback) => {
2214                let retries = matches!(invalid_verdict(&pending), InvalidVerdict::Retry(..))
2215                    .then(|| InvalidRetries(invalid_retries.0 + 1));
2216                abandon_turn(
2217                    &mut commands,
2218                    &mut assets,
2219                    run,
2220                    turn,
2221                    &outs,
2222                    &call,
2223                    &feedback,
2224                    retries,
2225                )
2226            }
2227            InvalidVerdict::Edit => {
2228                outs.content = edited_content(&outs.content, &pending);
2229                Ok(())
2230            }
2231        };
2232        if let Err(error) = judged {
2233            fail_content(&mut commands, run, error);
2234        }
2235    }
2236}
2237
2238/// Schedule and observe a provider retry when permitted, preserving history and
2239/// the model-call budget. Otherwise fail the run with provider or cancellation status.
2240fn provider_failed(
2241    commands: &mut Commands,
2242    run: Entity,
2243    report: &rig_core::error::ErrorReport,
2244    budget: usize,
2245    retried: usize,
2246    witness: Option<(&crate::bus::Witnessing, &crate::bus::Subjects)>,
2247) {
2248    if report.kind != ErrorKind::Cancelled && report.retryable && retried < budget {
2249        let attempt = retried + 1;
2250        commands.entity(run).insert((
2251            ProviderRetried(attempt),
2252            ProviderRetrying,
2253            RunPhase::Assembling,
2254        ));
2255        if let Some((witness, subjects)) = witness {
2256            witness::observe_provider_retry(
2257                witness,
2258                subjects.of_scope(run),
2259                attempt,
2260                budget,
2261                report,
2262            );
2263        }
2264        return;
2265    }
2266    let failure = if report.kind == ErrorKind::Cancelled {
2267        Failure::Cancelled(report.clone())
2268    } else {
2269        Failure::Provider(report.clone())
2270    };
2271    commands.entity(run).end(Failed(failure));
2272}
2273
2274/// Read completed turns without pending invalid calls into [`TurnRead`].
2275/// Unsupported outcomes and the turns
2276/// [`turn_failure`](rig_core::completion::message::turn_failure) fails end the
2277/// run, a failed turn with calls kept in its history; provider errors retry
2278/// within budget or fail. Unpermitted ordinary tool calls create invalid-call
2279/// entities and leave the turn unread until resolved.
2280pub fn read_turn(
2281    mut commands: Commands,
2282    turns: Query<(Entity, &ChildOf, &Outputs), Unread>,
2283    effects: Query<LandedEffect, NotRetrieval>,
2284    runs: Query<(
2285        &RunOf,
2286        &RunSeq,
2287        &ProviderRetried,
2288        &OutputToolName,
2289        &RunPhase,
2290    )>,
2291    pending: Query<&ChildOf, With<InvalidCall>>,
2292    children: Query<&Children>,
2293    adverts: Query<&Advert>,
2294    bound: Query<&Bound>,
2295    access: Query<&ToolAccess>,
2296    provider_retries: Query<&ProviderRetries>,
2297    subjects: crate::bus::Subjects,
2298    witness: Option<Res<crate::bus::Witnessing>>,
2299    mut assets: ResMut<BinaryAssets>,
2300) {
2301    let mut turns: Vec<_> = turns
2302        .iter()
2303        .filter_map(|(turn, turn_of, outs)| {
2304            let run = turn_of.parent();
2305            runs.get(run)
2306                .ok()
2307                .map(|(_, seq, ..)| (*seq, turn, run, outs))
2308        })
2309        .collect();
2310    turns.sort_by_key(|(seq, ..)| *seq);
2311    for (_, turn, run, outs) in turns {
2312        let Ok((RunOf(agent), _, provider_retried, minted, &RunPhase::AwaitingModel)) =
2313            runs.get(run)
2314        else {
2315            continue;
2316        };
2317        if !outs.done || pending.iter().any(|child_of| child_of.parent() == turn) {
2318            continue;
2319        }
2320        let Some(effect) = landed_effect(turn, &effects) else {
2321            continue;
2322        };
2323        commands.entity(turn).insert(Materialised);
2324        let response = match &effect.outcome.0 {
2325            Ok(Outcome::Completion(response)) => response,
2326            Ok(other) => {
2327                commands
2328                    .entity(run)
2329                    .end(Failed(Failure::Unsupported(format!(
2330                        "a {} answer to a completion",
2331                        other.family()
2332                    ))));
2333                continue;
2334            }
2335            Err(report) => {
2336                let budget = setting(run, *agent, &provider_retries)
2337                    .map_or(DEFAULT_PROVIDER_RETRIES, |retries| retries.0);
2338                let witness = witness.as_deref().map(|witness| (witness, &subjects));
2339                provider_failed(
2340                    &mut commands,
2341                    run,
2342                    report,
2343                    budget,
2344                    provider_retried.0,
2345                    witness,
2346                );
2347                continue;
2348            }
2349        };
2350        if let Some(message) = rig_core::completion::message::turn_failure(
2351            &outs.content,
2352            Some(&response.stop()),
2353            response.finish_reason().as_ref(),
2354        ) {
2355            // A failed turn's calls never run; the turn stays in the run's
2356            // history for display, as pi keeps it.
2357            let calls = outs
2358                .content
2359                .iter()
2360                .any(|block| matches!(block, AssistantContent::ToolCall(_)));
2361            if calls {
2362                let head = AssistantHead::of(&response.head());
2363                let kept = MessageParts::assistant(head.message(outs.content.clone()))
2364                    .and_then(|turn| spawn_deferred(&mut commands, &mut assets, run, turn));
2365                if let Err(error) = kept {
2366                    fail_content(&mut commands, run, error);
2367                    continue;
2368                }
2369            }
2370            let report = rig_core::error::ErrorReport::from(
2371                &rig_core::error::ProviderError::Response(message),
2372            );
2373            commands.entity(run).end(Failed(Failure::Provider(report)));
2374            continue;
2375        }
2376        let access = access.get(turn).ok();
2377        let granted = granted_tools(turn, &children, &adverts, &bound, access);
2378        let allowed = access.and_then(|access| access.allowed.as_ref());
2379        let read = TurnRead {
2380            head: AssistantHead::of(&response.head()),
2381            content: outs.content.clone(),
2382            granted,
2383            assistant: None,
2384        };
2385        let invalid: Vec<_> = read
2386            .calls()
2387            .filter(|call| {
2388                let name = call.function.name.as_str();
2389                (!read.granted.iter().any(|tool| tool.name == name)
2390                    || allowed.is_some_and(|allowed| !allowed.contains(name)))
2391                    && minted.0.as_deref() != Some(name)
2392            })
2393            .collect();
2394        if !invalid.is_empty() {
2395            commands.entity(turn).remove::<Materialised>();
2396            for call in invalid {
2397                commands.spawn((
2398                    InvalidCall {
2399                        id: call.id.clone(),
2400                        name: call.function.name.to_string(),
2401                        arguments: call.function.arguments_value(),
2402                        prefix: Vec::new(),
2403                        stream_offset: None,
2404                        origin: None,
2405                    },
2406                    ChildOf(turn),
2407                ));
2408            }
2409            continue;
2410        }
2411        commands.entity(turn).insert(read);
2412    }
2413}
2414
2415/// Materialise assistant history or retry a tool-free turn. Empty turns settle
2416/// empty unless retried; retries retain history only when feedback is supplied.
2417/// Tool-bearing retries fail as unsupported. Returns content conversion errors.
2418fn say_assistant(
2419    commands: &mut Commands,
2420    assets: &mut BinaryAssets,
2421    run: Entity,
2422    turn: Entity,
2423    read: &mut TurnRead,
2424    retry: Option<&Retry>,
2425) -> Result<(), ContentError> {
2426    let feedback_utterance = |commands: &mut Commands, assets: &mut BinaryAssets, feedback| {
2427        let user = MessageParts::User {
2428            content: vec![UserContent::text(feedback)],
2429        };
2430        spawn_deferred(commands, assets, run, user).map(drop)
2431    };
2432    if is_empty_assistant_turn(&read.content) {
2433        commands.entity(turn).remove::<(Retry, TurnRead)>();
2434        match retry {
2435            Some(Retry { feedback }) => {
2436                if let Some(feedback) = feedback {
2437                    feedback_utterance(commands, assets, feedback)?;
2438                }
2439                commands.entity(run).phase(RunPhase::Assembling);
2440            }
2441            None => {
2442                commands
2443                    .entity(run)
2444                    .end((RunResult(String::new()), Settled));
2445            }
2446        }
2447        return Ok(());
2448    }
2449    let assistant = MessageParts::assistant(read.head.message(read.content.clone()))?;
2450    if let Some(Retry { feedback }) = retry {
2451        commands.entity(turn).remove::<(Retry, TurnRead)>();
2452        if read.calls().next().is_some() {
2453            commands.entity(run).end(Failed(Failure::Unsupported(
2454                "a retry of a tool-bearing turn: steer the tool calls instead".to_owned(),
2455            )));
2456            return Ok(());
2457        }
2458        if let Some(feedback) = feedback {
2459            spawn_deferred(commands, assets, run, assistant)?;
2460            feedback_utterance(commands, assets, feedback)?;
2461        }
2462        commands.entity(run).phase(RunPhase::Assembling);
2463        return Ok(());
2464    }
2465    read.assistant = Some(spawn_deferred(commands, assets, run, assistant)?);
2466    Ok(())
2467}
2468
2469/// Materialise assistant history in run order, processing retries and empty
2470/// answers. Content errors fail only the affected run and clear its transient read.
2471pub fn materialise_assistant(
2472    mut commands: Commands,
2473    mut assets: ResMut<BinaryAssets>,
2474    mut turns: Query<(Entity, &ChildOf, &mut TurnRead, Option<&Retry>)>,
2475    runs: Query<&RunSeq>,
2476) {
2477    let mut turns: Vec<_> = turns
2478        .iter_mut()
2479        .filter_map(|(turn, turn_of, read, retry)| {
2480            let run = turn_of.parent();
2481            runs.get(run).ok().map(|seq| (*seq, turn, run, read, retry))
2482        })
2483        .collect();
2484    turns.sort_by_key(|(seq, ..)| *seq);
2485    for (_, turn, run, mut read, retry) in turns {
2486        if let Err(error) = say_assistant(&mut commands, &mut assets, run, turn, &mut read, retry) {
2487            fail_content(&mut commands, run, error);
2488            commands.entity(turn).remove::<TurnRead>();
2489        }
2490    }
2491}
2492
2493/// Spawn granted tool calls in call order with resolved context and handlers.
2494/// Calls beyond the concurrency limit receive batch-owned holds. Consumes the
2495/// turn read, links its assistant, and moves the run to `ResolvingTools`.
2496pub fn materialise_batch(
2497    mut commands: Commands,
2498    turns: Query<(Entity, &ChildOf, &TurnRead)>,
2499    runs: Query<(&RunOf, &RunSeq)>,
2500    tool_policies: Query<&ToolPolicy>,
2501    contexts: Query<&ToolContextSpec>,
2502) {
2503    let mut turns: Vec<_> = turns
2504        .iter()
2505        .filter_map(|(turn, turn_of, read)| {
2506            let run = turn_of.parent();
2507            runs.get(run)
2508                .ok()
2509                .map(|(RunOf(agent), seq)| (*seq, turn, run, *agent, read))
2510        })
2511        .collect();
2512    turns.sort_by_key(|(seq, ..)| *seq);
2513    for (_, turn, run, agent, read) in turns {
2514        let batch: Vec<_> = read
2515            .calls()
2516            .filter_map(|call| {
2517                read.granted
2518                    .iter()
2519                    .find(|tool| tool.name == call.function.name)
2520                    .map(|tool| (call, tool))
2521            })
2522            .collect();
2523        let (Some(assistant), false) = (read.assistant, batch.is_empty()) else {
2524            continue;
2525        };
2526        let concurrency = setting(run, agent, &tool_policies)
2527            .map_or(1, |policy| policy.concurrency)
2528            .max(1);
2529        let inputs = setting(run, agent, &contexts)
2530            .map(|spec| spec.0.for_dispatch())
2531            .unwrap_or_default();
2532        let count = batch.len();
2533        for (index, (call, tool)) in batch.into_iter().enumerate() {
2534            // Arguments that are not a JSON object never reach the tool: the
2535            // call is answered at once, and the model reads why.
2536            if let Some(raw) = &call.function.invalid_arguments {
2537                commands.spawn((
2538                    ToolCallSlot {
2539                        index,
2540                        id: call.id.clone(),
2541                        name: call.function.name.clone(),
2542                    },
2543                    EffectOutcome(Ok(rig_core::effect::Outcome::ToolResult {
2544                        result: rig_core::tool::ToolResult::skipped(
2545                            rig_core::transcript::invalid_arguments_feedback(
2546                                call.function.name.as_str(),
2547                                raw,
2548                            ),
2549                        ),
2550                    })),
2551                    ChildOf(turn),
2552                ));
2553                continue;
2554            }
2555            let mut effect = commands.spawn((
2556                PendingEffect::new(
2557                    tool.key.clone(),
2558                    EffectKind::ToolCall {
2559                        name: call.function.name.to_string(),
2560                        args: call.function.arguments_value().to_string(),
2561                    },
2562                ),
2563                ToolInputs(inputs.clone()),
2564                ToolCallSlot {
2565                    index,
2566                    id: call.id.clone(),
2567                    name: call.function.name.clone(),
2568                },
2569                ChildOf(turn),
2570            ));
2571            if let Some(handler) = tool.handler {
2572                effect.insert(ServedBy(handler));
2573            }
2574            if index >= concurrency {
2575                effect.insert(BatchHeld);
2576                let entity = effect.id();
2577                commands.queue(move |world: &mut World| {
2578                    // Refused only when the effect settled or dispatched
2579                    // between the spawn and this command: then the batch
2580                    // bound no longer applies to it.
2581                    let _ = crate::bus::acquire_hold(
2582                        world,
2583                        entity,
2584                        rig_core::observe::Emitter::versioned(
2585                            "rig-ecs/batch",
2586                            env!("CARGO_PKG_VERSION"),
2587                        ),
2588                    );
2589                });
2590            }
2591        }
2592        commands
2593            .entity(turn)
2594            .remove::<TurnRead>()
2595            .insert((Batch { calls: count }, TurnAssistant(assistant)));
2596        commands.entity(run).phase(RunPhase::ResolvingTools);
2597    }
2598}
2599
2600/// The reprompt a read turn in `Tool` mode earns while the budget lasts
2601/// (one `OutputRetries`, under `MaxTurns`): an output-tool call whose
2602/// arguments are not a JSON object, or that misses required fields, is
2603/// answered with a `Skipped` result saying so; a text
2604/// where the tool was due, unless it already is the structured output
2605/// (CONTRACT ยง4), is asked for the tool. `None` when the turn earns no
2606/// reprompt: `materialise_answer` settles it.
2607fn reprompt_for(
2608    read: &TurnRead,
2609    name: &str,
2610    schema: Option<&serde_json::Value>,
2611) -> Option<(MessageParts, Vec<ToolResultStatus>)> {
2612    match read.calls().find(|call| call.function.name == name) {
2613        Some(call) => {
2614            let feedback = if let Some(raw) = &call.function.invalid_arguments {
2615                rig_core::transcript::invalid_arguments_feedback(name, raw)
2616            } else {
2617                let missing = schema
2618                    .map(|schema| missing_required_fields(schema, &call.function.arguments_value()))
2619                    .unwrap_or_default();
2620                if missing.is_empty() {
2621                    return None;
2622                }
2623                reprompt_missing_fields(name, &missing)
2624            };
2625            let reprompt = MessageParts::User {
2626                content: vec![UserContent::ToolResult(
2627                    call.error_result(vec![ToolResultContent::text(feedback)]),
2628                )],
2629            };
2630            Some((reprompt, vec![ToolResultStatus::Skipped]))
2631        }
2632        None => {
2633            if text_satisfies_schema(schema, &assistant_text_from_choice(&read.content)) {
2634                return None;
2635            }
2636            let reprompt = MessageParts::User {
2637                content: vec![UserContent::text(reprompt_text_answer(name))],
2638            };
2639            Some((reprompt, Vec::new()))
2640        }
2641    }
2642}
2643
2644/// Reprompt tool-output turns at most once and within the model-call budget.
2645/// Commits feedback to history, consumes the turn read, and resumes assembly;
2646/// content errors fail the run. Exhausted budgets leave the answer for settlement.
2647pub fn materialise_reprompt(
2648    mut commands: Commands,
2649    mut assets: ResMut<BinaryAssets>,
2650    turns: Query<(Entity, &ChildOf, &TurnRead, &Folded)>,
2651    runs: Query<(&RunOf, &RunSeq, &Cursor, &OutputRetries, &OutputToolName)>,
2652    outputs: Query<&Output>,
2653    max_turns: Query<&MaxTurns>,
2654) {
2655    let mut turns: Vec<_> = turns
2656        .iter()
2657        .filter(|(_, _, _, Folded(mode))| *mode == OutputKind::Tool)
2658        .filter_map(|(turn, turn_of, read, _)| {
2659            let run = turn_of.parent();
2660            runs.get(run)
2661                .ok()
2662                .map(|(_, seq, ..)| (*seq, turn, run, read))
2663        })
2664        .collect();
2665    turns.sort_by_key(|(seq, ..)| *seq);
2666    for (_, turn, run, read) in turns {
2667        let Ok((RunOf(agent), _, cursor, retries, OutputToolName(Some(name)))) = runs.get(run)
2668        else {
2669            continue;
2670        };
2671        let agent = *agent;
2672        let limit = setting(run, agent, &max_turns).map_or(1, |limit| limit.0);
2673        if retries.0 >= 1 || cursor.turn >= limit {
2674            continue;
2675        }
2676        let schema = setting(run, agent, &outputs).and_then(|output| output.schema.as_ref());
2677        let Some((reprompt, statuses)) = reprompt_for(read, name, schema) else {
2678            continue;
2679        };
2680        commands
2681            .entity(turn)
2682            .remove::<TurnRead>()
2683            .insert(Reprompt(reprompt.to_message()));
2684        match spawn_deferred_with(&mut commands, &mut assets, run, reprompt, statuses) {
2685            Ok(_) => {
2686                commands
2687                    .entity(run)
2688                    .end((OutputRetries(retries.0 + 1), RunPhase::Assembling));
2689            }
2690            Err(error) => fail_content(&mut commands, run, error),
2691        }
2692    }
2693}
2694
2695/// Settle remaining reads with output-tool arguments or assistant text.
2696/// Output-tool history retains non-call content and appends the JSON answer;
2697/// the effect record retains the original call. Clears transient reads and fails
2698/// the run if history conversion fails, or if the output-tool call's
2699/// arguments are not a JSON object.
2700pub fn materialise_answer(
2701    mut commands: Commands,
2702    mut assets: ResMut<BinaryAssets>,
2703    turns: Query<(Entity, &ChildOf, &TurnRead, &Folded)>,
2704    runs: Query<(&RunSeq, &OutputToolName)>,
2705) {
2706    let mut turns: Vec<_> = turns
2707        .iter()
2708        .filter_map(|(turn, turn_of, read, folded)| {
2709            let run = turn_of.parent();
2710            runs.get(run)
2711                .ok()
2712                .map(|(seq, minted)| (*seq, turn, run, read, folded, minted))
2713        })
2714        .collect();
2715    turns.sort_by_key(|(seq, ..)| *seq);
2716    for (_, turn, run, read, Folded(mode), minted) in turns {
2717        commands.entity(turn).remove::<TurnRead>();
2718        let output_call = match (mode, minted.0.as_deref(), read.assistant) {
2719            (OutputKind::Tool, Some(name), Some(assistant)) => read
2720                .calls()
2721                .find(|call| call.function.name == name)
2722                .map(|call| (assistant, call)),
2723            _ => None,
2724        };
2725        let Some((assistant, call)) = output_call else {
2726            commands.entity(run).end((
2727                RunResult(assistant_text_from_choice(&read.content)),
2728                Settled,
2729            ));
2730            continue;
2731        };
2732        if let Some(raw) = &call.function.invalid_arguments {
2733            let report = rig_core::error::ErrorReport::from(
2734                &rig_core::error::ProviderError::Response(format!(
2735                    "the output tool `{}` was called with arguments that are not a JSON object: {raw}",
2736                    call.function.name
2737                )),
2738            );
2739            commands.entity(run).end(Failed(Failure::Provider(report)));
2740            continue;
2741        }
2742        let output = call.function.arguments_value().to_string();
2743        let mut final_content: Vec<_> = read
2744            .content
2745            .iter()
2746            .filter(|part| !matches!(part, AssistantContent::ToolCall(_)))
2747            .cloned()
2748            .collect();
2749        final_content.push(AssistantContent::text(output.clone()));
2750        let restated = MessageParts::assistant(read.head.message(final_content));
2751        match restated
2752            .and_then(|restated| replace_deferred(&mut commands, &mut assets, assistant, restated))
2753        {
2754            Ok(()) => {
2755                commands.entity(run).end((RunResult(output), Settled));
2756            }
2757            Err(error) => fail_content(&mut commands, run, error),
2758        }
2759    }
2760}
2761
2762/// Cancel an active run when its pending completion or batch tool effect is
2763/// removed. Existing failures remain unchanged.
2764pub fn effect_cancelled(
2765    removed: On<bevy_ecs::lifecycle::Remove, PendingEffect>,
2766    effects: Query<(&ChildOf, Has<ToolCallSlot>), With<PendingEffect>>,
2767    turns: Query<TurnState, With<Turn>>,
2768    runs: Query<RunState, With<Run>>,
2769    mut commands: Commands,
2770) {
2771    let effect = removed.event().entity;
2772    let Ok((turn_of, is_tool_call)) = effects.get(effect) else {
2773        return;
2774    };
2775    let turn = turn_of.parent();
2776    let Ok(TurnStateItem {
2777        run_of,
2778        materialised,
2779        batched,
2780    }) = turns.get(turn)
2781    else {
2782        return;
2783    };
2784    let run = run_of.parent();
2785    let Ok(RunStateItem { phase, failed }) = runs.get(run) else {
2786        return;
2787    };
2788    // A run already ended keeps its ending: `run_cancelled` writes the
2789    // reason before it despawns what was pending.
2790    if failed {
2791        return;
2792    }
2793    let cancelled = Failed(Failure::Cancelled(rig_core::serve::cancelled()));
2794    if is_tool_call && batched && phase == Some(&RunPhase::ResolvingTools) {
2795        commands.entity(turn).remove::<Batch>();
2796        commands.entity(run).end(cancelled);
2797    } else if !is_tool_call && !materialised && phase == Some(&RunPhase::AwaitingModel) {
2798        commands.entity(turn).insert(Materialised);
2799        commands.entity(run).end(cancelled);
2800    }
2801}
2802
2803/// Apply a run cancellation unless it already ended. Marks turns materialised
2804/// and despawns unissued effects without records. In-flight effects remain owned
2805/// by their handlers, which complete their records.
2806pub fn run_cancelled(
2807    added: On<Add, Cancelled>,
2808    reasons: Query<(&Cancelled, Has<Settled>, Has<Failed>)>,
2809    children: Query<&Children>,
2810    turns: Query<(), With<Turn>>,
2811    effects: Query<Has<Issued>, With<PendingEffect>>,
2812    mut commands: Commands,
2813) {
2814    let run = added.event().entity;
2815    let Ok((Cancelled(reason), settled, failed)) = reasons.get(run) else {
2816        return;
2817    };
2818    // A run that ended keeps its ending: a cancel after the fact is a
2819    // no-op, never a second ending.
2820    if settled || failed {
2821        return;
2822    }
2823    let mut pending: Vec<Entity> = Vec::new();
2824    let mut unread: Vec<Entity> = Vec::new();
2825    for child in children
2826        .get(run)
2827        .map(|c| c.iter().collect::<Vec<_>>())
2828        .unwrap_or_default()
2829    {
2830        if let Ok(issued) = effects.get(child) {
2831            if !issued {
2832                pending.push(child);
2833            }
2834            continue;
2835        }
2836        if turns.get(child).is_ok() {
2837            unread.push(child);
2838            for effect in children
2839                .get(child)
2840                .map(|c| c.iter().collect::<Vec<_>>())
2841                .unwrap_or_default()
2842            {
2843                if let Ok(false) = effects.get(effect) {
2844                    pending.push(effect);
2845                }
2846            }
2847        }
2848    }
2849    // The ending first, so the despawns' observer (`effect_cancelled`)
2850    // finds the run ended with this reason and leaves it.
2851    commands
2852        .entity(run)
2853        .remove::<RunPhase>()
2854        .insert(Failed(Failure::Cancelled(
2855            rig_core::error::ErrorReport::new(ErrorKind::Cancelled, reason.clone()),
2856        )));
2857    for effect in pending {
2858        commands.entity(effect).despawn();
2859    }
2860    for turn in unread {
2861        commands
2862            .entity(turn)
2863            .insert(Materialised)
2864            .remove::<(Batch, Fresh, RequestPatch)>();
2865    }
2866}