Skip to main content

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