Skip to main content

rig_agent/agent/prompt_request/
mod.rs

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