use serde::{Deserialize, Serialize}; use crate::ToolDescription; const MAX_TOOL_NAME_LEN: usize = 64; const MIN_DESCRIPTION_CHARS: usize = 40; const GENERIC_TOOL_NAMES: &[&str] = &["call_api", "manage", "execute", "request"]; const MULTI_ACTION_FIELD_NAMES: &[&str] = &["action", "mode", "operation", "type"]; const ENUM_LIKE_FIELD_NAMES: &[&str] = &["mode", "type", "status", "category"]; const MANY_PROJECTED_FIELDS_THRESHOLD: usize = 12; const LARGE_AGENT_TOOL_CATALOG_THRESHOLD: usize = 8; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ToolQualitySeverity { Info, Warning, Error, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ToolQualityFinding { pub severity: ToolQualitySeverity, pub code: String, pub message: String, #[serde(skip_serializing_if = "Option::is_none")] pub suggested_action: Option, #[serde(skip_serializing_if = "Option::is_none")] pub field_path: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ToolQualityReport { pub blocking: bool, pub findings: Vec, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ToolQualitySchemaKind { Object, Array, String, Integer, Number, Boolean, Enum, Null, Oneof, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ToolQualitySchemaNode { pub kind: ToolQualitySchemaKind, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub fields: Vec<(String, ToolQualitySchemaNode)>, #[serde(skip_serializing_if = "Option::is_none")] pub items: Option>, pub enum_values: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ToolQualityMappingRule { pub source: String, pub target: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct ToolQualityMappingSet { pub rules: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ToolQualityCatalogTool { pub name: String, pub display_name: String, pub description: String, } impl ToolQualityReport { pub fn new(findings: Vec) -> Self { let blocking = findings .iter() .any(|finding| finding.severity == ToolQualitySeverity::Error); Self { blocking, findings } } pub fn empty() -> Self { Self::new(Vec::new()) } } impl ToolQualitySchemaNode { pub fn object(description: Option<&str>, fields: Vec<(&str, ToolQualitySchemaNode)>) -> Self { Self { kind: ToolQualitySchemaKind::Object, description: description.map(ToOwned::to_owned), fields: fields .into_iter() .map(|(name, schema)| (name.to_owned(), schema)) .collect(), items: None, enum_values: Vec::new(), } } pub fn string(description: Option<&str>) -> Self { Self { kind: ToolQualitySchemaKind::String, description: description.map(ToOwned::to_owned), fields: Vec::new(), items: None, enum_values: Vec::new(), } } } pub fn analyze_tool_identity_quality( tool_name: &str, tool_description: &ToolDescription, ) -> ToolQualityReport { let mut findings = Vec::new(); let normalized_name = tool_name.trim(); if normalized_name.is_empty() { findings.push(finding( ToolQualitySeverity::Error, "tool_name_missing", "Имя инструмента обязательно.", Some("Укажите короткое техническое имя в snake_case, например get_customer_profile."), Some("name"), )); } else { if normalized_name.chars().count() > MAX_TOOL_NAME_LEN { findings.push(finding( ToolQualitySeverity::Warning, "tool_name_too_long", "Имя инструмента слишком длинное.", Some("Сократите имя до понятного действия и объекта."), Some("name"), )); } if !is_safe_tool_name(normalized_name) { findings.push(finding( ToolQualitySeverity::Error, "tool_name_invalid_format", "Имя инструмента должно быть в безопасном snake_case формате.", Some("Используйте только строчные английские буквы, цифры и подчеркивания."), Some("name"), )); } if GENERIC_TOOL_NAMES.contains(&normalized_name) { findings.push(finding( ToolQualitySeverity::Warning, "tool_name_too_generic", "Имя инструмента слишком общее.", Some("Назовите действие и объект явно, например create_crm_lead или get_ticket_status."), Some("name"), )); } } let description = tool_description.description.trim(); if description.is_empty() { findings.push(finding( ToolQualitySeverity::Error, "tool_description_missing", "Описание инструмента обязательно.", Some("Опишите, когда агенту нужно использовать этот инструмент и какой результат считается успешным."), Some("tool_description.description"), )); } else if description.chars().count() < MIN_DESCRIPTION_CHARS { findings.push(finding( ToolQualitySeverity::Warning, "tool_description_too_short", "Описание инструмента слишком короткое.", Some("Добавьте назначение, условия применения и ожидаемый успешный результат."), Some("tool_description.description"), )); } ToolQualityReport::new(findings) } pub fn analyze_tool_schema_quality( root_path: &str, schema: &ToolQualitySchemaNode, ) -> ToolQualityReport { let mut findings = Vec::new(); analyze_schema_node(root_path, schema, &mut findings); ToolQualityReport::new(findings) } pub fn analyze_tool_response_projection_quality( output_mapping: &ToolQualityMappingSet, ) -> ToolQualityReport { let mut findings = Vec::new(); for (index, rule) in output_mapping.rules.iter().enumerate() { let source = rule.source.trim(); if source == "$.response.body" || source == "$.response.data" { findings.push(finding( ToolQualitySeverity::Warning, "response_projection_full_body", "Результат инструмента отдает агенту весь ответ API.", Some("Выберите только поля, которые нужны агенту для ответа пользователю или следующего действия."), Some(&format!("output_mapping.rules.{index}.source")), )); } } if output_mapping.rules.len() >= MANY_PROJECTED_FIELDS_THRESHOLD { findings.push(finding( ToolQualitySeverity::Info, "response_projection_many_fields", "В результат инструмента попадает много полей.", Some("Проверьте, что каждое поле действительно нужно агенту. Лишние данные увеличивают риск ошибок и галлюцинаций."), Some("output_mapping.rules"), )); } ToolQualityReport::new(findings) } pub fn analyze_agent_tool_catalog_quality(tools: &[ToolQualityCatalogTool]) -> ToolQualityReport { let mut findings = Vec::new(); if tools.len() > LARGE_AGENT_TOOL_CATALOG_THRESHOLD { findings.push(finding( ToolQualitySeverity::Warning, "agent_catalog_too_large", "В каталоге агента много инструментов.", Some("Оставьте только инструменты, которые нужны этому агенту для конкретной задачи."), Some("agent.operations"), )); } for (left_index, left) in tools.iter().enumerate() { for (right_offset, right) in tools[left_index + 1..].iter().enumerate() { let right_index = left_index + right_offset + 1; if tools_are_similar(left, right) { findings.push(finding( ToolQualitySeverity::Warning, "agent_catalog_similar_tools", "У агента есть похожие инструменты.", Some("Переименуйте инструменты точнее или оставьте один вариант, чтобы модель не выбирала наугад."), Some(&format!("agent.operations.{left_index},{right_index}")), )); } } } ToolQualityReport::new(findings) } fn is_safe_tool_name(value: &str) -> bool { let mut chars = value.chars(); let Some(first) = chars.next() else { return false; }; if !first.is_ascii_lowercase() { return false; } chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') } fn tools_are_similar(left: &ToolQualityCatalogTool, right: &ToolQualityCatalogTool) -> bool { let left_tokens = catalog_tool_tokens(left); let right_tokens = catalog_tool_tokens(right); if left_tokens.is_empty() || right_tokens.is_empty() { return false; } let shared = left_tokens .iter() .filter(|token| right_tokens.contains(token)) .count(); let smaller = left_tokens.len().min(right_tokens.len()); shared >= 2 && shared * 100 / smaller >= 60 } fn catalog_tool_tokens(tool: &ToolQualityCatalogTool) -> Vec { let text = format!("{} {} {}", tool.name, tool.display_name, tool.description); let mut tokens = Vec::new(); for token in text .to_lowercase() .split(|ch: char| !(ch.is_ascii_alphanumeric() || ('а'..='я').contains(&ch) || ch == 'ё')) { if token.len() < 3 || tokens.iter().any(|existing| existing == token) { continue; } tokens.push(token.to_owned()); } tokens } fn analyze_schema_node( path: &str, schema: &ToolQualitySchemaNode, findings: &mut Vec, ) { if schema.kind == ToolQualitySchemaKind::Object && schema.fields.is_empty() { findings.push(finding( ToolQualitySeverity::Warning, "schema_object_without_fields", "Схема объекта не содержит параметров.", Some("Добавьте только те параметры, которые действительно нужны агенту для вызова инструмента."), Some(path), )); } for (field_name, field_schema) in &schema.fields { let field_path = format!("{path}.fields.{field_name}"); if is_missing_description(field_schema) { findings.push(finding( ToolQualitySeverity::Warning, "schema_field_missing_description", "У параметра нет описания.", Some("Опишите назначение параметра понятным языком, чтобы модель не угадывала его смысл."), Some(&format!("{field_path}.description")), )); } if MULTI_ACTION_FIELD_NAMES.contains(&field_name.as_str()) { findings.push(finding( ToolQualitySeverity::Warning, "schema_multi_action_parameter", "Параметр выглядит как переключатель нескольких действий.", Some("Лучше разделить разные действия на отдельные MCP инструменты с узкими именами и схемами."), Some(&field_path), )); } if ENUM_LIKE_FIELD_NAMES.contains(&field_name.as_str()) && field_schema.kind == ToolQualitySchemaKind::String && field_schema.enum_values.is_empty() { findings.push(finding( ToolQualitySeverity::Info, "schema_enum_recommended", "Для параметра с ограниченным набором значений лучше указать enum.", Some("Задайте допустимые значения явно, чтобы агент не придумывал варианты."), Some(&field_path), )); } analyze_schema_node(&field_path, field_schema, findings); } if let Some(items) = &schema.items { analyze_schema_node(&format!("{path}.items"), items, findings); } } fn is_missing_description(schema: &ToolQualitySchemaNode) -> bool { schema .description .as_deref() .is_none_or(|description| description.trim().is_empty()) } fn finding( severity: ToolQualitySeverity, code: &str, message: &str, suggested_action: Option<&str>, field_path: Option<&str>, ) -> ToolQualityFinding { ToolQualityFinding { severity, code: code.to_owned(), message: message.to_owned(), suggested_action: suggested_action.map(ToOwned::to_owned), field_path: field_path.map(ToOwned::to_owned), } }