Skip to main content

rig_agent/agent/
completion.rs

1use super::hook::{HookStack, RequestPatch};
2use super::prompt_request::{self, PromptRequest};
3use super::run::OutputMode;
4use super::runner::AgentRunner;
5use crate::{
6    agent::prompt_request::streaming::StreamingPromptRequest,
7    completion::{
8        Chat, CompletionError, CompletionModel, CompletionRequestBuilder, Document, GetTokenUsage,
9        Message, Prompt, PromptError, ToolDefinition, TypedPrompt,
10    },
11    json_utils,
12    streaming::{StreamingChat, StreamingPrompt},
13    tool::server::{ToolRegistrySnapshot, ToolServerError, ToolServerHandle},
14};
15use rig_core::{message::ToolChoice, wasm_compat::WasmCompatSend};
16use std::{collections::BTreeSet, sync::Arc};
17
18use super::UNKNOWN_AGENT_NAME;
19
20/// A prepared completion request plus the executable Rig tool names advertised
21/// to the provider for this turn.
22pub(crate) struct PreparedCompletionRequest<M: CompletionModel> {
23    pub(crate) builder: CompletionRequestBuilder<M>,
24    /// Exact implementations behind this turn's provider definitions.
25    pub(crate) tool_snapshot: Arc<ToolRegistrySnapshot>,
26    pub(crate) executable_tool_names: BTreeSet<String>,
27    pub(crate) allowed_tool_names: BTreeSet<String>,
28    /// When Tool output mode is active, the name of the synthetic output tool
29    /// advertised to the model (allowed but not executable). See #1928.
30    pub(crate) output_tool_name: Option<String>,
31}
32
33/// Base name of the synthetic output tool used by [`OutputMode::Tool`].
34const DEFAULT_OUTPUT_TOOL_NAME: &str = "final_result";
35
36/// Whether the active [`ToolChoice`] lets the model call the synthetic output
37/// tool. Tool output mode finalizes via that call, so when the choice forbids it
38/// (`None`, or a `Specific` allow-list that lists only the caller's real tools)
39/// Tool mode cannot work and must fall back to native structured output.
40fn tool_choice_permits_output_tool(tool_choice: Option<&ToolChoice>) -> bool {
41    matches!(
42        tool_choice,
43        None | Some(ToolChoice::Auto | ToolChoice::Required)
44    )
45}
46
47/// Whether the active [`ToolChoice`] can call the *named* synthetic output tool.
48///
49/// Unlike [`tool_choice_permits_output_tool`] — which runs during output-mode
50/// resolution, before the output-tool name is known, and so conservatively
51/// treats every `Specific` set as forbidding the call — this knows the committed
52/// output-tool name, so a `Specific` set that names it counts as callable. That
53/// matches [`allowed_tool_names_for_choice`], which advertises the output tool
54/// for exactly that choice. Only a `None` choice or a `Specific` set that omits
55/// the output tool genuinely cannot finalize a pinned Tool-mode turn.
56fn output_tool_callable(tool_choice: Option<&ToolChoice>, output_tool_name: &str) -> bool {
57    match tool_choice {
58        Some(ToolChoice::Specific { function_names }) => function_names
59            .iter()
60            .any(|name| name.as_str() == output_tool_name),
61        other => tool_choice_permits_output_tool(other),
62    }
63}
64
65/// Resolve the caller-facing [`OutputMode`] to a concrete mode for one request.
66///
67/// With no schema there is nothing to enforce, so the result is always `Native`
68/// (the synthetic tool and prompt injection only make sense with a schema).
69/// `Auto` becomes `Tool` only when a real executable tool is present, the tool
70/// choice permits the output-tool call, AND the provider does *not* compose
71/// native structured output with tools — i.e. only where the native constraint
72/// would actually suppress tool calls (#1928). On providers that compose them
73/// (OpenAI, Anthropic), `Auto` keeps guaranteed native structured output.
74/// `Tool` (explicit or via `Auto`) requires that the active [`ToolChoice`]
75/// permit the output-tool call; when it does not, it degrades to `Native` so
76/// structured output is still enforced rather than silently dropped. Explicit
77/// `Prompted`/`Native` are honored when a schema is present. The returned mode is
78/// never `Auto`.
79fn resolve_output_mode(
80    has_schema: bool,
81    has_executable_tools: bool,
82    output_tool_callable: bool,
83    provider_composes_native: bool,
84    requested: &OutputMode,
85) -> OutputMode {
86    if !has_schema {
87        return OutputMode::Native;
88    }
89    match requested {
90        OutputMode::Native => OutputMode::Native,
91        OutputMode::Prompted => OutputMode::Prompted,
92        OutputMode::Tool if output_tool_callable => OutputMode::Tool,
93        OutputMode::Tool => OutputMode::Native,
94        OutputMode::Auto
95            if has_executable_tools && output_tool_callable && !provider_composes_native =>
96        {
97            OutputMode::Tool
98        }
99        OutputMode::Auto => OutputMode::Native,
100    }
101}
102
103/// Pick a collision-safe name for the synthetic output tool, never shadowing a
104/// real executable tool (which would make the model's output call dispatchable).
105fn pick_output_tool_name(executable_tool_names: &BTreeSet<String>) -> String {
106    let mut name = DEFAULT_OUTPUT_TOOL_NAME.to_string();
107    let mut suffix = 1u32;
108    while executable_tool_names.contains(&name) {
109        name = format!("{DEFAULT_OUTPUT_TOOL_NAME}_{suffix}");
110        suffix += 1;
111    }
112    name
113}
114
115/// Compute the allowed tool names for a `tool_choice` **and** validate the
116/// effective request locally (no provider round-trip).
117///
118/// The effective advertised tool set for a turn is the executable tools (after
119/// any per-turn `active_tools` filtering) plus the synthetic output tool
120/// (`output_tool_name`) when structured output runs in Tool mode. Validation:
121///
122/// - [`ToolChoice::Required`] with **no** advertised tool (no executable tool and
123///   no output tool) is a local error — the model is forced to call a tool but
124///   none is advertised.
125/// - [`ToolChoice::Specific`] must name only advertised tools (executable tools
126///   or the output tool); an empty specific set is also an error.
127///
128/// `pre_filter_tool_names` is the full executable tool set *before* any per-turn
129/// `active_tools` filtering — `Some` only when an `active_tools` allow-list was
130/// applied. When the incompatibility was actually **caused** by that filter (a
131/// tool that would otherwise satisfy the choice was dropped), the error says so
132/// and suggests setting a compatible `tool_choice` in the same `RequestPatch`.
133/// A plain typo naming a tool that never existed is *not* blamed on the filter.
134pub(crate) fn allowed_tool_names_for_choice(
135    executable_tool_names: &BTreeSet<String>,
136    tool_choice: Option<&ToolChoice>,
137    output_tool_name: Option<&str>,
138    pre_filter_tool_names: Option<&BTreeSet<String>>,
139) -> Result<BTreeSet<String>, CompletionError> {
140    let has_advertised_tool = !executable_tool_names.is_empty() || output_tool_name.is_some();
141    let hint = |active_tools_caused: bool| {
142        if active_tools_caused {
143            " A per-turn `active_tools` allow-list narrowed the advertised tools this turn; \
144             set a compatible `tool_choice` in the same `RequestPatch`, or widen `active_tools`."
145        } else {
146            ""
147        }
148    };
149    // The advertised tools the model may call: executable tools + the output tool.
150    let advertised = || {
151        executable_tool_names
152            .iter()
153            .map(String::as_str)
154            .chain(output_tool_name)
155            .collect::<Vec<_>>()
156    };
157
158    let allowed = match tool_choice {
159        None | Some(ToolChoice::Auto) => executable_tool_names.clone(),
160        Some(ToolChoice::Required) => {
161            if !has_advertised_tool {
162                // The filter caused this only if there *were* tools before it ran.
163                let active_tools_caused = pre_filter_tool_names.is_some_and(|pf| !pf.is_empty());
164                return Err(CompletionError::RequestError(
165                    format!(
166                        "ToolChoice::Required forces the model to call a tool, but no tools are \
167                         advertised this turn.{}",
168                        hint(active_tools_caused)
169                    )
170                    .into(),
171                ));
172            }
173            executable_tool_names.clone()
174        }
175        Some(ToolChoice::None) => BTreeSet::new(),
176        Some(ToolChoice::Specific { function_names }) => {
177            if function_names.is_empty() {
178                return Err(CompletionError::RequestError(
179                    "ToolChoice::Specific requires at least one function name".into(),
180                ));
181            }
182
183            let requested = function_names.iter().cloned().collect::<BTreeSet<String>>();
184            let missing = function_names
185                .iter()
186                .map(String::as_str)
187                .filter(|name| {
188                    !executable_tool_names.contains(*name) && Some(*name) != output_tool_name
189                })
190                .collect::<Vec<_>>();
191
192            if !missing.is_empty() {
193                // The filter caused this only if a missing name existed pre-filter
194                // (i.e. `active_tools` dropped it) — not for a plain typo.
195                let active_tools_caused = pre_filter_tool_names
196                    .is_some_and(|pf| missing.iter().any(|name| pf.contains(*name)));
197                return Err(CompletionError::RequestError(
198                    format!(
199                        "ToolChoice::Specific requested tool names not advertised this turn: \
200                         {missing:?}. Advertised: {:?}.{}",
201                        advertised(),
202                        hint(active_tools_caused)
203                    )
204                    .into(),
205                ));
206            }
207
208            requested
209        }
210    };
211
212    Ok(allowed)
213}
214
215/// Helper function to build a completion request from agent components while
216/// preserving the executable Rig tool names sent to the provider.
217#[allow(clippy::too_many_arguments)]
218pub(crate) async fn build_prepared_completion_request<M: CompletionModel>(
219    model: &Arc<M>,
220    prompt: Message,
221    chat_history: &[Message],
222    preamble: Option<&str>,
223    static_context: &[Document],
224    temperature: Option<f64>,
225    max_tokens: Option<u64>,
226    additional_params: Option<&serde_json::Value>,
227    record_telemetry_content: bool,
228    tool_choice: Option<&ToolChoice>,
229    tool_server_handle: &ToolServerHandle,
230    output_schema: Option<&schemars::Schema>,
231    output_mode: &OutputMode,
232    committed_output_tool: Option<&str>,
233    output_tool_description: Option<&str>,
234    augment_output_preamble: bool,
235    request_patch: Option<&RequestPatch>,
236) -> Result<PreparedCompletionRequest<M>, CompletionError> {
237    // Apply a per-turn request patch (the merged patch from every `CompletionCall`
238    // hook): each set field replaces the agent's configured value for this turn,
239    // unset fields inherit it, `additional_params` is shallow-merged, and
240    // `extra_context`/`history` are applied below. This is per-turn only — it
241    // never mutates the agent's baseline.
242    let preamble = request_patch
243        .and_then(|o| o.preamble.as_deref())
244        .or(preamble);
245    let temperature = request_patch.and_then(|o| o.temperature).or(temperature);
246    let max_tokens = request_patch.and_then(|o| o.max_tokens).or(max_tokens);
247    let tool_choice = request_patch
248        .and_then(|o| o.tool_choice.as_ref())
249        .or(tool_choice);
250    // Provider passthrough params: when both the baseline and the override are
251    // JSON objects, shallow-merge them (top-level keys, the override winning);
252    // otherwise the override value wins wholesale when set, else the baseline.
253    // This keeps the override winning consistently instead of silently dropping a
254    // non-object patch — `json_utils::merge` returns its first argument unchanged
255    // when either side isn't an object.
256    let additional_params: Option<serde_json::Value> = match (
257        additional_params,
258        request_patch.and_then(|o| o.additional_params.as_ref()),
259    ) {
260        (Some(base), Some(patch)) if base.is_object() && patch.is_object() => {
261            Some(json_utils::merge(base.clone(), patch.clone()))
262        }
263        (base, patch) => patch.or(base).cloned(),
264    };
265    let active_tools = request_patch.and_then(|o| o.active_tools.as_deref());
266
267    // Retrieved tools keep their existing query-selection behavior: prefer the
268    // current prompt's RAG text, then the latest matching history message.
269    let retrieval_query = prompt.rag_text().or_else(|| {
270        chat_history
271            .iter()
272            .rev()
273            .find_map(|message| message.rag_text())
274    });
275
276    let mut tool_snapshot = tool_server_handle
277        .snapshot_tool_defs(retrieval_query)
278        .await
279        .map_err(|_| CompletionError::RequestError("Failed to get tool definitions".into()))?;
280
281    // When a per-turn `active_tools` allow-list is present, capture the full tool
282    // set BEFORE filtering: the synthetic output-tool name must avoid colliding
283    // with ANY advertised tool, not just this turn's narrowed set — a tool
284    // filtered out this turn can be advertised again on a later turn, while the
285    // output-tool name is pinned for the whole run, so picking against only the
286    // narrowed set could commit a name that collides once the filter lifts.
287    // Without a filter the full set equals `executable_tool_names` below, so we
288    // skip the extra allocation and reuse that.
289    let pre_filter_tool_names: Option<BTreeSet<String>> = active_tools.map(|_| {
290        tool_snapshot
291            .definitions()
292            .iter()
293            .map(|tool| tool.name.clone())
294            .collect()
295    });
296
297    // Apply a per-turn `active_tools` allow-list (from a `CompletionCall` hook):
298    // narrow the advertised tool set to the named tools BEFORE computing the
299    // executable set, so tool-choice resolution and invalid-tool-call validation
300    // all operate on the narrowed set. The synthetic output tool is appended
301    // later and is unaffected, so structured output still works under an empty
302    // allow-list. A name that isn't available this turn is a hook bug, surfaced
303    // as a request error (mirroring `ToolChoice::Specific`'s contract).
304    if let Some(allow) = active_tools {
305        if let Some(missing) = allow.iter().find(|name| {
306            !tool_snapshot
307                .definitions()
308                .iter()
309                .any(|tool| &tool.name == *name)
310        }) {
311            return Err(CompletionError::RequestError(
312                format!(
313                    "active_tools requested tool `{missing}`, which is not available this turn"
314                )
315                .into(),
316            ));
317        }
318        let allowed: BTreeSet<String> = allow.iter().cloned().collect();
319        tool_snapshot.retain_names(&allowed);
320    }
321
322    let mut tooldefs = tool_snapshot.definitions().to_vec();
323
324    // Executable tools are the real tool-server tools, computed BEFORE any
325    // synthetic output tool is appended.
326    let executable_tool_names: BTreeSet<String> =
327        tooldefs.iter().map(|tool| tool.name.clone()).collect();
328
329    // Resolve the effective output mode (#1928). Once the run has committed to a
330    // Tool-mode output tool on an earlier turn (signaled by `committed_output_
331    // tool`, which is persisted on the run via `output_tool_name`), stay in Tool
332    // mode and reuse that name — so a later turn whose tool set differs (e.g. RAG
333    // retrieved no tools) can't flip Tool -> Native and re-apply the native
334    // constraint that suppressed tools in the first place. Only Tool mode is
335    // pinned; Native/Prompted re-resolve, so a tool-less first turn can still
336    // become Tool once tools appear. Otherwise resolve from the request, the
337    // schema, the tool set, whether the tool choice permits the output-tool call,
338    // and whether the provider composes native structured output with tools.
339    let resolved_mode = if committed_output_tool.is_some() && output_schema.is_some() {
340        OutputMode::Tool
341    } else {
342        resolve_output_mode(
343            output_schema.is_some(),
344            !executable_tool_names.is_empty(),
345            tool_choice_permits_output_tool(tool_choice),
346            model.composes_native_output_with_tools(),
347            output_mode,
348        )
349    };
350
351    // In Tool mode, reuse the run's committed name or pick a collision-safe one
352    // against the full pre-filter set (or the executable set when unfiltered).
353    let output_tool_name = matches!(resolved_mode, OutputMode::Tool).then(|| {
354        committed_output_tool.map(str::to_owned).unwrap_or_else(|| {
355            pick_output_tool_name(
356                pre_filter_tool_names
357                    .as_ref()
358                    .unwrap_or(&executable_tool_names),
359            )
360        })
361    });
362
363    // A freshly picked name never collides, but a name pinned on turn 1 can if a
364    // real tool with that name becomes effective later (for example through a
365    // shared tool server, retrieval, or an MCP refresh). The output-tool
366    // intercept matches by name, so fail before provider I/O: advertising both
367    // definitions would make a call to the real tool finalize the run instead
368    // of reaching normal dispatch.
369    if let Some(name) = &output_tool_name
370        && executable_tool_names.contains(name)
371    {
372        return Err(CompletionError::RequestError(
373            format!(
374                "real tool `{name}` conflicts with the structured-output tool reserved for this \
375                 run; rename or remove the real tool, exclude it with `active_tools`, or make it \
376                 visible before starting a new run so Rig can reserve a different output-tool name"
377            )
378            .into(),
379        ));
380    }
381
382    // In committed Tool mode the run can only finalize by calling the synthetic
383    // output tool, and the mode is pinned (it cannot degrade to Native mid-run,
384    // see #1928). A `tool_choice` that forbids the output-tool call — `None`, or
385    // a `Specific` set that excludes it, e.g. from a per-turn `RequestPatch` —
386    // therefore produces a turn that cannot emit the structured result. The
387    // non-committed path degrades to Native via `resolve_output_mode`, so this
388    // only fires once a turn has committed Tool mode; warn rather than silently
389    // stall the run. Use the name-aware check so a `Specific` set that *names*
390    // the output tool (which `allowed_tool_names_for_choice` accepts) is not
391    // falsely flagged as unable to finalize.
392    if let Some(name) = &output_tool_name
393        && !output_tool_callable(tool_choice, name)
394    {
395        tracing::warn!(
396            "the active tool_choice forbids calling the structured-output tool while the \
397             run is pinned to Tool output mode; this turn cannot emit the structured \
398             result (check for a `RequestPatch` setting `tool_choice` to None or a \
399             Specific set that excludes the output tool)"
400        );
401    }
402
403    // Augment the preamble for Tool/Prompted modes, then prepend it as a system
404    // message (deferred from the original position so it can reference the tool).
405    let effective_preamble: Option<String> = {
406        let base = preamble.map(str::to_owned);
407        let instruction = match &resolved_mode {
408            OutputMode::Tool if augment_output_preamble => {
409                output_tool_name.as_deref().map(|name| {
410                    format!(
411                        "When you have gathered enough information to answer, call the `{name}` \
412                     tool exactly once with your final answer. Its arguments are the structured \
413                     result and must satisfy the required schema. Do not return the final answer \
414                     as plain text."
415                    )
416                })
417            }
418            OutputMode::Tool => None,
419            OutputMode::Prompted => output_schema.map(|schema| {
420                let schema_json = serde_json::to_string(schema.as_value()).unwrap_or_default();
421                format!(
422                    "Respond with ONLY a single JSON object that conforms to this JSON Schema. \
423                     Do not include any prose, explanation, or markdown code fences.\n{schema_json}"
424                )
425            }),
426            OutputMode::Native | OutputMode::Auto => None,
427        };
428        match (base, instruction) {
429            (Some(b), Some(i)) => Some(format!("{b}\n\n{i}")),
430            (Some(b), None) => Some(b),
431            (None, Some(i)) => Some(i),
432            (None, None) => None,
433        }
434    };
435
436    // A per-turn `history` patch replaces the prior messages sent to the provider
437    // *this turn only* (context-window compaction / summarization). The RAG query
438    // text above deliberately still derives from the original `chat_history`, so
439    // this changes only what is sent, never what is retrieved or persisted.
440    let messages_history: &[Message] = request_patch
441        .and_then(|o| o.history.as_deref())
442        .unwrap_or(chat_history);
443    let chat_history: Vec<Message> = if let Some(preamble) = &effective_preamble {
444        std::iter::once(Message::system(preamble.clone()))
445            .chain(messages_history.iter().cloned())
446            .collect()
447    } else {
448        messages_history.to_vec()
449    };
450
451    // In Tool mode, advertise the synthetic output tool to the provider (its name
452    // is added to `allowed_tool_names` below but never to `executable_tool_names`,
453    // so it is never dispatched to the tool server).
454    // `output_tool_name` is only `Some` when `output_schema` is `Some` (Tool mode
455    // requires a schema), so this match always fires in Tool mode.
456    if let (Some(name), Some(schema)) = (&output_tool_name, output_schema) {
457        tooldefs.push(crate::completion::ToolDefinition {
458            name: name.clone(),
459            description: output_tool_description
460                .unwrap_or(
461                    "Call this tool exactly once with your final answer when you are done. \
462                     Its arguments are the structured result and must satisfy the output schema.",
463                )
464                .to_string(),
465            parameters: schema.clone().to_value(),
466        });
467    }
468
469    let mut completion_request = model
470        .completion_request(prompt)
471        .messages(chat_history)
472        .temperature_opt(temperature)
473        .max_tokens_opt(max_tokens)
474        .additional_params_opt(additional_params)
475        .record_content_telemetry(record_telemetry_content)
476        .documents(static_context.to_vec())
477        .tools(tooldefs);
478
479    // Hook-supplied extra context documents (passive RAG) follow static context,
480    // with extras in hook registration order (they were merged in that order).
481    // Per-turn and non-sticky: the next turn re-resolves from the baseline.
482    if let Some(patch) = request_patch
483        && !patch.extra_context.is_empty()
484    {
485        completion_request = completion_request.documents(patch.extra_context.clone());
486    }
487
488    // Only Native mode sets the provider's native structured-output constraint.
489    if matches!(resolved_mode, OutputMode::Native) {
490        completion_request = completion_request.output_schema_opt(output_schema.cloned());
491    }
492
493    let completion_request = if let Some(tool_choice) = tool_choice {
494        completion_request.tool_choice(tool_choice.clone())
495    } else {
496        completion_request
497    };
498
499    // Validate the effective request locally (Required/Specific vs the effective
500    // advertised tool set, incl. the output tool) *before* building the send —
501    // so an impossible tool_choice/tool-set combination fails here with no
502    // provider round-trip, and names the `active_tools` filter when it caused it.
503    let mut allowed_tool_names = allowed_tool_names_for_choice(
504        &executable_tool_names,
505        tool_choice,
506        output_tool_name.as_deref(),
507        pre_filter_tool_names.as_ref(),
508    )?;
509    // The output tool must be allowed (so it isn't flagged as an invalid tool
510    // call) even though it is not executable.
511    if let Some(name) = &output_tool_name {
512        allowed_tool_names.insert(name.clone());
513    }
514
515    Ok(PreparedCompletionRequest {
516        builder: completion_request,
517        tool_snapshot: Arc::new(tool_snapshot),
518        executable_tool_names,
519        allowed_tool_names,
520        output_tool_name,
521    })
522}
523
524/// Struct representing an LLM agent. An agent is an LLM model combined with a preamble
525/// (i.e.: system prompt) and a static set of context documents and tools.
526/// All context documents and tools are always provided to the agent when prompted.
527///
528/// Default hooks attached with [`AgentBuilder::add_hook`](crate::agent::AgentBuilder::add_hook)
529/// are used for every prompt request, plus any added on the request or runner.
530///
531/// # Example
532/// ```no_run
533/// use rig_agent::prelude::*;
534/// use rig_core::{client::ProviderClient, providers::openai};
535///
536/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
537/// let openai = openai::Client::from_env()?;
538///
539/// let comedian_agent = openai
540///     .agent(openai::GPT_5_2)
541///     .preamble("You are a comedian here to entertain the user using humour and jokes.")
542///     .temperature(0.9)
543///     .build();
544///
545/// let response = comedian_agent.prompt("Entertain me!").await?;
546/// # Ok(())
547/// # }
548/// ```
549#[derive(Clone)]
550#[non_exhaustive]
551pub struct Agent<M>
552where
553    M: CompletionModel,
554{
555    /// Name of the agent used for logging and debugging
556    pub(crate) name: Option<String>,
557    /// Agent description. Primarily useful when using sub-agents as part of an agent workflow and converting agents to other formats.
558    pub(crate) description: Option<String>,
559    /// Completion model (e.g.: OpenAI's gpt-3.5-turbo-1106, Cohere's command-r)
560    pub(crate) model: Arc<M>,
561    /// System prompt
562    pub(crate) preamble: Option<String>,
563    /// Context documents always available to the agent
564    pub(crate) static_context: Vec<Document>,
565    /// Temperature of the model
566    pub(crate) temperature: Option<f64>,
567    /// Maximum number of tokens for the completion
568    pub(crate) max_tokens: Option<u64>,
569    /// Additional parameters to be passed to the model
570    pub(crate) additional_params: Option<serde_json::Value>,
571    /// Whether to record sensitive request, response, and tool content on GenAI spans.
572    ///
573    /// Defaults to `false`. Enabling this can expose prompts, retrieved context,
574    /// tool results, model responses, and other sensitive or high-cardinality data
575    /// through OpenTelemetry span attributes, which can increase observability
576    /// backend storage and query costs.
577    pub(crate) record_telemetry_content: bool,
578    pub(crate) tool_server_handle: ToolServerHandle,
579    /// Whether or not the underlying LLM should be forced to use a tool before providing a response.
580    pub(crate) tool_choice: Option<ToolChoice>,
581    /// Default total model-call budget, including the initial call and every
582    /// retry or continuation. `None` uses the implicit budget of one.
583    pub(crate) default_max_turns: Option<usize>,
584    /// Default hook stack applied to every prompt request and runner created
585    /// from this agent. Empty by default.
586    pub(crate) hooks: HookStack,
587    /// Optional JSON Schema for structured output. When set, providers that support
588    /// native structured outputs will constrain the model's response to match this schema.
589    pub(crate) output_schema: Option<schemars::Schema>,
590    /// How `output_schema` is enforced — tool call, native structured output, or
591    /// prompt injection (see [`OutputMode`] and issue #1928).
592    pub(crate) output_mode: OutputMode,
593    /// Optional conversation memory backend that loads/saves history per conversation id.
594    pub(crate) memory: Option<Arc<dyn rig_core::memory::ConversationMemory>>,
595    /// Optional default conversation id used when none is set per-request.
596    pub(crate) default_conversation_id: Option<String>,
597}
598
599impl<M> Agent<M>
600where
601    M: CompletionModel,
602{
603    /// Returns the configured agent name.
604    pub fn name(&self) -> Option<&str> {
605        self.name.as_deref()
606    }
607
608    /// Returns the configured agent description.
609    pub fn description(&self) -> Option<&str> {
610        self.description.as_deref()
611    }
612
613    pub(crate) fn name_or_default(&self) -> &str {
614        self.name.as_deref().unwrap_or(UNKNOWN_AGENT_NAME)
615    }
616
617    /// Build a hook-aware [`AgentRunner`] for this agent, seeded with the
618    /// agent's default hook stack. Attach more hooks with
619    /// [`AgentRunner::add_hook`], then call [`AgentRunner::run`].
620    pub fn runner(&self, prompt: impl Into<Message>) -> AgentRunner<M> {
621        AgentRunner::from_agent(self, prompt)
622    }
623
624    /// Resolve the provider-facing tool definitions available for a prompt.
625    ///
626    /// This read-only view does not expose tool dispatch. Agent execution and
627    /// tool lifecycle hooks remain owned by [`Self::runner`].
628    pub async fn tool_definitions(
629        &self,
630        prompt: Option<String>,
631    ) -> Result<Vec<ToolDefinition>, ToolServerError> {
632        self.tool_server_handle.get_tool_defs(prompt).await
633    }
634}
635
636// Here, we need to ensure that usage of `.prompt` on agent uses these redefinitions on the opaque
637//  `Prompt` trait so that when `.prompt` is used at the call-site, it'll use the more specific
638//  `PromptRequest` implementation for `Agent`, making the builder's usage fluent.
639//
640// References:
641//  - https://github.com/rust-lang/rust/issues/121718 (refining_impl_trait)
642
643#[allow(refining_impl_trait)]
644impl<M> Prompt for Agent<M>
645where
646    M: CompletionModel + 'static,
647{
648    fn prompt(
649        &self,
650        prompt: impl Into<Message> + WasmCompatSend,
651    ) -> PromptRequest<prompt_request::Standard, M> {
652        PromptRequest::from_agent(self, prompt)
653    }
654}
655
656#[allow(refining_impl_trait)]
657impl<M> Prompt for &Agent<M>
658where
659    M: CompletionModel + 'static,
660{
661    #[tracing::instrument(skip(self, prompt), fields(agent_name = self.name_or_default()))]
662    fn prompt(
663        &self,
664        prompt: impl Into<Message> + WasmCompatSend,
665    ) -> PromptRequest<prompt_request::Standard, M> {
666        PromptRequest::from_agent(*self, prompt)
667    }
668}
669
670#[allow(refining_impl_trait)]
671impl<M> Chat for Agent<M>
672where
673    M: CompletionModel + 'static,
674{
675    #[tracing::instrument(skip(self, prompt, chat_history), fields(agent_name = self.name_or_default()))]
676    async fn chat(
677        &self,
678        prompt: impl Into<Message> + WasmCompatSend,
679        chat_history: &mut Vec<Message>,
680    ) -> Result<String, PromptError> {
681        let response = PromptRequest::from_agent(self, prompt)
682            .history(chat_history.clone())
683            .extended_details()
684            .await?;
685
686        if let Some(messages) = response.messages {
687            chat_history.extend(messages);
688        }
689
690        Ok(response.output)
691    }
692}
693
694impl<M> StreamingPrompt<M, M::StreamingResponse> for Agent<M>
695where
696    M: CompletionModel + 'static,
697    M::StreamingResponse: GetTokenUsage,
698{
699    fn stream_prompt(
700        &self,
701        prompt: impl Into<Message> + WasmCompatSend,
702    ) -> StreamingPromptRequest<M> {
703        StreamingPromptRequest::<M>::from_agent(self, prompt)
704    }
705}
706
707impl<M> StreamingChat<M, M::StreamingResponse> for Agent<M>
708where
709    M: CompletionModel + 'static,
710    M::StreamingResponse: GetTokenUsage,
711{
712    fn stream_chat<I, T>(
713        &self,
714        prompt: impl Into<Message> + WasmCompatSend,
715        chat_history: I,
716    ) -> StreamingPromptRequest<M>
717    where
718        I: IntoIterator<Item = T>,
719        T: Into<Message>,
720    {
721        StreamingPromptRequest::<M>::from_agent(self, prompt).history(chat_history)
722    }
723}
724
725use crate::agent::prompt_request::TypedPromptRequest;
726use schemars::JsonSchema;
727use serde::de::DeserializeOwned;
728
729#[allow(refining_impl_trait)]
730impl<M> TypedPrompt for Agent<M>
731where
732    M: CompletionModel + 'static,
733{
734    type TypedRequest<T>
735        = TypedPromptRequest<T, prompt_request::Standard, M>
736    where
737        T: JsonSchema + DeserializeOwned + WasmCompatSend + 'static;
738
739    /// Send a prompt and receive a typed structured response.
740    ///
741    /// The JSON schema for `T` is automatically generated and sent to the provider.
742    /// Providers that support native structured outputs will constrain the model's
743    /// response to match this schema.
744    ///
745    /// # Example
746    /// ```rust,ignore
747    /// use rig_core::prelude::*;
748    /// use schemars::JsonSchema;
749    /// use serde::Deserialize;
750    ///
751    /// #[derive(Debug, Deserialize, JsonSchema)]
752    /// struct WeatherForecast {
753    ///     city: String,
754    ///     temperature_f: f64,
755    ///     conditions: String,
756    /// }
757    ///
758    /// let agent = client.agent("gpt-4o").build();
759    ///
760    /// // Type inferred from variable
761    /// let forecast: WeatherForecast = agent
762    ///     .prompt_typed("What's the weather in NYC?")
763    ///     .await?;
764    ///
765    /// // Or explicit turbofish syntax
766    /// let forecast = agent
767    ///     .prompt_typed::<WeatherForecast>("What's the weather in NYC?")
768    ///     .max_turns(3)
769    ///     .await?;
770    /// ```
771    fn prompt_typed<T>(
772        &self,
773        prompt: impl Into<Message> + WasmCompatSend,
774    ) -> TypedPromptRequest<T, prompt_request::Standard, M>
775    where
776        T: JsonSchema + DeserializeOwned + WasmCompatSend,
777    {
778        TypedPromptRequest::from_agent(self, prompt)
779    }
780}
781
782#[allow(refining_impl_trait)]
783impl<M> TypedPrompt for &Agent<M>
784where
785    M: CompletionModel + 'static,
786{
787    type TypedRequest<T>
788        = TypedPromptRequest<T, prompt_request::Standard, M>
789    where
790        T: JsonSchema + DeserializeOwned + WasmCompatSend + 'static;
791
792    fn prompt_typed<T>(
793        &self,
794        prompt: impl Into<Message> + WasmCompatSend,
795    ) -> TypedPromptRequest<T, prompt_request::Standard, M>
796    where
797        T: JsonSchema + DeserializeOwned + WasmCompatSend,
798    {
799        TypedPromptRequest::from_agent(*self, prompt)
800    }
801}
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806
807    fn tool_names(names: &[&str]) -> BTreeSet<String> {
808        names.iter().map(|name| (*name).to_string()).collect()
809    }
810
811    #[test]
812    fn allowed_tool_names_defaults_to_all_executable_tools() {
813        let executable = tool_names(&["add", "subtract"]);
814
815        assert_eq!(
816            allowed_tool_names_for_choice(&executable, None, None, None).unwrap(),
817            executable
818        );
819    }
820
821    #[test]
822    fn allowed_tool_names_auto_and_required_allow_all_executable_tools() {
823        let executable = tool_names(&["add", "subtract"]);
824
825        assert_eq!(
826            allowed_tool_names_for_choice(&executable, Some(&ToolChoice::Auto), None, None)
827                .unwrap(),
828            executable
829        );
830        assert_eq!(
831            allowed_tool_names_for_choice(&executable, Some(&ToolChoice::Required), None, None)
832                .unwrap(),
833            executable
834        );
835    }
836
837    #[test]
838    fn allowed_tool_names_none_allows_no_tools() {
839        let executable = tool_names(&["add", "subtract"]);
840
841        assert!(
842            allowed_tool_names_for_choice(&executable, Some(&ToolChoice::None), None, None)
843                .unwrap()
844                .is_empty()
845        );
846    }
847
848    #[test]
849    fn allowed_tool_names_specific_allows_requested_executable_tools() {
850        let executable = tool_names(&["add", "subtract"]);
851        let choice = ToolChoice::Specific {
852            function_names: vec!["add".to_string()],
853        };
854
855        assert_eq!(
856            allowed_tool_names_for_choice(&executable, Some(&choice), None, None).unwrap(),
857            tool_names(&["add"])
858        );
859    }
860
861    #[test]
862    fn allowed_tool_names_specific_rejects_missing_tools() {
863        let executable = tool_names(&["add"]);
864        let choice = ToolChoice::Specific {
865            function_names: vec!["missing".to_string()],
866        };
867
868        let err = allowed_tool_names_for_choice(&executable, Some(&choice), None, None)
869            .expect_err("missing specific tool should fail before provider request");
870
871        assert!(matches!(
872            err,
873            CompletionError::RequestError(err)
874                if err.to_string().contains("missing")
875                    && err.to_string().contains("add")
876        ));
877    }
878
879    #[test]
880    fn allowed_tool_names_specific_rejects_empty_names() {
881        let executable = tool_names(&["add"]);
882        let choice = ToolChoice::Specific {
883            function_names: vec![],
884        };
885
886        let err = allowed_tool_names_for_choice(&executable, Some(&choice), None, None)
887            .expect_err("empty specific tool choice should fail before provider request");
888
889        assert!(matches!(
890            err,
891            CompletionError::RequestError(err)
892                if err.to_string().contains("requires at least one function name")
893        ));
894    }
895
896    #[test]
897    fn output_tool_callable_honors_specific_naming_the_output_tool() {
898        // Auto / Required / no explicit choice all permit the output-tool call.
899        assert!(output_tool_callable(None, "final_result"));
900        assert!(output_tool_callable(
901            Some(&ToolChoice::Auto),
902            "final_result"
903        ));
904        assert!(output_tool_callable(
905            Some(&ToolChoice::Required),
906            "final_result"
907        ));
908        // A `Specific` set that NAMES the output tool can call it — the case the
909        // pinned Tool-mode stall warning must not flag (it is accepted by
910        // `allowed_tool_names_for_choice`, which advertises the output tool).
911        assert!(output_tool_callable(
912            Some(&ToolChoice::Specific {
913                function_names: vec!["final_result".to_string()],
914            }),
915            "final_result",
916        ));
917        // A `Specific` set that omits it — or `ToolChoice::None` — genuinely cannot
918        // finalize a pinned Tool-mode turn, so the warning should still fire there.
919        assert!(!output_tool_callable(
920            Some(&ToolChoice::Specific {
921                function_names: vec!["search".to_string()],
922            }),
923            "final_result",
924        ));
925        assert!(!output_tool_callable(
926            Some(&ToolChoice::None),
927            "final_result"
928        ));
929    }
930
931    #[test]
932    fn required_with_no_advertised_tool_is_local_error() {
933        let empty = tool_names(&[]);
934        let err = allowed_tool_names_for_choice(&empty, Some(&ToolChoice::Required), None, None)
935            .expect_err("Required with no advertised tool must fail locally");
936        assert!(matches!(
937            err,
938            CompletionError::RequestError(err) if err.to_string().contains("Required")
939        ));
940    }
941
942    #[test]
943    fn required_with_only_the_output_tool_is_allowed() {
944        // Structured-output Tool mode with no real tools: the model can still be
945        // forced to call the synthetic output tool, so Required is valid.
946        let empty = tool_names(&[]);
947        let allowed = allowed_tool_names_for_choice(
948            &empty,
949            Some(&ToolChoice::Required),
950            Some("final_result"),
951            None,
952        )
953        .expect("Required is satisfiable by the output tool");
954        // The output tool is added to the allowed set by the caller, so the
955        // executable-derived allowed set is empty here.
956        assert!(allowed.is_empty());
957    }
958
959    #[test]
960    fn required_with_active_tools_filter_names_the_filter_in_the_error() {
961        let empty = tool_names(&[]);
962        let err = allowed_tool_names_for_choice(
963            &empty,
964            Some(&ToolChoice::Required),
965            None,
966            Some(&tool_names(&["add"])),
967        )
968        .expect_err("Required after active_tools filtered everything must fail locally");
969        let msg = err.to_string();
970        assert!(
971            msg.contains("active_tools"),
972            "error should name active_tools: {msg}"
973        );
974        assert!(
975            msg.contains("RequestPatch"),
976            "error should suggest RequestPatch: {msg}"
977        );
978    }
979
980    #[test]
981    fn specific_naming_a_filtered_out_tool_is_a_local_error_with_hint() {
982        // active_tools narrowed the advertised set to {add}; Specific still names
983        // the now-filtered-out `subtract`.
984        let executable = tool_names(&["add"]);
985        let choice = ToolChoice::Specific {
986            function_names: vec!["subtract".to_string()],
987        };
988        let err = allowed_tool_names_for_choice(
989            &executable,
990            Some(&choice),
991            None,
992            Some(&tool_names(&["add", "subtract"])),
993        )
994        .expect_err("Specific naming a filtered-out tool must fail locally");
995        let msg = err.to_string();
996        assert!(
997            msg.contains("subtract"),
998            "error should name the missing tool: {msg}"
999        );
1000        assert!(
1001            msg.contains("active_tools"),
1002            "error should name active_tools: {msg}"
1003        );
1004    }
1005
1006    #[test]
1007    fn specific_may_name_the_output_tool() {
1008        // The effective advertised set includes the synthetic output tool.
1009        let empty = tool_names(&[]);
1010        let choice = ToolChoice::Specific {
1011            function_names: vec!["final_result".to_string()],
1012        };
1013        let allowed =
1014            allowed_tool_names_for_choice(&empty, Some(&choice), Some("final_result"), None)
1015                .expect("Specific naming the output tool is valid");
1016        assert_eq!(allowed, tool_names(&["final_result"]));
1017    }
1018
1019    #[test]
1020    fn specific_typo_is_not_blamed_on_active_tools() {
1021        // Specific names a tool that never existed (a typo), even though an
1022        // active_tools filter was applied. The error must NOT blame active_tools,
1023        // because the filter never had that tool to drop.
1024        let executable = tool_names(&["add"]);
1025        let choice = ToolChoice::Specific {
1026            function_names: vec!["nonexistent".to_string()],
1027        };
1028        let err = allowed_tool_names_for_choice(
1029            &executable,
1030            Some(&choice),
1031            None,
1032            Some(&tool_names(&["add"])),
1033        )
1034        .expect_err("Specific naming a non-existent tool must fail locally");
1035        let msg = err.to_string();
1036        assert!(msg.contains("nonexistent"), "error names the typo: {msg}");
1037        assert!(
1038            !msg.contains("active_tools"),
1039            "a plain typo must not be blamed on active_tools: {msg}"
1040        );
1041    }
1042
1043    #[test]
1044    fn resolve_output_mode_without_schema_is_always_native() {
1045        // No schema => nothing to enforce, regardless of the requested mode or tools.
1046        for requested in [
1047            OutputMode::Auto,
1048            OutputMode::Tool,
1049            OutputMode::Native,
1050            OutputMode::Prompted,
1051        ] {
1052            assert_eq!(
1053                resolve_output_mode(false, true, true, false, &requested),
1054                OutputMode::Native,
1055                "no schema should force Native for {requested:?}"
1056            );
1057            assert_eq!(
1058                resolve_output_mode(false, false, true, false, &requested),
1059                OutputMode::Native,
1060            );
1061        }
1062    }
1063
1064    #[test]
1065    fn resolve_output_mode_auto_picks_tool_only_when_tools_present() {
1066        // This is the #1928 fix: with tools on a provider that does NOT compose
1067        // native output with tools, the schema must not be a native `format`
1068        // constraint on every turn, so Auto routes to Tool.
1069        assert_eq!(
1070            resolve_output_mode(true, true, true, false, &OutputMode::Auto),
1071            OutputMode::Tool,
1072        );
1073        // No tools => native structured output is safe and preferred.
1074        assert_eq!(
1075            resolve_output_mode(true, false, true, false, &OutputMode::Auto),
1076            OutputMode::Native,
1077        );
1078    }
1079
1080    #[test]
1081    fn resolve_output_mode_auto_keeps_native_when_provider_composes() {
1082        // On providers that compose native structured output with tools (OpenAI,
1083        // Anthropic), Auto keeps guaranteed native output even with tools present.
1084        assert_eq!(
1085            resolve_output_mode(true, true, true, true, &OutputMode::Auto),
1086            OutputMode::Native,
1087        );
1088    }
1089
1090    #[test]
1091    fn resolve_output_mode_honors_explicit_choice_with_schema() {
1092        for (requested, expected) in [
1093            (OutputMode::Tool, OutputMode::Tool),
1094            (OutputMode::Native, OutputMode::Native),
1095            (OutputMode::Prompted, OutputMode::Prompted),
1096        ] {
1097            // Explicit modes are honored regardless of tools or provider support.
1098            assert_eq!(
1099                resolve_output_mode(true, true, true, false, &requested),
1100                expected
1101            );
1102            assert_eq!(
1103                resolve_output_mode(true, false, true, true, &requested),
1104                expected
1105            );
1106        }
1107    }
1108
1109    #[test]
1110    fn resolve_output_mode_degrades_to_native_when_output_tool_not_callable() {
1111        // Tool mode finalizes via the output-tool call; when the tool choice
1112        // forbids it (None / Specific), structured output must still be enforced
1113        // via Native rather than silently dropped (#1928 regression guard).
1114        assert_eq!(
1115            resolve_output_mode(true, true, false, false, &OutputMode::Auto),
1116            OutputMode::Native,
1117        );
1118        assert_eq!(
1119            resolve_output_mode(true, true, false, false, &OutputMode::Tool),
1120            OutputMode::Native,
1121        );
1122        // Prompted does not rely on tools, so it is unaffected.
1123        assert_eq!(
1124            resolve_output_mode(true, true, false, false, &OutputMode::Prompted),
1125            OutputMode::Prompted,
1126        );
1127    }
1128
1129    #[test]
1130    fn tool_choice_permits_output_tool_only_for_auto_required_or_unset() {
1131        assert!(tool_choice_permits_output_tool(None));
1132        assert!(tool_choice_permits_output_tool(Some(&ToolChoice::Auto)));
1133        assert!(tool_choice_permits_output_tool(Some(&ToolChoice::Required)));
1134        assert!(!tool_choice_permits_output_tool(Some(&ToolChoice::None)));
1135        assert!(!tool_choice_permits_output_tool(Some(
1136            &ToolChoice::Specific {
1137                function_names: vec!["add".to_string()],
1138            }
1139        )));
1140    }
1141
1142    #[test]
1143    fn pick_output_tool_name_defaults_when_unused() {
1144        let executable = tool_names(&["add", "subtract"]);
1145        assert_eq!(pick_output_tool_name(&executable), DEFAULT_OUTPUT_TOOL_NAME);
1146    }
1147
1148    #[test]
1149    fn pick_output_tool_name_avoids_collision_with_real_tools() {
1150        // A user tool literally named `final_result` must not be shadowed, or
1151        // the model's output call would be dispatched to the tool server.
1152        let executable = tool_names(&["final_result"]);
1153        assert_eq!(pick_output_tool_name(&executable), "final_result_1");
1154
1155        let executable = tool_names(&["final_result", "final_result_1"]);
1156        assert_eq!(pick_output_tool_name(&executable), "final_result_2");
1157    }
1158}