use rig_core::{
completion::{
CompletionRequest, Document, ToolDefinition,
message::{AssistantContent, CallId, Message, ToolChoice, ToolName, UserContent},
},
effect::{HandlerDescriptor, Outcome},
error::{ErrorKind, ErrorReport},
json_utils::to_canonical_string,
tool::{ToolExecutionError, ToolResult},
transcript::{tool_result_message, tool_result_output},
};
use crate::agent::{
Failure, MessageParts, OutputKind, OutputToolConfig,
content::parts::{ToolResultLimit, ToolResultStatus},
};
pub mod text {
pub const OUTPUT_TOOL_NAME: &str = "final_result";
pub const OUTPUT_TOOL_DESCRIPTION: &str = "Call this tool exactly once with your final answer when you are done. Its arguments are the structured result and must satisfy the output schema.";
pub fn output_tool_augmentation(name: &str) -> String {
format!(
"When you have gathered enough information to answer, call the `{name}` tool exactly once with your final answer. Its arguments are the structured result and must satisfy the required schema. Do not return the final answer as plain text."
)
}
pub fn prompted_augmentation(schema: &str) -> String {
format!(
"Respond with ONLY a single JSON object that conforms to this JSON Schema. Do not include any prose, explanation, or markdown code fences.\n{schema}"
)
}
pub fn reprompt_text_answer(name: &str) -> String {
format!(
"Provide your final answer by calling the `{name}` tool with the structured result as its arguments, not as plain text."
)
}
pub const AUGMENTATION_SEPARATOR: &str = "\n\n";
pub const TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER: &str =
"Tool not executed because another tool call in the same assistant turn was invalid.";
}
pub fn output_tool_callable(choice: Option<&ToolChoice>, name: &str) -> bool {
match choice {
None | Some(ToolChoice::Auto) | Some(ToolChoice::Required) => true,
Some(ToolChoice::None) => false,
Some(ToolChoice::Specific { function_names }) => {
function_names.iter().any(|named| named == name)
}
}
}
pub fn resolve_output(
mode: OutputKind,
has_schema: bool,
granted_tools: usize,
callable: bool,
provider_composes_native: bool,
) -> OutputKind {
if !has_schema {
return OutputKind::Native;
}
match mode {
OutputKind::Native => OutputKind::Native,
OutputKind::Prompted => OutputKind::Prompted,
OutputKind::Tool => {
if callable {
OutputKind::Tool
} else {
OutputKind::Native
}
}
OutputKind::Auto => {
if granted_tools > 0 && callable && !provider_composes_native {
OutputKind::Tool
} else {
OutputKind::Native
}
}
}
}
pub fn output_tool_name(granted: &[&str]) -> String {
let base = text::OUTPUT_TOOL_NAME;
if !granted.contains(&base) {
return base.to_owned();
}
let mut suffix = 1;
loop {
let candidate = format!("{base}_{suffix}");
if !granted.contains(&candidate.as_str()) {
return candidate;
}
suffix += 1;
}
}
pub fn missing_required_fields(
schema: &serde_json::Value,
arguments: &serde_json::Value,
) -> Vec<String> {
let required: Vec<&str> = schema
.get("required")
.and_then(serde_json::Value::as_array)
.map(|names| names.iter().filter_map(serde_json::Value::as_str).collect())
.unwrap_or_default();
match arguments.as_object() {
Some(object) => required
.into_iter()
.filter(|name| !object.contains_key(*name))
.map(str::to_owned)
.collect(),
None => required.into_iter().map(str::to_owned).collect(),
}
}
pub fn text_satisfies_schema(schema: Option<&serde_json::Value>, text: &str) -> bool {
serde_json::from_str::<serde_json::Value>(text.trim())
.ok()
.is_some_and(|value| {
schema.is_none_or(|schema| missing_required_fields(schema, &value).is_empty())
})
}
pub fn reprompt_missing_fields(name: &str, missing: &[String]) -> String {
format!(
"The `{name}` arguments were missing required field(s): {}. Call `{name}` again with every required field.",
missing.join(", ")
)
}
pub fn turn_is_empty(content: &[AssistantContent]) -> bool {
match content {
[] => true,
[AssistantContent::Text(text)] => text.text.is_empty() && text.additional_params.is_none(),
_ => false,
}
}
pub struct RequestGraph<'a> {
pub preamble: Option<&'a str>,
pub utterances: Vec<&'a MessageParts>,
pub documents: Vec<Document>,
pub tools: Vec<&'a HandlerDescriptor>,
pub temperature: Option<f64>,
pub max_tokens: Option<u64>,
pub additional_params: Option<&'a serde_json::Value>,
pub tool_choice: Option<&'a ToolChoice>,
pub output: OutputKind,
pub schema: Option<&'a serde_json::Value>,
pub output_tool: Option<&'a str>,
pub output_tool_config: Option<&'a OutputToolConfig>,
}
pub fn fold_request(
graph: &RequestGraph<'_>,
) -> Result<CompletionRequest, crate::agent::content::parts::ContentError> {
let mut chat_history: Vec<Message> = Vec::with_capacity(graph.utterances.len() + 2);
let system = system_message(graph);
if let Some(content) = system {
chat_history.push(Message::System { content });
}
chat_history.extend(graph.utterances.iter().map(|parts| parts.to_message()));
let mut tools: Vec<ToolDefinition> = graph
.tools
.iter()
.filter_map(|descriptor| tool_definition(descriptor))
.collect();
if graph.output == OutputKind::Tool
&& let (Some(name), Some(schema)) = (graph.output_tool, graph.schema)
{
tools.push(ToolDefinition {
name: name.to_owned(),
description: graph
.output_tool_config
.and_then(|config| config.description.as_deref())
.unwrap_or(text::OUTPUT_TOOL_DESCRIPTION)
.to_owned(),
parameters: schema.clone(),
});
}
let output_schema = match (graph.output, graph.schema) {
(OutputKind::Native, Some(schema)) => {
rig_core::schemars::Schema::try_from(schema.clone()).ok()
}
(OutputKind::Native, None)
| (OutputKind::Auto | OutputKind::Tool | OutputKind::Prompted, _) => None,
};
if chat_history.is_empty() {
return Err(crate::agent::content::parts::ContentError::Missing);
}
Ok(CompletionRequest {
model: None,
chat_history,
documents: graph.documents.clone(),
tools,
temperature: graph.temperature,
max_tokens: graph.max_tokens,
tool_choice: graph.tool_choice.cloned(),
additional_params: graph.additional_params.cloned(),
output_schema,
record_telemetry_content: false,
})
}
fn system_message(graph: &RequestGraph<'_>) -> Option<String> {
let augmentation = match (graph.output, graph.output_tool, graph.schema) {
(OutputKind::Tool, Some(name), _) => graph
.output_tool_config
.is_none_or(|config| config.augment_preamble)
.then(|| text::output_tool_augmentation(name)),
(OutputKind::Prompted, _, Some(schema)) => {
Some(text::prompted_augmentation(&to_canonical_string(schema)))
}
(OutputKind::Tool, None, _)
| (OutputKind::Prompted, _, None)
| (OutputKind::Auto | OutputKind::Native, _, _) => None,
};
match (graph.preamble, augmentation) {
(None, None) => None,
(Some(preamble), None) => Some(preamble.to_owned()),
(None, Some(augmentation)) => Some(augmentation),
(Some(preamble), Some(augmentation)) => Some(format!(
"{preamble}{}{augmentation}",
text::AUGMENTATION_SEPARATOR
)),
}
}
pub fn tool_definition(descriptor: &HandlerDescriptor) -> Option<ToolDefinition> {
match &descriptor.family {
rig_core::effect::FamilyDescriptor::Tool {
name,
description,
parameters,
..
} => Some(ToolDefinition {
name: name.clone(),
description: description.clone(),
parameters: parameters.clone(),
}),
rig_core::effect::FamilyDescriptor::Completion { .. }
| rig_core::effect::FamilyDescriptor::Embed { .. }
| rig_core::effect::FamilyDescriptor::Rerank { .. }
| rig_core::effect::FamilyDescriptor::Memory { .. }
| rig_core::effect::FamilyDescriptor::Retrieve { .. }
| rig_core::effect::FamilyDescriptor::Custom { .. } => None,
}
}
pub fn tool_result_part(
id: CallId,
name: ToolName,
outcome: &Result<Outcome, ErrorReport>,
) -> Result<(UserContent, ToolResultStatus), Failure> {
if let Some(failure) = tool_failure(outcome) {
return Err(failure);
}
let (result, status) = match outcome {
Ok(Outcome::ToolResult { result }) => (
result.clone(),
if result.is_success() {
ToolResultStatus::Ok
} else if result.is_refused() {
ToolResultStatus::Refused
} else if result.is_skipped() {
ToolResultStatus::Skipped
} else {
ToolResultStatus::Error
},
),
Ok(other) => (
ToolResult::failed(ToolExecutionError::other(format!(
"the tool handler answered with a {} outcome",
other.family()
))),
ToolResultStatus::WrongFamily,
),
Err(report) if report.kind == ErrorKind::Denied => (
ToolResult::skipped(report.message.clone()),
ToolResultStatus::Denied,
),
Err(report) => (
ToolResult::failed(
ToolExecutionError::other(report.message.clone())
.with_model_feedback(report.message.clone()),
),
ToolResultStatus::Error,
),
};
Ok((
tool_result_output(id, name, result.output().clone()),
status,
))
}
pub fn limit_tool_result_text(text: &str, limit: &ToolResultLimit) -> Option<String> {
if text.len() <= limit.max_bytes {
return None;
}
let mut head = limit.max_bytes / 2;
while !text.is_char_boundary(head) {
head -= 1;
}
let mut tail = text.len() - (limit.max_bytes - limit.max_bytes / 2);
while !text.is_char_boundary(tail) {
tail += 1;
}
let omitted = tail - head;
let marker = limit.marker.replace("{omitted}", &omitted.to_string());
let head = text.get(..head).unwrap_or_default();
let tail = text.get(tail..).unwrap_or_default();
let mut cut = String::with_capacity(head.len() + marker.len() + tail.len());
cut.push_str(head);
cut.push_str(&marker);
cut.push_str(tail);
Some(cut)
}
#[must_use]
pub fn tool_results_exceed(parts: &MessageParts, limit: &ToolResultLimit) -> bool {
let MessageParts::User { content } = parts else {
return false;
};
content.iter().any(|part| match part {
UserContent::ToolResult(result) => result.content.iter().any(|item| {
matches!(item, rig_core::message::ToolResultContent::Text(text) if text.text.len() > limit.max_bytes)
}),
_ => false,
})
}
pub fn limit_tool_results(parts: &mut MessageParts, limit: &ToolResultLimit) {
let MessageParts::User { content } = parts else {
return;
};
for part in content {
let UserContent::ToolResult(result) = part else {
continue;
};
for item in &mut result.content {
if let rig_core::message::ToolResultContent::Text(text) = item
&& let Some(cut) = limit_tool_result_text(&text.text, limit)
{
text.text = cut;
}
}
}
}
pub fn tool_failure(outcome: &Result<Outcome, ErrorReport>) -> Option<Failure> {
match outcome {
Ok(_) => None,
Err(report) if report.kind == ErrorKind::Cancelled => {
Some(Failure::Cancelled(report.clone()))
}
Err(report)
if matches!(
report.kind,
ErrorKind::BusClosed | ErrorKind::HandlerUnavailable | ErrorKind::Divergence
) =>
{
Some(Failure::Tool(report.clone()))
}
Err(_) => None,
}
}
pub fn invalid_peer_results(
content: &[AssistantContent],
id: &CallId,
text: &str,
) -> Option<MessageParts> {
let parts = content
.iter()
.filter_map(|part| match part {
AssistantContent::ToolCall(call) => Some(tool_result_message(
call.id.clone(),
call.function.name.clone(),
if &call.id == id {
text.to_owned()
} else {
text::TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER.to_owned()
},
)),
AssistantContent::Text(_)
| AssistantContent::Reasoning(_)
| AssistantContent::Image(_) => None,
})
.collect();
MessageParts::user(parts).ok()
}
pub fn retrieval_query(utterances: &[MessageParts]) -> String {
utterances
.iter()
.rev()
.find_map(|parts| parts.to_message().rag_text())
.unwrap_or_default()
}
pub fn answer_text(content: &[AssistantContent]) -> String {
content
.iter()
.filter_map(|part| match part {
AssistantContent::Text(text) => Some(text.text.as_str()),
AssistantContent::Reasoning(_)
| AssistantContent::Image(_)
| AssistantContent::ToolCall(_) => None,
})
.collect()
}
pub fn user_text(text: &str) -> Message {
Message::User {
content: vec![UserContent::text(text)],
}
}
#[cfg(test)]
mod tests;