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, ModelHandle, 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<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/// A single extraction run with an overridden default model.
86///
87/// The model is the default candidate for every retry in this run;
88/// model-selection hooks may replace it before each attempt. The originating
89/// [`Extractor`]'s default model is unchanged.
90#[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
99/// Generate the `Extractor` methods that delegate to the same-named
100/// [`ExtractorRun`] method on [`Extractor::default_run`], appending the
101/// retry-semantics doc paragraphs shared by all of them. Methods marked
102/// `=> usage` also carry the shared usage-accounting paragraph.
103macro_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        ///
115        /// The function will retry the extraction if the initial attempt fails or
116        /// if the model does not call the `submit` tool.
117        ///
118        /// The number of retries is determined by the `retries` field on the Extractor struct.
119        $(#[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    /// Set a different default model for this extractor's subsequent attempts.
135    pub fn with_model_handle(mut self, model: ModelHandle) -> Self {
136        self.agent.set_model_handle(model);
137        self
138    }
139
140    /// Set one extraction run's default model without changing this extractor.
141    ///
142    /// The handle is the default candidate for every retry of the run — not a
143    /// hard pin: model-selection hooks may replace it before each attempt.
144    pub fn using_model(&self, model: ModelHandle) -> ExtractorRun<'_, T> {
145        ExtractorRun {
146            extractor: self,
147            model: Some(model),
148        }
149    }
150
151    /// A run with no model override: the extractor's own default model.
152    fn default_run(&self) -> ExtractorRun<'_, T> {
153        ExtractorRun {
154            extractor: self,
155            model: None,
156        }
157    }
158
159    /// Erase and set a typed default model for one extraction run.
160    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        /// Attempts to extract data from the given text with a number of retries.
169        extract() -> T;
170
171        /// Attempts to extract data from the given text with a number of retries.
172        extract_with_chat_history(chat_history: Vec<Message>) -> T;
173
174        /// Attempts to extract data from the given text with a number of retries,
175        /// returning both the extracted data and accumulated token usage.
176        extract_with_usage() -> ExtractionResponse<T> => usage;
177
178        /// Attempts to extract data from the given text with a number of retries,
179        /// providing chat history context, and returning both the extracted data
180        /// and accumulated token usage.
181        extract_with_chat_history_with_usage(chat_history: Vec<Message>) -> ExtractionResponse<T>
182            => usage;
183    }
184
185    /// Runs the extraction with the retry semantics shared by all public
186    /// `extract*` methods, returning the extracted data and the token usage
187    /// accumulated across all attempts, including failed ones. The accumulated
188    /// usage is only observable on success: when every attempt fails, the
189    /// returned error cannot carry it.
190    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        // If the loop finishes without a successful extraction, return the last error encountered.
219        Err(last_error.unwrap_or(ExtractionError::NoData))
220    }
221
222    /// Performs a single extraction attempt, returning its outcome alongside
223    /// the token usage it consumed. Usage is reported even when the attempt
224    /// fails after a billed completion (e.g. the model never called `submit`);
225    /// it is zero whenever the completion call itself returns an error, since
226    /// `CompletionError` carries no usage — even if the provider billed the
227    /// request (e.g. an unparseable response body).
228    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        // A run-local model is the default candidate for THIS attempt only;
239        // model-selection hooks may still replace it per retry.
240        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    /// Extract structured data with the run-local model.
287    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    /// Extract structured data with chat history and the run-local model.
295    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    /// Extract structured data and usage with the run-local model.
307    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    /// Extract structured data with chat history and usage using the run-local model.
316    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
329/// Builder for the Extractor
330pub 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
339/// Generate the `ExtractorBuilder` setters that forward verbatim to the inner
340/// [`AgentBuilder`] method of the same name. Doc comments live at each
341/// invocation; `preamble` (which wraps its argument) and `retries`
342/// (builder-local) stay hand-written.
343macro_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    /// Create an extractor builder from an opaque runtime model handle.
368    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    /// Add additional preamble to the extractor
386    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        /// Add a context document to the extractor
395        context(doc: &str);
396
397        /// Add dynamic context retrieved from a vector store on every extraction attempt.
398        ///
399        /// This delegates to [`AgentBuilder::dynamic_context`] and therefore uses the
400        /// same completion-call hook lifecycle as an agent.
401        dynamic_context[I: VectorStoreIndexDyn + 'static](samples: usize, index: I);
402
403        additional_params(params: serde_json::Value);
404
405        /// Set the maximum number of tokens for the completion
406        max_tokens(max_tokens: u64);
407
408        /// Set the `tool_choice` option for the inner Agent.
409        tool_choice(choice: ToolChoice);
410
411        /// Add a provider-independent lifecycle hook to every extraction attempt.
412        ///
413        /// Completion-response hooks receive canonical Rig content, usage, prompt,
414        /// and message ID fields, just like hooks attached directly to an agent.
415        add_hook[H: AgentHook + 'static](hook: H);
416    }
417
418    /// Set the maximum number of retries for the extractor.
419    pub fn retries(mut self, retries: u64) -> Self {
420        self.retries = Some(retries);
421        self
422    }
423
424    /// Build the Extractor
425    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}