Skip to main content

rig_core/tool/
feedback.rs

1//! What the model reads about its tool calls: a tool's result shaped as a
2//! tool result, the error result of a call that never ran, what a call
3//! that did not finish says ([`not_finished`]), and the feedback for
4//! unavailable tool names and arguments that do not fit a tool.
5//!
6//! ```
7//! use rig_core::tool::feedback::invalid_arguments_feedback;
8//!
9//! assert!(invalid_arguments_feedback("add", "[1]").contains("not a JSON object"));
10//! ```
11
12use crate::message::{
13    self, AssistantContent, CallId, ToolCall, ToolName, ToolResultContent, UserContent,
14};
15use crate::tool::{ToolResult, Touches};
16
17/// Shape a tool's result as the tool result the model reads, without
18/// reparsing text. Anything but a success is an error result.
19pub fn tool_result_output(call: CallId, name: ToolName, result: &ToolResult) -> UserContent {
20    UserContent::ToolResult(message::ToolResult {
21        call,
22        name,
23        content: result.output().clone().into_content(),
24        is_error: !result.is_success(),
25    })
26}
27
28/// Constructs the error result of a call that never ran, its text verbatim,
29/// such as recovery feedback or a skip reason. JSON-shaped text is not
30/// reinterpreted as structured or multimodal output.
31pub fn tool_result_message(call: CallId, name: ToolName, message: String) -> UserContent {
32    UserContent::ToolResult(message::ToolResult {
33        call,
34        name,
35        content: vec![ToolResultContent::text(message)],
36        is_error: true,
37    })
38}
39
40/// What the model reads when its call to `tool` sent `raw`, arguments that
41/// are not a JSON object: the tool never ran, and the model calls it again.
42/// `raw` is echoed up to [`ECHOED_CHARS`].
43pub fn invalid_arguments_feedback(tool: &str, raw: &str) -> String {
44    format!(
45        "The arguments for tool `{tool}` are not a JSON object: {}. \
46         Call the tool again with a JSON object as its arguments.",
47        echoed(raw)
48    )
49}
50
51/// Corrects a call to an unavailable `name`, listing `tools` in sorted order.
52/// The caller must supply only names the agent is allowed to use.
53pub fn unknown_tool_feedback<'a>(name: &str, tools: impl IntoIterator<Item = &'a str>) -> String {
54    let mut tools: Vec<_> = tools.into_iter().collect();
55    tools.sort_unstable();
56    let available = if tools.is_empty() {
57        "You have no tools available. Answer without calling tools.".to_owned()
58    } else {
59        format!(
60            "Your tools are: `{}`. Call one by its exact name.",
61            tools.join("`, `")
62        )
63    };
64    format!("There is no tool named `{name}`. {available}")
65}
66
67/// What the model reads for its call to `tool` that its reply ended in the
68/// middle of, having reached the output limit after sending `raw` of the
69/// arguments (a response's
70/// [`unfinished_calls`](crate::completion::CompletionResponse::unfinished_calls)):
71/// the tool never ran, and sending as much again would be cut off again.
72/// `raw` is echoed up to [`ECHOED_CHARS`].
73pub fn output_limit_feedback(tool: &str, raw: &str) -> String {
74    format!(
75        "Not run: your reply reached the output limit before `{tool}`'s arguments ended. Send \
76         less in one call: split the work into smaller calls, such as writing part of a file \
77         and adding the rest with edits. The arguments began: {}",
78        echoed(raw)
79    )
80}
81
82/// How much of a call's raw arguments its feedback echoes, in characters.
83pub const ECHOED_CHARS: usize = 200;
84
85/// `raw` up to [`ECHOED_CHARS`], saying how much more there was.
86fn echoed(raw: &str) -> String {
87    match raw.char_indices().nth(ECHOED_CHARS) {
88        Some((end, _)) => format!(
89            "{}… ({} more characters)",
90            raw.get(..end).unwrap_or_default(),
91            raw.len().saturating_sub(end)
92        ),
93        None => raw.to_owned(),
94    }
95}
96
97/// The result every other call of a turn gets when one call was retried or
98/// skipped: none of the turn's calls ran.
99pub const TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER: &str =
100    "Tool not executed because another tool call in the same assistant turn was invalid.";
101
102/// The tool results answering a turn with an invalid call, in call order:
103/// `feedback` for the call `invalid`, [`TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER`]
104/// for every other call. Empty when `content` has no tool calls.
105pub fn invalid_call_feedback(
106    content: &[AssistantContent],
107    invalid: &CallId,
108    feedback: &str,
109) -> Vec<UserContent> {
110    content
111        .iter()
112        .filter_map(|part| match part {
113            AssistantContent::ToolCall(call) if &call.id == invalid => Some(tool_result_message(
114                call.id.clone(),
115                call.function.name.clone(),
116                feedback.to_owned(),
117            )),
118            AssistantContent::ToolCall(call) => Some(not_executed(call)),
119            _ => None,
120        })
121        .collect()
122}
123
124/// The result of a call the run did not execute because a call beside it
125/// was invalid.
126pub fn not_executed(call: &ToolCall) -> UserContent {
127    tool_result_message(
128        call.id.clone(),
129        call.function.name.clone(),
130        TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER.to_owned(),
131    )
132}
133
134/// Why `call`'s arguments do not fit a tool whose JSON schema is
135/// `parameters`, for the model to read as the call's error: they are not a
136/// JSON object ([`invalid_arguments_feedback`]), name an argument the
137/// schema does not declare, or do not [`validate`](crate::tool::validate)
138/// against it (up to five of the problems are named). `None` when they fit
139/// as far as the validator checks; the tool checks the rest.
140///
141/// A schema that declares `properties` is read as naming every argument,
142/// stricter than JSON Schema, so a model that guesses an argument name hears
143/// the real ones: unless it allows more (`additionalProperties` other than
144/// `false`, or `patternProperties`), an undeclared argument is refused. A
145/// schema that declares no `properties`, such as `{}` or
146/// `{"type": "object"}`, accepts any argument.
147pub fn arguments_refusal(parameters: &serde_json::Value, call: &ToolCall) -> Option<String> {
148    let name = call.function.name.as_str();
149    if let Some(raw) = &call.function.invalid_arguments {
150        return Some(invalid_arguments_feedback(name, raw));
151    }
152    let allows_more = parameters
153        .get("additionalProperties")
154        .is_some_and(|more| more != &serde_json::Value::Bool(false))
155        || parameters.get("patternProperties").is_some();
156    let declared = parameters
157        .get("properties")
158        .and_then(serde_json::Value::as_object)
159        .filter(|_| !allows_more);
160    fn quoted<'a>(names: impl Iterator<Item = &'a String>) -> String {
161        names
162            .map(|arg| format!("`{arg}`"))
163            .collect::<Vec<_>>()
164            .join(", ")
165    }
166    let unknown = quoted(
167        call.function
168            .arguments
169            .keys()
170            .filter(|arg| declared.is_some_and(|declared| !declared.contains_key(arg.as_str()))),
171    );
172    if let Some(declared) = declared.filter(|_| !unknown.is_empty()) {
173        let mut known = quoted(declared.keys());
174        if known.is_empty() {
175            known = "none".to_owned();
176        }
177        return Some(format!(
178            "`{name}` has no argument {unknown}. Its arguments are: {known}. Call it again with \
179             only those."
180        ));
181    }
182    const SHOWN: usize = 5;
183    let arguments = serde_json::Value::Object(call.function.arguments.clone());
184    let problems = crate::tool::validate::validate(parameters, &arguments).err()?;
185    let mut named: Vec<String> = problems
186        .iter()
187        .take(SHOWN)
188        .map(ToString::to_string)
189        .collect();
190    if let Some(more) = problems.len().checked_sub(SHOWN).filter(|more| *more > 0) {
191        named.push(format!("and {more} more"));
192    }
193    Some(format!(
194        "`{name}`'s arguments do not fit its schema: {}. Call it again with arguments that fit.",
195        named.join("; ")
196    ))
197}
198
199/// What the model reads of a call that did not finish, for any early end
200/// ("stopped" means any of them: the user stopped the turn, a plugin or a
201/// failure ended it, or the session restarted), from whether it `started`
202/// and its `footprint`: one never started was not run; one that started and
203/// only reads ([`Touches::reads_only`]) is safe to run again; any other,
204/// [`Touches::Independent`] too, may have partly run. `why` is said as it
205/// is, without a final period.
206///
207/// ```
208/// use rig_core::tool::feedback::not_finished;
209/// use rig_core::tool::{Scope, Touches};
210///
211/// let reads = Touches::Reads(Scope::Anything);
212/// let why = "the user stopped the turn";
213/// assert_eq!(not_finished(why, false, &reads), "Not run: the user stopped the turn.");
214/// assert!(not_finished(why, true, &reads).ends_with("run it again if you still need it."));
215/// assert!(not_finished(why, true, &Touches::Independent).contains("may have partly run"));
216/// ```
217pub fn not_finished(why: &str, started: bool, footprint: &Touches) -> String {
218    let why = why.trim().trim_end_matches('.');
219    match (started, footprint.reads_only()) {
220        (false, _) => format!("Not run: {why}."),
221        (true, true) => format!("Stopped: {why}. It only read; run it again if you still need it."),
222        (true, false) => {
223            format!("Stopped: {why}. It may have partly run; check before running it again.")
224        }
225    }
226}
227
228#[cfg(test)]
229mod tests;