use crate::ai::types::{Block, Message, Request, Role};
use serde_json::{Value, json};
use std::borrow::Cow;
pub const INVALID_ARGUMENTS: &str = "__invalid_arguments";
const MISSING_RESULT: &str = "La herramienta no llegó a ejecutarse (se interrumpió el turno).";
const EMPTY_RESULT: &str = "(sin salida)";
pub fn tool_input(raw: &str) -> Value {
if raw.trim().is_empty() {
return json!({});
}
serde_json::from_str(raw).unwrap_or_else(|_| json!({ INVALID_ARGUMENTS: raw }))
}
fn is_empty_text(block: &Block) -> bool {
matches!(block, Block::Text(text) if text.trim().is_empty())
}
fn tool_uses(message: &Message) -> impl Iterator<Item = &str> {
message
.blocks
.iter()
.filter_map(|block| if let Block::ToolUse { id, .. } = block { Some(id.as_str()) } else { None })
}
fn answers(message: Option<&Message>, id: &str) -> bool {
message.is_some_and(|message| {
message.role == Role::User
&& message.blocks.iter().any(|block| matches!(block, Block::ToolResult { id: done, .. } if done == id))
})
}
fn is_clean(messages: &[Message]) -> bool {
messages.iter().enumerate().all(|(index, message)| {
let previous = index.checked_sub(1).map(|index| &messages[index]);
!message.blocks.is_empty()
&& !message.blocks.iter().any(is_empty_text)
&& tool_uses(message).all(|id| answers(messages.get(index + 1), id))
&& message.blocks.iter().all(|block| match block {
Block::ToolResult { id, content, .. } => {
!content.is_empty() && previous.is_some_and(|previous| tool_uses(previous).any(|used| used == id))
}
_ => true,
})
})
}
fn repair(messages: &[Message]) -> Vec<Message> {
let mut out: Vec<Message> = Vec::with_capacity(messages.len() + 1);
let mut asked: Vec<String> = Vec::new();
for message in messages {
let mut blocks: Vec<Block> = Vec::with_capacity(message.blocks.len());
if message.role == Role::User {
for id in &asked {
if !message.blocks.iter().any(|block| matches!(block, Block::ToolResult { id: done, .. } if done == id))
{
blocks.push(Block::ToolResult { id: id.clone(), content: MISSING_RESULT.into(), is_error: true });
}
}
} else if !asked.is_empty() {
out.push(Message { role: Role::User, blocks: missing_results(&asked) });
}
for block in &message.blocks {
match block {
_ if is_empty_text(block) => {}
Block::ToolResult { id, .. } if !asked.contains(id) => {}
Block::ToolResult { id, content, is_error } if content.is_empty() => {
blocks.push(Block::ToolResult { id: id.clone(), content: EMPTY_RESULT.into(), is_error: *is_error })
}
other => blocks.push(other.clone()),
}
}
asked =
if message.role == Role::Assistant { tool_uses(message).map(str::to_owned).collect() } else { Vec::new() };
if !blocks.is_empty() {
out.push(Message { role: message.role, blocks });
}
}
if !asked.is_empty() {
out.push(Message { role: Role::User, blocks: missing_results(&asked) });
}
out
}
fn missing_results(ids: &[String]) -> Vec<Block> {
ids.iter().map(|id| Block::ToolResult { id: id.clone(), content: MISSING_RESULT.into(), is_error: true }).collect()
}
pub fn prepare(request: &Request) -> Cow<'_, Request> {
if is_clean(&request.messages) {
return Cow::Borrowed(request);
}
Cow::Owned(Request { messages: repair(&request.messages), ..request.clone() })
}
pub fn object_schema(schema: &Value) -> Value {
let mut schema = if schema.is_object() { schema.clone() } else { json!({}) };
schema["type"] = json!("object");
if !schema["properties"].is_object() {
schema["properties"] = json!({});
}
schema
}
pub fn tool_name(name: &str) -> String {
let clean: String =
name.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect();
clean.chars().take(64).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn tool_use(id: &str) -> Block {
Block::ToolUse { id: id.into(), name: "ls".into(), input: json!({}) }
}
fn result(id: &str, content: &str) -> Block {
Block::ToolResult { id: id.into(), content: content.into(), is_error: false }
}
fn request(messages: Vec<Message>) -> Request {
Request { model: "m".into(), system: None, messages, tools: Vec::new(), max_tokens: 1 }
}
#[test]
fn un_historial_valido_no_se_copia() {
let ok = request(vec![
Message::user("hola"),
Message::assistant(vec![tool_use("a")]),
Message { role: Role::User, blocks: vec![result("a", "x")] },
]);
assert!(matches!(prepare(&ok), Cow::Borrowed(_)));
}
#[test]
fn repara_herramientas_sin_respuesta_texto_vacio_y_resultados_huerfanos() {
let broken = request(vec![
Message::user("hola"),
Message::assistant(vec![Block::Text(" ".into()), tool_use("a"), tool_use("b")]),
Message { role: Role::User, blocks: vec![result("b", ""), result("zz", "huérfano")] },
Message::assistant(vec![Block::Text(String::new())]),
Message::assistant(vec![tool_use("c")]),
]);
let fixed = prepare(&broken).into_owned();
assert_eq!(fixed.messages[1].blocks, [tool_use("a"), tool_use("b")]);
let Block::ToolResult { id, is_error, .. } = &fixed.messages[2].blocks[0] else { panic!() };
assert_eq!((id.as_str(), *is_error), ("a", true), "el que faltaba va primero");
assert_eq!(fixed.messages[2].blocks[1], result("b", EMPTY_RESULT));
assert_eq!(fixed.messages[2].blocks.len(), 2, "el huérfano se quita");
assert_eq!(fixed.messages.len(), 5, "el asistente vacío desaparece y se cierra la última llamada");
assert!(matches!(&fixed.messages[4].blocks[0], Block::ToolResult { id, .. } if id == "c"));
assert!(is_clean(&fixed.messages));
}
#[test]
fn argumentos_rotos_esquemas_y_nombres() {
assert_eq!(tool_input(""), json!({}));
assert_eq!(tool_input("{\"a\":1}"), json!({"a": 1}));
assert_eq!(tool_input("{\"a\":"), json!({ INVALID_ARGUMENTS: "{\"a\":" }));
assert_eq!(object_schema(&json!({"type": "object"})), json!({"type": "object", "properties": {}}));
assert_eq!(object_schema(&Value::Null)["type"], "object");
assert_eq!(tool_name("mcp__my.server__read file"), "mcp__my_server__read_file");
assert_eq!(tool_name(&"x".repeat(80)).len(), 64);
}
}