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//! 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/// What the model reads for its call to `tool` that its reply ended in the
52/// middle of, having reached the output limit after sending `raw` of the
53/// arguments (a response's
54/// [`unfinished_calls`](crate::completion::CompletionResponse::unfinished_calls)):
55/// the tool never ran, and sending as much again would be cut off again.
56/// `raw` is echoed up to [`ECHOED_CHARS`].
57pub fn output_limit_feedback(tool: &str, raw: &str) -> String {
58    format!(
59        "Not run: your reply reached the output limit before `{tool}`'s arguments ended. Send \
60         less in one call: split the work into smaller calls, such as writing part of a file \
61         and adding the rest with edits. The arguments began: {}",
62        echoed(raw)
63    )
64}
65
66/// How much of a call's raw arguments its feedback echoes, in characters.
67pub const ECHOED_CHARS: usize = 200;
68
69/// `raw` up to [`ECHOED_CHARS`], saying how much more there was.
70fn echoed(raw: &str) -> String {
71    match raw.char_indices().nth(ECHOED_CHARS) {
72        Some((end, _)) => format!(
73            "{}… ({} more characters)",
74            raw.get(..end).unwrap_or_default(),
75            raw.len().saturating_sub(end)
76        ),
77        None => raw.to_owned(),
78    }
79}
80
81/// The result every other call of a turn gets when one call was retried or
82/// skipped: none of the turn's calls ran.
83pub const TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER: &str =
84    "Tool not executed because another tool call in the same assistant turn was invalid.";
85
86/// The tool results answering a turn with an invalid call, in call order:
87/// `feedback` for the call `invalid`, [`TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER`]
88/// for every other call. Empty when `content` has no tool calls.
89pub fn invalid_call_feedback(
90    content: &[AssistantContent],
91    invalid: &CallId,
92    feedback: &str,
93) -> Vec<UserContent> {
94    content
95        .iter()
96        .filter_map(|part| match part {
97            AssistantContent::ToolCall(call) if &call.id == invalid => Some(tool_result_message(
98                call.id.clone(),
99                call.function.name.clone(),
100                feedback.to_owned(),
101            )),
102            AssistantContent::ToolCall(call) => Some(not_executed(call)),
103            _ => None,
104        })
105        .collect()
106}
107
108/// The result of a call the run did not execute because a call beside it
109/// was invalid.
110pub fn not_executed(call: &ToolCall) -> UserContent {
111    tool_result_message(
112        call.id.clone(),
113        call.function.name.clone(),
114        TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER.to_owned(),
115    )
116}
117
118/// Why `call`'s arguments do not fit a tool whose JSON schema is
119/// `parameters`, for the model to read as the call's error: they are not a
120/// JSON object ([`invalid_arguments_feedback`]), name an argument the
121/// schema does not declare, or do not [`validate`](crate::tool::validate)
122/// against it (up to five of the problems are named). `None` when they fit
123/// as far as the validator checks; the tool checks the rest.
124///
125/// A schema that declares `properties` is read as naming every argument,
126/// stricter than JSON Schema, so a model that guesses an argument name hears
127/// the real ones: unless it allows more (`additionalProperties` other than
128/// `false`, or `patternProperties`), an undeclared argument is refused. A
129/// schema that declares no `properties`, such as `{}` or
130/// `{"type": "object"}`, accepts any argument.
131pub fn arguments_refusal(parameters: &serde_json::Value, call: &ToolCall) -> Option<String> {
132    let name = call.function.name.as_str();
133    if let Some(raw) = &call.function.invalid_arguments {
134        return Some(invalid_arguments_feedback(name, raw));
135    }
136    let allows_more = parameters
137        .get("additionalProperties")
138        .is_some_and(|more| more != &serde_json::Value::Bool(false))
139        || parameters.get("patternProperties").is_some();
140    let declared = parameters
141        .get("properties")
142        .and_then(serde_json::Value::as_object)
143        .filter(|_| !allows_more);
144    fn quoted<'a>(names: impl Iterator<Item = &'a String>) -> String {
145        names
146            .map(|arg| format!("`{arg}`"))
147            .collect::<Vec<_>>()
148            .join(", ")
149    }
150    let unknown = quoted(
151        call.function
152            .arguments
153            .keys()
154            .filter(|arg| declared.is_some_and(|declared| !declared.contains_key(arg.as_str()))),
155    );
156    if let Some(declared) = declared.filter(|_| !unknown.is_empty()) {
157        let mut known = quoted(declared.keys());
158        if known.is_empty() {
159            known = "none".to_owned();
160        }
161        return Some(format!(
162            "`{name}` has no argument {unknown}. Its arguments are: {known}. Call it again with \
163             only those."
164        ));
165    }
166    const SHOWN: usize = 5;
167    let arguments = serde_json::Value::Object(call.function.arguments.clone());
168    let problems = crate::tool::validate::validate(parameters, &arguments).err()?;
169    let mut named: Vec<String> = problems
170        .iter()
171        .take(SHOWN)
172        .map(ToString::to_string)
173        .collect();
174    if let Some(more) = problems.len().checked_sub(SHOWN).filter(|more| *more > 0) {
175        named.push(format!("and {more} more"));
176    }
177    Some(format!(
178        "`{name}`'s arguments do not fit its schema: {}. Call it again with arguments that fit.",
179        named.join("; ")
180    ))
181}
182
183/// What the model reads of a call that did not finish, for any early end
184/// ("stopped" means any of them: the user stopped the turn, a plugin or a
185/// failure ended it, or the session restarted), from whether it `started`
186/// and its `footprint`: one never started was not run; one that started and
187/// only reads ([`Touches::reads_only`]) is safe to run again; any other,
188/// [`Touches::Independent`] too, may have partly run. `why` is said as it
189/// is, without a final period.
190///
191/// ```
192/// use rig_core::tool::feedback::not_finished;
193/// use rig_core::tool::{Scope, Touches};
194///
195/// let reads = Touches::Reads(Scope::Anything);
196/// let why = "the user stopped the turn";
197/// assert_eq!(not_finished(why, false, &reads), "Not run: the user stopped the turn.");
198/// assert!(not_finished(why, true, &reads).ends_with("run it again if you still need it."));
199/// assert!(not_finished(why, true, &Touches::Independent).contains("may have partly run"));
200/// ```
201pub fn not_finished(why: &str, started: bool, footprint: &Touches) -> String {
202    let why = why.trim().trim_end_matches('.');
203    match (started, footprint.reads_only()) {
204        (false, _) => format!("Not run: {why}."),
205        (true, true) => format!("Stopped: {why}. It only read; run it again if you still need it."),
206        (true, false) => {
207            format!("Stopped: {why}. It may have partly run; check before running it again.")
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests;