наблюдаемость: измерять бюджет каталога MCP
This commit is contained in:
@@ -10,6 +10,7 @@ pub mod observability;
|
||||
pub mod operation;
|
||||
pub mod protocol;
|
||||
pub mod secret;
|
||||
pub mod tool_catalog;
|
||||
pub mod tool_quality;
|
||||
pub mod workspace;
|
||||
|
||||
@@ -50,6 +51,7 @@ pub mod domain {
|
||||
};
|
||||
pub use crate::protocol::{AuthKind, ExportMode, HttpMethod, Protocol};
|
||||
pub use crate::secret::{Secret, SecretKind, SecretStatus, SecretVersion};
|
||||
pub use crate::tool_catalog::{ToolCatalogAnalysis, ToolCatalogBudget};
|
||||
pub use crate::tool_quality::{
|
||||
ToolQualityCatalogTool, ToolQualityFinding, ToolQualityMappingRule, ToolQualityMappingSet,
|
||||
ToolQualityReport, ToolQualitySchemaKind, ToolQualitySchemaNode, ToolQualitySeverity,
|
||||
@@ -141,6 +143,10 @@ pub use operation::{
|
||||
};
|
||||
pub use protocol::{AuthKind, ExportMode, HttpMethod, Protocol};
|
||||
pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion};
|
||||
pub use tool_catalog::{
|
||||
RECOMMENDED_TOOL_CATALOG_CONTEXT_TOKENS, ToolCatalogAnalysis, ToolCatalogAnalysisError,
|
||||
ToolCatalogBudget, analyze_tool_catalog,
|
||||
};
|
||||
pub use tool_quality::{
|
||||
ToolQualityCatalogTool, ToolQualityFinding, ToolQualityMappingRule, ToolQualityMappingSet,
|
||||
ToolQualityReport, ToolQualitySchemaKind, ToolQualitySchemaNode, ToolQualitySeverity,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::tool_quality::{
|
||||
ToolQualityCatalogTool, ToolQualityFinding, ToolQualityReport, ToolQualitySeverity,
|
||||
analyze_agent_tool_catalog_quality,
|
||||
};
|
||||
|
||||
pub const RECOMMENDED_TOOL_CATALOG_CONTEXT_TOKENS: usize = 4_096;
|
||||
const ESTIMATED_TOKEN_UTF8_BYTES: usize = 3;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ToolCatalogBudget {
|
||||
pub tool_count: usize,
|
||||
pub serialized_bytes: usize,
|
||||
pub estimated_context_tokens: usize,
|
||||
pub largest_tool_estimated_context_tokens: usize,
|
||||
pub recommended_context_tokens: usize,
|
||||
pub exceeds_recommended_budget: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ToolCatalogAnalysis {
|
||||
pub budget: ToolCatalogBudget,
|
||||
pub quality: ToolQualityReport,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ToolCatalogAnalysisError {
|
||||
#[error("tool catalog and definitions length mismatch")]
|
||||
DefinitionCountMismatch,
|
||||
#[error("tool catalog definition is missing string field: {0}")]
|
||||
DefinitionFieldMissing(&'static str),
|
||||
#[error("tool catalog definition serialization failed: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
pub fn analyze_tool_catalog(
|
||||
tools: &[ToolQualityCatalogTool],
|
||||
definitions: &[Value],
|
||||
) -> Result<ToolCatalogAnalysis, ToolCatalogAnalysisError> {
|
||||
if tools.len() != definitions.len() {
|
||||
return Err(ToolCatalogAnalysisError::DefinitionCountMismatch);
|
||||
}
|
||||
|
||||
let mut serialized_bytes = 0usize;
|
||||
let mut estimated_context_tokens = 0usize;
|
||||
let mut largest_tool_estimated_context_tokens = 0usize;
|
||||
for definition in definitions {
|
||||
let definition_bytes = serde_json::to_vec(definition)?.len();
|
||||
let definition_tokens = estimate_context_tokens(definition_bytes);
|
||||
serialized_bytes = serialized_bytes.saturating_add(definition_bytes);
|
||||
estimated_context_tokens = estimated_context_tokens.saturating_add(definition_tokens);
|
||||
largest_tool_estimated_context_tokens =
|
||||
largest_tool_estimated_context_tokens.max(definition_tokens);
|
||||
}
|
||||
|
||||
let exceeds_recommended_budget =
|
||||
estimated_context_tokens > RECOMMENDED_TOOL_CATALOG_CONTEXT_TOKENS;
|
||||
let budget = ToolCatalogBudget {
|
||||
tool_count: tools.len(),
|
||||
serialized_bytes,
|
||||
estimated_context_tokens,
|
||||
largest_tool_estimated_context_tokens,
|
||||
recommended_context_tokens: RECOMMENDED_TOOL_CATALOG_CONTEXT_TOKENS,
|
||||
exceeds_recommended_budget,
|
||||
};
|
||||
let mut quality = analyze_agent_tool_catalog_quality(tools);
|
||||
if exceeds_recommended_budget {
|
||||
quality.findings.push(ToolQualityFinding {
|
||||
severity: ToolQualitySeverity::Warning,
|
||||
code: "agent_catalog_context_budget_high".to_owned(),
|
||||
message: "Каталог инструментов занимает слишком много контекста модели.".to_owned(),
|
||||
suggested_action: Some(
|
||||
"Сократите описания и схемы либо разделите инструменты между специализированными агентами."
|
||||
.to_owned(),
|
||||
),
|
||||
field_path: Some("agent.operations".to_owned()),
|
||||
});
|
||||
quality = ToolQualityReport::new(quality.findings);
|
||||
}
|
||||
|
||||
Ok(ToolCatalogAnalysis { budget, quality })
|
||||
}
|
||||
|
||||
fn estimate_context_tokens(serialized_bytes: usize) -> usize {
|
||||
serialized_bytes.div_ceil(ESTIMATED_TOKEN_UTF8_BYTES)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod unit {
|
||||
mod tool_catalog;
|
||||
mod tool_quality;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
use crank_core::{
|
||||
ToolCatalogAnalysis, ToolQualityCatalogTool, ToolQualitySeverity, analyze_tool_catalog,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn measures_the_actual_serialized_catalog_definition() {
|
||||
let definitions = vec![json!({
|
||||
"name": "get_exchange_rate",
|
||||
"title": "Получить курс",
|
||||
"description": "Возвращает актуальный курс выбранной валютной пары.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base": {"type": "string"},
|
||||
"quote": {"type": "string"}
|
||||
},
|
||||
"required": ["base", "quote"]
|
||||
}
|
||||
})];
|
||||
|
||||
let analysis = analyze_tool_catalog(&[tool("get_exchange_rate")], &definitions).unwrap();
|
||||
let expected_bytes = serde_json::to_vec(&definitions[0]).unwrap().len();
|
||||
|
||||
assert_eq!(analysis.budget.tool_count, 1);
|
||||
assert_eq!(analysis.budget.serialized_bytes, expected_bytes);
|
||||
assert!(analysis.budget.estimated_context_tokens > 0);
|
||||
assert_eq!(
|
||||
analysis.budget.largest_tool_estimated_context_tokens,
|
||||
analysis.budget.estimated_context_tokens
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warns_when_actual_catalog_exceeds_context_budget() {
|
||||
let tools = (0..9)
|
||||
.map(|index| tool(&format!("large_tool_{index}")))
|
||||
.collect::<Vec<_>>();
|
||||
let definitions = (0..9)
|
||||
.map(|index| {
|
||||
json!({
|
||||
"name": format!("large_tool_{index}"),
|
||||
"description": "x".repeat(1_500),
|
||||
"inputSchema": {"type": "object", "properties": {}}
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let analysis = analyze_tool_catalog(&tools, &definitions).unwrap();
|
||||
|
||||
assert!(analysis.budget.exceeds_recommended_budget);
|
||||
assert!(has_warning(&analysis, "agent_catalog_context_budget_high"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_mismatch_between_catalog_tools_and_definitions() {
|
||||
let error = analyze_tool_catalog(&[tool("one")], &[]).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"tool catalog and definitions length mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
fn tool(name: &str) -> ToolQualityCatalogTool {
|
||||
ToolQualityCatalogTool {
|
||||
name: name.to_owned(),
|
||||
display_name: name.to_owned(),
|
||||
description: format!("Инструмент {name} выполняет одну конкретную операцию."),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_warning(analysis: &ToolCatalogAnalysis, code: &str) -> bool {
|
||||
analysis
|
||||
.quality
|
||||
.findings
|
||||
.iter()
|
||||
.any(|finding| finding.code == code && finding.severity == ToolQualitySeverity::Warning)
|
||||
}
|
||||
Reference in New Issue
Block a user