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, 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<M, T>
77where
78 M: CompletionModel,
79 T: JsonSchema + for<'a> Deserialize<'a> + WasmCompatSend + WasmCompatSync,
80{
81 agent: Agent<M>,
82 _t: PhantomData<T>,
83 retries: u64,
84}
85
86impl<M, T> Extractor<M, T>
87where
88 M: CompletionModel,
89 T: JsonSchema + for<'a> Deserialize<'a> + WasmCompatSend + WasmCompatSync,
90{
91 pub async fn extract(
98 &self,
99 text: impl Into<Message> + WasmCompatSend,
100 ) -> Result<T, ExtractionError> {
101 let (data, _usage) = self.retry_extract(text.into(), vec![]).await?;
102 Ok(data)
103 }
104
105 pub async fn extract_with_chat_history(
112 &self,
113 text: impl Into<Message> + WasmCompatSend,
114 chat_history: Vec<Message>,
115 ) -> Result<T, ExtractionError> {
116 let (data, _usage) = self.retry_extract(text.into(), chat_history).await?;
117 Ok(data)
118 }
119
120 pub async fn extract_with_usage(
134 &self,
135 text: impl Into<Message> + WasmCompatSend,
136 ) -> Result<ExtractionResponse<T>, ExtractionError> {
137 let (data, usage) = self.retry_extract(text.into(), vec![]).await?;
138 Ok(ExtractionResponse { data, usage })
139 }
140
141 pub async fn extract_with_chat_history_with_usage(
156 &self,
157 text: impl Into<Message> + WasmCompatSend,
158 chat_history: Vec<Message>,
159 ) -> Result<ExtractionResponse<T>, ExtractionError> {
160 let (data, usage) = self.retry_extract(text.into(), chat_history).await?;
161 Ok(ExtractionResponse { data, usage })
162 }
163
164 async fn retry_extract(
170 &self,
171 text: Message,
172 chat_history: Vec<Message>,
173 ) -> Result<(T, Usage), ExtractionError> {
174 let mut last_error = None;
175 let mut usage = Usage::new();
176
177 for i in 0..=self.retries {
178 tracing::debug!(
179 "Attempting to extract JSON. Retries left: {retries}",
180 retries = self.retries - i
181 );
182 let (result, attempt_usage) = self.extract_json_with_usage(&text, &chat_history).await;
183 usage += attempt_usage;
184 match result {
185 Ok(data) => return Ok((data, usage)),
186 Err(e) => {
187 let suffix = if i < self.retries { " Retrying..." } else { "" };
188 tracing::warn!("Attempt {i} to extract JSON failed: {e:?}.{suffix}");
189 last_error = Some(e);
190 }
191 }
192 }
193
194 Err(last_error.unwrap_or(ExtractionError::NoData))
196 }
197
198 async fn extract_json_with_usage(
205 &self,
206 text: &Message,
207 messages: &[Message],
208 ) -> (Result<T, ExtractionError>, Usage) {
209 let (result, error_usage) = self
210 .agent
211 .runner(text.clone())
212 .history(messages.iter().cloned())
213 .max_turns(1)
214 .output_tool(
215 SUBMIT_TOOL_NAME,
216 "Submit the structured data you extracted from the provided text.",
217 false,
218 )
219 .ignore_unhandled_invalid_tool_calls()
220 .run_with_error_usage()
221 .await;
222 let response = match result {
223 Ok(response) => response,
224 Err(PromptError::CompletionError(e)) => {
225 return (Err(ExtractionError::CompletionError(e)), error_usage);
226 }
227 Err(e) => return (Err(e.into()), error_usage),
228 };
229 let usage = response.usage;
230
231 let submissions = response.output_tool_calls();
232 if submissions == 0 {
233 tracing::warn!(
234 "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."
235 );
236 return (Err(ExtractionError::NoData), usage);
237 }
238 if submissions > 1 {
239 tracing::warn!(
240 "Multiple submit calls detected, using the first one. Providers / agents should only ensure one submit call."
241 );
242 }
243
244 (
245 serde_json::from_str(&response.output).map_err(ExtractionError::from),
246 usage,
247 )
248 }
249}
250
251pub struct ExtractorBuilder<M, T>
253where
254 M: CompletionModel,
255 T: JsonSchema + for<'a> Deserialize<'a> + Serialize + WasmCompatSend + WasmCompatSync + 'static,
256{
257 agent_builder: AgentBuilder<M>,
258 _t: PhantomData<T>,
259 retries: Option<u64>,
260}
261
262impl<M, T> ExtractorBuilder<M, T>
263where
264 M: CompletionModel,
265 T: JsonSchema + for<'a> Deserialize<'a> + Serialize + WasmCompatSend + WasmCompatSync + 'static,
266{
267 pub fn new(model: M) -> Self {
268 Self {
269 agent_builder: AgentBuilder::new(model)
270 .preamble("\
271 You are an AI assistant whose purpose is to extract structured data from the provided text.\n\
272 You will have access to a `submit` function that defines the structure of the data to extract from the provided text.\n\
273 Use the `submit` function to submit the structured data.\n\
274 Be sure to fill out every field and ALWAYS CALL THE `submit` function, even with default values!!!.
275 ")
276 .output_schema::<T>()
277 .tool_choice(ToolChoice::Required)
278 .output_mode(OutputMode::Tool),
279 retries: None,
280 _t: PhantomData,
281 }
282 }
283
284 pub fn preamble(mut self, preamble: &str) -> Self {
286 self.agent_builder = self.agent_builder.append_preamble(&format!(
287 "\n=============== ADDITIONAL INSTRUCTIONS ===============\n{preamble}"
288 ));
289 self
290 }
291
292 pub fn context(mut self, doc: &str) -> Self {
294 self.agent_builder = self.agent_builder.context(doc);
295 self
296 }
297
298 pub fn dynamic_context<I>(mut self, samples: usize, index: I) -> Self
303 where
304 I: VectorStoreIndexDyn + 'static,
305 {
306 self.agent_builder = self.agent_builder.dynamic_context(samples, index);
307 self
308 }
309
310 pub fn additional_params(mut self, params: serde_json::Value) -> Self {
311 self.agent_builder = self.agent_builder.additional_params(params);
312 self
313 }
314
315 pub fn max_tokens(mut self, max_tokens: u64) -> Self {
317 self.agent_builder = self.agent_builder.max_tokens(max_tokens);
318 self
319 }
320
321 pub fn retries(mut self, retries: u64) -> Self {
323 self.retries = Some(retries);
324 self
325 }
326
327 pub fn tool_choice(mut self, choice: ToolChoice) -> Self {
329 self.agent_builder = self.agent_builder.tool_choice(choice);
330 self
331 }
332
333 pub fn add_hook<H>(mut self, hook: H) -> Self
338 where
339 H: AgentHook + 'static,
340 {
341 self.agent_builder = self.agent_builder.add_hook(hook);
342 self
343 }
344
345 pub fn build(self) -> Extractor<M, T> {
347 Extractor {
348 agent: self.agent_builder.build(),
349 _t: PhantomData,
350 retries: self.retries.unwrap_or(0),
351 }
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use std::sync::{
358 Arc, Mutex,
359 atomic::{AtomicUsize, Ordering},
360 };
361
362 use serde_json::json;
363
364 use super::*;
365 use crate::agent::{CompletionResponseEvent, HookContext, ModelTurnAction, ObservationAction};
366 use crate::test_utils::{MockCompletionModel, MockTurn};
367 use rig_core::message::{AssistantContent, ToolCall, ToolFunction};
368 use rig_core::vector_store::{
369 VectorSearchRequest, VectorStoreError, VectorStoreIndex, request::Filter,
370 };
371
372 #[derive(Debug, PartialEq, Deserialize, Serialize, JsonSchema)]
373 struct Person {
374 name: String,
375 }
376
377 fn usage(total_tokens: u64) -> Usage {
378 Usage {
379 total_tokens,
380 ..Usage::new()
381 }
382 }
383
384 fn extractor(
385 model: MockCompletionModel,
386 retries: u64,
387 ) -> Extractor<MockCompletionModel, Person> {
388 ExtractorBuilder::new(model).retries(retries).build()
389 }
390
391 fn submit_turn(name: &str) -> MockTurn {
392 MockTurn::tool_call("id1", SUBMIT_TOOL_NAME, json!({ "name": name }))
393 }
394
395 fn tool_call(id: &str, name: &str, arguments: serde_json::Value) -> AssistantContent {
396 AssistantContent::ToolCall(ToolCall::new(
397 id.to_string(),
398 ToolFunction::new(name.to_string(), arguments),
399 ))
400 }
401
402 #[derive(Clone, Default)]
403 struct LifecycleCounts {
404 completion_calls: Arc<AtomicUsize>,
405 completion_responses: Arc<AtomicUsize>,
406 model_turns: Arc<AtomicUsize>,
407 invalid_tool_calls: Arc<AtomicUsize>,
408 }
409
410 impl AgentHook for LifecycleCounts {
411 async fn on_completion_call(
412 &self,
413 _ctx: &HookContext,
414 _event: crate::agent::CompletionCallEvent<'_>,
415 ) -> crate::agent::CompletionCallAction {
416 self.completion_calls.fetch_add(1, Ordering::SeqCst);
417 crate::agent::CompletionCallAction::Continue
418 }
419
420 async fn on_completion_response(
421 &self,
422 _ctx: &HookContext,
423 _event: CompletionResponseEvent<'_>,
424 ) -> ObservationAction {
425 self.completion_responses.fetch_add(1, Ordering::SeqCst);
426 ObservationAction::Continue
427 }
428
429 async fn on_model_turn_finished(
430 &self,
431 _ctx: &HookContext,
432 _event: crate::agent::ModelTurnFinished<'_>,
433 ) -> ModelTurnAction {
434 self.model_turns.fetch_add(1, Ordering::SeqCst);
435 ModelTurnAction::Continue
436 }
437
438 async fn on_invalid_tool_call(
439 &self,
440 _ctx: &HookContext,
441 _event: &crate::agent::InvalidToolCallContext,
442 ) -> Option<crate::agent::InvalidToolCallAction> {
443 self.invalid_tool_calls.fetch_add(1, Ordering::SeqCst);
444 None
445 }
446 }
447
448 type ExtractorResponseSnapshot = (Message, Vec<AssistantContent>, Usage, Option<String>);
449
450 #[derive(Clone, Default)]
451 struct ExtractorResponseCapture {
452 snapshot: Arc<Mutex<Option<ExtractorResponseSnapshot>>>,
453 }
454
455 impl AgentHook for ExtractorResponseCapture {
456 async fn on_completion_response(
457 &self,
458 _ctx: &HookContext,
459 event: CompletionResponseEvent<'_>,
460 ) -> ObservationAction {
461 *self.snapshot.lock().expect("extractor response snapshot") = Some((
462 event.prompt.clone(),
463 event.content.iter().cloned().collect(),
464 event.usage,
465 event.message_id.map(str::to_owned),
466 ));
467 ObservationAction::continue_run()
468 }
469 }
470
471 struct StopBeforeCompletion;
472
473 impl AgentHook for StopBeforeCompletion {
474 async fn on_completion_call(
475 &self,
476 _ctx: &HookContext,
477 _event: crate::agent::CompletionCallEvent<'_>,
478 ) -> crate::agent::CompletionCallAction {
479 crate::agent::CompletionCallAction::stop("extractor stopped")
480 }
481 }
482
483 struct ExtractorContextIndex {
484 queries: Arc<Mutex<Vec<(String, u64)>>>,
485 }
486
487 impl VectorStoreIndex for ExtractorContextIndex {
488 type Filter = Filter<serde_json::Value>;
489
490 async fn top_n<T: for<'a> Deserialize<'a> + WasmCompatSend>(
491 &self,
492 req: VectorSearchRequest,
493 ) -> Result<Vec<(f64, String, T)>, VectorStoreError> {
494 self.queries
495 .lock()
496 .expect("extractor query recorder")
497 .push((req.query().to_string(), req.samples()));
498 let value = serde_json::from_value(json!({ "question": "retrieved" }))?;
499 Ok(vec![(1.0, "extractor-context".to_string(), value)])
500 }
501
502 async fn top_n_ids(
503 &self,
504 _req: VectorSearchRequest,
505 ) -> Result<Vec<(f64, String)>, VectorStoreError> {
506 Ok(vec![(1.0, "extractor-context".to_string())])
507 }
508 }
509
510 #[derive(Clone, Copy)]
511 enum StopFirstBilledResponseAt {
512 CompletionResponse,
513 ModelTurnFinished,
514 }
515
516 #[derive(Clone)]
517 struct StopFirstBilledResponse {
518 phase: StopFirstBilledResponseAt,
519 calls: Arc<AtomicUsize>,
520 }
521
522 impl AgentHook for StopFirstBilledResponse {
523 async fn on_completion_response(
524 &self,
525 _ctx: &HookContext,
526 _event: CompletionResponseEvent<'_>,
527 ) -> ObservationAction {
528 if matches!(self.phase, StopFirstBilledResponseAt::CompletionResponse)
529 && self.calls.fetch_add(1, Ordering::SeqCst) == 0
530 {
531 ObservationAction::stop("stop first billed response")
532 } else {
533 ObservationAction::continue_run()
534 }
535 }
536
537 async fn on_model_turn_finished(
538 &self,
539 _ctx: &HookContext,
540 _event: crate::agent::ModelTurnFinished<'_>,
541 ) -> ModelTurnAction {
542 if matches!(self.phase, StopFirstBilledResponseAt::ModelTurnFinished)
543 && self.calls.fetch_add(1, Ordering::SeqCst) == 0
544 {
545 ModelTurnAction::stop("stop first billed model turn")
546 } else {
547 ModelTurnAction::continue_run()
548 }
549 }
550 }
551
552 struct StopOnInvalidToolCall;
553
554 impl AgentHook for StopOnInvalidToolCall {
555 async fn on_invalid_tool_call(
556 &self,
557 _ctx: &HookContext,
558 _event: &crate::agent::InvalidToolCallContext,
559 ) -> Option<crate::agent::InvalidToolCallAction> {
560 Some(crate::agent::InvalidToolCallAction::stop(
561 "unexpected extractor tool call",
562 ))
563 }
564 }
565
566 struct RepairUnexpectedAsSubmit;
567
568 impl AgentHook for RepairUnexpectedAsSubmit {
569 async fn on_invalid_tool_call(
570 &self,
571 _ctx: &HookContext,
572 _event: &crate::agent::InvalidToolCallContext,
573 ) -> Option<crate::agent::InvalidToolCallAction> {
574 Some(crate::agent::InvalidToolCallAction::repair(
575 SUBMIT_TOOL_NAME,
576 ))
577 }
578 }
579
580 struct SkipUnexpected;
581
582 impl AgentHook for SkipUnexpected {
583 async fn on_invalid_tool_call(
584 &self,
585 _ctx: &HookContext,
586 _event: &crate::agent::InvalidToolCallContext,
587 ) -> Option<crate::agent::InvalidToolCallAction> {
588 Some(crate::agent::InvalidToolCallAction::skip(
589 "ignored by extractor hook",
590 ))
591 }
592 }
593
594 #[tokio::test]
595 async fn extractor_runs_through_full_response_lifecycle() {
596 let model = MockCompletionModel::new([submit_turn("John")]);
597 let counts = LifecycleCounts::default();
598 let response = ExtractorBuilder::<_, Person>::new(model.clone())
599 .add_hook(counts.clone())
600 .build()
601 .extract("John")
602 .await
603 .expect("extraction should succeed");
604
605 assert_eq!(response.name, "John");
606 assert_eq!(model.request_count(), 1);
607 assert_eq!(counts.completion_calls.load(Ordering::SeqCst), 1);
608 assert_eq!(counts.completion_responses.load(Ordering::SeqCst), 1);
609 assert_eq!(counts.model_turns.load(Ordering::SeqCst), 1);
610 }
611
612 #[tokio::test]
613 async fn extractor_hook_receives_canonical_response_fields() {
614 let capture = ExtractorResponseCapture::default();
615 let expected_usage = usage(23);
616 let response =
617 ExtractorBuilder::<_, Person>::new(MockCompletionModel::new([submit_turn("John")
618 .with_usage(expected_usage)
619 .with_message_id("extractor-message")]))
620 .add_hook(capture.clone())
621 .build()
622 .extract("John")
623 .await
624 .expect("extraction should succeed");
625 assert_eq!(response.name, "John");
626
627 let (prompt, content, observed_usage, message_id) = capture
628 .snapshot
629 .lock()
630 .expect("extractor response snapshot")
631 .clone()
632 .expect("extractor response hook should fire");
633 assert_eq!(prompt, Message::user("John"));
634 assert_eq!(observed_usage, expected_usage);
635 assert_eq!(message_id.as_deref(), Some("extractor-message"));
636 assert!(matches!(
637 content.as_slice(),
638 [AssistantContent::ToolCall(tool_call)]
639 if tool_call.function.name == SUBMIT_TOOL_NAME
640 && tool_call.function.arguments == json!({"name": "John"})
641 ));
642 }
643
644 #[tokio::test]
645 async fn extractor_dynamic_context_uses_the_agent_hook_lifecycle() {
646 let model = MockCompletionModel::new([submit_turn("John")]);
647 let probe = model.clone();
648 let queries = Arc::new(Mutex::new(Vec::new()));
649 let response = ExtractorBuilder::<_, Person>::new(model)
650 .dynamic_context(
651 2,
652 ExtractorContextIndex {
653 queries: queries.clone(),
654 },
655 )
656 .build()
657 .extract("John")
658 .await
659 .expect("extraction should succeed");
660
661 assert_eq!(response.name, "John");
662 assert_eq!(
663 *queries.lock().expect("extractor queries"),
664 vec![("John".to_string(), 2)]
665 );
666 let requests = probe.requests();
667 let request = requests.first().expect("one extractor request");
668 assert!(
669 request
670 .documents
671 .iter()
672 .any(|document| document.id == "extractor-context"
673 && document.text == "{\n \"question\": \"retrieved\"\n}")
674 );
675 }
676
677 #[tokio::test]
678 async fn extractor_completion_call_stop_prevents_provider_io() {
679 let model = MockCompletionModel::new([submit_turn("John")]);
680 let error = ExtractorBuilder::<_, Person>::new(model.clone())
681 .add_hook(StopBeforeCompletion)
682 .build()
683 .extract("John")
684 .await
685 .expect_err("terminating hook should cancel extraction");
686
687 assert!(matches!(
688 error,
689 ExtractionError::PromptError(PromptError::PromptCancelled { reason, .. })
690 if reason == "extractor stopped"
691 ));
692 assert_eq!(model.request_count(), 0);
693 }
694
695 #[tokio::test]
696 async fn usage_accumulates_across_failed_attempts() {
697 let model = MockCompletionModel::new([
698 MockTurn::text("no submit call").with_usage(usage(10)),
699 submit_turn("John").with_usage(usage(5)),
700 ]);
701
702 let response = extractor(model, 1)
703 .extract_with_usage("John")
704 .await
705 .expect("second attempt should succeed");
706
707 assert_eq!(
708 response.data,
709 Person {
710 name: "John".to_string()
711 }
712 );
713 assert_eq!(response.usage.total_tokens, 15);
714 }
715
716 async fn assert_billed_hook_termination_usage(phase: StopFirstBilledResponseAt) {
717 let model = MockCompletionModel::new([
718 submit_turn("ignored").with_usage(usage(10)),
719 submit_turn("John").with_usage(usage(5)),
720 ]);
721 let response = ExtractorBuilder::<_, Person>::new(model)
722 .retries(1)
723 .add_hook(StopFirstBilledResponse {
724 phase,
725 calls: Arc::new(AtomicUsize::new(0)),
726 })
727 .build()
728 .extract_with_usage("John")
729 .await
730 .expect("second attempt should succeed");
731
732 assert_eq!(response.data.name, "John");
733 assert_eq!(response.usage.total_tokens, 15);
734 }
735
736 #[tokio::test]
737 async fn completion_response_hook_termination_preserves_billed_usage() {
738 assert_billed_hook_termination_usage(StopFirstBilledResponseAt::CompletionResponse).await;
739 }
740
741 #[tokio::test]
742 async fn model_turn_finished_hook_termination_preserves_billed_usage() {
743 assert_billed_hook_termination_usage(StopFirstBilledResponseAt::ModelTurnFinished).await;
744 }
745
746 #[tokio::test]
747 async fn unexpected_tool_call_preserves_usage_and_retries() {
748 let model = MockCompletionModel::new([
749 MockTurn::tool_call("unknown", "unexpected", json!({})).with_usage(usage(10)),
750 submit_turn("John").with_usage(usage(5)),
751 ]);
752
753 let response = extractor(model, 1)
754 .extract_with_usage("John")
755 .await
756 .expect("second attempt should succeed");
757
758 assert_eq!(response.data.name, "John");
759 assert_eq!(response.usage.total_tokens, 15);
760 }
761
762 #[tokio::test]
763 async fn unexpected_tool_call_runs_hooks_before_extractor_fallback() {
764 let model = MockCompletionModel::new([
765 MockTurn::tool_call("unknown", "unexpected", json!({})).with_usage(usage(10)),
766 submit_turn("John").with_usage(usage(5)),
767 ]);
768 let counts = LifecycleCounts::default();
769
770 let response = ExtractorBuilder::<_, Person>::new(model)
771 .retries(1)
772 .add_hook(counts.clone())
773 .build()
774 .extract_with_usage("John")
775 .await
776 .expect("deferred invalid call should use extractor fallback");
777
778 assert_eq!(response.data.name, "John");
779 assert_eq!(response.usage.total_tokens, 15);
780 assert_eq!(counts.invalid_tool_calls.load(Ordering::SeqCst), 1);
781 assert_eq!(counts.completion_responses.load(Ordering::SeqCst), 2);
782 assert_eq!(counts.model_turns.load(Ordering::SeqCst), 2);
783 }
784
785 #[tokio::test]
786 async fn unexpected_tool_call_hook_can_stop_extraction() {
787 let model =
788 MockCompletionModel::new([MockTurn::tool_call("unknown", "unexpected", json!({}))]);
789
790 let error = ExtractorBuilder::<_, Person>::new(model)
791 .add_hook(StopOnInvalidToolCall)
792 .build()
793 .extract("John")
794 .await
795 .expect_err("invalid-tool hook should retain control");
796
797 assert!(matches!(
798 error,
799 ExtractionError::PromptError(PromptError::PromptCancelled { reason, .. })
800 if reason == "unexpected extractor tool call"
801 ));
802 }
803
804 #[tokio::test]
805 async fn unexpected_tool_call_hook_can_repair_to_submit() {
806 let model = MockCompletionModel::new([MockTurn::tool_call(
807 "unknown",
808 "unexpected",
809 json!({ "name": "John" }),
810 )]);
811
812 let response = ExtractorBuilder::<_, Person>::new(model)
813 .add_hook(RepairUnexpectedAsSubmit)
814 .build()
815 .extract("John")
816 .await
817 .expect("repaired output-tool call should finalize extraction");
818
819 assert_eq!(response.name, "John");
820 }
821
822 #[tokio::test]
823 async fn skip_hook_preserves_valid_submit_sibling() {
824 let turn = MockTurn::from_contents([
825 tool_call("unknown", "unexpected", json!({})),
826 tool_call("submit", SUBMIT_TOOL_NAME, json!({ "name": "John" })),
827 ])
828 .expect("two tool calls");
829 let model = MockCompletionModel::new([turn]);
830
831 let response = ExtractorBuilder::<_, Person>::new(model)
832 .add_hook(SkipUnexpected)
833 .build()
834 .extract("John")
835 .await
836 .expect("skipping an invalid sibling should preserve submit");
837
838 assert_eq!(response.name, "John");
839 }
840
841 #[tokio::test]
842 async fn submit_call_wins_over_unexpected_sibling_call() {
843 let turn = MockTurn::from_contents([
844 tool_call("unknown", "unexpected", json!({})),
845 tool_call("submit", SUBMIT_TOOL_NAME, json!({ "name": "John" })),
846 ])
847 .expect("two tool calls")
848 .with_usage(usage(7));
849 let model = MockCompletionModel::new([turn]);
850
851 let response = extractor(model, 0)
852 .extract_with_usage("John")
853 .await
854 .expect("submit should remain authoritative");
855
856 assert_eq!(response.data.name, "John");
857 assert_eq!(response.usage.total_tokens, 7);
858 }
859
860 #[tokio::test]
861 async fn submit_call_wins_before_unexpected_sibling_call() {
862 let turn = MockTurn::from_contents([
863 tool_call("submit", SUBMIT_TOOL_NAME, json!({ "name": "John" })),
864 tool_call("unknown", "unexpected", json!({})),
865 ])
866 .expect("two tool calls");
867
868 let response = extractor(MockCompletionModel::new([turn]), 0)
869 .extract("John")
870 .await
871 .expect("an earlier submit should remain authoritative");
872
873 assert_eq!(response.name, "John");
874 }
875
876 #[tokio::test]
877 async fn multiple_unexpected_calls_surrounding_submit_are_ignored() {
878 let turn = MockTurn::from_contents([
879 tool_call("unknown-before", "unexpected_before", json!({})),
880 tool_call("submit", SUBMIT_TOOL_NAME, json!({ "name": "John" })),
881 tool_call("unknown-after", "unexpected_after", json!({})),
882 ])
883 .expect("three tool calls");
884
885 let response = extractor(MockCompletionModel::new([turn]), 0)
886 .extract("John")
887 .await
888 .expect("unexpected siblings should not displace submit");
889
890 assert_eq!(response.name, "John");
891 }
892
893 #[tokio::test]
894 async fn transport_errors_contribute_no_usage() {
895 let model = MockCompletionModel::new([
896 MockTurn::error("boom"),
897 submit_turn("John").with_usage(usage(5)),
898 ]);
899
900 let response = extractor(model, 1)
901 .extract_with_usage("John")
902 .await
903 .expect("second attempt should succeed");
904
905 assert_eq!(response.usage.total_tokens, 5);
906 }
907
908 #[tokio::test]
909 async fn single_successful_attempt_reports_its_own_usage() {
910 let model = MockCompletionModel::new([submit_turn("John").with_usage(usage(7))]);
911
912 let response = extractor(model, 0)
913 .extract_with_usage("John")
914 .await
915 .expect("extraction should succeed");
916
917 assert_eq!(response.usage.total_tokens, 7);
918 }
919
920 #[tokio::test]
921 async fn exhausted_retries_return_last_error() {
922 let model =
923 MockCompletionModel::new([MockTurn::text("no submit call").with_usage(usage(10))]);
924
925 let err = extractor(model, 0)
926 .extract("John")
927 .await
928 .expect_err("extraction should fail");
929
930 assert!(matches!(err, ExtractionError::NoData));
931 }
932
933 #[tokio::test]
934 async fn exhausted_retries_return_error_from_final_attempt() {
935 let model = MockCompletionModel::new([MockTurn::error("first"), MockTurn::error("second")]);
936
937 let err = extractor(model, 1)
938 .extract("John")
939 .await
940 .expect_err("extraction should fail");
941
942 assert!(matches!(
943 err,
944 ExtractionError::CompletionError(CompletionError::ProviderError(message))
945 if message == "second"
946 ));
947 }
948}