use crate::clients::base::ToolCall;
use crate::core::message::{Message, MessageType};
use crate::core::tool_spec::ToolSpec;
use crate::guardrails::step_enforcer::StepEnforcer;
use indexmap::IndexSet;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WorkflowStateForScoring {
pub required_steps: Vec<String>,
pub completed_steps: Vec<String>,
pub pending_steps: Vec<String>,
pub terminal_tools: Vec<String>,
pub recent_errors: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ToolSpecForScoring {
pub name: String,
pub description: String,
pub parameters: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CandidateCallForScoring {
pub name: String,
pub arguments: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ScoringContext {
pub schema_version: String,
pub user_request: String,
pub workflow_state: WorkflowStateForScoring,
pub available_tools: Vec<ToolSpecForScoring>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<ScoringMetadata>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ScoringMetadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scenario_family: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requires_transform: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requires_synthesis: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requires_all_tool_facts: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub must_acknowledge_missing_data: Option<bool>,
}
impl ScoringContext {
#[allow(clippy::too_many_arguments)]
pub fn new(
user_request: impl Into<String>,
required_steps: Vec<String>,
completed_steps: Vec<String>,
pending_steps: Vec<String>,
terminal_tools: Vec<String>,
recent_errors: Vec<String>,
tool_specs: &[ToolSpec],
) -> Self {
let available_tools = tool_specs
.iter()
.map(|spec| ToolSpecForScoring {
name: spec.name.clone(),
description: spec.description.clone(),
parameters: spec
.json_schema
.clone()
.unwrap_or_else(default_object_schema),
})
.collect();
Self {
schema_version: "toolcall-verifier-input/v1".to_string(),
user_request: user_request.into(),
workflow_state: WorkflowStateForScoring {
required_steps,
completed_steps,
pending_steps,
terminal_tools,
recent_errors,
},
available_tools,
metadata: None,
}
}
pub fn with_metadata(mut self, metadata: ScoringMetadata) -> Self {
self.metadata = Some(metadata);
self
}
pub fn from_step_enforcer(
user_request: impl Into<String>,
step_enforcer: &StepEnforcer,
terminal_tools: &IndexSet<String>,
recent_errors: Vec<String>,
tool_specs: &[ToolSpec],
) -> Self {
Self::new(
user_request,
step_enforcer.tracker.required_steps().to_vec(),
step_enforcer.completed_steps().keys().cloned().collect(),
step_enforcer.pending(),
terminal_tools.iter().cloned().collect(),
recent_errors,
tool_specs,
)
}
}
pub fn recent_errors_from_messages(messages: &[Message], limit: usize) -> Vec<String> {
let mut errors = messages
.iter()
.rev()
.filter_map(|message| {
let is_error = matches!(
message.metadata.msg_type,
MessageType::RetryNudge | MessageType::StepNudge | MessageType::PrerequisiteNudge
) || (message.metadata.msg_type == MessageType::ToolResult
&& has_error_prefix(&message.content));
if is_error && !message.content.trim().is_empty() {
Some(message.content.clone())
} else {
None
}
})
.take(limit)
.collect::<Vec<_>>();
errors.reverse();
errors
}
fn has_error_prefix(content: &str) -> bool {
let normalized = content.trim_start().to_ascii_lowercase();
normalized.starts_with("[toolerror]")
|| normalized.starts_with("[toolresolutionerror]")
|| normalized.starts_with("[toolexecutionerror]")
|| normalized.starts_with("[tool_error]")
|| normalized.starts_with("[unknown_tool]")
|| normalized.starts_with("[invalidarguments]")
|| normalized.starts_with("[guardrail]")
}
fn default_object_schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {}
})
}
fn py_list(values: &[String]) -> String {
if values.is_empty() {
return "[]".to_string();
}
let body = values
.iter()
.map(|value| format!("'{}'", py_single_quote_escape(value)))
.collect::<Vec<_>>()
.join(", ");
format!("[{body}]")
}
fn py_single_quote_escape(value: &str) -> String {
value.replace('\\', "\\\\").replace('\'', "\\'")
}
fn value_from_index_map(values: &indexmap::IndexMap<String, Value>) -> Value {
Value::Object(
values
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect(),
)
}
fn python_json_dumps_sort_keys(value: &Value) -> String {
match value {
Value::Null => "null".to_string(),
Value::Bool(value) => value.to_string(),
Value::Number(value) => value.to_string(),
Value::String(value) => python_json_string(value),
Value::Array(values) => {
let body = values
.iter()
.map(python_json_dumps_sort_keys)
.collect::<Vec<_>>()
.join(", ");
format!("[{body}]")
}
Value::Object(values) => python_json_object(values),
}
}
fn python_json_object(values: &Map<String, Value>) -> String {
let mut keys = values.keys().collect::<Vec<_>>();
keys.sort();
let body = keys
.into_iter()
.map(|key| {
format!(
"{}: {}",
python_json_string(key),
python_json_dumps_sort_keys(&values[key])
)
})
.collect::<Vec<_>>()
.join(", ");
format!("{{{body}}}")
}
fn python_json_string(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\u{08}' => out.push_str("\\b"),
'\u{0c}' => out.push_str("\\f"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
ch if ch <= '\u{1f}' => out.push_str(&format!("\\u{:04x}", ch as u32)),
ch if ch as u32 <= 0x7f => out.push(ch),
ch if ch as u32 <= 0xffff => out.push_str(&format!("\\u{:04x}", ch as u32)),
ch => {
let code = ch as u32 - 0x1_0000;
let high = 0xd800 + ((code >> 10) & 0x3ff);
let low = 0xdc00 + (code & 0x3ff);
out.push_str(&format!("\\u{high:04x}\\u{low:04x}"));
}
}
}
out.push('"');
out
}
fn optional_json_string(value: Option<&str>) -> String {
value
.map(python_json_string)
.unwrap_or_else(|| "null".to_string())
}
fn optional_json_bool(value: Option<bool>) -> &'static str {
match value {
Some(true) => "true",
Some(false) => "false",
None => "null",
}
}
pub fn serialize_state_v1(ctx: &ScoringContext, candidate: &ToolCall) -> String {
let mut out = String::new();
out.push_str("SCHEMA_VERSION:\n");
out.push_str(&ctx.schema_version);
out.push_str("\n\nUSER_REQUEST:\n");
out.push_str(&ctx.user_request);
out.push_str("\n\nWORKFLOW_STATE:\n");
out.push_str(&format!(
"required_steps={}\n",
py_list(&ctx.workflow_state.required_steps)
));
out.push_str(&format!(
"completed_steps={}\n",
py_list(&ctx.workflow_state.completed_steps)
));
out.push_str(&format!(
"pending_steps={}\n",
py_list(&ctx.workflow_state.pending_steps)
));
out.push_str(&format!(
"terminal_tools={}\n",
py_list(&ctx.workflow_state.terminal_tools)
));
out.push_str(&format!(
"recent_errors={}",
py_list(&ctx.workflow_state.recent_errors)
));
out.push_str("\n\nAVAILABLE_TOOLS:\n");
let tool_text = ctx
.available_tools
.iter()
.map(|tool| {
format!(
"{}: {}\nPARAMETERS: {}",
tool.name,
tool.description,
python_json_dumps_sort_keys(&tool.parameters)
)
})
.collect::<Vec<_>>()
.join("\n\n");
out.push_str(&tool_text);
let candidate_json = Value::Object(
[
(
"arguments".to_string(),
value_from_index_map(&candidate.args),
),
("name".to_string(), Value::String(candidate.tool.clone())),
]
.into_iter()
.collect(),
);
out.push_str("\n\nCANDIDATE_CALL:\n");
out.push_str(&python_json_dumps_sort_keys(&candidate_json));
out
}
pub fn serialize_state_v2(ctx: &ScoringContext, candidate: &ToolCall) -> String {
let mut out = serialize_state_v1(ctx, candidate);
out.push_str("\n\nSCORING_METADATA:\n");
let metadata = ctx.metadata.as_ref();
out.push_str(&format!(
"scenario_family={}\n",
optional_json_string(metadata.and_then(|value| value.scenario_family.as_deref()))
));
out.push_str(&format!(
"requires_transform={}\n",
optional_json_bool(metadata.and_then(|value| value.requires_transform))
));
out.push_str(&format!(
"requires_synthesis={}\n",
optional_json_bool(metadata.and_then(|value| value.requires_synthesis))
));
out.push_str(&format!(
"requires_all_tool_facts={}\n",
optional_json_bool(metadata.and_then(|value| value.requires_all_tool_facts))
));
out.push_str(&format!(
"must_acknowledge_missing_data={}",
optional_json_bool(metadata.and_then(|value| value.must_acknowledge_missing_data))
));
out
}