use std::collections::{HashMap, HashSet};
use anyllm_translate::openai::{
ChatCompletionRequest, ChatContent, ChatMessage, ChatRole, ChatToolChoice,
};
use forge_guardrails::{
rescue_tool_call, retry_nudge, unknown_tool_nudge, validate_tool_arguments, ToolSpec,
};
use tokenizer::{JsonValue, ParsedToolCall, ReasoningEffort};
use crate::cancel::Cancel;
use crate::handler::{
cancelled_before_start, plan, run_full, tool_names, AppState, GenError, Generated, RunControl,
};
mod extra_formats;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GuardrailConfig {
pub rescue: bool,
pub validate: bool,
pub retries: u8,
}
impl GuardrailConfig {
pub const OFF: Self = Self {
rescue: false,
validate: false,
retries: 0,
};
}
impl Default for GuardrailConfig {
fn default() -> Self {
Self {
rescue: true,
validate: true,
retries: 1,
}
}
}
impl GuardrailConfig {
pub fn active(&self) -> bool {
self.rescue || self.validate
}
}
#[derive(Debug, PartialEq)]
pub(crate) enum Verdict {
Accept,
Rescued(Vec<ParsedToolCall>),
Retry(String),
}
pub(crate) fn tool_specs(request: &ChatCompletionRequest) -> HashMap<String, ToolSpec> {
let offered = tool_names(request);
let mut specs = HashMap::new();
for tool in request.tools.iter().flatten() {
let name = &tool.function.name;
if !offered.contains(name) {
continue;
}
let Some(parameters) = tool.function.parameters.as_ref() else {
continue;
};
let description = tool.function.description.clone().unwrap_or_default();
if let Ok(spec) = ToolSpec::from_json_schema(name.clone(), description, parameters) {
specs.insert(name.clone(), spec);
}
}
specs
}
fn to_forge_call(call: &ParsedToolCall) -> forge_guardrails::ToolCall {
let args = serde_json::from_str(&call.arguments_json).unwrap_or_default();
forge_guardrails::ToolCall::new(call.name.clone(), args).with_id(call.id.clone())
}
fn from_forge_call(call: &forge_guardrails::ToolCall, index: usize) -> Option<ParsedToolCall> {
let arguments_json = serde_json::to_string(&call.args).ok()?;
let arguments = JsonValue::parse(&arguments_json).ok()?;
Some(ParsedToolCall {
id: call
.id
.clone()
.unwrap_or_else(|| format!("toolu_rescued_{index}")),
name: call.tool.clone(),
arguments,
arguments_json,
})
}
fn requires_tool_call(request: &ChatCompletionRequest) -> bool {
match &request.tool_choice {
Some(ChatToolChoice::Simple(choice)) => choice == "required",
Some(ChatToolChoice::Named(_)) => true,
None => false,
}
}
fn rescue_candidate(text: &str) -> bool {
const CONTROL_MARKERS: &[&str] = &[
"[ARGS]",
"<function=",
"[TOOL_CALLS]",
"<arg_key>",
"<invoke ",
"call:",
"<|tool_call_begin|>",
"<longcat_tool_call>",
];
if CONTROL_MARKERS.iter().any(|marker| text.contains(marker)) {
return true;
}
matches!(
serde_json::from_str::<serde_json::Value>(text.trim()),
Ok(serde_json::Value::Object(_) | serde_json::Value::Array(_))
)
}
pub(crate) fn inspect(
generated: &Generated,
specs: &HashMap<String, ToolSpec>,
offered: &HashSet<String>,
requires_call: bool,
config: &GuardrailConfig,
) -> Verdict {
if offered.is_empty() {
return Verdict::Accept;
}
let names: Vec<&str> = offered.iter().map(String::as_str).collect();
let mut rescued = Vec::new();
if generated.calls.is_empty() && config.rescue {
let try_rescue = |text: &str, rescued: &mut Vec<ParsedToolCall>| {
let local = extra_formats::rescue(text, &names);
if local.is_empty() {
rescued.extend(
rescue_tool_call(text, &names)
.iter()
.enumerate()
.filter_map(|(i, c)| from_forge_call(c, i)),
);
} else {
*rescued = local;
}
};
if !generated.released_span_text.is_empty() {
try_rescue(&generated.released_span_text, &mut rescued);
}
if rescued.is_empty() && rescue_candidate(&generated.text) {
try_rescue(&generated.text, &mut rescued);
}
}
let calls: &[ParsedToolCall] = if rescued.is_empty() {
&generated.calls
} else {
&rescued
};
if calls.is_empty() {
return if requires_call {
Verdict::Retry(retry_nudge(&generated.text))
} else {
Verdict::Accept
};
}
let mut messages = Vec::new();
if config.validate {
for call in calls {
let Some(spec) = specs.get(&call.name) else {
if !offered.contains(&call.name) {
messages.push(unknown_tool_nudge(&call.name, &names));
}
continue;
};
let errors = validate_tool_arguments(&to_forge_call(call), spec);
if !errors.is_empty() {
messages.push(format!(
"Your call to `{}` had invalid arguments: {}.",
call.name,
errors
.iter()
.map(|e| e.message())
.collect::<Vec<_>>()
.join("; ")
));
}
}
}
if messages.is_empty() {
return if rescued.is_empty() {
Verdict::Accept
} else {
Verdict::Rescued(rescued)
};
}
Verdict::Retry(format!(
"{} {}",
messages.join(" "),
"Call the tool again with corrected arguments."
))
}
fn with_retry_turn(
request: &ChatCompletionRequest,
said: &str,
calls: &[ParsedToolCall],
nudge: &str,
) -> ChatCompletionRequest {
let mut retried = request.clone();
if !said.trim().is_empty() || !calls.is_empty() {
retried.messages.push(ChatMessage {
role: ChatRole::Assistant,
content: (!said.is_empty()).then(|| ChatContent::Text(said.to_string())),
name: None,
tool_calls: (!calls.is_empty()).then(|| {
calls
.iter()
.cloned()
.map(crate::response::tool_call)
.collect()
}),
tool_call_id: None,
refusal: None,
reasoning_content: None,
thinking_blocks: None,
});
}
retried.messages.push(ChatMessage {
role: ChatRole::User,
content: Some(ChatContent::Text(nudge.to_string())),
name: None,
tool_calls: None,
tool_call_id: None,
refusal: None,
reasoning_content: None,
thinking_blocks: None,
});
retried
}
pub(crate) async fn run_guarded(
model: AppState,
request: &ChatCompletionRequest,
effort: ReasoningEffort,
cancel: Cancel,
) -> Result<Generated, GenError> {
let gate = match model.generation_queue() {
Some(queue) => match queue.acquire(&cancel).await {
Some(permit) => Some(permit),
None => {
return Ok(Generated {
text: String::new(),
reasoning: String::new(),
calls: Vec::new(),
decode: cancelled_before_start(),
released_span_text: String::new(),
})
}
},
None => None,
};
let config = model.guardrails();
let offered = tool_names(request);
let requires_call = requires_tool_call(request);
let specs = if config.validate {
tool_specs(request)
} else {
HashMap::new()
};
let mut attempt = request.clone();
let mut budget = config.retries;
loop {
let planned =
plan(&model, &attempt).map_err(|e| GenError::Join(format!("guardrail replan: {e}")))?;
let generated = run_full(
model.clone(),
planned.prompt_ids,
planned.config,
planned.images,
offered.clone(),
effort,
RunControl {
cancel: cancel.clone(),
permit: gate.clone(),
},
)
.await?;
if generated.decode.reason == runtime::StopReason::Cancelled || !config.active() {
return Ok(generated);
}
match inspect(&generated, &specs, &offered, requires_call, &config) {
Verdict::Accept => return Ok(generated),
Verdict::Rescued(calls) => {
return Ok(Generated {
text: String::new(),
calls,
..generated
});
}
Verdict::Retry(nudge) => {
if budget == 0 {
return Ok(generated);
}
budget -= 1;
attempt = with_retry_turn(&attempt, &generated.text, &generated.calls, &nudge);
}
}
}
}
#[cfg(test)]
mod tests;