Files
crank/crates/crank-core/src/tool_quality.rs
T
github-ops 7099faad2b
Deploy / deploy (push) Successful in 1m34s
CI / Rust Checks (push) Successful in 5m3s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 4m44s
Warn about confusing agent tool catalogs
2026-06-20 21:09:45 +00:00

384 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub field_path: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolQualityReport {
pub blocking: bool,
pub findings: Vec<ToolQualityFinding>,
}
#[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<String>,
pub fields: Vec<(String, ToolQualitySchemaNode)>,
#[serde(skip_serializing_if = "Option::is_none")]
pub items: Option<Box<ToolQualitySchemaNode>>,
pub enum_values: Vec<String>,
}
#[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<ToolQualityMappingRule>,
}
#[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<ToolQualityFinding>) -> 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<String> {
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<ToolQualityFinding>,
) {
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),
}
}