агенты: добавить поиск инструментов по каталогу

This commit is contained in:
2026-07-21 13:12:46 +03:00
parent 63f8ee333f
commit 99bd05c145
35 changed files with 2088 additions and 155 deletions
+167 -2
View File
@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
use time::OffsetDateTime;
use crate::ids::{AgentId, OperationId, WorkspaceId};
@@ -36,11 +37,149 @@ pub struct AgentVersion {
pub version: u32,
pub status: AgentStatus,
pub instructions: Value,
pub tool_selection_policy: Value,
#[serde(default)]
pub tool_selection_policy: ToolSelectionPolicy,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolAccessMode {
#[default]
#[serde(alias = "allow_list")]
Direct,
Search,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolGroup {
pub id: String,
pub name: String,
pub description: String,
#[serde(default)]
pub tool_names: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolSearchSettings {
#[serde(default = "default_search_result_limit")]
pub max_results: usize,
}
impl Default for ToolSearchSettings {
fn default() -> Self {
Self {
max_results: default_search_result_limit(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolSelectionPolicy {
#[serde(default)]
pub mode: ToolAccessMode,
#[serde(default)]
pub groups: Vec<ToolGroup>,
#[serde(default)]
pub search: ToolSearchSettings,
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum ToolSelectionPolicyError {
#[error("search result limit must be between 1 and 20")]
InvalidSearchResultLimit,
#[error("tool groups are only allowed in search mode")]
GroupsRequireSearchMode,
#[error("tool group id {group_id} is invalid")]
InvalidGroupId { group_id: String },
#[error("tool group id {group_id} is duplicated")]
DuplicateGroupId { group_id: String },
#[error("tool group {group_id} must have a name and description")]
IncompleteGroup { group_id: String },
#[error("tool group {group_id} references unknown tool {tool_name}")]
UnknownTool { group_id: String, tool_name: String },
#[error("tool group {group_id} contains duplicate tool {tool_name}")]
DuplicateTool { group_id: String, tool_name: String },
#[error("tool name {tool_name} is reserved by search mode")]
ReservedToolName { tool_name: String },
}
impl ToolSelectionPolicy {
pub fn validate_for_tools<'a>(
&self,
tool_names: impl IntoIterator<Item = &'a str>,
) -> Result<(), ToolSelectionPolicyError> {
use std::collections::BTreeSet;
if !(1..=20).contains(&self.search.max_results) {
return Err(ToolSelectionPolicyError::InvalidSearchResultLimit);
}
if self.mode == ToolAccessMode::Direct && !self.groups.is_empty() {
return Err(ToolSelectionPolicyError::GroupsRequireSearchMode);
}
let known_tools = tool_names.into_iter().collect::<BTreeSet<_>>();
if self.mode == ToolAccessMode::Search
&& let Some(tool_name) = known_tools
.iter()
.find(|tool_name| matches!(**tool_name, "search_tools" | "call_tool"))
{
return Err(ToolSelectionPolicyError::ReservedToolName {
tool_name: (*tool_name).to_owned(),
});
}
let mut group_ids = BTreeSet::new();
for group in &self.groups {
if !valid_group_id(&group.id) {
return Err(ToolSelectionPolicyError::InvalidGroupId {
group_id: group.id.clone(),
});
}
if !group_ids.insert(group.id.as_str()) {
return Err(ToolSelectionPolicyError::DuplicateGroupId {
group_id: group.id.clone(),
});
}
if group.name.trim().is_empty() || group.description.trim().is_empty() {
return Err(ToolSelectionPolicyError::IncompleteGroup {
group_id: group.id.clone(),
});
}
let mut grouped_tools = BTreeSet::new();
for tool_name in &group.tool_names {
if !known_tools.contains(tool_name.as_str()) {
return Err(ToolSelectionPolicyError::UnknownTool {
group_id: group.id.clone(),
tool_name: tool_name.clone(),
});
}
if !grouped_tools.insert(tool_name.as_str()) {
return Err(ToolSelectionPolicyError::DuplicateTool {
group_id: group.id.clone(),
tool_name: tool_name.clone(),
});
}
}
}
Ok(())
}
}
const fn default_search_result_limit() -> usize {
8
}
fn valid_group_id(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 64
&& value
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentOperationBinding {
pub operation_id: OperationId,
@@ -56,7 +195,10 @@ mod tests {
use serde_json::json;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use super::{Agent, AgentStatus, AgentVersion};
use super::{
Agent, AgentStatus, AgentVersion, ToolAccessMode, ToolGroup, ToolSelectionPolicy,
ToolSelectionPolicyError,
};
use crate::ids::{AgentId, WorkspaceId};
#[test]
@@ -98,5 +240,28 @@ mod tests {
version.created_at,
OffsetDateTime::parse("2026-03-25T12:00:00Z", &Rfc3339).unwrap()
);
assert_eq!(version.tool_selection_policy.mode, ToolAccessMode::Direct);
}
#[test]
fn search_policy_validates_groups_against_published_tools() {
let policy = ToolSelectionPolicy {
mode: ToolAccessMode::Search,
groups: vec![ToolGroup {
id: "finance".to_owned(),
name: "Finance".to_owned(),
description: "Invoices and payments".to_owned(),
tool_names: vec!["create_invoice".to_owned()],
}],
..ToolSelectionPolicy::default()
};
assert_eq!(
policy.validate_for_tools(["list_invoices"]),
Err(ToolSelectionPolicyError::UnknownTool {
group_id: "finance".to_owned(),
tool_name: "create_invoice".to_owned(),
})
);
}
}