Add schema quality checks
Deploy / deploy (push) Successful in 1m36s
CI / Rust Checks (push) Successful in 5m43s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 2s
CI / Frontend E2E (push) Successful in 4m36s

This commit is contained in:
github-ops
2026-06-20 19:59:55 +00:00
parent 2719c5f7bf
commit d79ec95028
4 changed files with 242 additions and 10 deletions
+2 -1
View File
@@ -63,6 +63,7 @@ pub use operation::{
pub use protocol::{AuthKind, ExportMode, HttpMethod, Protocol};
pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion};
pub use tool_quality::{
ToolQualityFinding, ToolQualityReport, ToolQualitySeverity, analyze_tool_identity_quality,
ToolQualityFinding, ToolQualityReport, ToolQualitySchemaKind, ToolQualitySchemaNode,
ToolQualitySeverity, analyze_tool_identity_quality, analyze_tool_schema_quality,
};
pub use workspace::{Workspace, WorkspaceStatus};
+127
View File
@@ -5,6 +5,8 @@ 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"];
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -31,6 +33,31 @@ pub struct ToolQualityReport {
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>,
}
impl ToolQualityReport {
pub fn new(findings: Vec<ToolQualityFinding>) -> Self {
let blocking = findings
@@ -45,6 +72,31 @@ impl ToolQualityReport {
}
}
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,
@@ -114,6 +166,15 @@ pub fn analyze_tool_identity_quality(
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)
}
fn is_safe_tool_name(value: &str) -> bool {
let mut chars = value.chars();
let Some(first) = chars.next() else {
@@ -126,6 +187,72 @@ fn is_safe_tool_name(value: &str) -> bool {
chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
}
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,
+69 -2
View File
@@ -1,6 +1,6 @@
use crank_core::{
ToolDescription, ToolQualityFinding, ToolQualityReport, ToolQualitySeverity,
analyze_tool_identity_quality,
ToolDescription, ToolQualityFinding, ToolQualityReport, ToolQualitySchemaNode,
ToolQualitySeverity, analyze_tool_identity_quality, analyze_tool_schema_quality,
};
#[test]
@@ -114,6 +114,73 @@ fn warns_about_short_description() {
));
}
#[test]
fn warns_about_object_schema_without_fields() {
let schema = ToolQualitySchemaNode::object(None, vec![]);
let report = analyze_tool_schema_quality("input_schema", &schema);
assert!(has_finding(
&report,
"schema_object_without_fields",
ToolQualitySeverity::Warning
));
}
#[test]
fn warns_about_parameter_without_description() {
let schema = ToolQualitySchemaNode::object(
Some("Входные параметры"),
vec![("customer_id", ToolQualitySchemaNode::string(None))],
);
let report = analyze_tool_schema_quality("input_schema", &schema);
assert!(has_finding(
&report,
"schema_field_missing_description",
ToolQualitySeverity::Warning
));
}
#[test]
fn warns_about_multi_action_parameter_names() {
let schema = ToolQualitySchemaNode::object(
Some("Входные параметры"),
vec![(
"action",
ToolQualitySchemaNode::string(Some("Что нужно сделать")),
)],
);
let report = analyze_tool_schema_quality("input_schema", &schema);
assert!(has_finding(
&report,
"schema_multi_action_parameter",
ToolQualitySeverity::Warning
));
}
#[test]
fn recommends_enum_for_mode_like_string_parameter() {
let schema = ToolQualitySchemaNode::object(
Some("Входные параметры"),
vec![(
"mode",
ToolQualitySchemaNode::string(Some("Режим поиска клиента")),
)],
);
let report = analyze_tool_schema_quality("input_schema", &schema);
assert!(has_finding(
&report,
"schema_enum_recommended",
ToolQualitySeverity::Info
));
}
fn has_finding(report: &ToolQualityReport, code: &str, severity: ToolQualitySeverity) -> bool {
report
.findings