Skip to main content

rig_agent/
extractor.rs

1//! This module provides high-level abstractions for extracting structured data from text using LLMs.
2//!
3//! Note: The target structure must implement the `serde::Deserialize`, `serde::Serialize`,
4//! and `schemars::JsonSchema` traits. Those can be easily derived using the `derive` macro.
5//!
6//! # Example
7//! ```no_run
8//! use rig_agent::prelude::*;
9//! use rig_core::providers::openai;
10//!
11//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
12//! // Initialize the OpenAI client
13//! let openai = openai::Client::new("your-open-ai-api-key")?;
14//!
15//! // Define the structure of the data you want to extract
16//! #[derive(serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
17//! struct Person {
18//!    name: Option<String>,
19//!    age: Option<u8>,
20//!    profession: Option<String>,
21//! }
22//!
23//! // Create the extractor
24//! let extractor = openai.extractor::<Person>(openai::GPT_4O)
25//!     .build();
26//!
27//! // Extract structured data from text
28//! let person = extractor.extract("John Doe is a 30 year old doctor.").await?;
29//! # Ok(())
30//! # }
31//! ```
32
33use 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/// Response from an extraction operation containing the extracted data and usage information.
52#[derive(Debug, Clone)]
53pub struct ExtractionResponse<T> {
54    /// The extracted structured data
55    pub data: T,
56    /// Accumulated token usage across all attempts (including retries)
57    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
75/// Extractor for structured data from text
76pub 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    /// Attempts to extract data from the given text with a number of retries.
92    ///
93    /// The function will retry the extraction if the initial attempt fails or
94    /// if the model does not call the `submit` tool.
95    ///
96    /// The number of retries is determined by the `retries` field on the Extractor struct.
97    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    /// Attempts to extract data from the given text with a number of retries.
106    ///
107    /// The function will retry the extraction if the initial attempt fails or
108    /// if the model does not call the `submit` tool.
109    ///
110    /// The number of retries is determined by the `retries` field on the Extractor struct.
111    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    /// Attempts to extract data from the given text with a number of retries,
121    /// returning both the extracted data and accumulated token usage.
122    ///
123    /// The function will retry the extraction if the initial attempt fails or
124    /// if the model does not call the `submit` tool.
125    ///
126    /// The number of retries is determined by the `retries` field on the Extractor struct.
127    ///
128    /// Usage accumulates across all retry attempts, including attempts that received
129    /// a billed response but failed extraction (e.g. the model never called `submit`).
130    /// Attempts whose completion call itself returned an error (e.g. network failures
131    /// or unparseable provider responses) contribute no usage, and when every attempt
132    /// fails the returned error carries no usage information at all.
133    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    /// Attempts to extract data from the given text with a number of retries,
142    /// providing chat history context, and returning both the extracted data
143    /// and accumulated token usage.
144    ///
145    /// The function will retry the extraction if the initial attempt fails or
146    /// if the model does not call the `submit` tool.
147    ///
148    /// The number of retries is determined by the `retries` field on the Extractor struct.
149    ///
150    /// Usage accumulates across all retry attempts, including attempts that received
151    /// a billed response but failed extraction (e.g. the model never called `submit`).
152    /// Attempts whose completion call itself returned an error (e.g. network failures
153    /// or unparseable provider responses) contribute no usage, and when every attempt
154    /// fails the returned error carries no usage information at all.
155    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    /// Runs the extraction with the retry semantics shared by all public
165    /// `extract*` methods, returning the extracted data and the token usage
166    /// accumulated across all attempts, including failed ones. The accumulated
167    /// usage is only observable on success: when every attempt fails, the
168    /// returned error cannot carry it.
169    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        // If the loop finishes without a successful extraction, return the last error encountered.
195        Err(last_error.unwrap_or(ExtractionError::NoData))
196    }
197
198    /// Performs a single extraction attempt, returning its outcome alongside
199    /// the token usage it consumed. Usage is reported even when the attempt
200    /// fails after a billed completion (e.g. the model never called `submit`);
201    /// it is zero whenever the completion call itself returns an error, since
202    /// `CompletionError` carries no usage — even if the provider billed the
203    /// request (e.g. an unparseable response body).
204    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
251/// Builder for the Extractor
252pub 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    /// Add additional preamble to the extractor
285    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    /// Add a context document to the extractor
293    pub fn context(mut self, doc: &str) -> Self {
294        self.agent_builder = self.agent_builder.context(doc);
295        self
296    }
297
298    /// Add dynamic context retrieved from a vector store on every extraction attempt.
299    ///
300    /// This delegates to [`AgentBuilder::dynamic_context`] and therefore uses the
301    /// same completion-call hook lifecycle as an agent.
302    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    /// Set the maximum number of tokens for the completion
316    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    /// Set the maximum number of retries for the extractor.
322    pub fn retries(mut self, retries: u64) -> Self {
323        self.retries = Some(retries);
324        self
325    }
326
327    /// Set the `tool_choice` option for the inner Agent.
328    pub fn tool_choice(mut self, choice: ToolChoice) -> Self {
329        self.agent_builder = self.agent_builder.tool_choice(choice);
330        self
331    }
332
333    /// Add a provider-independent lifecycle hook to every extraction attempt.
334    ///
335    /// Completion-response hooks receive canonical Rig content, usage, prompt,
336    /// and message ID fields, just like hooks attached directly to an agent.
337    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    /// Build the Extractor
346    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}