1use std::marker::PhantomData;
34
35use schemars::JsonSchema;
36use serde::{Deserialize, Serialize};
37
38use rig_core::{
39 message::{Message, ToolChoice},
40 vector_store::VectorStoreIndexDyn,
41 wasm_compat::{WasmCompatSend, WasmCompatSync},
42};
43
44use crate::{
45 agent::{Agent, AgentBuilder, AgentHook, ModelHandle, OutputMode},
46 completion::{CompletionError, CompletionModel, PromptError, Usage},
47};
48
49const SUBMIT_TOOL_NAME: &str = "submit";
50
51#[derive(Debug, Clone)]
53pub struct ExtractionResponse<T> {
54 pub data: T,
56 pub usage: Usage,
58}
59
60#[derive(Debug, thiserror::Error)]
61pub enum ExtractionError {
62 #[error("No data extracted")]
63 NoData,
64
65 #[error("Failed to deserialize the extracted data: {0}")]
66 DeserializationError(#[from] serde_json::Error),
67
68 #[error("CompletionError: {0}")]
69 CompletionError(#[from] CompletionError),
70
71 #[error("PromptError: {0}")]
72 PromptError(#[from] PromptError),
73}
74
75pub struct Extractor<T>
77where
78 T: JsonSchema + for<'a> Deserialize<'a> + WasmCompatSend + WasmCompatSync,
79{
80 agent: Agent,
81 _t: PhantomData<T>,
82 retries: u64,
83}
84
85#[must_use = "an extraction override does nothing until an extract method is awaited"]
91pub struct ExtractorRun<'a, T>
92where
93 T: JsonSchema + for<'de> Deserialize<'de> + WasmCompatSend + WasmCompatSync,
94{
95 extractor: &'a Extractor<T>,
96 model: Option<ModelHandle>,
97}
98
99macro_rules! forward_default_run {
104 (@usage_doc usage) => {
105 "\nUsage accumulates across all retry attempts, including attempts that received\n\
106 a billed response but failed extraction (e.g. the model never called `submit`).\n\
107 Attempts whose completion call itself returned an error (e.g. network failures\n\
108 or unparseable provider responses) contribute no usage, and when every attempt\n\
109 fails the returned error carries no usage information at all."
110 };
111 ($( $(#[$attr:meta])* $name:ident ( $($arg:ident : $ty:ty),* ) -> $ret:ty
112 $(=> $usage:ident)?; )+) => {$(
113 $(#[$attr])*
114 $(#[doc = forward_default_run!(@usage_doc $usage)])?
120 pub async fn $name(
121 &self,
122 text: impl Into<Message> + WasmCompatSend,
123 $($arg: $ty),*
124 ) -> Result<$ret, ExtractionError> {
125 self.default_run().$name(text $(, $arg)*).await
126 }
127 )+};
128}
129
130impl<T> Extractor<T>
131where
132 T: JsonSchema + for<'a> Deserialize<'a> + WasmCompatSend + WasmCompatSync,
133{
134 pub fn with_model_handle(mut self, model: ModelHandle) -> Self {
136 self.agent.set_model_handle(model);
137 self
138 }
139
140 pub fn using_model(&self, model: ModelHandle) -> ExtractorRun<'_, T> {
145 ExtractorRun {
146 extractor: self,
147 model: Some(model),
148 }
149 }
150
151 fn default_run(&self) -> ExtractorRun<'_, T> {
153 ExtractorRun {
154 extractor: self,
155 model: None,
156 }
157 }
158
159 pub fn using_model_value<M>(&self, model: M) -> ExtractorRun<'_, T>
161 where
162 M: CompletionModel + 'static,
163 {
164 self.using_model(ModelHandle::new(model))
165 }
166
167 forward_default_run! {
168 extract() -> T;
170
171 extract_with_chat_history(chat_history: Vec<Message>) -> T;
173
174 extract_with_usage() -> ExtractionResponse<T> => usage;
177
178 extract_with_chat_history_with_usage(chat_history: Vec<Message>) -> ExtractionResponse<T>
182 => usage;
183 }
184
185 async fn retry_extract(
191 &self,
192 text: Message,
193 chat_history: Vec<Message>,
194 model: Option<&ModelHandle>,
195 ) -> Result<(T, Usage), ExtractionError> {
196 let mut last_error = None;
197 let mut usage = Usage::new();
198
199 for i in 0..=self.retries {
200 tracing::debug!(
201 "Attempting to extract JSON. Retries left: {retries}",
202 retries = self.retries - i
203 );
204 let (result, attempt_usage) = self
205 .extract_json_with_usage(&text, &chat_history, model)
206 .await;
207 usage += attempt_usage;
208 match result {
209 Ok(data) => return Ok((data, usage)),
210 Err(e) => {
211 let suffix = if i < self.retries { " Retrying..." } else { "" };
212 tracing::warn!("Attempt {i} to extract JSON failed: {e:?}.{suffix}");
213 last_error = Some(e);
214 }
215 }
216 }
217
218 Err(last_error.unwrap_or(ExtractionError::NoData))
220 }
221
222 async fn extract_json_with_usage(
229 &self,
230 text: &Message,
231 messages: &[Message],
232 model: Option<&ModelHandle>,
233 ) -> (Result<T, ExtractionError>, Usage) {
234 let mut runner = self
235 .agent
236 .runner(text.clone())
237 .history(messages.iter().cloned());
238 if let Some(model) = model {
241 runner = runner.using_model(model.clone());
242 }
243 let (result, error_usage) = runner
244 .max_turns(1)
245 .output_tool(
246 SUBMIT_TOOL_NAME,
247 "Submit the structured data you extracted from the provided text.",
248 false,
249 )
250 .ignore_unhandled_invalid_tool_calls()
251 .run_with_error_usage()
252 .await;
253 let response = match result {
254 Ok(response) => response,
255 Err(PromptError::CompletionError(e)) => {
256 return (Err(ExtractionError::CompletionError(e)), error_usage);
257 }
258 Err(e) => return (Err(e.into()), error_usage),
259 };
260 let usage = response.usage;
261
262 let submissions = response.output_tool_calls();
263 if submissions == 0 {
264 tracing::warn!(
265 "The submit tool was not called. If this happens more than once, please ensure the model you are using is powerful enough to reliably call tools."
266 );
267 return (Err(ExtractionError::NoData), usage);
268 }
269 if submissions > 1 {
270 tracing::warn!(
271 "Multiple submit calls detected, using the first one. Providers / agents should only ensure one submit call."
272 );
273 }
274
275 (
276 serde_json::from_str(&response.output).map_err(ExtractionError::from),
277 usage,
278 )
279 }
280}
281
282impl<T> ExtractorRun<'_, T>
283where
284 T: JsonSchema + for<'de> Deserialize<'de> + WasmCompatSend + WasmCompatSync,
285{
286 pub async fn extract(
288 &self,
289 text: impl Into<Message> + WasmCompatSend,
290 ) -> Result<T, ExtractionError> {
291 Ok(self.extract_with_usage(text).await?.data)
292 }
293
294 pub async fn extract_with_chat_history(
296 &self,
297 text: impl Into<Message> + WasmCompatSend,
298 chat_history: Vec<Message>,
299 ) -> Result<T, ExtractionError> {
300 Ok(self
301 .extract_with_chat_history_with_usage(text, chat_history)
302 .await?
303 .data)
304 }
305
306 pub async fn extract_with_usage(
308 &self,
309 text: impl Into<Message> + WasmCompatSend,
310 ) -> Result<ExtractionResponse<T>, ExtractionError> {
311 self.extract_with_chat_history_with_usage(text, vec![])
312 .await
313 }
314
315 pub async fn extract_with_chat_history_with_usage(
317 &self,
318 text: impl Into<Message> + WasmCompatSend,
319 chat_history: Vec<Message>,
320 ) -> Result<ExtractionResponse<T>, ExtractionError> {
321 let (data, usage) = self
322 .extractor
323 .retry_extract(text.into(), chat_history, self.model.as_ref())
324 .await?;
325 Ok(ExtractionResponse { data, usage })
326 }
327}
328
329pub struct ExtractorBuilder<T>
331where
332 T: JsonSchema + for<'a> Deserialize<'a> + Serialize + WasmCompatSend + WasmCompatSync + 'static,
333{
334 agent_builder: AgentBuilder,
335 _t: PhantomData<T>,
336 retries: Option<u64>,
337}
338
339macro_rules! forward_agent_builder {
344 ($( $(#[$attr:meta])* $name:ident $([$gen:ident : $($bound:tt)+])?
345 ( $($arg:ident : $ty:ty),* );)+) => {$(
346 $(#[$attr])*
347 pub fn $name $(<$gen>)? (mut self, $($arg: $ty),*) -> Self
348 $(where $gen: $($bound)+)?
349 {
350 self.agent_builder = self.agent_builder.$name($($arg),*);
351 self
352 }
353 )+};
354}
355
356impl<T> ExtractorBuilder<T>
357where
358 T: JsonSchema + for<'a> Deserialize<'a> + Serialize + WasmCompatSend + WasmCompatSync + 'static,
359{
360 pub fn new<M>(model: M) -> Self
361 where
362 M: CompletionModel + 'static,
363 {
364 Self::from_model_handle(ModelHandle::new(model))
365 }
366
367 pub fn from_model_handle(model: ModelHandle) -> Self {
369 Self {
370 agent_builder: AgentBuilder::from_model_handle(model)
371 .preamble("\
372 You are an AI assistant whose purpose is to extract structured data from the provided text.\n\
373 You will have access to a `submit` function that defines the structure of the data to extract from the provided text.\n\
374 Use the `submit` function to submit the structured data.\n\
375 Be sure to fill out every field and ALWAYS CALL THE `submit` function, even with default values!!!.
376 ")
377 .output_schema::<T>()
378 .tool_choice(ToolChoice::Required)
379 .output_mode(OutputMode::Tool),
380 retries: None,
381 _t: PhantomData,
382 }
383 }
384
385 pub fn preamble(mut self, preamble: &str) -> Self {
387 self.agent_builder = self.agent_builder.append_preamble(&format!(
388 "\n=============== ADDITIONAL INSTRUCTIONS ===============\n{preamble}"
389 ));
390 self
391 }
392
393 forward_agent_builder! {
394 context(doc: &str);
396
397 dynamic_context[I: VectorStoreIndexDyn + 'static](samples: usize, index: I);
402
403 additional_params(params: serde_json::Value);
404
405 max_tokens(max_tokens: u64);
407
408 tool_choice(choice: ToolChoice);
410
411 add_hook[H: AgentHook + 'static](hook: H);
416 }
417
418 pub fn retries(mut self, retries: u64) -> Self {
420 self.retries = Some(retries);
421 self
422 }
423
424 pub fn build(self) -> Extractor<T> {
426 Extractor {
427 agent: self.agent_builder.build(),
428 _t: PhantomData,
429 retries: self.retries.unwrap_or(0),
430 }
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use std::sync::{
437 Arc, Mutex,
438 atomic::{AtomicUsize, Ordering},
439 };
440
441 use serde_json::json;
442
443 use super::*;
444 use crate::agent::{CompletionResponseEvent, HookContext, ModelTurnAction, ObservationAction};
445 use crate::test_utils::{MockCompletionModel, MockTurn};
446 use rig_core::message::{AssistantContent, ToolCall, ToolFunction};
447 use rig_core::vector_store::{
448 VectorSearchRequest, VectorStoreError, VectorStoreIndex, request::Filter,
449 };
450
451 #[derive(Debug, PartialEq, Deserialize, Serialize, JsonSchema)]
452 struct Person {
453 name: String,
454 }
455
456 fn usage(total_tokens: u64) -> Usage {
457 Usage {
458 total_tokens,
459 ..Usage::new()
460 }
461 }
462
463 fn extractor(model: MockCompletionModel, retries: u64) -> Extractor<Person> {
464 ExtractorBuilder::new(model).retries(retries).build()
465 }
466
467 fn submit_turn(name: &str) -> MockTurn {
468 MockTurn::tool_call("id1", SUBMIT_TOOL_NAME, json!({ "name": name }))
469 }
470
471 fn tool_call(id: &str, name: &str, arguments: serde_json::Value) -> AssistantContent {
472 AssistantContent::ToolCall(ToolCall::from_wire(
473 id,
474 ToolFunction::new(name.to_string(), arguments),
475 ))
476 }
477
478 #[derive(Clone, Default)]
479 struct LifecycleCounts {
480 completion_calls: Arc<AtomicUsize>,
481 completion_responses: Arc<AtomicUsize>,
482 model_turns: Arc<AtomicUsize>,
483 invalid_tool_calls: Arc<AtomicUsize>,
484 }
485
486 impl AgentHook for LifecycleCounts {
487 async fn on_completion_call(
488 &self,
489 _ctx: &HookContext,
490 _event: crate::agent::CompletionCallEvent<'_>,
491 ) -> crate::agent::CompletionCallAction {
492 self.completion_calls.fetch_add(1, Ordering::SeqCst);
493 crate::agent::CompletionCallAction::Continue
494 }
495
496 async fn on_completion_response(
497 &self,
498 _ctx: &HookContext,
499 _event: CompletionResponseEvent<'_>,
500 ) -> ObservationAction {
501 self.completion_responses.fetch_add(1, Ordering::SeqCst);
502 ObservationAction::Continue
503 }
504
505 async fn on_model_turn_finished(
506 &self,
507 _ctx: &HookContext,
508 _event: crate::agent::ModelTurnFinished<'_>,
509 ) -> ModelTurnAction {
510 self.model_turns.fetch_add(1, Ordering::SeqCst);
511 ModelTurnAction::Continue
512 }
513
514 async fn on_invalid_tool_call(
515 &self,
516 _ctx: &HookContext,
517 _event: &crate::agent::InvalidToolCallContext,
518 ) -> Option<crate::agent::InvalidToolCallAction> {
519 self.invalid_tool_calls.fetch_add(1, Ordering::SeqCst);
520 None
521 }
522 }
523
524 type ExtractorResponseSnapshot = (Message, Vec<AssistantContent>, Usage, Option<String>);
525
526 #[derive(Clone, Default)]
527 struct ExtractorResponseCapture {
528 snapshot: Arc<Mutex<Option<ExtractorResponseSnapshot>>>,
529 }
530
531 impl AgentHook for ExtractorResponseCapture {
532 async fn on_completion_response(
533 &self,
534 _ctx: &HookContext,
535 event: CompletionResponseEvent<'_>,
536 ) -> ObservationAction {
537 *self.snapshot.lock().expect("extractor response snapshot") = Some((
538 event.prompt.clone(),
539 event.content.clone(),
540 event.usage,
541 event.message_id.map(str::to_owned),
542 ));
543 ObservationAction::continue_run()
544 }
545 }
546
547 struct StopBeforeCompletion;
548
549 impl AgentHook for StopBeforeCompletion {
550 async fn on_completion_call(
551 &self,
552 _ctx: &HookContext,
553 _event: crate::agent::CompletionCallEvent<'_>,
554 ) -> crate::agent::CompletionCallAction {
555 crate::agent::CompletionCallAction::stop("extractor stopped")
556 }
557 }
558
559 struct ExtractorContextIndex {
560 queries: Arc<Mutex<Vec<(String, u64)>>>,
561 }
562
563 impl VectorStoreIndex for ExtractorContextIndex {
564 type Filter = Filter<serde_json::Value>;
565
566 async fn top_n<T: for<'a> Deserialize<'a> + WasmCompatSend>(
567 &self,
568 req: VectorSearchRequest,
569 ) -> Result<Vec<(f64, String, T)>, VectorStoreError> {
570 self.queries
571 .lock()
572 .expect("extractor query recorder")
573 .push((req.query().to_string(), req.samples()));
574 let value = serde_json::from_value(json!({ "question": "retrieved" }))?;
575 Ok(vec![(1.0, "extractor-context".to_string(), value)])
576 }
577
578 async fn top_n_ids(
579 &self,
580 _req: VectorSearchRequest,
581 ) -> Result<Vec<(f64, String)>, VectorStoreError> {
582 Ok(vec![(1.0, "extractor-context".to_string())])
583 }
584 }
585
586 #[derive(Clone, Copy)]
587 enum StopFirstBilledResponseAt {
588 CompletionResponse,
589 ModelTurnFinished,
590 }
591
592 #[derive(Clone)]
593 struct StopFirstBilledResponse {
594 phase: StopFirstBilledResponseAt,
595 calls: Arc<AtomicUsize>,
596 }
597
598 impl AgentHook for StopFirstBilledResponse {
599 async fn on_completion_response(
600 &self,
601 _ctx: &HookContext,
602 _event: CompletionResponseEvent<'_>,
603 ) -> ObservationAction {
604 if matches!(self.phase, StopFirstBilledResponseAt::CompletionResponse)
605 && self.calls.fetch_add(1, Ordering::SeqCst) == 0
606 {
607 ObservationAction::stop("stop first billed response")
608 } else {
609 ObservationAction::continue_run()
610 }
611 }
612
613 async fn on_model_turn_finished(
614 &self,
615 _ctx: &HookContext,
616 _event: crate::agent::ModelTurnFinished<'_>,
617 ) -> ModelTurnAction {
618 if matches!(self.phase, StopFirstBilledResponseAt::ModelTurnFinished)
619 && self.calls.fetch_add(1, Ordering::SeqCst) == 0
620 {
621 ModelTurnAction::stop("stop first billed model turn")
622 } else {
623 ModelTurnAction::continue_run()
624 }
625 }
626 }
627
628 struct StopOnInvalidToolCall;
629
630 impl AgentHook for StopOnInvalidToolCall {
631 async fn on_invalid_tool_call(
632 &self,
633 _ctx: &HookContext,
634 _event: &crate::agent::InvalidToolCallContext,
635 ) -> Option<crate::agent::InvalidToolCallAction> {
636 Some(crate::agent::InvalidToolCallAction::stop(
637 "unexpected extractor tool call",
638 ))
639 }
640 }
641
642 struct RepairUnexpectedAsSubmit;
643
644 impl AgentHook for RepairUnexpectedAsSubmit {
645 async fn on_invalid_tool_call(
646 &self,
647 _ctx: &HookContext,
648 _event: &crate::agent::InvalidToolCallContext,
649 ) -> Option<crate::agent::InvalidToolCallAction> {
650 Some(crate::agent::InvalidToolCallAction::repair(
651 SUBMIT_TOOL_NAME,
652 ))
653 }
654 }
655
656 struct SkipUnexpected;
657
658 impl AgentHook for SkipUnexpected {
659 async fn on_invalid_tool_call(
660 &self,
661 _ctx: &HookContext,
662 _event: &crate::agent::InvalidToolCallContext,
663 ) -> Option<crate::agent::InvalidToolCallAction> {
664 Some(crate::agent::InvalidToolCallAction::skip(
665 "ignored by extractor hook",
666 ))
667 }
668 }
669
670 #[tokio::test]
671 async fn extractor_runs_through_full_response_lifecycle() {
672 let model = MockCompletionModel::new([submit_turn("John")]);
673 let counts = LifecycleCounts::default();
674 let response = ExtractorBuilder::<Person>::new(model.clone())
675 .add_hook(counts.clone())
676 .build()
677 .extract("John")
678 .await
679 .expect("extraction should succeed");
680
681 assert_eq!(response.name, "John");
682 assert_eq!(model.request_count(), 1);
683 assert_eq!(counts.completion_calls.load(Ordering::SeqCst), 1);
684 assert_eq!(counts.completion_responses.load(Ordering::SeqCst), 1);
685 assert_eq!(counts.model_turns.load(Ordering::SeqCst), 1);
686 }
687
688 #[tokio::test]
689 async fn extractor_hook_receives_canonical_response_fields() {
690 let capture = ExtractorResponseCapture::default();
691 let expected_usage = usage(23);
692 let response =
693 ExtractorBuilder::<Person>::new(MockCompletionModel::new([submit_turn("John")
694 .with_usage(expected_usage)
695 .with_message_id("extractor-message")]))
696 .add_hook(capture.clone())
697 .build()
698 .extract("John")
699 .await
700 .expect("extraction should succeed");
701 assert_eq!(response.name, "John");
702
703 let (prompt, content, observed_usage, message_id) = capture
704 .snapshot
705 .lock()
706 .expect("extractor response snapshot")
707 .clone()
708 .expect("extractor response hook should fire");
709 assert_eq!(prompt, Message::user("John"));
710 assert_eq!(observed_usage, expected_usage);
711 assert_eq!(message_id.as_deref(), Some("extractor-message"));
712 assert!(matches!(
713 content.as_slice(),
714 [AssistantContent::ToolCall(tool_call)]
715 if tool_call.function.name == SUBMIT_TOOL_NAME
716 && tool_call.function.arguments == json!({"name": "John"})
717 ));
718 }
719
720 #[tokio::test]
721 async fn extractor_dynamic_context_uses_the_agent_hook_lifecycle() {
722 let model = MockCompletionModel::new([submit_turn("John")]);
723 let probe = model.clone();
724 let queries = Arc::new(Mutex::new(Vec::new()));
725 let response = ExtractorBuilder::<Person>::new(model)
726 .dynamic_context(
727 2,
728 ExtractorContextIndex {
729 queries: queries.clone(),
730 },
731 )
732 .build()
733 .extract("John")
734 .await
735 .expect("extraction should succeed");
736
737 assert_eq!(response.name, "John");
738 assert_eq!(
739 *queries.lock().expect("extractor queries"),
740 vec![("John".to_string(), 2)]
741 );
742 let requests = probe.requests();
743 let request = requests.first().expect("one extractor request");
744 assert!(
745 request
746 .documents
747 .iter()
748 .any(|document| document.id == "extractor-context"
749 && document.text == "{\n \"question\": \"retrieved\"\n}")
750 );
751 }
752
753 #[tokio::test]
754 async fn extractor_completion_call_stop_prevents_provider_io() {
755 let model = MockCompletionModel::new([submit_turn("John")]);
756 let error = ExtractorBuilder::<Person>::new(model.clone())
757 .add_hook(StopBeforeCompletion)
758 .build()
759 .extract("John")
760 .await
761 .expect_err("terminating hook should cancel extraction");
762
763 assert!(matches!(
764 error,
765 ExtractionError::PromptError(PromptError::PromptCancelled { reason, .. })
766 if reason == "extractor stopped"
767 ));
768 assert_eq!(model.request_count(), 0);
769 }
770
771 #[tokio::test]
772 async fn usage_accumulates_across_failed_attempts() {
773 let model = MockCompletionModel::new([
774 MockTurn::text("no submit call").with_usage(usage(10)),
775 submit_turn("John").with_usage(usage(5)),
776 ]);
777
778 let response = extractor(model, 1)
779 .extract_with_usage("John")
780 .await
781 .expect("second attempt should succeed");
782
783 assert_eq!(
784 response.data,
785 Person {
786 name: "John".to_string()
787 }
788 );
789 assert_eq!(response.usage.total_tokens, 15);
790 }
791
792 async fn assert_billed_hook_termination_usage(phase: StopFirstBilledResponseAt) {
793 let model = MockCompletionModel::new([
794 submit_turn("ignored").with_usage(usage(10)),
795 submit_turn("John").with_usage(usage(5)),
796 ]);
797 let response = ExtractorBuilder::<Person>::new(model)
798 .retries(1)
799 .add_hook(StopFirstBilledResponse {
800 phase,
801 calls: Arc::new(AtomicUsize::new(0)),
802 })
803 .build()
804 .extract_with_usage("John")
805 .await
806 .expect("second attempt should succeed");
807
808 assert_eq!(response.data.name, "John");
809 assert_eq!(response.usage.total_tokens, 15);
810 }
811
812 #[tokio::test]
813 async fn completion_response_hook_termination_preserves_billed_usage() {
814 assert_billed_hook_termination_usage(StopFirstBilledResponseAt::CompletionResponse).await;
815 }
816
817 #[tokio::test]
818 async fn model_turn_finished_hook_termination_preserves_billed_usage() {
819 assert_billed_hook_termination_usage(StopFirstBilledResponseAt::ModelTurnFinished).await;
820 }
821
822 #[tokio::test]
823 async fn unexpected_tool_call_preserves_usage_and_retries() {
824 let model = MockCompletionModel::new([
825 MockTurn::tool_call("unknown", "unexpected", json!({})).with_usage(usage(10)),
826 submit_turn("John").with_usage(usage(5)),
827 ]);
828
829 let response = extractor(model, 1)
830 .extract_with_usage("John")
831 .await
832 .expect("second attempt should succeed");
833
834 assert_eq!(response.data.name, "John");
835 assert_eq!(response.usage.total_tokens, 15);
836 }
837
838 #[tokio::test]
839 async fn unexpected_tool_call_runs_hooks_before_extractor_fallback() {
840 let model = MockCompletionModel::new([
841 MockTurn::tool_call("unknown", "unexpected", json!({})).with_usage(usage(10)),
842 submit_turn("John").with_usage(usage(5)),
843 ]);
844 let counts = LifecycleCounts::default();
845
846 let response = ExtractorBuilder::<Person>::new(model)
847 .retries(1)
848 .add_hook(counts.clone())
849 .build()
850 .extract_with_usage("John")
851 .await
852 .expect("deferred invalid call should use extractor fallback");
853
854 assert_eq!(response.data.name, "John");
855 assert_eq!(response.usage.total_tokens, 15);
856 assert_eq!(counts.invalid_tool_calls.load(Ordering::SeqCst), 1);
857 assert_eq!(counts.completion_responses.load(Ordering::SeqCst), 2);
858 assert_eq!(counts.model_turns.load(Ordering::SeqCst), 2);
859 }
860
861 #[tokio::test]
862 async fn unexpected_tool_call_hook_can_stop_extraction() {
863 let model =
864 MockCompletionModel::new([MockTurn::tool_call("unknown", "unexpected", json!({}))]);
865
866 let error = ExtractorBuilder::<Person>::new(model)
867 .add_hook(StopOnInvalidToolCall)
868 .build()
869 .extract("John")
870 .await
871 .expect_err("invalid-tool hook should retain control");
872
873 assert!(matches!(
874 error,
875 ExtractionError::PromptError(PromptError::PromptCancelled { reason, .. })
876 if reason == "unexpected extractor tool call"
877 ));
878 }
879
880 #[tokio::test]
881 async fn unexpected_tool_call_hook_can_repair_to_submit() {
882 let model = MockCompletionModel::new([MockTurn::tool_call(
883 "unknown",
884 "unexpected",
885 json!({ "name": "John" }),
886 )]);
887
888 let response = ExtractorBuilder::<Person>::new(model)
889 .add_hook(RepairUnexpectedAsSubmit)
890 .build()
891 .extract("John")
892 .await
893 .expect("repaired output-tool call should finalize extraction");
894
895 assert_eq!(response.name, "John");
896 }
897
898 #[tokio::test]
899 async fn skip_hook_preserves_valid_submit_sibling() {
900 let turn = MockTurn::from_contents([
901 tool_call("unknown", "unexpected", json!({})),
902 tool_call("submit", SUBMIT_TOOL_NAME, json!({ "name": "John" })),
903 ]);
904 let model = MockCompletionModel::new([turn]);
905
906 let response = ExtractorBuilder::<Person>::new(model)
907 .add_hook(SkipUnexpected)
908 .build()
909 .extract("John")
910 .await
911 .expect("skipping an invalid sibling should preserve submit");
912
913 assert_eq!(response.name, "John");
914 }
915
916 #[tokio::test]
917 async fn submit_call_wins_over_unexpected_sibling_call() {
918 let turn = MockTurn::from_contents([
919 tool_call("unknown", "unexpected", json!({})),
920 tool_call("submit", SUBMIT_TOOL_NAME, json!({ "name": "John" })),
921 ])
922 .with_usage(usage(7));
923 let model = MockCompletionModel::new([turn]);
924
925 let response = extractor(model, 0)
926 .extract_with_usage("John")
927 .await
928 .expect("submit should remain authoritative");
929
930 assert_eq!(response.data.name, "John");
931 assert_eq!(response.usage.total_tokens, 7);
932 }
933
934 #[tokio::test]
935 async fn submit_call_wins_before_unexpected_sibling_call() {
936 let turn = MockTurn::from_contents([
937 tool_call("submit", SUBMIT_TOOL_NAME, json!({ "name": "John" })),
938 tool_call("unknown", "unexpected", json!({})),
939 ]);
940
941 let response = extractor(MockCompletionModel::new([turn]), 0)
942 .extract("John")
943 .await
944 .expect("an earlier submit should remain authoritative");
945
946 assert_eq!(response.name, "John");
947 }
948
949 #[tokio::test]
950 async fn multiple_unexpected_calls_surrounding_submit_are_ignored() {
951 let turn = MockTurn::from_contents([
952 tool_call("unknown-before", "unexpected_before", json!({})),
953 tool_call("submit", SUBMIT_TOOL_NAME, json!({ "name": "John" })),
954 tool_call("unknown-after", "unexpected_after", json!({})),
955 ]);
956
957 let response = extractor(MockCompletionModel::new([turn]), 0)
958 .extract("John")
959 .await
960 .expect("unexpected siblings should not displace submit");
961
962 assert_eq!(response.name, "John");
963 }
964
965 #[tokio::test]
966 async fn transport_errors_contribute_no_usage() {
967 let model = MockCompletionModel::new([
968 MockTurn::error("boom"),
969 submit_turn("John").with_usage(usage(5)),
970 ]);
971
972 let response = extractor(model, 1)
973 .extract_with_usage("John")
974 .await
975 .expect("second attempt should succeed");
976
977 assert_eq!(response.usage.total_tokens, 5);
978 }
979
980 #[tokio::test]
981 async fn single_successful_attempt_reports_its_own_usage() {
982 let model = MockCompletionModel::new([submit_turn("John").with_usage(usage(7))]);
983
984 let response = extractor(model, 0)
985 .extract_with_usage("John")
986 .await
987 .expect("extraction should succeed");
988
989 assert_eq!(response.usage.total_tokens, 7);
990 }
991
992 #[tokio::test]
993 async fn exhausted_retries_return_last_error() {
994 let model =
995 MockCompletionModel::new([MockTurn::text("no submit call").with_usage(usage(10))]);
996
997 let err = extractor(model, 0)
998 .extract("John")
999 .await
1000 .expect_err("extraction should fail");
1001
1002 assert!(matches!(err, ExtractionError::NoData));
1003 }
1004
1005 #[tokio::test]
1006 async fn exhausted_retries_return_error_from_final_attempt() {
1007 let model = MockCompletionModel::new([MockTurn::error("first"), MockTurn::error("second")]);
1008
1009 let err = extractor(model, 1)
1010 .extract("John")
1011 .await
1012 .expect_err("extraction should fail");
1013
1014 assert!(matches!(
1015 err,
1016 ExtractionError::CompletionError(CompletionError::ProviderError(message))
1017 if message == "second"
1018 ));
1019 }
1020}