Skip to main content

rig_core/agent/prompt_request/
mod.rs

1pub mod streaming;
2
3use super::{Agent, hook::AgentHook, run::OutputMode, runner::AgentRunner};
4use crate::{
5    OneOrMany,
6    completion::{CompletionModel, Message, PromptError, Usage},
7    message::{AssistantContent, ToolResultContent, UserContent},
8    tool::ToolCallExtensions,
9    wasm_compat::{WasmBoxedFuture, WasmCompatSend},
10};
11use serde::{Deserialize, Serialize};
12use std::{future::IntoFuture, marker::PhantomData};
13
14/// Generate the request-builder setters that forward verbatim to an inner
15/// receiver — `AgentRunner` for the blocking builder, the wrapped
16/// `PromptRequest` for the typed builder, and the `AgentRunner` for the
17/// streaming builder. Only the setters whose signature *and* documentation are
18/// identical across all three builders live here; `max_turns`, `add_hook`, and
19/// `tool_concurrency`, whose docs are builder-specific, stay hand-written (the
20/// blocking builders share `tool_concurrency` via [`forward_tool_concurrency`]).
21/// `$recv` is the field name to delegate through (`runner` or `inner`).
22macro_rules! forward_prompt_setters {
23    ($recv:ident) => {
24        /// Attach a per-call [`ToolCallExtensions`] for this request.
25        ///
26        /// Every tool the agent executes during this request can read the
27        /// caller-provided values (auth tokens, session IDs, conversation state, …)
28        /// via [`Tool::call_with_extensions`](crate::tool::Tool::call_with_extensions),
29        /// without the model ever seeing them.
30        pub fn tool_extensions(mut self, extensions: ToolCallExtensions) -> Self {
31            self.$recv = self.$recv.tool_extensions(extensions);
32            self
33        }
34
35        /// Add chat history to the prompt request.
36        pub fn history<H, Item>(mut self, history: H) -> Self
37        where
38            H: IntoIterator<Item = Item>,
39            Item: Into<Message>,
40        {
41            self.$recv = self.$recv.history(history);
42            self
43        }
44
45        /// Set the conversation id used to load and persist memory for this request.
46        ///
47        /// Overrides any default conversation id set on the agent. If memory is not
48        /// configured on the agent, this has no effect.
49        pub fn conversation(mut self, id: impl Into<String>) -> Self {
50            self.$recv = self.$recv.conversation(id);
51            self
52        }
53
54        /// Disable conversation memory for this request.
55        ///
56        /// History will neither be loaded from nor saved to the agent's memory backend.
57        pub fn without_memory(mut self) -> Self {
58            self.$recv = self.$recv.without_memory();
59            self
60        }
61
62        /// Set the retry budget for invalid tool-call recovery.
63        ///
64        /// Invalid tool-call retries also consume the total model-call budget.
65        pub fn max_invalid_tool_call_retries(mut self, retries: usize) -> Self {
66            self.$recv = self.$recv.max_invalid_tool_call_retries(retries);
67            self
68        }
69    };
70}
71pub(crate) use forward_prompt_setters;
72
73/// Generate the `tool_concurrency` setter for the blocking builders, whose doc
74/// is identical to each other but differs from the streaming builder's (the
75/// streaming version documents how tool items are ordered in the emitted
76/// stream). `$recv` is the field name to delegate through (`runner` or `inner`).
77macro_rules! forward_tool_concurrency {
78    ($recv:ident) => {
79        /// Execute up to `concurrency` of a turn's tool calls at once.
80        ///
81        /// See [`AgentRunner::tool_concurrency`] for ordering guarantees: the tool
82        /// batch commits and surfaces atomically, so persisted history and streamed
83        /// tool results are both in tool-call order (results are surfaced only after
84        /// the whole batch settles successfully).
85        pub fn tool_concurrency(mut self, concurrency: usize) -> Self {
86            self.$recv = self.$recv.tool_concurrency(concurrency);
87            self
88        }
89    };
90}
91
92pub trait PromptType {}
93pub struct Standard;
94pub struct Extended;
95
96impl PromptType for Standard {}
97impl PromptType for Extended {}
98
99/// A builder for creating prompt requests with customizable options.
100/// Uses generics to track which options have been set during the build process.
101///
102/// When the agent has no configured `default_max_turns`, the implicit budget is
103/// one model call. Use [`.max_turns()`](Self::max_turns) to override the agent's
104/// configured or implicit budget; a tool call followed by a model-authored final
105/// answer generally requires at least two model calls.
106pub struct PromptRequest<S, M>
107where
108    S: PromptType,
109    M: CompletionModel,
110{
111    /// The hook-aware driver this request configures and runs.
112    pub(crate) runner: AgentRunner<M>,
113    /// Phantom data to track the type of the request (Standard vs Extended).
114    state: PhantomData<S>,
115}
116
117impl<M> PromptRequest<Standard, M>
118where
119    M: CompletionModel,
120{
121    /// Create a new PromptRequest from an agent, cloning the agent's data and
122    /// default hook stack.
123    pub fn from_agent(agent: &Agent<M>, prompt: impl Into<Message>) -> Self {
124        PromptRequest {
125            runner: AgentRunner::from_agent(agent, prompt),
126            state: PhantomData,
127        }
128    }
129}
130
131impl<S, M> PromptRequest<S, M>
132where
133    S: PromptType,
134    M: CompletionModel,
135{
136    /// Enable returning extended details for responses (includes aggregated token usage
137    /// and the full message history accumulated during the agent loop).
138    ///
139    /// Note: This changes the type of the response from `.send` to return a `PromptResponse` struct
140    /// instead of a simple `String`. This is useful for tracking token usage across multiple turns
141    /// of conversation and inspecting the full message exchange.
142    pub fn extended_details(self) -> PromptRequest<Extended, M> {
143        PromptRequest {
144            runner: self.runner,
145            state: PhantomData,
146        }
147    }
148
149    /// Set the total model-call budget, including the initial call and every
150    /// retry or continuation. Zero emits no model calls; one permits only the
151    /// initial call. Exceeding the budget returns
152    /// [`crate::completion::request::PromptError::MaxTurnsError`].
153    pub fn max_turns(mut self, max_turns: usize) -> Self {
154        self.runner = self.runner.max_turns(max_turns);
155        self
156    }
157
158    /// Append a hook for this request (on top of any the agent already carries).
159    /// Hooks run in registration order; how their results compose is
160    /// event-dependent (`CompletionCall` request patches accumulate and merge,
161    /// `ToolCall`/`ToolResult` rewrites chain, and only observe-only/recovery
162    /// events use first-non-`Continue`-wins). See the
163    /// [`hook`](crate::agent::hook) module docs.
164    pub fn add_hook<H>(mut self, hook: H) -> Self
165    where
166        H: AgentHook<M> + 'static,
167    {
168        self.runner = self.runner.add_hook(hook);
169        self
170    }
171
172    forward_prompt_setters!(runner);
173    forward_tool_concurrency!(runner);
174}
175
176/// Due to: [RFC 2515](https://github.com/rust-lang/rust/issues/63063), we have to use a `BoxFuture`
177///  for the `IntoFuture` implementation. In the future, we should be able to use `impl Future<...>`
178///  directly via the associated type.
179impl<M> IntoFuture for PromptRequest<Standard, M>
180where
181    M: CompletionModel + 'static,
182{
183    type Output = Result<String, PromptError>;
184    type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
185
186    fn into_future(self) -> Self::IntoFuture {
187        Box::pin(self.send())
188    }
189}
190
191impl<M> IntoFuture for PromptRequest<Extended, M>
192where
193    M: CompletionModel + 'static,
194{
195    type Output = Result<PromptResponse, PromptError>;
196    type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
197
198    fn into_future(self) -> Self::IntoFuture {
199        Box::pin(self.send())
200    }
201}
202
203impl<M> PromptRequest<Standard, M>
204where
205    M: CompletionModel,
206{
207    async fn send(self) -> Result<String, PromptError> {
208        self.extended_details().send().await.map(|resp| resp.output)
209    }
210}
211
212/// Details for one successfully completed completion request made by an agent run.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
214#[non_exhaustive]
215pub struct CompletionCall {
216    /// Zero-based index of the completion request within this agent run.
217    pub call_index: usize,
218    /// Token usage reported for this completion request.
219    ///
220    /// Zero-valued usage is [`Usage`]'s documented sentinel for missing
221    /// provider usage metrics; rig does not distinguish "reported all zeros"
222    /// from "unreported".
223    #[serde(default, deserialize_with = "usage_null_as_default")]
224    pub usage: Usage,
225}
226
227impl CompletionCall {
228    /// Create details for one completion request in an agent run.
229    pub fn new(call_index: usize, usage: Usage) -> Self {
230        Self { call_index, usage }
231    }
232}
233
234/// Tolerate `null` usage from data serialized before rig dropped the
235/// `Option<Usage>` encoding of missing provider usage metrics.
236///
237/// This tolerance requires a self-describing format such as JSON; data
238/// serialized with non-self-describing formats (e.g. bincode) from before the
239/// change cannot round-trip.
240fn usage_null_as_default<'de, D>(deserializer: D) -> Result<Usage, D::Error>
241where
242    D: serde::Deserializer<'de>,
243{
244    Ok(Option::<Usage>::deserialize(deserializer)?.unwrap_or_default())
245}
246
247/// The result of an agent run, returned by **both** the blocking
248/// ([`PromptRequest`]) and streaming ([`StreamingPromptRequest`]) surfaces so a
249/// call site reads identically whether it used `.prompt()` or `.stream_prompt()`.
250///
251/// On the streaming surface this is the payload of the terminal
252/// [`MultiTurnStreamItem::FinalResponse`] item.
253///
254/// [`StreamingPromptRequest`]: crate::agent::StreamingPromptRequest
255/// [`MultiTurnStreamItem::FinalResponse`]: crate::agent::MultiTurnStreamItem::FinalResponse
256#[derive(Debug, Clone, Serialize, Deserialize)]
257// Serialize *and* deserialize both go through `PromptResponseRepr` so the two
258// directions agree on `content`'s wire shape (an `Option`). Routing only
259// deserialize through the shadow would make serialize write a bare `OneOrMany`
260// while deserialize expects an `Option`, breaking round-trips for positional /
261// non-self-describing formats (e.g. bincode). The repr carries the field serde
262// attributes, so the JSON shape is unchanged.
263#[serde(from = "PromptResponseRepr", into = "PromptResponseRepr")]
264#[non_exhaustive]
265pub struct PromptResponse {
266    /// Concatenated assistant text for the final turn.
267    pub output: String,
268    /// Aggregated token usage across the whole run.
269    pub usage: Usage,
270    /// Successfully completed completion requests made by this agent run.
271    ///
272    /// `usage` remains the aggregate across the whole run. Use the last
273    /// entry's usage to inspect the final completion request's prompt/context
274    /// length. Zero-valued entry usage means the provider reported no usage
275    /// metrics for that request.
276    pub completion_calls: Vec<CompletionCall>,
277    /// Accumulated message history for the run (the run's persisted transcript),
278    /// unless memory/history bookkeeping was disabled for the request.
279    pub messages: Option<Vec<Message>>,
280    /// Structured assistant content for the final turn.
281    ///
282    /// Where [`output`](Self::output) is the concatenated text, this preserves
283    /// the individual content parts (text, reasoning, images, …).
284    pub content: OneOrMany<AssistantContent>,
285}
286
287/// Serde shadow for [`PromptResponse`]. `content` is an `Option` here so runs
288/// serialized before the field existed still deserialize: a missing `content`
289/// reconstructs the structured final turn from `output` (a single text part),
290/// keeping [`PromptResponse::output`] and [`PromptResponse::content`] consistent
291/// for legacy data rather than defaulting to empty text. It carries the field
292/// serde attributes for both directions, keeping the serialized shape identical
293/// (`completion_calls` omitted when empty; `messages`/`content` always present).
294#[derive(Serialize, Deserialize)]
295struct PromptResponseRepr {
296    output: String,
297    usage: Usage,
298    #[serde(default, skip_serializing_if = "Vec::is_empty")]
299    completion_calls: Vec<CompletionCall>,
300    messages: Option<Vec<Message>>,
301    #[serde(default)]
302    content: Option<OneOrMany<AssistantContent>>,
303}
304
305impl From<PromptResponseRepr> for PromptResponse {
306    fn from(repr: PromptResponseRepr) -> Self {
307        let content = repr
308            .content
309            .unwrap_or_else(|| OneOrMany::one(AssistantContent::text(repr.output.clone())));
310        Self {
311            output: repr.output,
312            usage: repr.usage,
313            completion_calls: repr.completion_calls,
314            messages: repr.messages,
315            content,
316        }
317    }
318}
319
320impl From<PromptResponse> for PromptResponseRepr {
321    fn from(response: PromptResponse) -> Self {
322        Self {
323            output: response.output,
324            usage: response.usage,
325            completion_calls: response.completion_calls,
326            messages: response.messages,
327            content: Some(response.content),
328        }
329    }
330}
331
332impl std::fmt::Display for PromptResponse {
333    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334        self.output.fmt(f)
335    }
336}
337
338impl PromptResponse {
339    pub fn new(output: impl Into<String>, usage: Usage) -> Self {
340        let output = output.into();
341        Self {
342            content: OneOrMany::one(AssistantContent::text(output.clone())),
343            output,
344            usage,
345            completion_calls: Vec::new(),
346            messages: None,
347        }
348    }
349
350    /// An empty run result (empty output, zero usage, no history).
351    pub fn empty() -> Self {
352        Self::new(String::new(), Usage::new())
353    }
354
355    pub fn with_messages(mut self, messages: Vec<Message>) -> Self {
356        self.messages = Some(messages);
357        self
358    }
359
360    /// Attach completion call details to this response.
361    pub fn with_completion_calls(mut self, completion_calls: Vec<CompletionCall>) -> Self {
362        self.completion_calls = completion_calls;
363        self
364    }
365
366    /// Set the structured assistant content for the final turn.
367    pub fn with_content(mut self, content: OneOrMany<AssistantContent>) -> Self {
368        self.content = content;
369        self
370    }
371
372    /// The concatenated assistant text for the final turn.
373    pub fn output(&self) -> &str {
374        &self.output
375    }
376
377    /// Aggregated token usage across the whole run.
378    pub fn usage(&self) -> Usage {
379        self.usage
380    }
381
382    /// The run's accumulated message history, if tracked.
383    pub fn messages(&self) -> Option<&[Message]> {
384        self.messages.as_deref()
385    }
386
387    /// The structured assistant content for the final turn.
388    pub fn content(&self) -> &OneOrMany<AssistantContent> {
389        &self.content
390    }
391
392    /// Returns successfully completed completion requests made by this agent run.
393    ///
394    /// Zero-valued entry usage means the provider reported no usage metrics
395    /// for that request.
396    pub fn completion_calls(&self) -> &[CompletionCall] {
397        &self.completion_calls
398    }
399
400    /// Number of completion requests this agent run made.
401    pub fn requests(&self) -> usize {
402        self.completion_calls.len()
403    }
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize)]
407#[non_exhaustive]
408pub struct TypedPromptResponse<T> {
409    pub output: T,
410    pub usage: Usage,
411    /// Successfully completed completion requests made by this agent run.
412    ///
413    /// `usage` remains the aggregate across the whole run. Use the last
414    /// entry's usage to inspect the final completion request's prompt/context
415    /// length. Zero-valued entry usage means the provider reported no usage
416    /// metrics for that request.
417    #[serde(default, skip_serializing_if = "Vec::is_empty")]
418    pub completion_calls: Vec<CompletionCall>,
419}
420
421impl<T> TypedPromptResponse<T> {
422    pub fn new(output: T, usage: Usage) -> Self {
423        Self {
424            output,
425            usage,
426            completion_calls: Vec::new(),
427        }
428    }
429
430    /// Attach completion call details to this response.
431    pub fn with_completion_calls(mut self, completion_calls: Vec<CompletionCall>) -> Self {
432        self.completion_calls = completion_calls;
433        self
434    }
435
436    /// Returns successfully completed completion requests made by this agent run.
437    ///
438    /// Zero-valued entry usage means the provider reported no usage metrics
439    /// for that request.
440    pub fn completion_calls(&self) -> &[CompletionCall] {
441        &self.completion_calls
442    }
443
444    /// Number of completion requests this agent run made.
445    pub fn requests(&self) -> usize {
446        self.completion_calls.len()
447    }
448}
449
450pub(crate) const TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER: &str =
451    "Tool not executed because another tool call in the same assistant turn was invalid.";
452
453/// Combine input history with new messages for building completion requests.
454pub(crate) fn build_history_for_request(
455    chat_history: Option<&[Message]>,
456    new_messages: &[Message],
457) -> Vec<Message> {
458    let input = chat_history.unwrap_or(&[]);
459    input.iter().chain(new_messages.iter()).cloned().collect()
460}
461
462/// Build the full history for error reporting (input + new messages).
463pub(crate) fn build_full_history(
464    chat_history: Option<&[Message]>,
465    new_messages: Vec<Message>,
466) -> Vec<Message> {
467    let input = chat_history.unwrap_or(&[]);
468    input.iter().cloned().chain(new_messages).collect()
469}
470
471/// Wrap already-shaped tool-result content for the model (see
472/// [`tool_result_output`] / [`tool_result_message`]).
473fn tool_result_with(
474    id: String,
475    call_id: Option<String>,
476    content: OneOrMany<ToolResultContent>,
477) -> UserContent {
478    match call_id {
479        Some(call_id) => UserContent::tool_result_with_call_id(id, call_id, content),
480        None => UserContent::tool_result(id, content),
481    }
482}
483
484/// Shape a **real tool output** as a tool result. Routes through
485/// [`ToolResultContent::from_tool_output`], which parses structured/multimodal
486/// payloads (text, images, …). Use this only for actual tool-server output.
487pub(crate) fn tool_result_output(
488    id: String,
489    call_id: Option<String>,
490    output: String,
491) -> UserContent {
492    tool_result_with(id, call_id, ToolResultContent::from_tool_output(output))
493}
494
495/// Shape a **synthetic message** (a hook skip reason, recovery feedback, or a
496/// "not executed" notice) as a tool result. Emitted **verbatim as text** and
497/// never re-parsed as structured tool output, so a JSON-shaped message is not
498/// silently reinterpreted as an image/multimodal result. Used identically by the
499/// blocking and streaming drivers so synthetic results match across both.
500pub(crate) fn tool_result_message(
501    id: String,
502    call_id: Option<String>,
503    message: String,
504) -> UserContent {
505    tool_result_with(
506        id,
507        call_id,
508        OneOrMany::one(ToolResultContent::text(message)),
509    )
510}
511
512pub(crate) fn invalid_tool_retry_user_message(
513    assistant_content: &OneOrMany<AssistantContent>,
514    invalid_tool_call_id: &str,
515    feedback: String,
516) -> Option<Message> {
517    let retry_results = assistant_content
518        .iter()
519        .filter_map(|content| match content {
520            AssistantContent::ToolCall(tool_call) if tool_call.id == invalid_tool_call_id => {
521                Some(tool_result_message(
522                    tool_call.id.clone(),
523                    tool_call.call_id.clone(),
524                    feedback.clone(),
525                ))
526            }
527            AssistantContent::ToolCall(tool_call) => Some(tool_result_message(
528                tool_call.id.clone(),
529                tool_call.call_id.clone(),
530                TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER.to_string(),
531            )),
532            _ => None,
533        })
534        .collect::<Vec<_>>();
535
536    Some(Message::User {
537        content: OneOrMany::from_iter_optional(retry_results)?,
538    })
539}
540
541pub(crate) fn is_empty_assistant_turn(choice: &OneOrMany<AssistantContent>) -> bool {
542    choice.len() == 1
543        && matches!(
544            choice.first(),
545            AssistantContent::Text(text) if text.text.is_empty() && text.additional_params.is_none()
546        )
547}
548
549pub(crate) fn assistant_text_from_choice(choice: &OneOrMany<AssistantContent>) -> String {
550    choice
551        .iter()
552        .filter_map(|content| match content {
553            AssistantContent::Text(text) => Some(text.text.as_str()),
554            _ => None,
555        })
556        .collect()
557}
558
559impl<M> PromptRequest<Extended, M>
560where
561    M: CompletionModel,
562{
563    async fn send(self) -> Result<PromptResponse, PromptError> {
564        self.runner.run().await
565    }
566}
567
568// ================================================================
569// TypedPromptRequest - for structured output with automatic deserialization
570// ================================================================
571
572use crate::completion::StructuredOutputError;
573use schemars::{JsonSchema, schema_for};
574use serde::de::DeserializeOwned;
575
576/// A builder for creating typed prompt requests that return deserialized structured output.
577///
578/// This struct wraps a standard `PromptRequest` and adds:
579/// - Automatic JSON schema generation from the target type `T`
580/// - Automatic deserialization of the response into `T`
581///
582/// The type parameter `S` represents the state of the request (Standard or Extended).
583/// Use `.extended_details()` to transition to Extended state for usage tracking.
584///
585/// # Example
586/// ```rust,ignore
587/// let forecast: WeatherForecast = agent
588///     .prompt_typed("What's the weather in NYC?")
589///     .max_turns(3)
590///     .await?;
591/// ```
592pub struct TypedPromptRequest<T, S, M>
593where
594    T: JsonSchema + DeserializeOwned + WasmCompatSend,
595    S: PromptType,
596    M: CompletionModel,
597{
598    inner: PromptRequest<S, M>,
599    _phantom: std::marker::PhantomData<T>,
600}
601
602impl<T, M> TypedPromptRequest<T, Standard, M>
603where
604    T: JsonSchema + DeserializeOwned + WasmCompatSend,
605    M: CompletionModel,
606{
607    /// Create a new TypedPromptRequest from an agent.
608    ///
609    /// This automatically sets the output schema based on the type parameter `T`.
610    pub fn from_agent(agent: &Agent<M>, prompt: impl Into<Message>) -> Self {
611        let mut inner = PromptRequest::from_agent(agent, prompt);
612        // Override the output schema with the schema for T
613        inner.runner.output_schema = Some(schema_for!(T));
614        // Typed prompts deserialize the model's final string, so they pin
615        // `Native` structured output to keep the typed API's behavior unchanged
616        // across all providers (#1928). Routing the typed path through `Tool`
617        // output mode for tool-using agents on non-composing providers is a
618        // follow-up; use the untyped `output_schema`/`output_mode` API for
619        // tool-composing structured output today.
620        inner.runner.output_mode = OutputMode::Native;
621        Self {
622            inner,
623            _phantom: std::marker::PhantomData,
624        }
625    }
626}
627
628impl<T, S, M> TypedPromptRequest<T, S, M>
629where
630    T: JsonSchema + DeserializeOwned + WasmCompatSend,
631    S: PromptType,
632    M: CompletionModel,
633{
634    /// Enable returning extended details for responses (includes aggregated token usage).
635    ///
636    /// Note: This changes the type of the response from `.send()` to return a `TypedPromptResponse<T>` struct
637    /// instead of just `T`. This is useful for tracking token usage across multiple turns
638    /// of conversation.
639    pub fn extended_details(self) -> TypedPromptRequest<T, Extended, M> {
640        TypedPromptRequest {
641            inner: self.inner.extended_details(),
642            _phantom: std::marker::PhantomData,
643        }
644    }
645
646    /// Set the total model-call budget, including the initial call and every
647    /// retry or continuation. Zero emits no model calls; one permits only the
648    /// initial call. Exceeding the budget returns a
649    /// [`StructuredOutputError::PromptError`] wrapping a `MaxTurnsError`.
650    pub fn max_turns(mut self, max_turns: usize) -> Self {
651        self.inner = self.inner.max_turns(max_turns);
652        self
653    }
654
655    /// Append a hook to this request's hook stack (on top of any the agent
656    /// already carries).
657    pub fn add_hook<H>(mut self, hook: H) -> Self
658    where
659        H: AgentHook<M> + 'static,
660    {
661        self.inner = self.inner.add_hook(hook);
662        self
663    }
664
665    forward_prompt_setters!(inner);
666    forward_tool_concurrency!(inner);
667}
668
669/// Deserialize a typed structured response from the model's final text.
670///
671/// Tries a direct parse first (the common path — native and tool-call output is
672/// already clean JSON), then falls back to the first balanced JSON value in the
673/// text so prose or markdown code fences around the JSON don't break weaker
674/// `Prompted`/best-effort output (#1928).
675fn deserialize_structured_output<T: DeserializeOwned>(text: &str) -> Result<T, serde_json::Error> {
676    let trimmed = text.trim();
677    match serde_json::from_str::<T>(trimmed) {
678        Ok(value) => Ok(value),
679        Err(direct_err) => {
680            let Some(start) = trimmed.find(['{', '[']) else {
681                return Err(direct_err);
682            };
683            serde_json::Deserializer::from_str(&trimmed[start..])
684                .into_iter::<T>()
685                .next()
686                .unwrap_or(Err(direct_err))
687        }
688    }
689}
690
691impl<T, M> TypedPromptRequest<T, Standard, M>
692where
693    T: JsonSchema + DeserializeOwned + WasmCompatSend,
694    M: CompletionModel,
695{
696    /// Send the typed prompt request and deserialize the response.
697    async fn send(self) -> Result<T, StructuredOutputError> {
698        let response = self.inner.send().await.map_err(Box::new)?;
699
700        if response.is_empty() {
701            return Err(StructuredOutputError::EmptyResponse);
702        }
703
704        let parsed: T = deserialize_structured_output(&response)?;
705        Ok(parsed)
706    }
707}
708
709impl<T, M> TypedPromptRequest<T, Extended, M>
710where
711    T: JsonSchema + DeserializeOwned + WasmCompatSend,
712    M: CompletionModel,
713{
714    /// Send the typed prompt request with extended details and deserialize the response.
715    async fn send(self) -> Result<TypedPromptResponse<T>, StructuredOutputError> {
716        let response = self.inner.send().await.map_err(Box::new)?;
717
718        if response.output.is_empty() {
719            return Err(StructuredOutputError::EmptyResponse);
720        }
721
722        let parsed: T = deserialize_structured_output(&response.output)?;
723        Ok(TypedPromptResponse::new(parsed, response.usage)
724            .with_completion_calls(response.completion_calls))
725    }
726}
727
728impl<T, M> IntoFuture for TypedPromptRequest<T, Standard, M>
729where
730    T: JsonSchema + DeserializeOwned + WasmCompatSend + 'static,
731    M: CompletionModel + 'static,
732{
733    type Output = Result<T, StructuredOutputError>;
734    type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
735
736    fn into_future(self) -> Self::IntoFuture {
737        Box::pin(self.send())
738    }
739}
740
741impl<T, M> IntoFuture for TypedPromptRequest<T, Extended, M>
742where
743    T: JsonSchema + DeserializeOwned + WasmCompatSend + 'static,
744    M: CompletionModel + 'static,
745{
746    type Output = Result<TypedPromptResponse<T>, StructuredOutputError>;
747    type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
748
749    fn into_future(self) -> Self::IntoFuture {
750        Box::pin(self.send())
751    }
752}
753
754#[cfg(test)]
755mod tests {
756    use super::{CompletionCall, PromptResponse, PromptResponseRepr, TypedPromptResponse};
757    use crate::{
758        agent::{
759            AgentBuilder,
760            hook::{AgentHook, Flow, HookContext, InvalidToolCallContext, StepEvent},
761        },
762        completion::{
763            AssistantContent, CompletionError, CompletionRequest, Message, Prompt, PromptError,
764            StructuredOutputError, TypedPrompt, Usage,
765        },
766        message::{Text, ToolCall, ToolChoice, ToolFunction, UserContent},
767        test_utils::{
768            AppendFailingMemory, CountingMemory, FailingMemory, MockAddTool, MockCompletionModel,
769            MockExtensionsProbeTool, MockOperationArgs, MockSubtractTool, MockToolError, MockTurn,
770            SessionId,
771        },
772        tool::{Tool, ToolCallExtensions},
773    };
774    use schemars::JsonSchema;
775    use serde::{Deserialize, Serialize};
776    use serde_json::json;
777    use std::sync::{
778        Arc, Mutex,
779        atomic::{AtomicU32, Ordering},
780    };
781
782    #[derive(Serialize)]
783    struct SerializeOnly {
784        value: &'static str,
785    }
786
787    #[derive(Deserialize)]
788    struct DeserializeOnly {
789        value: String,
790    }
791
792    #[derive(Debug, Deserialize, JsonSchema, PartialEq)]
793    struct TypedAnswer {
794        value: String,
795    }
796
797    #[test]
798    fn deserialize_structured_output_tolerates_fences_and_prose() {
799        // Clean JSON (native / output-tool path).
800        assert_eq!(
801            super::deserialize_structured_output::<TypedAnswer>(r#"{"value":"x"}"#).unwrap(),
802            TypedAnswer { value: "x".into() }
803        );
804        // Markdown-fenced JSON (weak Prompted-mode models).
805        assert_eq!(
806            super::deserialize_structured_output::<TypedAnswer>("```json\n{\"value\":\"y\"}\n```")
807                .unwrap(),
808            TypedAnswer { value: "y".into() }
809        );
810        // Prose around the JSON object.
811        assert_eq!(
812            super::deserialize_structured_output::<TypedAnswer>(
813                "Here you go: {\"value\":\"z\"} — hope that helps!"
814            )
815            .unwrap(),
816            TypedAnswer { value: "z".into() }
817        );
818        // No JSON at all still errors.
819        assert!(super::deserialize_structured_output::<TypedAnswer>("no json here").is_err());
820    }
821
822    #[derive(Clone)]
823    struct PanicOnUnknownToolHook;
824
825    impl AgentHook<MockCompletionModel> for PanicOnUnknownToolHook {
826        async fn on_event(
827            &self,
828            _ctx: &HookContext,
829            event: StepEvent<'_, MockCompletionModel>,
830        ) -> Flow {
831            match event {
832                StepEvent::CompletionResponse { .. } => {
833                    panic!("unknown tool response should fail before response hooks run")
834                }
835                StepEvent::ToolCall { .. } => {
836                    panic!("unknown tool call should fail before tool hooks run")
837                }
838                _ => Flow::cont(),
839            }
840        }
841    }
842
843    #[derive(Clone)]
844    struct PanicOnToolCallHook;
845
846    impl AgentHook<MockCompletionModel> for PanicOnToolCallHook {
847        async fn on_event(
848            &self,
849            _ctx: &HookContext,
850            event: StepEvent<'_, MockCompletionModel>,
851        ) -> Flow {
852            match event {
853                StepEvent::ToolCall { .. } => {
854                    panic!("recovered invalid turn should not invoke normal tool hooks")
855                }
856                _ => Flow::cont(),
857            }
858        }
859    }
860
861    #[derive(Clone)]
862    struct SkipDefaultApiAndPanicOnToolCallHook;
863
864    impl AgentHook<MockCompletionModel> for SkipDefaultApiAndPanicOnToolCallHook {
865        async fn on_event(
866            &self,
867            _ctx: &HookContext,
868            event: StepEvent<'_, MockCompletionModel>,
869        ) -> Flow {
870            match event {
871                StepEvent::InvalidToolCall(context) => {
872                    SkipDefaultApiHook
873                        .on_event(_ctx, StepEvent::InvalidToolCall(context))
874                        .await
875                }
876                event @ StepEvent::ToolCall { .. } => {
877                    PanicOnToolCallHook.on_event(_ctx, event).await
878                }
879                _ => Flow::cont(),
880            }
881        }
882    }
883
884    #[derive(Clone)]
885    struct RepairDefaultApiHook;
886
887    impl AgentHook<MockCompletionModel> for RepairDefaultApiHook {
888        async fn on_event(
889            &self,
890            _ctx: &HookContext,
891            event: StepEvent<'_, MockCompletionModel>,
892        ) -> Flow {
893            match event {
894                StepEvent::InvalidToolCall(context) => {
895                    assert_eq!(context.tool_name, "default_api");
896                    Flow::repair("add")
897                }
898                _ => Flow::cont(),
899            }
900        }
901    }
902
903    #[derive(Clone)]
904    struct RepairToSubtractHook;
905
906    impl AgentHook<MockCompletionModel> for RepairToSubtractHook {
907        async fn on_event(
908            &self,
909            _ctx: &HookContext,
910            event: StepEvent<'_, MockCompletionModel>,
911        ) -> Flow {
912            match event {
913                StepEvent::InvalidToolCall(_) => Flow::repair("subtract"),
914                _ => Flow::cont(),
915            }
916        }
917    }
918
919    #[derive(Clone)]
920    struct RetryDefaultApiHook;
921
922    impl AgentHook<MockCompletionModel> for RetryDefaultApiHook {
923        async fn on_event(
924            &self,
925            _ctx: &HookContext,
926            event: StepEvent<'_, MockCompletionModel>,
927        ) -> Flow {
928            match event {
929                StepEvent::InvalidToolCall(context) => {
930                    let allowed_tools = &context.allowed_tools;
931                    Flow::retry(format!("Use one of these tools instead: {allowed_tools:?}"))
932                }
933                _ => Flow::cont(),
934            }
935        }
936    }
937
938    #[derive(Clone)]
939    struct SkipDefaultApiHook;
940
941    impl AgentHook<MockCompletionModel> for SkipDefaultApiHook {
942        async fn on_event(
943            &self,
944            _ctx: &HookContext,
945            event: StepEvent<'_, MockCompletionModel>,
946        ) -> Flow {
947            match event {
948                StepEvent::InvalidToolCall(_) => Flow::skip("default_api is not available"),
949                _ => Flow::cont(),
950            }
951        }
952    }
953
954    #[derive(Clone, Default)]
955    struct RecordingInvalidToolCallHook {
956        contexts: Arc<Mutex<Vec<InvalidToolCallContext>>>,
957    }
958
959    impl RecordingInvalidToolCallHook {
960        fn observed(&self) -> Vec<InvalidToolCallContext> {
961            self.contexts
962                .lock()
963                .expect("invalid tool context records mutex was poisoned")
964                .clone()
965        }
966    }
967
968    impl AgentHook<MockCompletionModel> for RecordingInvalidToolCallHook {
969        async fn on_event(
970            &self,
971            _ctx: &HookContext,
972            event: StepEvent<'_, MockCompletionModel>,
973        ) -> Flow {
974            match event {
975                StepEvent::InvalidToolCall(context) => {
976                    self.contexts
977                        .lock()
978                        .expect("invalid tool context records mutex was poisoned")
979                        .push(context.clone());
980                    Flow::fail()
981                }
982                _ => Flow::cont(),
983            }
984        }
985    }
986
987    #[derive(Clone)]
988    struct CountingAddTool {
989        calls: Arc<AtomicU32>,
990    }
991
992    impl Tool for CountingAddTool {
993        const NAME: &'static str = "add";
994        type Error = MockToolError;
995        type Args = MockOperationArgs;
996        type Output = i32;
997
998        fn description(&self) -> String {
999            MockAddTool.description()
1000        }
1001
1002        fn parameters(&self) -> serde_json::Value {
1003            MockAddTool.parameters()
1004        }
1005
1006        async fn call(&self, _args: Self::Args) -> Result<Self::Output, Self::Error> {
1007            self.calls.fetch_add(1, Ordering::SeqCst);
1008            Ok(0)
1009        }
1010    }
1011
1012    fn usage(input_tokens: u64, output_tokens: u64) -> Usage {
1013        Usage {
1014            input_tokens,
1015            output_tokens,
1016            total_tokens: input_tokens + output_tokens,
1017            cached_input_tokens: 0,
1018            cache_creation_input_tokens: 0,
1019            tool_use_prompt_tokens: 0,
1020            reasoning_tokens: 0,
1021        }
1022    }
1023
1024    #[test]
1025    fn typed_prompt_response_serializes_with_serialize_only_output() {
1026        let response = TypedPromptResponse::new(
1027            SerializeOnly { value: "ok" },
1028            Usage {
1029                input_tokens: 1,
1030                output_tokens: 2,
1031                total_tokens: 3,
1032                cached_input_tokens: 0,
1033                cache_creation_input_tokens: 0,
1034                tool_use_prompt_tokens: 0,
1035                reasoning_tokens: 0,
1036            },
1037        );
1038
1039        let json = serde_json::to_string(&response).expect("serialize typed prompt response");
1040        assert!(json.contains("\"value\":\"ok\""));
1041    }
1042
1043    #[test]
1044    fn typed_prompt_response_deserializes_with_deserialize_only_output() {
1045        let response: TypedPromptResponse<DeserializeOnly> = serde_json::from_str(
1046            r#"{"output":{"value":"ok"},"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3,"cached_input_tokens":0,"cache_creation_input_tokens":0,"reasoning_tokens":0}}"#,
1047        )
1048        .expect("deserialize typed prompt response");
1049
1050        assert_eq!(response.requests(), 0);
1051        assert_eq!(response.output.value, "ok");
1052        assert_eq!(response.usage.input_tokens, 1);
1053        assert_eq!(response.usage.output_tokens, 2);
1054        assert_eq!(response.usage.total_tokens, 3);
1055    }
1056
1057    #[test]
1058    fn prompt_response_serializes_completion_calls_with_missing_usage() {
1059        let reported_usage = usage(3, 4);
1060        let response = PromptResponse::new("ok", reported_usage).with_completion_calls(vec![
1061            CompletionCall::new(0, Usage::new()),
1062            CompletionCall::new(1, reported_usage),
1063        ]);
1064
1065        let value = serde_json::to_value(&response).expect("serialize prompt response");
1066
1067        // Unreported usage serializes as a plain zero-valued object: zero is
1068        // Usage's documented sentinel for missing provider metrics, so there
1069        // is no null encoding to keep in sync.
1070        assert_eq!(
1071            value.get("completion_calls"),
1072            Some(&json!([
1073                {
1074                    "call_index": 0,
1075                    "usage": {
1076                        "input_tokens": 0,
1077                        "output_tokens": 0,
1078                        "total_tokens": 0,
1079                        "cached_input_tokens": 0,
1080                        "cache_creation_input_tokens": 0,
1081                        "tool_use_prompt_tokens": 0,
1082                        "reasoning_tokens": 0,
1083                    }
1084                },
1085                {
1086                    "call_index": 1,
1087                    "usage": {
1088                        "input_tokens": 3,
1089                        "output_tokens": 4,
1090                        "total_tokens": 7,
1091                        "cached_input_tokens": 0,
1092                        "cache_creation_input_tokens": 0,
1093                        "tool_use_prompt_tokens": 0,
1094                        "reasoning_tokens": 0,
1095                    }
1096                }
1097            ]))
1098        );
1099
1100        let response: PromptResponse =
1101            serde_json::from_value(value).expect("deserialize prompt response");
1102        assert_eq!(
1103            response.completion_calls(),
1104            &[
1105                CompletionCall::new(0, Usage::new()),
1106                CompletionCall::new(1, reported_usage)
1107            ]
1108        );
1109        assert_eq!(response.requests(), 2);
1110    }
1111
1112    #[test]
1113    fn prompt_response_deserializes_pre_monoid_null_usage_format() {
1114        // Fixture captured from rig before CompletionCall.usage dropped its
1115        // Option encoding; `"usage": null` must map to zero-valued usage.
1116        let fixture = r#"{"output":"ok","usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"cached_input_tokens":0,"cache_creation_input_tokens":0,"tool_use_prompt_tokens":0,"reasoning_tokens":0},"completion_calls":[{"call_index":0,"usage":null},{"call_index":1,"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"cached_input_tokens":0,"cache_creation_input_tokens":0,"tool_use_prompt_tokens":0,"reasoning_tokens":0}}],"messages":[{"role":"user","content":[{"type":"text","text":"add things"}]}]}"#;
1117
1118        let response: PromptResponse =
1119            serde_json::from_str(fixture).expect("old-format response should deserialize");
1120        assert_eq!(
1121            response.completion_calls(),
1122            &[
1123                CompletionCall::new(0, Usage::new()),
1124                CompletionCall::new(1, usage(3, 4))
1125            ]
1126        );
1127    }
1128
1129    #[test]
1130    fn prompt_response_missing_content_reconstructs_from_output() {
1131        // Runs serialized before `content` existed must not deserialize to empty
1132        // text: the structured final turn is reconstructed from `output`, so
1133        // `output()` and `content()` stay consistent for legacy data.
1134        let mut value = serde_json::to_value(PromptResponse::new("hello", Usage::new()))
1135            .expect("serialize prompt response");
1136        value
1137            .as_object_mut()
1138            .expect("prompt response serializes to a JSON object")
1139            .remove("content");
1140        assert!(
1141            value.get("content").is_none(),
1142            "fixture must omit the content field to model legacy data"
1143        );
1144
1145        let response: PromptResponse = serde_json::from_value(value)
1146            .expect("legacy response without content should deserialize");
1147
1148        assert_eq!(response.output(), "hello");
1149        assert_eq!(response.content().iter().count(), 1);
1150        assert_eq!(response.content().first(), AssistantContent::text("hello"));
1151    }
1152
1153    #[test]
1154    fn prompt_response_missing_content_empty_output_stays_empty_text() {
1155        let mut value =
1156            serde_json::to_value(PromptResponse::empty()).expect("serialize prompt response");
1157        value
1158            .as_object_mut()
1159            .expect("prompt response serializes to a JSON object")
1160            .remove("content");
1161
1162        let response: PromptResponse = serde_json::from_value(value)
1163            .expect("legacy empty response without content should deserialize");
1164
1165        assert_eq!(response.output(), "");
1166        assert_eq!(response.content().first(), AssistantContent::text(""));
1167    }
1168
1169    #[test]
1170    fn prompt_response_roundtrip_preserves_explicit_content() {
1171        // An explicitly-set `content` (e.g. the streaming surface's structured
1172        // final turn) must survive a serialize/deserialize round-trip and is not
1173        // clobbered by the output-derived fallback.
1174        let response = PromptResponse::new("visible text", Usage::new())
1175            .with_content(crate::OneOrMany::one(AssistantContent::text("structured")));
1176
1177        let value = serde_json::to_value(&response).expect("serialize prompt response");
1178        assert!(
1179            value.get("content").is_some(),
1180            "content is part of the serialized shape"
1181        );
1182
1183        let round: PromptResponse =
1184            serde_json::from_value(value).expect("deserialize prompt response");
1185        assert_eq!(round.output(), "visible text");
1186        // The stored content is "structured" — distinct from `output` — proving the
1187        // output-derived fallback only fills a genuinely absent `content`. (Compare
1188        // the text directly to sidestep the unrelated `Text::additional_params`
1189        // serde round-trip asymmetry.)
1190        let AssistantContent::Text(text) = round.content().first() else {
1191            panic!("expected text content, got {:?}", round.content().first());
1192        };
1193        assert_eq!(text.text, "structured");
1194    }
1195
1196    #[test]
1197    fn prompt_response_serialize_and_deserialize_agree_on_wire_shape() {
1198        // Serialize *and* deserialize both route through `PromptResponseRepr`, so
1199        // the two directions agree on `content`'s wire shape (an `Option`).
1200        // Routing only deserialize through the shadow would make serialize write a
1201        // bare `OneOrMany` while deserialize expects an `Option`, breaking
1202        // round-trips for positional / non-self-describing formats. Assert this
1203        // structurally: the message content types use `#[serde(flatten)]`, which no
1204        // length-prefixed binary format can encode, and self-describing formats
1205        // (JSON) collapse `Some(x)` and `x` to identical bytes, hiding the mismatch.
1206        let response = PromptResponse::new("hi", usage(1, 2))
1207            .with_completion_calls(vec![CompletionCall::new(0, usage(1, 2))]);
1208
1209        let from_response = serde_json::to_value(&response).expect("serialize response");
1210        let from_shadow = serde_json::to_value(PromptResponseRepr::from(response.clone()))
1211            .expect("serialize shadow");
1212        assert_eq!(
1213            from_response, from_shadow,
1214            "serialize must route through the same shadow as deserialize"
1215        );
1216
1217        // ...and the value still round-trips back to an equivalent response.
1218        let round: PromptResponse =
1219            serde_json::from_value(from_response).expect("deserialize response");
1220        assert_eq!(round.output(), "hi");
1221        assert_eq!(round.usage(), usage(1, 2));
1222        assert_eq!(
1223            round.completion_calls(),
1224            &[CompletionCall::new(0, usage(1, 2))]
1225        );
1226    }
1227
1228    #[tokio::test]
1229    async fn prompt_response_records_completion_call_without_reported_usage() {
1230        let model = MockCompletionModel::new([MockTurn::text("ok")]);
1231        let agent = AgentBuilder::new(model).build();
1232
1233        let response = agent
1234            .prompt("say ok")
1235            .extended_details()
1236            .await
1237            .expect("prompt should succeed");
1238
1239        assert_eq!(response.output, "ok");
1240        assert_eq!(response.usage, Usage::new());
1241        assert_eq!(
1242            response.completion_calls(),
1243            &[CompletionCall::new(0, Usage::new())]
1244        );
1245    }
1246
1247    #[tokio::test]
1248    async fn typed_prompt_response_preserves_completion_calls() {
1249        let call_usage = Usage {
1250            input_tokens: 4,
1251            output_tokens: 6,
1252            total_tokens: 10,
1253            cached_input_tokens: 0,
1254            cache_creation_input_tokens: 0,
1255            tool_use_prompt_tokens: 0,
1256            reasoning_tokens: 0,
1257        };
1258        let model =
1259            MockCompletionModel::new([MockTurn::text(r#"{"value":"ok"}"#).with_usage(call_usage)]);
1260        let agent = AgentBuilder::new(model).build();
1261
1262        let response = agent
1263            .prompt_typed::<TypedAnswer>("return typed json")
1264            .extended_details()
1265            .await
1266            .expect("typed prompt should succeed");
1267
1268        assert_eq!(
1269            response.output,
1270            TypedAnswer {
1271                value: "ok".to_string()
1272            }
1273        );
1274        assert_eq!(response.usage, call_usage);
1275        assert_eq!(
1276            response.completion_calls(),
1277            &[CompletionCall::new(0, call_usage)]
1278        );
1279    }
1280
1281    fn validate_follow_up_tool_history(request: &CompletionRequest) {
1282        let history = request.chat_history.iter().cloned().collect::<Vec<_>>();
1283        assert_eq!(
1284            history.len(),
1285            3,
1286            "follow-up request should contain the prompt, assistant tool call, and user tool result: {history:?}"
1287        );
1288
1289        assert!(matches!(
1290            history.first(),
1291            Some(Message::User { content })
1292                if matches!(
1293                    content.first(),
1294                    UserContent::Text(text) if text.text == "do tool work"
1295                )
1296        ));
1297
1298        assert!(matches!(
1299            history.get(1),
1300            Some(Message::Assistant { content, .. })
1301                if matches!(
1302                    content.first(),
1303                    AssistantContent::ToolCall(tool_call)
1304                        if tool_call.id == "tool_call_1"
1305                            && tool_call.call_id.as_deref() == Some("call_1")
1306                )
1307        ));
1308
1309        assert!(matches!(
1310            history.get(2),
1311            Some(Message::User { content })
1312                if matches!(
1313                    content.first(),
1314                    UserContent::ToolResult(tool_result)
1315                        if tool_result.id == "tool_call_1"
1316                            && tool_result.call_id.as_deref() == Some("call_1")
1317                )
1318        ));
1319    }
1320
1321    fn history_contains_tool_call(history: &[Message], tool_name: &str) -> bool {
1322        history.iter().any(|message| {
1323            matches!(
1324                message,
1325                Message::Assistant { content, .. }
1326                    if content.iter().any(|item| matches!(
1327                        item,
1328                        AssistantContent::ToolCall(tool_call)
1329                            if tool_call.function.name == tool_name
1330                    ))
1331            )
1332        })
1333    }
1334
1335    #[tokio::test]
1336    async fn unknown_tool_call_fails_before_non_streaming_second_request() {
1337        let model = MockCompletionModel::new([
1338            MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 1, "y": 2})),
1339            MockTurn::text("should not be requested"),
1340        ]);
1341        let recorded = model.clone();
1342        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
1343
1344        let err = agent
1345            .prompt("use the tool")
1346            .add_hook(PanicOnUnknownToolHook)
1347            .max_turns(3)
1348            .await
1349            .expect_err("unknown model-emitted tool should fail");
1350
1351        match err {
1352            PromptError::UnknownToolCall {
1353                tool_name,
1354                available_tools,
1355                allowed_tools,
1356                chat_history,
1357            } => {
1358                assert_eq!(tool_name, "default_api");
1359                assert_eq!(available_tools, vec!["add".to_string()]);
1360                assert_eq!(allowed_tools, vec!["add".to_string()]);
1361                assert!(history_contains_tool_call(&chat_history, "default_api"));
1362            }
1363            other => panic!("expected UnknownToolCall, got {other:?}"),
1364        }
1365        assert_eq!(recorded.request_count(), 1);
1366    }
1367
1368    /// The motivating use-case: a `ToolCallExtensions` set on the prompt request is
1369    /// threaded all the way to the tool the agent loop executes.
1370    #[tokio::test]
1371    async fn tool_extensions_reach_tool_through_agent_loop() {
1372        let model = MockCompletionModel::new([
1373            MockTurn::tool_call("tool_call_1", "context_probe", json!({})),
1374            MockTurn::text("done"),
1375        ]);
1376        let probe = MockExtensionsProbeTool::default();
1377        let agent = AgentBuilder::new(model).tool(probe.clone()).build();
1378
1379        let mut extensions = ToolCallExtensions::new();
1380        extensions.insert(SessionId("abc-123".to_string()));
1381
1382        let out = agent
1383            .prompt("use the tool")
1384            .tool_extensions(extensions)
1385            .max_turns(3)
1386            .await
1387            .expect("run succeeds");
1388
1389        assert_eq!(out, "done");
1390        assert_eq!(probe.observed().as_deref(), Some("session:abc-123"));
1391    }
1392
1393    /// Extensions persist for the whole run, across *multiple* tool-call rounds
1394    /// (the headline value prop). The model calls the probe in two consecutive
1395    /// rounds; both must observe the same injected value, not just the first.
1396    #[tokio::test]
1397    async fn tool_extensions_persist_across_multiple_rounds() {
1398        let model = MockCompletionModel::new([
1399            MockTurn::tool_call("c1", "context_probe", json!({})),
1400            MockTurn::tool_call("c2", "context_probe", json!({})),
1401            MockTurn::text("done"),
1402        ]);
1403        let probe = MockExtensionsProbeTool::default();
1404        let agent = AgentBuilder::new(model).tool(probe.clone()).build();
1405
1406        let mut extensions = ToolCallExtensions::new();
1407        extensions.insert(SessionId("abc-123".to_string()));
1408
1409        let out = agent
1410            .prompt("use the tool twice")
1411            .tool_extensions(extensions)
1412            .max_turns(5)
1413            .await
1414            .expect("run succeeds");
1415
1416        assert_eq!(out, "done");
1417        assert_eq!(
1418            probe.observations(),
1419            vec!["session:abc-123".to_string(), "session:abc-123".to_string()],
1420        );
1421    }
1422
1423    /// Without a context, the same tool runs with an empty one (no panic, no
1424    /// stale value) — the backward-compatible default path.
1425    #[tokio::test]
1426    async fn tool_runs_with_empty_context_when_none_supplied() {
1427        let model = MockCompletionModel::new([
1428            MockTurn::tool_call("tool_call_1", "context_probe", json!({})),
1429            MockTurn::text("done"),
1430        ]);
1431        let probe = MockExtensionsProbeTool::default();
1432        let agent = AgentBuilder::new(model).tool(probe.clone()).build();
1433
1434        let out = agent
1435            .prompt("use the tool")
1436            .max_turns(3)
1437            .await
1438            .expect("run succeeds");
1439
1440        assert_eq!(out, "done");
1441        // Reaches `call_with_extensions` with an empty context (the override is the
1442        // single entry point), so it observes "no-session" rather than the
1443        // plain-`call` "call-no-context".
1444        assert_eq!(probe.observed().as_deref(), Some("no-session"));
1445    }
1446
1447    /// Pins the probe's sentinel: its plain `call` body records
1448    /// `"call-no-context"`. The dispatched-run tests above assert `"no-session"`
1449    /// instead, which is what proves dispatch routes through `call_with_extensions`
1450    /// rather than `call`.
1451    #[tokio::test]
1452    async fn probe_plain_call_records_sentinel() {
1453        let probe = MockExtensionsProbeTool::default();
1454        let out = probe.call(json!({})).await.expect("call succeeds");
1455        assert_eq!(out, "call-no-context");
1456        assert_eq!(probe.observed().as_deref(), Some("call-no-context"));
1457    }
1458
1459    #[tokio::test]
1460    async fn invalid_tool_call_context_uses_completed_tool_call_provider_id() {
1461        let invalid_hook = RecordingInvalidToolCallHook::default();
1462        let model = MockCompletionModel::new([
1463            MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 1, "y": 2}))
1464                .with_call_id("provider_call_1"),
1465            MockTurn::text("should not be requested"),
1466        ]);
1467        let recorded = model.clone();
1468        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
1469
1470        let err = agent
1471            .prompt("use the tool")
1472            .add_hook(invalid_hook.clone())
1473            .max_turns(3)
1474            .await
1475            .expect_err("invalid tool should fail");
1476
1477        assert!(matches!(err, PromptError::UnknownToolCall { .. }));
1478        assert_eq!(recorded.request_count(), 1);
1479        let contexts = invalid_hook.observed();
1480        assert_eq!(contexts.len(), 1);
1481        let context = &contexts[0];
1482        assert_eq!(context.tool_name, "default_api");
1483        assert_eq!(context.tool_call_id.as_deref(), Some("tool_call_1"));
1484        assert_eq!(context.internal_call_id, None);
1485        assert!(!context.is_streaming);
1486    }
1487
1488    #[tokio::test]
1489    async fn disallowed_specific_tool_call_fails_before_non_streaming_second_request() {
1490        let model = MockCompletionModel::new([
1491            MockTurn::tool_call("tool_call_1", "subtract", json!({"x": 3, "y": 1})),
1492            MockTurn::text("should not be requested"),
1493        ]);
1494        let recorded = model.clone();
1495        let agent = AgentBuilder::new(model)
1496            .tool(MockAddTool)
1497            .tool(MockSubtractTool)
1498            .tool_choice(ToolChoice::Specific {
1499                function_names: vec!["add".to_string()],
1500            })
1501            .build();
1502
1503        let err = agent
1504            .prompt("use the allowed tool")
1505            .add_hook(PanicOnUnknownToolHook)
1506            .max_turns(3)
1507            .await
1508            .expect_err("disallowed model-emitted tool should fail");
1509
1510        match err {
1511            PromptError::UnknownToolCall {
1512                tool_name,
1513                available_tools,
1514                allowed_tools,
1515                chat_history,
1516            } => {
1517                assert_eq!(tool_name, "subtract");
1518                assert_eq!(
1519                    available_tools,
1520                    vec!["add".to_string(), "subtract".to_string()]
1521                );
1522                assert_eq!(allowed_tools, vec!["add".to_string()]);
1523                assert!(history_contains_tool_call(&chat_history, "subtract"));
1524            }
1525            other => panic!("expected UnknownToolCall, got {other:?}"),
1526        }
1527        assert_eq!(recorded.request_count(), 1);
1528    }
1529
1530    #[tokio::test]
1531    async fn tool_choice_none_rejects_non_streaming_tool_call() {
1532        let model = MockCompletionModel::new([
1533            MockTurn::tool_call("tool_call_1", "add", json!({"x": 1, "y": 2})),
1534            MockTurn::text("should not be requested"),
1535        ]);
1536        let recorded = model.clone();
1537        let agent = AgentBuilder::new(model)
1538            .tool(MockAddTool)
1539            .tool_choice(ToolChoice::None)
1540            .build();
1541
1542        let err = agent
1543            .prompt("do not use tools")
1544            .add_hook(PanicOnUnknownToolHook)
1545            .max_turns(3)
1546            .await
1547            .expect_err("ToolChoice::None should reject returned tool calls");
1548
1549        match err {
1550            PromptError::UnknownToolCall {
1551                tool_name,
1552                available_tools,
1553                allowed_tools,
1554                chat_history,
1555            } => {
1556                assert_eq!(tool_name, "add");
1557                assert_eq!(available_tools, vec!["add".to_string()]);
1558                assert!(allowed_tools.is_empty());
1559                assert!(history_contains_tool_call(&chat_history, "add"));
1560            }
1561            other => panic!("expected UnknownToolCall, got {other:?}"),
1562        }
1563        assert_eq!(recorded.request_count(), 1);
1564    }
1565
1566    #[tokio::test]
1567    async fn invalid_tool_call_hook_can_repair_non_streaming_tool_name() {
1568        let model = MockCompletionModel::new([
1569            MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1570            MockTurn::text("done"),
1571        ]);
1572        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
1573
1574        let response = agent
1575            .prompt("add")
1576            .add_hook(RepairDefaultApiHook)
1577            .max_turns(3)
1578            .extended_details()
1579            .await
1580            .expect("repaired tool call should execute");
1581
1582        assert_eq!(response.output, "done");
1583        let messages = response.messages.expect("messages should be present");
1584        assert!(history_contains_tool_call(&messages, "add"));
1585        assert!(!history_contains_tool_call(&messages, "default_api"));
1586        assert!(messages.iter().any(|message| {
1587            matches!(
1588                message,
1589                Message::User { content }
1590                    if content.iter().any(|content| {
1591                        matches!(
1592                            content,
1593                            UserContent::ToolResult(result)
1594                                if result.content.iter().any(|content| {
1595                                    matches!(
1596                                        content,
1597                                        crate::message::ToolResultContent::Text(text)
1598                                            if text.text == "5"
1599                                    )
1600                                })
1601                        )
1602                    })
1603            )
1604        }));
1605    }
1606
1607    #[tokio::test]
1608    async fn invalid_tool_call_hook_retry_adds_feedback_and_retries_non_streaming() {
1609        let model = MockCompletionModel::new([
1610            MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1611            MockTurn::text("retried"),
1612        ]);
1613        let recorded = model.clone();
1614        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
1615
1616        let response = agent
1617            .prompt("add")
1618            .add_hook(RetryDefaultApiHook)
1619            .max_invalid_tool_call_retries(1)
1620            .max_turns(3)
1621            .extended_details()
1622            .await
1623            .expect("retry should recover");
1624
1625        assert_eq!(response.output, "retried");
1626        assert_eq!(recorded.request_count(), 2);
1627        let messages = response.messages.expect("messages should be present");
1628        assert!(messages.iter().any(|message| {
1629            matches!(
1630                message,
1631                Message::User { content }
1632                    if content.iter().any(|content| {
1633                        matches!(
1634                            content,
1635                            UserContent::ToolResult(result)
1636                                if result.content.iter().any(|content| {
1637                                    matches!(
1638                                        content,
1639                                        crate::message::ToolResultContent::Text(text)
1640                                            if text.text.contains("Use one of these tools instead")
1641                                    )
1642                                })
1643                        )
1644                    })
1645            )
1646        }));
1647    }
1648
1649    #[tokio::test]
1650    async fn invalid_tool_call_hook_retries_mixed_non_streaming_turn_without_executing_valid_call()
1651    {
1652        let add_calls = Arc::new(AtomicU32::new(0));
1653        let mut valid_tool_call = ToolCall::new(
1654            "tool_call_1".to_string(),
1655            ToolFunction::new("add".to_string(), json!({"x": 2, "y": 3})),
1656        );
1657        valid_tool_call.call_id = Some("call_1".to_string());
1658        let mut invalid_tool_call = ToolCall::new(
1659            "tool_call_2".to_string(),
1660            ToolFunction::new("default_api".to_string(), json!({"x": 4, "y": 5})),
1661        );
1662        invalid_tool_call.call_id = Some("call_2".to_string());
1663        let model = MockCompletionModel::new([
1664            MockTurn::from_contents([
1665                AssistantContent::ToolCall(valid_tool_call),
1666                AssistantContent::ToolCall(invalid_tool_call),
1667            ])
1668            .expect("tool-call response should be non-empty"),
1669            MockTurn::text("retried"),
1670        ]);
1671        let recorded = model.clone();
1672        let agent = AgentBuilder::new(model)
1673            .tool(CountingAddTool {
1674                calls: add_calls.clone(),
1675            })
1676            .build();
1677
1678        let response = agent
1679            .prompt("add")
1680            .add_hook(RetryDefaultApiHook)
1681            .max_invalid_tool_call_retries(1)
1682            .max_turns(3)
1683            .extended_details()
1684            .await
1685            .expect("retry should recover");
1686
1687        assert_eq!(response.output, "retried");
1688        assert_eq!(add_calls.load(Ordering::SeqCst), 0);
1689        let requests = recorded.requests();
1690        assert_eq!(requests.len(), 2);
1691        let retry_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
1692        assert_eq!(retry_history.len(), 3);
1693        assert!(matches!(
1694            retry_history.get(1),
1695            Some(Message::Assistant { content, .. })
1696                if content.iter().any(|item| matches!(
1697                    item,
1698                    AssistantContent::ToolCall(tool_call)
1699                        if tool_call.id == "tool_call_1"
1700                            && tool_call.function.name == "add"
1701                ))
1702                    && content.iter().any(|item| matches!(
1703                        item,
1704                        AssistantContent::ToolCall(tool_call)
1705                            if tool_call.id == "tool_call_2"
1706                                && tool_call.function.name == "default_api"
1707                    ))
1708        ));
1709        assert!(matches!(
1710            retry_history.get(2),
1711            Some(Message::User { content })
1712                if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
1713                    && content.iter().any(|item| matches!(
1714                        item,
1715                        UserContent::ToolResult(result)
1716                            if result.id == "tool_call_1"
1717                                && result.call_id.as_deref() == Some("call_1")
1718                                && result.content.iter().any(|content| matches!(
1719                                    content,
1720                                    crate::message::ToolResultContent::Text(text)
1721                                        if text.text == super::TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
1722                                ))
1723                    ))
1724                    && content.iter().any(|item| matches!(
1725                        item,
1726                        UserContent::ToolResult(result)
1727                            if result.id == "tool_call_2"
1728                                && result.call_id.as_deref() == Some("call_2")
1729                                && result.content.iter().any(|content| matches!(
1730                                    content,
1731                                    crate::message::ToolResultContent::Text(text)
1732                                        if text.text.contains("Use one of these tools instead")
1733                                ))
1734            ))
1735        ));
1736    }
1737
1738    #[tokio::test]
1739    async fn invalid_tool_call_hook_skips_mixed_non_streaming_turn_without_executing_valid_call() {
1740        let add_calls = Arc::new(AtomicU32::new(0));
1741        let mut valid_tool_call = ToolCall::new(
1742            "tool_call_1".to_string(),
1743            ToolFunction::new("add".to_string(), json!({"x": 2, "y": 3})),
1744        );
1745        valid_tool_call.call_id = Some("call_1".to_string());
1746        let mut invalid_tool_call = ToolCall::new(
1747            "tool_call_2".to_string(),
1748            ToolFunction::new("default_api".to_string(), json!({"x": 4, "y": 5})),
1749        );
1750        invalid_tool_call.call_id = Some("call_2".to_string());
1751        let model = MockCompletionModel::new([
1752            MockTurn::from_contents([
1753                AssistantContent::ToolCall(valid_tool_call),
1754                AssistantContent::ToolCall(invalid_tool_call),
1755            ])
1756            .expect("tool-call response should be non-empty"),
1757            MockTurn::text("skipped"),
1758        ]);
1759        let agent = AgentBuilder::new(model)
1760            .tool(CountingAddTool {
1761                calls: add_calls.clone(),
1762            })
1763            .build();
1764
1765        let response = agent
1766            .prompt("add")
1767            .add_hook(SkipDefaultApiAndPanicOnToolCallHook)
1768            .max_turns(3)
1769            .extended_details()
1770            .await
1771            .expect("skip should recover without executing peer tools");
1772
1773        assert_eq!(response.output, "skipped");
1774        assert_eq!(add_calls.load(Ordering::SeqCst), 0);
1775        let messages = response.messages.expect("messages should be present");
1776        assert!(history_contains_tool_call(&messages, "add"));
1777        assert!(history_contains_tool_call(&messages, "default_api"));
1778        assert!(matches!(
1779            messages.get(2),
1780            Some(Message::User { content })
1781                if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
1782                    && content.iter().any(|item| matches!(
1783                        item,
1784                        UserContent::ToolResult(result)
1785                            if result.id == "tool_call_1"
1786                                && result.call_id.as_deref() == Some("call_1")
1787                                && result.content.iter().any(|content| matches!(
1788                                    content,
1789                                    crate::message::ToolResultContent::Text(text)
1790                                        if text.text == super::TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
1791                                ))
1792                    ))
1793                    && content.iter().any(|item| matches!(
1794                        item,
1795                        UserContent::ToolResult(result)
1796                            if result.id == "tool_call_2"
1797                                && result.call_id.as_deref() == Some("call_2")
1798                                && result.content.iter().any(|content| matches!(
1799                                    content,
1800                                    crate::message::ToolResultContent::Text(text)
1801                                        if text.text == "default_api is not available"
1802                                ))
1803                    ))
1804        ));
1805    }
1806
1807    #[tokio::test]
1808    async fn invalid_tool_call_hook_retry_budget_exhaustion_fails() {
1809        let model = MockCompletionModel::new([
1810            MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1811            MockTurn::text("should not be requested"),
1812        ]);
1813        let recorded = model.clone();
1814        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
1815
1816        let err = agent
1817            .prompt("add")
1818            .add_hook(RetryDefaultApiHook)
1819            .max_invalid_tool_call_retries(0)
1820            .max_turns(3)
1821            .await
1822            .expect_err("retry without budget should fail");
1823
1824        match err {
1825            PromptError::UnknownToolCall {
1826                tool_name,
1827                chat_history,
1828                ..
1829            } => {
1830                assert_eq!(tool_name, "default_api");
1831                assert!(history_contains_tool_call(&chat_history, "default_api"));
1832            }
1833            other => panic!("expected UnknownToolCall, got {other:?}"),
1834        }
1835        assert_eq!(recorded.request_count(), 1);
1836    }
1837
1838    #[tokio::test]
1839    async fn invalid_tool_call_hook_can_skip_structured_non_streaming_call() {
1840        let model = MockCompletionModel::new([
1841            MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1842            MockTurn::text("skipped"),
1843        ]);
1844        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
1845
1846        let response = agent
1847            .prompt("add")
1848            .add_hook(SkipDefaultApiHook)
1849            .max_turns(3)
1850            .extended_details()
1851            .await
1852            .expect("skip should continue with synthetic tool result");
1853
1854        assert_eq!(response.output, "skipped");
1855        let messages = response.messages.expect("messages should be present");
1856        assert!(history_contains_tool_call(&messages, "default_api"));
1857        assert!(messages.iter().any(|message| {
1858            matches!(
1859                message,
1860                Message::User { content }
1861                    if content.iter().any(|content| {
1862                        matches!(
1863                            content,
1864                            UserContent::ToolResult(result)
1865                                if result.content.iter().any(|content| {
1866                                    matches!(
1867                                        content,
1868                                        crate::message::ToolResultContent::Text(text)
1869                                            if text.text == "default_api is not available"
1870                                    )
1871                                })
1872                        )
1873                    })
1874            )
1875        }));
1876    }
1877
1878    #[tokio::test]
1879    async fn skip_under_specific_tool_choice_returns_synthetic_feedback() {
1880        let model = MockCompletionModel::new([
1881            MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1882            MockTurn::text("skipped"),
1883        ]);
1884        let agent = AgentBuilder::new(model)
1885            .tool(MockAddTool)
1886            .tool_choice(ToolChoice::Specific {
1887                function_names: vec!["add".to_string()],
1888            })
1889            .build();
1890
1891        let response = agent
1892            .prompt("add")
1893            .add_hook(SkipDefaultApiHook)
1894            .max_turns(3)
1895            .extended_details()
1896            .await
1897            .expect("skip should produce synthetic feedback under Specific");
1898
1899        assert_eq!(response.output, "skipped");
1900        let messages = response.messages.expect("messages should be present");
1901        assert!(history_contains_tool_call(&messages, "default_api"));
1902        assert!(messages.iter().any(|message| {
1903            matches!(
1904                message,
1905                Message::User { content }
1906                    if content.iter().any(|content| {
1907                        matches!(
1908                            content,
1909                            UserContent::ToolResult(result)
1910                                if result.id == "tool_call_1"
1911                                    && result.content.iter().any(|content| {
1912                                        matches!(
1913                                            content,
1914                                            crate::message::ToolResultContent::Text(text)
1915                                                if text.text == "default_api is not available"
1916                                        )
1917                                    })
1918                        )
1919                    })
1920            )
1921        }));
1922    }
1923
1924    #[tokio::test]
1925    async fn repair_to_disallowed_specific_tool_fails() {
1926        let model = MockCompletionModel::new([
1927            MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1928            MockTurn::text("should not be requested"),
1929        ]);
1930        let recorded = model.clone();
1931        let agent = AgentBuilder::new(model)
1932            .tool(MockAddTool)
1933            .tool(MockSubtractTool)
1934            .tool_choice(ToolChoice::Specific {
1935                function_names: vec!["add".to_string()],
1936            })
1937            .build();
1938
1939        let err = agent
1940            .prompt("add")
1941            .add_hook(RepairToSubtractHook)
1942            .max_turns(3)
1943            .await
1944            .expect_err("repair to a disallowed tool should fail");
1945
1946        match err {
1947            PromptError::UnknownToolCall { tool_name, .. } => {
1948                assert_eq!(tool_name, "subtract");
1949            }
1950            other => panic!("expected UnknownToolCall, got {other:?}"),
1951        }
1952        assert_eq!(recorded.request_count(), 1);
1953    }
1954
1955    #[tokio::test]
1956    async fn repair_under_tool_choice_none_fails() {
1957        let model = MockCompletionModel::new([
1958            MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1959            MockTurn::text("should not be requested"),
1960        ]);
1961        let recorded = model.clone();
1962        let agent = AgentBuilder::new(model)
1963            .tool(MockAddTool)
1964            .tool_choice(ToolChoice::None)
1965            .build();
1966
1967        let err = agent
1968            .prompt("do not use tools")
1969            .add_hook(RepairDefaultApiHook)
1970            .max_turns(3)
1971            .await
1972            .expect_err("ToolChoice::None should reject repaired tool calls");
1973
1974        match err {
1975            PromptError::UnknownToolCall { tool_name, .. } => {
1976                assert_eq!(tool_name, "add");
1977            }
1978            other => panic!("expected UnknownToolCall, got {other:?}"),
1979        }
1980        assert_eq!(recorded.request_count(), 1);
1981    }
1982
1983    #[tokio::test]
1984    async fn skip_under_tool_choice_none_fails() {
1985        let model = MockCompletionModel::new([
1986            MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1987            MockTurn::text("should not be requested"),
1988        ]);
1989        let recorded = model.clone();
1990        let agent = AgentBuilder::new(model)
1991            .tool(MockAddTool)
1992            .tool_choice(ToolChoice::None)
1993            .build();
1994
1995        let err = agent
1996            .prompt("do not use tools")
1997            .add_hook(SkipDefaultApiHook)
1998            .max_turns(3)
1999            .await
2000            .expect_err("ToolChoice::None should reject skipped tool calls");
2001
2002        match err {
2003            PromptError::UnknownToolCall { tool_name, .. } => {
2004                assert_eq!(tool_name, "default_api");
2005            }
2006            other => panic!("expected UnknownToolCall, got {other:?}"),
2007        }
2008        assert_eq!(recorded.request_count(), 1);
2009    }
2010
2011    #[tokio::test]
2012    async fn typed_prompt_default_invalid_tool_call_fails_fast() {
2013        let model = MockCompletionModel::new([
2014            MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2015            MockTurn::text(r#"{"value":"should not be requested"}"#),
2016        ]);
2017        let recorded = model.clone();
2018        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2019
2020        let err = agent
2021            .prompt_typed::<TypedAnswer>("return typed json")
2022            .add_hook(PanicOnUnknownToolHook)
2023            .max_turns(3)
2024            .await
2025            .expect_err("typed prompt should preserve fail-fast default");
2026
2027        match err {
2028            StructuredOutputError::PromptError(err) => match *err {
2029                PromptError::UnknownToolCall { tool_name, .. } => {
2030                    assert_eq!(tool_name, "default_api");
2031                }
2032                other => panic!("expected UnknownToolCall, got {other:?}"),
2033            },
2034            other => panic!("expected prompt error, got {other:?}"),
2035        }
2036        assert_eq!(recorded.request_count(), 1);
2037    }
2038
2039    #[tokio::test]
2040    async fn typed_prompt_invalid_tool_call_hook_can_repair_tool_name() {
2041        let model = MockCompletionModel::new([
2042            MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2043            MockTurn::text(r#"{"value":"repaired"}"#),
2044        ]);
2045        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2046
2047        let response = agent
2048            .prompt_typed::<TypedAnswer>("return typed json")
2049            .add_hook(RepairDefaultApiHook)
2050            .max_turns(3)
2051            .await
2052            .expect("typed prompt should repair invalid tool call");
2053
2054        assert_eq!(
2055            response,
2056            TypedAnswer {
2057                value: "repaired".to_string()
2058            }
2059        );
2060    }
2061
2062    #[tokio::test]
2063    async fn typed_prompt_invalid_tool_call_hook_can_retry_and_parse_response() {
2064        let model = MockCompletionModel::new([
2065            MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2066            MockTurn::text(r#"{"value":"retried"}"#),
2067        ]);
2068        let recorded = model.clone();
2069        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2070
2071        let response = agent
2072            .prompt_typed::<TypedAnswer>("return typed json")
2073            .add_hook(RetryDefaultApiHook)
2074            .max_invalid_tool_call_retries(1)
2075            .max_turns(3)
2076            .await
2077            .expect("typed prompt should retry invalid tool call");
2078
2079        assert_eq!(
2080            response,
2081            TypedAnswer {
2082                value: "retried".to_string()
2083            }
2084        );
2085        assert_eq!(recorded.request_count(), 2);
2086    }
2087
2088    #[tokio::test]
2089    async fn typed_prompt_invalid_tool_call_retry_budget_exhaustion_fails() {
2090        let model = MockCompletionModel::new([
2091            MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2092            MockTurn::text(r#"{"value":"should not be requested"}"#),
2093        ]);
2094        let recorded = model.clone();
2095        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2096
2097        let err = agent
2098            .prompt_typed::<TypedAnswer>("return typed json")
2099            .add_hook(RetryDefaultApiHook)
2100            .max_invalid_tool_call_retries(0)
2101            .max_turns(3)
2102            .await
2103            .expect_err("typed prompt should fail when retry budget is exhausted");
2104
2105        match err {
2106            StructuredOutputError::PromptError(err) => match *err {
2107                PromptError::UnknownToolCall { tool_name, .. } => {
2108                    assert_eq!(tool_name, "default_api");
2109                }
2110                other => panic!("expected UnknownToolCall, got {other:?}"),
2111            },
2112            other => panic!("expected prompt error, got {other:?}"),
2113        }
2114        assert_eq!(recorded.request_count(), 1);
2115    }
2116
2117    #[tokio::test]
2118    async fn invalid_specific_tool_choice_fails_before_non_streaming_provider_request() {
2119        let model = MockCompletionModel::text("should not be requested");
2120        let recorded = model.clone();
2121        let agent = AgentBuilder::new(model)
2122            .tool(MockAddTool)
2123            .tool_choice(ToolChoice::Specific {
2124                function_names: vec!["missing".to_string()],
2125            })
2126            .build();
2127
2128        let err = agent
2129            .prompt("use the missing tool")
2130            .await
2131            .expect_err("invalid ToolChoice::Specific should fail before provider request");
2132
2133        match err {
2134            PromptError::CompletionError(CompletionError::RequestError(err)) => {
2135                let msg = err.to_string();
2136                assert!(msg.contains("missing"), "got: {msg}");
2137                assert!(msg.contains("add"), "got: {msg}");
2138            }
2139            other => panic!("expected CompletionError::RequestError, got {other:?}"),
2140        }
2141        assert_eq!(recorded.request_count(), 0);
2142    }
2143
2144    #[tokio::test]
2145    async fn allowed_specific_tool_call_executes_normally() {
2146        let model = MockCompletionModel::new([
2147            MockTurn::tool_call("tool_call_1", "add", json!({"x": 1, "y": 2})),
2148            MockTurn::text("done"),
2149        ]);
2150        let recorded = model.clone();
2151        let agent = AgentBuilder::new(model)
2152            .tool(MockAddTool)
2153            .tool_choice(ToolChoice::Specific {
2154                function_names: vec!["add".to_string()],
2155            })
2156            .build();
2157
2158        let response = agent
2159            .prompt("use the allowed tool")
2160            .max_turns(3)
2161            .await
2162            .expect("allowed specific tool should execute");
2163
2164        assert_eq!(response, "done");
2165        assert_eq!(recorded.request_count(), 2);
2166    }
2167
2168    #[tokio::test]
2169    async fn prompt_request_stops_cleanly_on_empty_terminal_turn() {
2170        let first_call_usage = Usage {
2171            input_tokens: 1,
2172            output_tokens: 1,
2173            total_tokens: 2,
2174            cached_input_tokens: 0,
2175            cache_creation_input_tokens: 0,
2176            tool_use_prompt_tokens: 0,
2177            reasoning_tokens: 0,
2178        };
2179        let second_call_usage = Usage {
2180            input_tokens: 1,
2181            output_tokens: 1,
2182            total_tokens: 2,
2183            cached_input_tokens: 0,
2184            cache_creation_input_tokens: 0,
2185            tool_use_prompt_tokens: 0,
2186            reasoning_tokens: 0,
2187        };
2188        let model = MockCompletionModel::new([
2189            MockTurn::tool_call("tool_call_1", "add", json!({"x": 1, "y": 2}))
2190                .with_call_id("call_1")
2191                .with_usage(first_call_usage),
2192            MockTurn::text("").with_usage(second_call_usage),
2193        ]);
2194        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2195
2196        let response = agent
2197            .prompt("do tool work")
2198            .max_turns(3)
2199            .extended_details()
2200            .await
2201            .expect("empty terminal turn should not error");
2202
2203        assert!(response.output.is_empty());
2204        assert_eq!(
2205            response.usage,
2206            Usage {
2207                input_tokens: 2,
2208                output_tokens: 2,
2209                total_tokens: 4,
2210                cached_input_tokens: 0,
2211                cache_creation_input_tokens: 0,
2212                tool_use_prompt_tokens: 0,
2213                reasoning_tokens: 0,
2214            }
2215        );
2216        assert_eq!(
2217            response.completion_calls(),
2218            &[
2219                CompletionCall::new(0, first_call_usage),
2220                CompletionCall::new(1, second_call_usage)
2221            ]
2222        );
2223
2224        let history = response
2225            .messages
2226            .expect("extended response should include history");
2227        assert_eq!(history.len(), 3);
2228        assert!(matches!(
2229            history.first(),
2230            Some(Message::User { content })
2231                if matches!(
2232                    content.first(),
2233                    UserContent::Text(text) if text.text == "do tool work"
2234                )
2235        ));
2236        assert!(history.iter().any(|message| matches!(
2237            message,
2238            Message::Assistant { content, .. }
2239                if matches!(
2240                    content.first(),
2241                    AssistantContent::ToolCall(tool_call)
2242                        if tool_call.id == "tool_call_1"
2243                            && tool_call.call_id.as_deref() == Some("call_1")
2244                )
2245        )));
2246        assert!(history.iter().any(|message| matches!(
2247            message,
2248            Message::User { content }
2249                if matches!(
2250                    content.first(),
2251                    UserContent::ToolResult(tool_result)
2252                        if tool_result.id == "tool_call_1"
2253                            && tool_result.call_id.as_deref() == Some("call_1")
2254                )
2255        )));
2256        assert!(!history.iter().any(|message| matches!(
2257            message,
2258            Message::Assistant { content, .. }
2259                if content.iter().any(|item| matches!(
2260                    item,
2261                    AssistantContent::Text(text) if text.text.is_empty()
2262                ))
2263        )));
2264        let requests = agent.model.requests();
2265        assert_eq!(requests.len(), 2);
2266        validate_follow_up_tool_history(&requests[1]);
2267    }
2268
2269    #[tokio::test]
2270    async fn prompt_request_concatenates_text_blocks_without_inserted_newlines() {
2271        let model = MockCompletionModel::new([MockTurn::from_contents([
2272            AssistantContent::Text(Text::new("According to the document, ")),
2273            AssistantContent::Text(Text::new("the grass is green")),
2274            AssistantContent::Text(Text::new(" and the sky is blue.")),
2275        ])
2276        .expect("mock response should contain text blocks")]);
2277        let agent = AgentBuilder::new(model).build();
2278
2279        let response = agent
2280            .prompt("answer with cited spans")
2281            .await
2282            .expect("prompt should succeed");
2283
2284        assert_eq!(
2285            response,
2286            "According to the document, the grass is green and the sky is blue."
2287        );
2288    }
2289
2290    #[tokio::test]
2291    async fn prompt_request_preserves_metadata_only_text_turn_in_history() {
2292        let metadata = json!({
2293            "citations": [{
2294                "type": "web_search_result_location",
2295                "cited_text": "Claude Shannon was born in 1916.",
2296                "url": "https://example.com/shannon",
2297                "title": null,
2298                "encrypted_index": "encrypted-reference"
2299            }]
2300        });
2301        let model =
2302            MockCompletionModel::new([MockTurn::from_content(AssistantContent::Text(Text {
2303                text: String::new(),
2304                additional_params: Some(metadata.clone()),
2305            }))]);
2306        let agent = AgentBuilder::new(model).build();
2307
2308        let response = agent
2309            .prompt("answer with cited metadata")
2310            .extended_details()
2311            .await
2312            .expect("metadata-only text turn should succeed");
2313
2314        assert!(response.output.is_empty());
2315        let history = response
2316            .messages
2317            .expect("extended response should include history");
2318        assert!(history.iter().any(|message| matches!(
2319            message,
2320            Message::Assistant { content, .. }
2321                if matches!(
2322                    content.first(),
2323                    AssistantContent::Text(text)
2324                        if text.text.is_empty()
2325                            && text.additional_params.as_ref() == Some(&metadata)
2326                )
2327        )));
2328    }
2329
2330    // ----- Conversation memory integration tests -----
2331
2332    use crate::memory::{ConversationMemory, InMemoryConversationMemory};
2333
2334    #[tokio::test]
2335    async fn memory_loads_into_request_history() {
2336        let memory = InMemoryConversationMemory::new();
2337        memory
2338            .append(
2339                "thread-1",
2340                vec![Message::user("hello"), Message::assistant("hi there")],
2341            )
2342            .await
2343            .unwrap();
2344
2345        let model = MockCompletionModel::text("ack");
2346        let recorded = model.clone();
2347
2348        let agent = AgentBuilder::new(model).memory(memory).build();
2349        let _ = agent
2350            .prompt("ping")
2351            .conversation("thread-1")
2352            .await
2353            .expect("prompt should succeed");
2354
2355        let received = recorded.requests()[0]
2356            .chat_history
2357            .iter()
2358            .cloned()
2359            .collect::<Vec<_>>();
2360        assert_eq!(
2361            received.len(),
2362            3,
2363            "loaded memory (2) + current prompt should appear in request: {received:?}"
2364        );
2365    }
2366
2367    #[tokio::test]
2368    async fn memory_appends_full_turn_after_success() {
2369        let memory = InMemoryConversationMemory::new();
2370        let model = MockCompletionModel::text("ack");
2371        let agent = AgentBuilder::new(model).memory(memory.clone()).build();
2372
2373        let _ = agent
2374            .prompt("hello")
2375            .conversation("t1")
2376            .await
2377            .expect("prompt should succeed");
2378
2379        let stored = memory.load("t1").await.unwrap();
2380        assert_eq!(stored.len(), 2, "user prompt + assistant response saved");
2381    }
2382
2383    #[tokio::test]
2384    async fn explicit_with_history_overrides_memory() {
2385        let memory = CountingMemory::default();
2386        memory
2387            .inner()
2388            .append("t1", vec![Message::user("from-memory")])
2389            .await
2390            .unwrap();
2391
2392        let model = MockCompletionModel::text("ack");
2393        let recorded = model.clone();
2394
2395        let agent = AgentBuilder::new(model).memory(memory.clone()).build();
2396        let _ = agent
2397            .prompt("hello")
2398            .conversation("t1")
2399            .history(vec![Message::user("from-caller")])
2400            .await
2401            .expect("prompt should succeed");
2402
2403        assert_eq!(memory.load_count(), 0, "load skipped");
2404        let appends = memory.append_count();
2405        assert_eq!(appends, 0, "append skipped");
2406
2407        let received = recorded.requests()[0]
2408            .chat_history
2409            .iter()
2410            .cloned()
2411            .collect::<Vec<_>>();
2412        assert_eq!(received.len(), 2, "caller history (1) + current prompt");
2413        assert!(matches!(
2414            received.first(),
2415            Some(Message::User { content })
2416                if matches!(content.first(), UserContent::Text(t) if t.text == "from-caller")
2417        ));
2418    }
2419
2420    #[tokio::test]
2421    async fn memory_unchanged_on_provider_error() {
2422        let memory = InMemoryConversationMemory::new();
2423        let model = MockCompletionModel::new([MockTurn::error("boom")]);
2424
2425        let agent = AgentBuilder::new(model).memory(memory.clone()).build();
2426        let result = agent.prompt("hello").conversation("t1").await;
2427        assert!(result.is_err());
2428
2429        let stored = memory.load("t1").await.unwrap();
2430        assert!(stored.is_empty(), "no append on error");
2431    }
2432
2433    #[tokio::test]
2434    async fn missing_conversation_id_behaves_as_no_memory() {
2435        let memory = CountingMemory::default();
2436        let model = MockCompletionModel::text("ack");
2437        let agent = AgentBuilder::new(model).memory(memory.clone()).build();
2438
2439        let _ = agent.prompt("hello").await.expect("prompt should succeed");
2440
2441        assert_eq!(memory.load_count(), 0);
2442        assert_eq!(memory.append_count(), 0);
2443    }
2444
2445    #[tokio::test]
2446    async fn default_conversation_id_is_used_when_none_per_request() {
2447        let memory = InMemoryConversationMemory::new();
2448        let model = MockCompletionModel::text("ack");
2449        let agent = AgentBuilder::new(model)
2450            .memory(memory.clone())
2451            .conversation("default-thread")
2452            .build();
2453
2454        let _ = agent.prompt("hello").await.expect("prompt should succeed");
2455        let stored = memory.load("default-thread").await.unwrap();
2456        assert_eq!(stored.len(), 2);
2457    }
2458
2459    #[tokio::test]
2460    async fn with_filter_truncates_loaded_history() {
2461        let memory = InMemoryConversationMemory::new()
2462            .with_filter(|msgs: Vec<Message>| msgs.into_iter().rev().take(2).rev().collect());
2463        memory
2464            .append(
2465                "t1",
2466                vec![
2467                    Message::user("1"),
2468                    Message::assistant("2"),
2469                    Message::user("3"),
2470                    Message::assistant("4"),
2471                ],
2472            )
2473            .await
2474            .unwrap();
2475
2476        let model = MockCompletionModel::text("ack");
2477        let recorded = model.clone();
2478        let agent = AgentBuilder::new(model).memory(memory).build();
2479
2480        let _ = agent
2481            .prompt("ping")
2482            .conversation("t1")
2483            .await
2484            .expect("prompt should succeed");
2485
2486        let received = recorded.requests()[0]
2487            .chat_history
2488            .iter()
2489            .cloned()
2490            .collect::<Vec<_>>();
2491        assert_eq!(
2492            received.len(),
2493            3,
2494            "window-truncated history (2) + current prompt"
2495        );
2496    }
2497
2498    #[tokio::test]
2499    async fn without_memory_disables_for_request() {
2500        let memory = CountingMemory::default();
2501        let model = MockCompletionModel::text("ack");
2502        let agent = AgentBuilder::new(model)
2503            .memory(memory.clone())
2504            .conversation("t1")
2505            .build();
2506
2507        let _ = agent
2508            .prompt("hello")
2509            .without_memory()
2510            .await
2511            .expect("prompt should succeed");
2512
2513        assert_eq!(memory.load_count(), 0);
2514        assert_eq!(memory.append_count(), 0);
2515    }
2516
2517    #[tokio::test]
2518    async fn memory_load_error_surfaces_as_prompt_error() {
2519        let model = MockCompletionModel::text("ack");
2520        let agent = AgentBuilder::new(model)
2521            .memory(FailingMemory::default())
2522            .build();
2523        let result = agent.prompt("hello").conversation("t1").await;
2524
2525        match result {
2526            Err(PromptError::CompletionError(CompletionError::RequestError(err))) => {
2527                let msg = format!("{err}");
2528                assert!(msg.contains("load boom"), "got: {msg}");
2529            }
2530            other => panic!("expected PromptError::CompletionError(RequestError), got {other:?}"),
2531        }
2532    }
2533
2534    #[tokio::test]
2535    async fn memory_append_error_does_not_drop_response() {
2536        let model = MockCompletionModel::text("ack");
2537        let agent = AgentBuilder::new(model)
2538            .memory(AppendFailingMemory::default())
2539            .build();
2540        let response: String = agent
2541            .prompt("hello")
2542            .conversation("t1")
2543            .await
2544            .expect("append failure must not block successful completion");
2545
2546        assert!(!response.is_empty());
2547    }
2548}