Skip to main content

rig_agent/agent/
completion.rs

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