Skip to main content

turnframe_tasks/
engine.rs

1//! Running a task: the call, repairs with the exact error, votes, escalation, records.
2//!
3//! One run is: resolve the instructions, build the request from the task's messages
4//! and schema and its kind's profile, reserve the call, send it, parse and check the
5//! answer. A structurally wrong answer goes back to the same model with the error,
6//! up to the profile's repairs. Votes run concurrently and are compared with the
7//! task's own `agree`; a vote without a strict majority escalates, asks or fails, as
8//! the profile says. Every call leaves a record in the scope.
9
10use std::sync::{Arc, Mutex, PoisonError};
11use std::time::{Duration, Instant};
12
13use futures::future::join_all;
14use turnframe_core::effort::Effort;
15use turnframe_core::hash::Digest;
16use turnframe_core::locale::Locale;
17use turnframe_core::observe::{NoopObserver, Observer, Signal, SignalLabels};
18use turnframe_core::prompt::{PromptSelector, PromptSource};
19use turnframe_core::replay::{BudgetReport, TaskParams, TaskRecord, TaskVerdict};
20use turnframe_provider::fallback::{FallbackOptions, FallbackStage, execute_with_fallback};
21use turnframe_provider::ids::ModelRef;
22use turnframe_provider::request::{CacheHint, Message, ModelRequest, OutputSpec};
23use turnframe_provider::response::ModelResponse;
24use turnframe_provider::router::{ProviderRouter, RoutingPolicy};
25use turnframe_provider::structured::{CompiledSchema, SchemaCache, parse_structured};
26
27use crate::budget::{Budget, BudgetBound, BudgetTracker};
28use crate::instructions::{self, Instructions};
29use crate::profile::{Disagreement, TaskProfile, TaskProfiles};
30use crate::task::{ModelTask, TaskId, TaskKind};
31
32/// The request metadata key carrying the task id a call runs as: `u2/extract#repair1`.
33pub const TASK_LABEL: &str = "task";
34
35/// The request metadata key carrying the turn a call belongs to, when the scope names it.
36pub const TURN_LABEL: &str = "turn";
37
38/// Framing of a repair round when no prompt source supplies `<name>.repair`.
39const BUILT_IN_REPAIR: &str = "Your previous answer was not accepted. Answer again with a \
40     document that satisfies the schema and fixes this:";
41
42/// What a turn keeps of each call beyond the parsed answer.
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
44#[non_exhaustive]
45pub struct RecordPolicy {
46    /// Keep the request as sent. It carries the user's words.
47    pub keep_prompts: bool,
48    /// Keep the answer as received, before parsing.
49    pub keep_raw_output: bool,
50}
51
52/// The spending and the records of one phase of one turn.
53#[derive(Debug)]
54pub struct TaskScope {
55    budget: BudgetTracker,
56    records: Mutex<Vec<TaskRecord>>,
57    locale: Locale,
58    turn: Option<String>,
59    profiles: Option<TaskProfiles>,
60    effort: Option<Effort>,
61}
62
63impl TaskScope {
64    /// A scope bounded by `budget`, for a turn in `locale`.
65    #[must_use]
66    pub fn new(budget: Budget, locale: Locale) -> Self {
67        Self {
68            budget: BudgetTracker::new(budget),
69            records: Mutex::new(Vec::new()),
70            locale,
71            turn: None,
72            profiles: None,
73            effort: None,
74        }
75    }
76
77    /// Runs this scope's tasks under `profiles` instead of the engine's.
78    #[must_use]
79    pub fn with_profiles(mut self, profiles: TaskProfiles) -> Self {
80        self.profiles = Some(profiles);
81        self
82    }
83
84    /// Labels this scope's task signals with the turn's effort.
85    #[must_use]
86    pub const fn with_effort(mut self, effort: Effort) -> Self {
87        self.effort = Some(effort);
88        self
89    }
90
91    /// The effort this scope's turn runs at, when it was given one.
92    #[must_use]
93    pub const fn effort(&self) -> Option<Effort> {
94        self.effort
95    }
96
97    fn labels(&self) -> SignalLabels {
98        let labels = SignalLabels::default();
99        match self.effort {
100            Some(effort) => labels.with_effort(effort),
101            None => labels,
102        }
103    }
104
105    /// Labels every call of this scope with the turn it belongs to, for a trace or a
106    /// provider's own logs to group them.
107    #[must_use]
108    pub fn for_turn(mut self, turn: impl Into<String>) -> Self {
109        self.turn = Some(turn.into());
110        self
111    }
112
113    /// Every call recorded so far, in the order the calls finished.
114    #[must_use]
115    pub fn records(&self) -> Vec<TaskRecord> {
116        self.lock().clone()
117    }
118
119    /// What was spent.
120    #[must_use]
121    pub fn budget_report(&self) -> BudgetReport {
122        self.budget.report()
123    }
124
125    /// The first bound reached, if any.
126    #[must_use]
127    pub fn exhausted(&self) -> Option<BudgetBound> {
128        self.budget.exhausted()
129    }
130
131    /// The turn's locale.
132    #[must_use]
133    pub const fn locale(&self) -> &Locale {
134        &self.locale
135    }
136
137    fn push(&self, record: TaskRecord) {
138        self.lock().push(record);
139    }
140
141    fn mark(&self, task_id: &str, verdict: &TaskVerdict) {
142        if let Some(record) = self.lock().iter_mut().find(|r| r.task_id == task_id) {
143            record.verdict = verdict.clone();
144        }
145    }
146
147    fn lock(&self) -> std::sync::MutexGuard<'_, Vec<TaskRecord>> {
148        self.records.lock().unwrap_or_else(PoisonError::into_inner)
149    }
150}
151
152/// Where a task call sits: its identifier, its parent, and how deep its chain already is.
153#[derive(Debug, Clone, Copy)]
154pub struct TaskCall<'a> {
155    /// The task's identifier.
156    pub id: &'a TaskId,
157    /// The task that asked for this one.
158    pub parent: Option<&'a TaskId>,
159    /// Calls already made in this chain; the first call of a turn is at depth 1.
160    pub depth: u8,
161}
162
163/// Why a task produced no answer to use.
164#[derive(Debug, Clone, PartialEq, Eq)]
165#[non_exhaustive]
166pub enum TaskFailure {
167    /// The profile switches the task off.
168    Disabled,
169    /// A bound of the turn's budget was reached.
170    Budget(BudgetBound),
171    /// No profile can serve the task.
172    Routing(String),
173    /// Every provider attempt failed.
174    Provider(String),
175    /// The answers failed their checks, repairs included.
176    Invalid {
177        /// Stable code of the last failed check.
178        code: String,
179        /// The failure in words.
180        reason: String,
181    },
182    /// The votes found no majority and the profile treats that as failure.
183    Disagreement,
184}
185
186impl TaskFailure {
187    /// Stable label, for records and metrics.
188    #[must_use]
189    pub fn code(&self) -> String {
190        match self {
191            Self::Disabled => "disabled".to_owned(),
192            Self::Budget(bound) => format!("budget_{}", bound.as_str()),
193            Self::Routing(_) => "routing".to_owned(),
194            Self::Provider(code) => format!("provider_{code}"),
195            Self::Invalid { code, .. } => code.clone(),
196            Self::Disagreement => "vote_disagreement".to_owned(),
197        }
198    }
199}
200
201/// What a task run produced, and how deep its chain ended.
202#[derive(Debug, Clone)]
203pub enum TaskOutcome<O> {
204    /// An answer to use.
205    Accepted {
206        /// The answer.
207        output: O,
208        /// Depth of the last call made.
209        depth: u8,
210    },
211    /// The votes found no majority, and the profile hands the answers back to ask.
212    Disagreed {
213        /// Every answer the votes produced.
214        answers: Vec<O>,
215        /// Depth of the last call made.
216        depth: u8,
217    },
218    /// No answer to use.
219    Failed {
220        /// Why.
221        failure: TaskFailure,
222        /// Depth of the last call made.
223        depth: u8,
224    },
225}
226
227impl<O> TaskOutcome<O> {
228    /// The answer, when there is one to use.
229    #[must_use]
230    pub fn accepted(self) -> Option<O> {
231        match self {
232            Self::Accepted { output, .. } => Some(output),
233            _ => None,
234        }
235    }
236
237    /// Depth of the last call made, for the task that follows.
238    #[must_use]
239    pub const fn depth(&self) -> u8 {
240        match self {
241            Self::Accepted { depth, .. }
242            | Self::Disagreed { depth, .. }
243            | Self::Failed { depth, .. } => *depth,
244        }
245    }
246
247    fn label(&self) -> String {
248        match self {
249            Self::Accepted { .. } => "accepted".to_owned(),
250            Self::Disagreed { .. } => "disagreed".to_owned(),
251            Self::Failed { failure, .. } => failure.code(),
252        }
253    }
254}
255
256/// Runs model tasks. Cheap to clone; shared by every turn.
257#[derive(Clone)]
258pub struct TaskEngine {
259    router: Arc<dyn ProviderRouter>,
260    routing: RoutingPolicy,
261    fallback: Arc<FallbackOptions>,
262    schemas: SchemaCache,
263    profiles: TaskProfiles,
264    prompts: Option<Arc<dyn PromptSource>>,
265    selector: PromptSelector,
266    records: RecordPolicy,
267    observer: Arc<dyn Observer>,
268}
269
270impl std::fmt::Debug for TaskEngine {
271    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272        f.debug_struct("TaskEngine")
273            .field("profiles", &self.profiles)
274            .field("records", &self.records)
275            .finish_non_exhaustive()
276    }
277}
278
279/// Builds a [`TaskEngine`].
280#[derive(Debug)]
281pub struct TaskEngineBuilder {
282    engine: TaskEngine,
283}
284
285impl TaskEngineBuilder {
286    /// Base routing policy; each call adds its profile's model tag.
287    #[must_use]
288    pub fn routing(mut self, routing: RoutingPolicy) -> Self {
289        self.engine.routing = routing;
290        self
291    }
292
293    /// Transport retry and fallback options.
294    #[must_use]
295    pub fn fallback(mut self, options: FallbackOptions) -> Self {
296        self.engine.fallback = Arc::new(options);
297        self
298    }
299
300    /// The profiles every task kind runs under.
301    #[must_use]
302    pub fn profiles(mut self, profiles: TaskProfiles) -> Self {
303        self.engine.profiles = profiles;
304        self
305    }
306
307    /// A prompt source asked for each task's instructions, pinned or labelled by `selector`.
308    #[must_use]
309    pub fn prompts(mut self, source: Arc<dyn PromptSource>, selector: PromptSelector) -> Self {
310        self.engine.prompts = Some(source);
311        self.engine.selector = selector;
312        self
313    }
314
315    /// What each record keeps beyond the parsed answer.
316    #[must_use]
317    pub fn records(mut self, policy: RecordPolicy) -> Self {
318        self.engine.records = policy;
319        self
320    }
321
322    /// Where the task signals go.
323    #[must_use]
324    pub fn observer(mut self, observer: Arc<dyn Observer>) -> Self {
325        self.engine.observer = observer;
326        self
327    }
328
329    /// The engine.
330    #[must_use]
331    pub fn build(self) -> TaskEngine {
332        self.engine
333    }
334}
335
336/// What a task run needs beside the task: resolved once, shared by votes and repairs.
337#[derive(Clone)]
338struct Prepared {
339    kind: TaskKind,
340    parent: Option<String>,
341    profile: TaskProfile,
342    instructions: Instructions,
343    repair: Instructions,
344    schema: CompiledSchema,
345    messages: Vec<Message>,
346}
347
348/// One chain of calls: the first call and its repairs.
349enum Chain<O> {
350    Answered {
351        output: O,
352        depth: u8,
353        record: String,
354    },
355    Unusable {
356        failure: TaskFailure,
357        depth: u8,
358    },
359}
360
361impl TaskEngine {
362    /// An engine over `router` with the shipped profiles.
363    #[must_use]
364    pub fn builder(router: Arc<dyn ProviderRouter>) -> TaskEngineBuilder {
365        TaskEngineBuilder {
366            engine: Self {
367                router,
368                routing: RoutingPolicy::new(),
369                fallback: Arc::new(FallbackOptions::new()),
370                schemas: SchemaCache::new(),
371                profiles: TaskProfiles::new(),
372                prompts: None,
373                selector: PromptSelector::Latest,
374                records: RecordPolicy::default(),
375                observer: Arc::new(NoopObserver),
376            },
377        }
378    }
379
380    /// The profiles in force.
381    #[must_use]
382    pub const fn profiles(&self) -> &TaskProfiles {
383        &self.profiles
384    }
385
386    /// The profile `kind` runs under in `scope`: the scope's, else the engine's.
387    #[must_use]
388    pub fn profile(&self, scope: &TaskScope, kind: TaskKind) -> TaskProfile {
389        scope.profiles.as_ref().unwrap_or(&self.profiles).get(kind)
390    }
391
392    /// Runs `task` on `input`.
393    pub async fn run<T: ModelTask>(
394        &self,
395        scope: &TaskScope,
396        call: TaskCall<'_>,
397        task: &T,
398        input: &T::Input,
399    ) -> TaskOutcome<T::Output> {
400        self.run_inner(scope, call, task, input, None).await
401    }
402
403    /// Runs `task` again after `previous` was found wanting, telling the model why.
404    ///
405    /// For a semantic check made by another task (a verifier) and not by `check`.
406    pub async fn run_with_feedback<T: ModelTask>(
407        &self,
408        scope: &TaskScope,
409        call: TaskCall<'_>,
410        task: &T,
411        input: &T::Input,
412        previous: &T::Output,
413        feedback: &str,
414    ) -> TaskOutcome<T::Output> {
415        self.run_inner(scope, call, task, input, Some((previous, feedback)))
416            .await
417    }
418
419    async fn run_inner<T: ModelTask>(
420        &self,
421        scope: &TaskScope,
422        call: TaskCall<'_>,
423        task: &T,
424        input: &T::Input,
425        feedback: Option<(&T::Output, &str)>,
426    ) -> TaskOutcome<T::Output> {
427        let outcome = match self.prepare(scope, call, task, input, feedback).await {
428            Ok(prepared) => self.run_prepared(scope, call, task, input, &prepared).await,
429            Err(failure) => TaskOutcome::Failed {
430                failure,
431                depth: call.depth,
432            },
433        };
434        let labels = scope
435            .labels()
436            .with_purpose(task.kind().as_str())
437            .with_error_code(outcome.label());
438        self.observer
439            .observe_labeled(&Signal::TaskCompleted, &labels);
440        outcome
441    }
442
443    async fn prepare<T: ModelTask>(
444        &self,
445        scope: &TaskScope,
446        call: TaskCall<'_>,
447        task: &T,
448        input: &T::Input,
449        feedback: Option<(&T::Output, &str)>,
450    ) -> Result<Prepared, TaskFailure> {
451        let kind = task.kind();
452        let profile = self.profile(scope, kind);
453        if !profile.enabled {
454            return Err(TaskFailure::Disabled);
455        }
456        let schema =
457            self.schemas
458                .compile(&task.schema(input))
459                .map_err(|error| TaskFailure::Invalid {
460                    code: "schema_compile".to_owned(),
461                    reason: error.to_string(),
462                })?;
463        let name = task.prompt_name();
464        let instructions = instructions::resolve(
465            self.prompts.as_ref(),
466            &self.selector,
467            name,
468            scope.locale(),
469            task.instructions(),
470        )
471        .await;
472        let repair = instructions::resolve(
473            self.prompts.as_ref(),
474            &self.selector,
475            &format!("{name}.repair"),
476            scope.locale(),
477            BUILT_IN_REPAIR,
478        )
479        .await;
480        let mut messages = task.render(input);
481        if let Some((previous, note)) = feedback {
482            messages.push(Message::assistant(
483                serde_json::to_string(previous).unwrap_or_default(),
484            ));
485            messages.push(Message::user(format!("{}\n\n{note}", repair.text)));
486        }
487        Ok(Prepared {
488            kind,
489            parent: call.parent.map(|parent| parent.as_str().to_owned()),
490            profile,
491            instructions,
492            repair,
493            schema,
494            messages,
495        })
496    }
497
498    async fn run_prepared<T: ModelTask>(
499        &self,
500        scope: &TaskScope,
501        call: TaskCall<'_>,
502        task: &T,
503        input: &T::Input,
504        prepared: &Prepared,
505    ) -> TaskOutcome<T::Output> {
506        let tag = prepared.profile.model.as_deref();
507        let votes = prepared.profile.votes.max(1);
508        if votes == 1 {
509            let id = call.id.as_str().to_owned();
510            return match self
511                .chain(scope, prepared, task, input, tag, call.depth, id, None)
512                .await
513            {
514                Chain::Answered { output, depth, .. } => TaskOutcome::Accepted { output, depth },
515                Chain::Unusable { failure, depth } => {
516                    self.escalate(scope, call, task, input, prepared, failure, depth)
517                        .await
518                }
519            };
520        }
521
522        let temperature = Some(prepared.profile.vote_temperature);
523        let chains = join_all((1..=votes).map(|vote| {
524            let id = call.id.call(format!("vote{vote}"));
525            self.chain(
526                scope,
527                prepared,
528                task,
529                input,
530                tag,
531                call.depth,
532                id,
533                temperature,
534            )
535        }))
536        .await;
537        let depth = chains
538            .iter()
539            .map(|chain| match chain {
540                Chain::Answered { depth, .. } | Chain::Unusable { depth, .. } => *depth,
541            })
542            .max()
543            .unwrap_or(call.depth);
544        let answered: Vec<(&T::Output, &str)> = chains
545            .iter()
546            .filter_map(|chain| match chain {
547                Chain::Answered { output, record, .. } => Some((output, record.as_str())),
548                Chain::Unusable { .. } => None,
549            })
550            .collect();
551        if let Some(winner) = majority(task, &answered, usize::from(votes)) {
552            for (index, (_, record)) in answered.iter().enumerate() {
553                if !winner.contains(&index) {
554                    scope.mark(record, &TaskVerdict::Outvoted);
555                }
556            }
557            let output = answered[winner[0]].0.clone();
558            return TaskOutcome::Accepted { output, depth };
559        }
560
561        self.observer.observe_labeled(
562            &Signal::TaskVoteDisagreement,
563            &scope.labels().with_purpose(prepared.kind.as_str()),
564        );
565        match prepared.profile.on_disagreement {
566            Disagreement::Reread if !answered.is_empty() => {
567                let shown: Vec<String> = answered
568                    .iter()
569                    .map(|(output, _)| serde_json::to_string(output).unwrap_or_default())
570                    .collect();
571                let mut again = prepared.clone();
572                again.messages.push(Message::user(format!(
573                    "Readings of this that disagreed:\n{}\n\nRead it again and give the answer \
574                     the message supports.",
575                    shown.join("\n")
576                )));
577                let id = call.id.call("reread");
578                match self
579                    .chain(scope, &again, task, input, tag, depth, id, None)
580                    .await
581                {
582                    Chain::Answered { output, depth, .. } => {
583                        TaskOutcome::Accepted { output, depth }
584                    }
585                    Chain::Unusable { failure, depth } => TaskOutcome::Failed { failure, depth },
586                }
587            }
588            Disagreement::Escalate => {
589                self.escalate(
590                    scope,
591                    call,
592                    task,
593                    input,
594                    prepared,
595                    TaskFailure::Disagreement,
596                    depth,
597                )
598                .await
599            }
600            Disagreement::Clarify => TaskOutcome::Disagreed {
601                answers: answered
602                    .into_iter()
603                    .map(|(output, _)| output.clone())
604                    .collect(),
605                depth,
606            },
607            _ => TaskOutcome::Failed {
608                failure: TaskFailure::Disagreement,
609                depth,
610            },
611        }
612    }
613
614    #[allow(clippy::too_many_arguments)]
615    async fn escalate<T: ModelTask>(
616        &self,
617        scope: &TaskScope,
618        call: TaskCall<'_>,
619        task: &T,
620        input: &T::Input,
621        prepared: &Prepared,
622        failure: TaskFailure,
623        depth: u8,
624    ) -> TaskOutcome<T::Output> {
625        let escalates = matches!(
626            failure,
627            TaskFailure::Invalid { .. }
628                | TaskFailure::Disagreement
629                | TaskFailure::Provider(_)
630                | TaskFailure::Routing(_)
631        );
632        let Some(tag) = prepared
633            .profile
634            .escalate_to
635            .as_deref()
636            .filter(|_| escalates)
637        else {
638            return TaskOutcome::Failed { failure, depth };
639        };
640        self.observer.observe_labeled(
641            &Signal::TaskEscalated,
642            &scope
643                .labels()
644                .with_purpose(prepared.kind.as_str())
645                .with_error_code(failure.code()),
646        );
647        let id = call.id.call("escalation");
648        match self
649            .chain(
650                scope,
651                prepared,
652                task,
653                input,
654                Some(tag),
655                depth.saturating_add(1),
656                id,
657                None,
658            )
659            .await
660        {
661            Chain::Answered { output, depth, .. } => TaskOutcome::Accepted { output, depth },
662            Chain::Unusable { failure, depth } => TaskOutcome::Failed { failure, depth },
663        }
664    }
665
666    /// One call and its repair rounds, starting at `depth`.
667    #[allow(clippy::too_many_arguments)]
668    async fn chain<T: ModelTask>(
669        &self,
670        scope: &TaskScope,
671        prepared: &Prepared,
672        task: &T,
673        input: &T::Input,
674        tag: Option<&str>,
675        depth: u8,
676        id: String,
677        temperature: Option<f32>,
678    ) -> Chain<T::Output> {
679        let mut messages = prepared.messages.clone();
680        let mut depth = depth;
681        let mut failure = TaskFailure::Invalid {
682            code: "no_answer".to_owned(),
683            reason: String::new(),
684        };
685        let rounds = prepared.profile.repairs.saturating_add(1);
686        for round in 0..rounds {
687            let record_id = if round == 0 {
688                id.clone()
689            } else {
690                format!("{id}#repair{round}")
691            };
692            if let Err(bound) = scope.budget.reserve(depth) {
693                self.observer.observe_labeled(
694                    &Signal::BudgetExhausted,
695                    &scope.labels().with_error_code(bound.as_str()),
696                );
697                let mut record = self.record(prepared, &record_id, depth, None);
698                record.verdict = TaskVerdict::Failed {
699                    code: format!("budget_{}", bound.as_str()),
700                };
701                scope.push(record);
702                return Chain::Unusable {
703                    failure: TaskFailure::Budget(bound),
704                    depth,
705                };
706            }
707            let mut retries = prepared.profile.retries;
708            let mut call_id = record_id.clone();
709            let (request, answer) = loop {
710                let request = self.request(scope, prepared, &messages, temperature, &call_id);
711                let answer = self.send(scope, prepared, tag, &request).await;
712                match &answer {
713                    Err(TaskFailure::Provider(kind)) if retries > 0 && retried_in_place(kind) => {
714                        let mut record = self.record(prepared, &call_id, depth, Some(&request));
715                        record.verdict = TaskVerdict::Failed {
716                            code: format!("provider_{kind}"),
717                        };
718                        scope.push(record);
719                        if let Err(bound) = scope.budget.reserve(depth) {
720                            return Chain::Unusable {
721                                failure: TaskFailure::Budget(bound),
722                                depth,
723                            };
724                        }
725                        retries -= 1;
726                        call_id =
727                            format!("{record_id}#retry{}", prepared.profile.retries - retries);
728                    }
729                    _ => break (request, answer),
730                }
731            };
732            let mut record = self.record(prepared, &call_id, depth, Some(&request));
733            let response = match answer {
734                Ok((response, served_by, latency)) => {
735                    record.provider_key = Some(served_by.provider.clone());
736                    record.model_key = Some(served_by.model.clone());
737                    record.input_tokens = Some(response.usage.input);
738                    record.output_tokens = Some(response.usage.output);
739                    record.latency_ms = u64::try_from(latency.as_millis()).ok();
740                    response
741                }
742                Err(failed) => {
743                    record.verdict = TaskVerdict::Failed {
744                        code: failed.code(),
745                    };
746                    scope.push(record);
747                    return Chain::Unusable {
748                        failure: failed,
749                        depth,
750                    };
751                }
752            };
753            let raw = response.text();
754            if self.records.keep_raw_output {
755                record.raw_output = Some(raw.clone());
756            }
757            match judge(task, input, &prepared.schema, &response) {
758                Ok(output) => {
759                    record.parsed = serde_json::to_value(&output).ok();
760                    record.verdict = TaskVerdict::Accepted;
761                    scope.push(record);
762                    return Chain::Answered {
763                        output,
764                        depth,
765                        record: record_id,
766                    };
767                }
768                Err((code, reason)) => {
769                    record.verdict = TaskVerdict::Rejected {
770                        code: code.clone(),
771                        reason: reason.clone(),
772                    };
773                    scope.push(record);
774                    if round + 1 < rounds {
775                        self.observer.observe_labeled(
776                            &Signal::TaskRepaired,
777                            &scope
778                                .labels()
779                                .with_purpose(prepared.kind.as_str())
780                                .with_error_code(code.clone()),
781                        );
782                        messages.push(Message::assistant(raw));
783                        messages.push(Message::user(format!(
784                            "{}\n\n{reason}",
785                            prepared.repair.text
786                        )));
787                        depth = depth.saturating_add(1);
788                    }
789                    failure = TaskFailure::Invalid { code, reason };
790                }
791            }
792        }
793        Chain::Unusable { failure, depth }
794    }
795
796    fn request(
797        &self,
798        scope: &TaskScope,
799        prepared: &Prepared,
800        messages: &[Message],
801        temperature: Option<f32>,
802        task_id: &str,
803    ) -> ModelRequest {
804        let profile = &prepared.profile;
805        let timeout = scope
806            .budget
807            .call_timeout(profile.timeout_secs.map(Duration::from_secs));
808        let mut request = ModelRequest::new(prepared.kind)
809            .with_system(prepared.instructions.text.clone())
810            .with_output(OutputSpec::json(
811                prepared.kind.as_str(),
812                prepared.schema.schema().clone(),
813            ))
814            .with_timeout(timeout)
815            .with_cache_hint(CacheHint::System);
816        for message in messages {
817            request = request.with_message(message.clone());
818        }
819        if let Some(temperature) = temperature.or(profile.temperature) {
820            request = request.with_temperature(temperature);
821        }
822        if let Some(tokens) = profile.max_output_tokens {
823            request = request.with_max_output_tokens(tokens);
824        }
825        if let Some(effort) = profile.reasoning_effort {
826            request = request.with_reasoning_effort(effort);
827        }
828        // A task id is a label by construction; one that is not is left off, not sent.
829        let _ = request.metadata.insert(TASK_LABEL, task_id);
830        if let Some(turn) = &scope.turn {
831            let _ = request.metadata.insert(TURN_LABEL, turn.clone());
832        }
833        request
834    }
835
836    async fn send(
837        &self,
838        scope: &TaskScope,
839        prepared: &Prepared,
840        tag: Option<&str>,
841        request: &ModelRequest,
842    ) -> Result<(ModelResponse, ModelRef, Duration), TaskFailure> {
843        let routing = match tag {
844            Some(tag) => self.routing.clone().with_required_tag(tag),
845            None => self.routing.clone(),
846        };
847        let candidates = self
848            .router
849            .select(prepared.kind, &request.requirements(), &routing)
850            .map_err(|error| {
851                crate::signals::observe_routing_error(self.observer.as_ref(), &error);
852                TaskFailure::Routing(error.to_string())
853            })?;
854        let stage = if prepared.kind.is_critical() {
855            FallbackStage::PreCommit
856        } else {
857            FallbackStage::PostCommitNarration
858        };
859        let _permit = scope.budget.permit().await;
860        let started = Instant::now();
861        let outcome = execute_with_fallback(&candidates, request, stage, &self.fallback)
862            .await
863            .map_err(|failure| {
864                crate::signals::observe_attempts(self.observer.as_ref(), &failure.attempts);
865                // The detail is sanitized and redacted by the adapter: safe to log.
866                tracing::warn!(
867                    target: "turnframe.tasks",
868                    task = request.metadata.get(TASK_LABEL).unwrap_or_default(),
869                    kind = failure.error.kind().as_str(),
870                    detail = failure.error.detail().map_or("", |detail| detail.as_str()),
871                    "a task call failed at the provider"
872                );
873                TaskFailure::Provider(failure.error.kind().as_str().to_owned())
874            })?;
875        let latency = started.elapsed();
876        crate::signals::observe_attempts(self.observer.as_ref(), &outcome.attempts);
877        scope.budget.record_tokens(outcome.response.usage.input);
878        let served_by = outcome.served_by();
879        self.observer.observe_duration(
880            &Signal::TaskLatency,
881            latency,
882            &scope
883                .labels()
884                .with_purpose(prepared.kind.as_str())
885                .with_provider(served_by.provider.clone())
886                .with_model(served_by.model.clone()),
887        );
888        Ok((outcome.response, served_by, latency))
889    }
890
891    fn record(
892        &self,
893        prepared: &Prepared,
894        task_id: &str,
895        depth: u8,
896        request: Option<&ModelRequest>,
897    ) -> TaskRecord {
898        let mut record = TaskRecord::new(task_id, prepared.kind.as_str(), TaskVerdict::Accepted);
899        record.parent.clone_from(&prepared.parent);
900        record.depth = depth;
901        record.prompt_ref = Some(prepared.instructions.reference.clone());
902        if let Some(request) = request {
903            record.params = params_of(request);
904            record.input_digest = Some(digest_of(request));
905            if self.records.keep_prompts {
906                record.rendered = serde_json::to_value(request).ok();
907            }
908        }
909        record
910    }
911}
912
913/// Parses and checks one answer, naming what failed in words a repair can quote.
914fn judge<T: ModelTask>(
915    task: &T,
916    input: &T::Input,
917    schema: &CompiledSchema,
918    response: &ModelResponse,
919) -> Result<T::Output, (String, String)> {
920    let output: T::Output = parse_structured(response, schema)
921        .map_err(|error| ("schema".to_owned(), error.to_string()))?;
922    task.check(input, &output)
923        .map_err(|error| (error.code.to_owned(), error.message))?;
924    Ok(output)
925}
926
927/// The largest group of agreeing answers, when it is a strict majority of `votes`.
928fn majority<T: ModelTask>(
929    task: &T,
930    answered: &[(&T::Output, &str)],
931    votes: usize,
932) -> Option<Vec<usize>> {
933    let mut groups: Vec<Vec<usize>> = Vec::new();
934    for (index, (output, _)) in answered.iter().enumerate() {
935        match groups
936            .iter_mut()
937            .find(|group| task.agree(answered[group[0]].0, output))
938        {
939            Some(group) => group.push(index),
940            None => groups.push(vec![index]),
941        }
942    }
943    let largest = groups
944        .into_iter()
945        .max_by_key(|group| (group.len(), usize::MAX - group[0]))?;
946    (largest.len() * 2 > votes).then_some(largest)
947}
948
949fn params_of(request: &ModelRequest) -> TaskParams {
950    let mut params = TaskParams::default();
951    params.temperature = request.temperature;
952    params.max_output_tokens = request.max_output_tokens;
953    params.reasoning_effort = request
954        .reasoning_effort
955        .map(|effort| effort.as_str().to_owned());
956    params.seed = request.seed;
957    params.timeout_ms = u64::try_from(request.timeout.as_millis()).unwrap_or(u64::MAX);
958    params
959}
960
961/// Digest of what the model was shown: instructions, messages and schema, not the id.
962fn digest_of(request: &ModelRequest) -> Digest {
963    let shown = serde_json::json!({
964        "system": request.system,
965        "messages": request.messages,
966        "output": request.output,
967    });
968    Digest::of_bytes(&serde_json::to_vec(&shown).unwrap_or_default())
969}
970
971/// Whether a provider failure of this kind may pass on the same call sent again. A
972/// request the provider found invalid, or credentials it refused, fail the same way.
973fn retried_in_place(kind: &str) -> bool {
974    matches!(
975        kind,
976        "refusal" | "content_filter" | "malformed" | "transport" | "server" | "timeout" | "other"
977    )
978}