Warn about confusing agent tool catalogs
This commit is contained in:
@@ -316,6 +316,17 @@
|
|||||||
<span class="drawer-section-count" x-text="tfKey('agents.drawer.selected', { count: form.selectedOps.length })"></span>
|
<span class="drawer-section-count" x-text="tfKey('agents.drawer.selected', { count: form.selectedOps.length })"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="drawer-section-sub" x-text="operationsSubText()">Select the MCP tools available to this agent.</div>
|
<div class="drawer-section-sub" x-text="operationsSubText()">Select the MCP tools available to this agent.</div>
|
||||||
|
<div class="agents-rec-callout" x-show="agentToolFindings.length > 0" style="display:none; margin-bottom: 12px;">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="8" cy="8" r="7"/><path d="M8 4.5v4M8 11.5v.2"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<strong data-i18n="agents.drawer.finding.title">Recommendation</strong>
|
||||||
|
<template x-for="finding in agentToolFindings" :key="finding">
|
||||||
|
<div x-text="finding"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ops-picker">
|
<div class="ops-picker">
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
|
|||||||
@@ -195,6 +195,35 @@ document.addEventListener('alpine:init', function() {
|
|||||||
return this.tKey(this.isCommunityBuild ? 'agents.drawer.operations_sub_community' : 'agents.drawer.operations_sub');
|
return this.tKey(this.isCommunityBuild ? 'agents.drawer.operations_sub_community' : 'agents.drawer.operations_sub');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
get selectedOperations() {
|
||||||
|
var selected = this.form.selectedOps;
|
||||||
|
return this.operations.filter(function(operation) {
|
||||||
|
return selected.includes(operation.id);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
get agentToolFindings() {
|
||||||
|
var findings = [];
|
||||||
|
var selected = this.selectedOperations;
|
||||||
|
if (selected.length > 8) {
|
||||||
|
findings.push(this.tKey('agents.drawer.finding.too_many_tools'));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var leftIndex = 0; leftIndex < selected.length; leftIndex += 1) {
|
||||||
|
for (var rightIndex = leftIndex + 1; rightIndex < selected.length; rightIndex += 1) {
|
||||||
|
if (this.operationsLookSimilar(selected[leftIndex], selected[rightIndex])) {
|
||||||
|
findings.push(this.tfKey('agents.drawer.finding.similar_tools', {
|
||||||
|
left: selected[leftIndex].display_name || selected[leftIndex].name,
|
||||||
|
right: selected[rightIndex].display_name || selected[rightIndex].name,
|
||||||
|
}));
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings;
|
||||||
|
},
|
||||||
|
|
||||||
get filteredOps() {
|
get filteredOps() {
|
||||||
var query = this.opSearch.toLowerCase().trim();
|
var query = this.opSearch.toLowerCase().trim();
|
||||||
if (!query) return this.operations;
|
if (!query) return this.operations;
|
||||||
@@ -267,6 +296,28 @@ document.addEventListener('alpine:init', function() {
|
|||||||
return this.form.selectedOps.includes(operationId);
|
return this.form.selectedOps.includes(operationId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
operationsLookSimilar(left, right) {
|
||||||
|
var leftTokens = this.operationTokens(left);
|
||||||
|
var rightTokens = this.operationTokens(right);
|
||||||
|
if (!leftTokens.length || !rightTokens.length) return false;
|
||||||
|
var shared = leftTokens.filter(function(token) { return rightTokens.includes(token); }).length;
|
||||||
|
var smaller = Math.min(leftTokens.length, rightTokens.length);
|
||||||
|
return shared >= 2 && (shared / smaller) >= 0.6;
|
||||||
|
},
|
||||||
|
|
||||||
|
operationTokens(operation) {
|
||||||
|
var text = [
|
||||||
|
operation.name || '',
|
||||||
|
operation.display_name || '',
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
var tokens = [];
|
||||||
|
text.split(/[^a-z0-9а-яё]+/i).forEach(function(token) {
|
||||||
|
if (token.length < 3 || tokens.includes(token)) return;
|
||||||
|
tokens.push(token);
|
||||||
|
});
|
||||||
|
return tokens;
|
||||||
|
},
|
||||||
|
|
||||||
async saveAgent() {
|
async saveAgent() {
|
||||||
var self = this;
|
var self = this;
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -765,6 +765,9 @@ var TRANSLATIONS = {
|
|||||||
'agents.drawer.selected': '{count} selected',
|
'agents.drawer.selected': '{count} selected',
|
||||||
'agents.drawer.operations_sub': 'Select the MCP tools available to this agent.',
|
'agents.drawer.operations_sub': 'Select the MCP tools available to this agent.',
|
||||||
'agents.drawer.operations_sub_community': 'Select the MCP tools available to this agent.',
|
'agents.drawer.operations_sub_community': 'Select the MCP tools available to this agent.',
|
||||||
|
'agents.drawer.finding.title': 'Recommendation',
|
||||||
|
'agents.drawer.finding.too_many_tools': 'This agent has many tools. Keep only the tools needed for one concrete task.',
|
||||||
|
'agents.drawer.finding.similar_tools': 'Tools “{left}” and “{right}” look similar. Rename them more precisely or keep one of them.',
|
||||||
'agents.drawer.filter_ops': 'Filter operations…',
|
'agents.drawer.filter_ops': 'Filter operations…',
|
||||||
'agents.drawer.ops_no_match': 'No operations match "{query}"',
|
'agents.drawer.ops_no_match': 'No operations match "{query}"',
|
||||||
'agents.drawer.ops_selected': '{count} operations selected',
|
'agents.drawer.ops_selected': '{count} operations selected',
|
||||||
@@ -1577,6 +1580,9 @@ var TRANSLATIONS = {
|
|||||||
'agents.drawer.selected': 'Выбрано: {count}',
|
'agents.drawer.selected': 'Выбрано: {count}',
|
||||||
'agents.drawer.operations_sub': 'Выберите MCP инструменты, которые будут доступны для этого агента.',
|
'agents.drawer.operations_sub': 'Выберите MCP инструменты, которые будут доступны для этого агента.',
|
||||||
'agents.drawer.operations_sub_community': 'Выберите MCP инструменты, которые будут доступны для этого агента.',
|
'agents.drawer.operations_sub_community': 'Выберите MCP инструменты, которые будут доступны для этого агента.',
|
||||||
|
'agents.drawer.finding.title': 'Рекомендация',
|
||||||
|
'agents.drawer.finding.too_many_tools': 'У агента выбрано много инструментов. Оставьте только те, которые нужны для одной конкретной задачи.',
|
||||||
|
'agents.drawer.finding.similar_tools': 'Инструменты «{left}» и «{right}» похожи. Переименуйте их точнее или оставьте один вариант.',
|
||||||
'agents.drawer.filter_ops': 'Фильтр операций…',
|
'agents.drawer.filter_ops': 'Фильтр операций…',
|
||||||
'agents.drawer.ops_no_match': 'Нет операций по запросу "{query}"',
|
'agents.drawer.ops_no_match': 'Нет операций по запросу "{query}"',
|
||||||
'agents.drawer.ops_selected': 'Выбрано операций: {count}',
|
'agents.drawer.ops_selected': 'Выбрано операций: {count}',
|
||||||
|
|||||||
@@ -63,9 +63,9 @@ pub use operation::{
|
|||||||
pub use protocol::{AuthKind, ExportMode, HttpMethod, Protocol};
|
pub use protocol::{AuthKind, ExportMode, HttpMethod, Protocol};
|
||||||
pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion};
|
pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion};
|
||||||
pub use tool_quality::{
|
pub use tool_quality::{
|
||||||
ToolQualityFinding, ToolQualityMappingRule, ToolQualityMappingSet, ToolQualityReport,
|
ToolQualityCatalogTool, ToolQualityFinding, ToolQualityMappingRule, ToolQualityMappingSet,
|
||||||
ToolQualitySchemaKind, ToolQualitySchemaNode, ToolQualitySeverity,
|
ToolQualityReport, ToolQualitySchemaKind, ToolQualitySchemaNode, ToolQualitySeverity,
|
||||||
analyze_tool_identity_quality, analyze_tool_response_projection_quality,
|
analyze_agent_tool_catalog_quality, analyze_tool_identity_quality,
|
||||||
analyze_tool_schema_quality,
|
analyze_tool_response_projection_quality, analyze_tool_schema_quality,
|
||||||
};
|
};
|
||||||
pub use workspace::{Workspace, WorkspaceStatus};
|
pub use workspace::{Workspace, WorkspaceStatus};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const GENERIC_TOOL_NAMES: &[&str] = &["call_api", "manage", "execute", "request"
|
|||||||
const MULTI_ACTION_FIELD_NAMES: &[&str] = &["action", "mode", "operation", "type"];
|
const MULTI_ACTION_FIELD_NAMES: &[&str] = &["action", "mode", "operation", "type"];
|
||||||
const ENUM_LIKE_FIELD_NAMES: &[&str] = &["mode", "type", "status", "category"];
|
const ENUM_LIKE_FIELD_NAMES: &[&str] = &["mode", "type", "status", "category"];
|
||||||
const MANY_PROJECTED_FIELDS_THRESHOLD: usize = 12;
|
const MANY_PROJECTED_FIELDS_THRESHOLD: usize = 12;
|
||||||
|
const LARGE_AGENT_TOOL_CATALOG_THRESHOLD: usize = 8;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -70,6 +71,13 @@ pub struct ToolQualityMappingSet {
|
|||||||
pub rules: Vec<ToolQualityMappingRule>,
|
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 {
|
impl ToolQualityReport {
|
||||||
pub fn new(findings: Vec<ToolQualityFinding>) -> Self {
|
pub fn new(findings: Vec<ToolQualityFinding>) -> Self {
|
||||||
let blocking = findings
|
let blocking = findings
|
||||||
@@ -218,6 +226,37 @@ pub fn analyze_tool_response_projection_quality(
|
|||||||
ToolQualityReport::new(findings)
|
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 {
|
fn is_safe_tool_name(value: &str) -> bool {
|
||||||
let mut chars = value.chars();
|
let mut chars = value.chars();
|
||||||
let Some(first) = chars.next() else {
|
let Some(first) = chars.next() else {
|
||||||
@@ -230,6 +269,37 @@ fn is_safe_tool_name(value: &str) -> bool {
|
|||||||
chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
|
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(
|
fn analyze_schema_node(
|
||||||
path: &str,
|
path: &str,
|
||||||
schema: &ToolQualitySchemaNode,
|
schema: &ToolQualitySchemaNode,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crank_core::{
|
use crank_core::{
|
||||||
ToolDescription, ToolQualityFinding, ToolQualityMappingRule, ToolQualityMappingSet,
|
ToolDescription, ToolQualityCatalogTool, ToolQualityFinding, ToolQualityMappingRule,
|
||||||
ToolQualityReport, ToolQualitySchemaNode, ToolQualitySeverity, analyze_tool_identity_quality,
|
ToolQualityMappingSet, ToolQualityReport, ToolQualitySchemaNode, ToolQualitySeverity,
|
||||||
|
analyze_agent_tool_catalog_quality, analyze_tool_identity_quality,
|
||||||
analyze_tool_response_projection_quality, analyze_tool_schema_quality,
|
analyze_tool_response_projection_quality, analyze_tool_schema_quality,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -235,6 +236,79 @@ fn marks_large_output_projection_as_info() {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warns_about_large_agent_tool_catalog() {
|
||||||
|
let tools: Vec<ToolQualityCatalogTool> = (0..9)
|
||||||
|
.map(|index| {
|
||||||
|
catalog_tool(
|
||||||
|
&format!("tool_{index}"),
|
||||||
|
&format!("Tool {index}"),
|
||||||
|
"Does one task",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let report = analyze_agent_tool_catalog_quality(&tools);
|
||||||
|
|
||||||
|
assert!(has_finding(
|
||||||
|
&report,
|
||||||
|
"agent_catalog_too_large",
|
||||||
|
ToolQualitySeverity::Warning
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warns_about_similar_agent_tools() {
|
||||||
|
let tools = vec![
|
||||||
|
catalog_tool(
|
||||||
|
"get_exchange_rate",
|
||||||
|
"Получить курс валюты",
|
||||||
|
"Получает последний курс валюты",
|
||||||
|
),
|
||||||
|
catalog_tool(
|
||||||
|
"fetch_exchange_rate",
|
||||||
|
"Запросить курс валюты",
|
||||||
|
"Получает текущий курс валюты",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let report = analyze_agent_tool_catalog_quality(&tools);
|
||||||
|
|
||||||
|
assert!(has_finding(
|
||||||
|
&report,
|
||||||
|
"agent_catalog_similar_tools",
|
||||||
|
ToolQualitySeverity::Warning
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_small_catalog_with_distinct_tools() {
|
||||||
|
let tools = vec![
|
||||||
|
catalog_tool(
|
||||||
|
"get_exchange_rate",
|
||||||
|
"Получить курс валюты",
|
||||||
|
"Получает курс валюты",
|
||||||
|
),
|
||||||
|
catalog_tool(
|
||||||
|
"get_weather_forecast",
|
||||||
|
"Получить прогноз погоды",
|
||||||
|
"Получает прогноз погоды",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let report = analyze_agent_tool_catalog_quality(&tools);
|
||||||
|
|
||||||
|
assert!(report.findings.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn catalog_tool(name: &str, display_name: &str, description: &str) -> ToolQualityCatalogTool {
|
||||||
|
ToolQualityCatalogTool {
|
||||||
|
name: name.to_owned(),
|
||||||
|
display_name: display_name.to_owned(),
|
||||||
|
description: description.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn has_finding(report: &ToolQualityReport, code: &str, severity: ToolQualitySeverity) -> bool {
|
fn has_finding(report: &ToolQualityReport, code: &str, severity: ToolQualitySeverity) -> bool {
|
||||||
report
|
report
|
||||||
.findings
|
.findings
|
||||||
|
|||||||
Reference in New Issue
Block a user