1use 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
32pub const TASK_LABEL: &str = "task";
34
35pub const TURN_LABEL: &str = "turn";
37
38const 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
44#[non_exhaustive]
45pub struct RecordPolicy {
46 pub keep_prompts: bool,
48 pub keep_raw_output: bool,
50}
51
52#[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 #[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 #[must_use]
79 pub fn with_profiles(mut self, profiles: TaskProfiles) -> Self {
80 self.profiles = Some(profiles);
81 self
82 }
83
84 #[must_use]
86 pub const fn with_effort(mut self, effort: Effort) -> Self {
87 self.effort = Some(effort);
88 self
89 }
90
91 #[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 #[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 #[must_use]
115 pub fn records(&self) -> Vec<TaskRecord> {
116 self.lock().clone()
117 }
118
119 #[must_use]
121 pub fn budget_report(&self) -> BudgetReport {
122 self.budget.report()
123 }
124
125 #[must_use]
127 pub fn exhausted(&self) -> Option<BudgetBound> {
128 self.budget.exhausted()
129 }
130
131 #[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#[derive(Debug, Clone, Copy)]
154pub struct TaskCall<'a> {
155 pub id: &'a TaskId,
157 pub parent: Option<&'a TaskId>,
159 pub depth: u8,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
165#[non_exhaustive]
166pub enum TaskFailure {
167 Disabled,
169 Budget(BudgetBound),
171 Routing(String),
173 Provider(String),
175 Invalid {
177 code: String,
179 reason: String,
181 },
182 Disagreement,
184}
185
186impl TaskFailure {
187 #[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#[derive(Debug, Clone)]
203pub enum TaskOutcome<O> {
204 Accepted {
206 output: O,
208 depth: u8,
210 },
211 Disagreed {
213 answers: Vec<O>,
215 depth: u8,
217 },
218 Failed {
220 failure: TaskFailure,
222 depth: u8,
224 },
225}
226
227impl<O> TaskOutcome<O> {
228 #[must_use]
230 pub fn accepted(self) -> Option<O> {
231 match self {
232 Self::Accepted { output, .. } => Some(output),
233 _ => None,
234 }
235 }
236
237 #[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#[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#[derive(Debug)]
281pub struct TaskEngineBuilder {
282 engine: TaskEngine,
283}
284
285impl TaskEngineBuilder {
286 #[must_use]
288 pub fn routing(mut self, routing: RoutingPolicy) -> Self {
289 self.engine.routing = routing;
290 self
291 }
292
293 #[must_use]
295 pub fn fallback(mut self, options: FallbackOptions) -> Self {
296 self.engine.fallback = Arc::new(options);
297 self
298 }
299
300 #[must_use]
302 pub fn profiles(mut self, profiles: TaskProfiles) -> Self {
303 self.engine.profiles = profiles;
304 self
305 }
306
307 #[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 #[must_use]
317 pub fn records(mut self, policy: RecordPolicy) -> Self {
318 self.engine.records = policy;
319 self
320 }
321
322 #[must_use]
324 pub fn observer(mut self, observer: Arc<dyn Observer>) -> Self {
325 self.engine.observer = observer;
326 self
327 }
328
329 #[must_use]
331 pub fn build(self) -> TaskEngine {
332 self.engine
333 }
334}
335
336#[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
348enum 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 #[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 #[must_use]
382 pub const fn profiles(&self) -> &TaskProfiles {
383 &self.profiles
384 }
385
386 #[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 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 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 #[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 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 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
913fn 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
927fn 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
961fn 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
971fn retried_in_place(kind: &str) -> bool {
974 matches!(
975 kind,
976 "refusal" | "content_filter" | "malformed" | "transport" | "server" | "timeout" | "other"
977 )
978}