агенты: добавить поиск инструментов по каталогу
This commit is contained in:
@@ -43,7 +43,7 @@ use crate::{
|
||||
DEFAULT_PROTOCOL_VERSION, is_notification, is_request, is_response, jsonrpc_error,
|
||||
jsonrpc_result, method_name, negotiated_protocol_version, params, request_id,
|
||||
},
|
||||
manifest::tool_definitions,
|
||||
manifest::catalog_tool_definitions,
|
||||
rate_limit::{
|
||||
enforce_post_rate_limit, enforce_transport_rate_limit, rate_limited_jsonrpc_response,
|
||||
rate_limited_status_response,
|
||||
@@ -53,6 +53,7 @@ use crate::{
|
||||
ToolErrorContract, generic_tool_error_contract, runtime_error_code,
|
||||
tool_error_contract_from_runtime, tool_error_text, tool_error_value,
|
||||
},
|
||||
tool_search::handle_catalog_tool_call,
|
||||
transport::{
|
||||
AllowedOrigins, HEADER_MCP_SESSION_ID, ResponseMode, json_response,
|
||||
negotiate_post_response_mode, protocol_version_from_headers, resolve_request_id,
|
||||
@@ -98,7 +99,7 @@ struct ApprovalDecisionPayload {
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ResolvedToolCall {
|
||||
pub(super) struct ResolvedToolCall {
|
||||
tool: PublishedAgentTool,
|
||||
}
|
||||
|
||||
@@ -679,11 +680,11 @@ async fn mcp_post(
|
||||
|
||||
match state
|
||||
.catalog
|
||||
.list_tools(&session.workspace_slug, &session.agent_slug)
|
||||
.get_catalog(&session.workspace_slug, &session.agent_slug)
|
||||
.await
|
||||
{
|
||||
Ok(tools) => {
|
||||
let definitions = tools.iter().flat_map(tool_definitions).collect::<Vec<_>>();
|
||||
Ok(catalog) => {
|
||||
let definitions = catalog_tool_definitions(&catalog);
|
||||
|
||||
transport_response(
|
||||
StatusCode::OK,
|
||||
@@ -715,45 +716,31 @@ async fn mcp_post(
|
||||
}
|
||||
};
|
||||
|
||||
let mut arguments = if tool_call_params.arguments.is_null() {
|
||||
let arguments = if tool_call_params.arguments.is_null() {
|
||||
json!({})
|
||||
} else {
|
||||
tool_call_params.arguments
|
||||
};
|
||||
let confirmation_token = take_confirmation_token(&mut arguments);
|
||||
|
||||
match state
|
||||
.catalog
|
||||
.list_tools(&session.workspace_slug, &session.agent_slug)
|
||||
.get_catalog(&session.workspace_slug, &session.agent_slug)
|
||||
.await
|
||||
{
|
||||
Ok(tools) => match resolve_generated_tool(&tools, &tool_call_params.name) {
|
||||
Some(resolved) => {
|
||||
handle_tool_call(
|
||||
state.clone(),
|
||||
&session,
|
||||
&message,
|
||||
response_mode,
|
||||
&credential,
|
||||
resolved,
|
||||
arguments,
|
||||
confirmation_token,
|
||||
&transport_request_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => transport_response(
|
||||
StatusCode::OK,
|
||||
jsonrpc_error(
|
||||
request_id(&message),
|
||||
-32602,
|
||||
format!("tool {} was not found", tool_call_params.name),
|
||||
),
|
||||
Ok(catalog) => {
|
||||
handle_catalog_tool_call(
|
||||
state.clone(),
|
||||
&session,
|
||||
&message,
|
||||
response_mode,
|
||||
None,
|
||||
Some(&session.protocol_version),
|
||||
),
|
||||
},
|
||||
&credential,
|
||||
&catalog,
|
||||
&tool_call_params.name,
|
||||
arguments,
|
||||
&transport_request_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => internal_jsonrpc_error(&message, error),
|
||||
}
|
||||
}
|
||||
@@ -785,7 +772,7 @@ async fn mcp_post(
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_tool_call(
|
||||
pub(super) async fn handle_tool_call(
|
||||
state: Arc<AppState>,
|
||||
session: &SessionState,
|
||||
message: &Value,
|
||||
@@ -1381,7 +1368,7 @@ fn internal_jsonrpc_error(message: &Value, error: impl std::fmt::Display) -> Res
|
||||
)
|
||||
}
|
||||
|
||||
fn take_confirmation_token(arguments: &mut Value) -> Option<String> {
|
||||
pub(super) fn take_confirmation_token(arguments: &mut Value) -> Option<String> {
|
||||
let Value::Object(object) = arguments else {
|
||||
return None;
|
||||
};
|
||||
@@ -1479,7 +1466,7 @@ fn success_tool_response(
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_error_response(
|
||||
pub(super) fn tool_error_response(
|
||||
message: &Value,
|
||||
response_mode: ResponseMode,
|
||||
protocol_version: &str,
|
||||
@@ -1517,7 +1504,7 @@ fn add_millis(timestamp: OffsetDateTime, millis: u64) -> OffsetDateTime {
|
||||
timestamp + delta
|
||||
}
|
||||
|
||||
fn resolve_generated_tool(
|
||||
pub(super) fn resolve_generated_tool(
|
||||
tools: &[PublishedAgentTool],
|
||||
tool_name: &str,
|
||||
) -> Option<ResolvedToolCall> {
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::{
|
||||
};
|
||||
|
||||
use crank_core::{CacheScope, CoordinationStateStore, CoordinationStateValue};
|
||||
use crank_registry::{PostgresRegistry, PublishedAgentTool, RegistryError};
|
||||
use crank_registry::{PostgresRegistry, PublishedAgentCatalog, PublishedAgentTool, RegistryError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tracing::{info, warn};
|
||||
@@ -27,15 +27,14 @@ struct CatalogKey {
|
||||
agent_slug: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CachedCatalog {
|
||||
loaded_at: Option<Instant>,
|
||||
tools: Vec<PublishedAgentTool>,
|
||||
catalog: PublishedAgentCatalog,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct CatalogSnapshot {
|
||||
tools: Vec<PublishedAgentTool>,
|
||||
catalog: PublishedAgentCatalog,
|
||||
generated_at_ms: u64,
|
||||
}
|
||||
|
||||
@@ -59,12 +58,23 @@ impl PublishedToolCatalog {
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
) -> Result<Vec<PublishedAgentTool>, RegistryError> {
|
||||
Ok(self.get_catalog(workspace_slug, agent_slug).await?.tools)
|
||||
}
|
||||
|
||||
pub async fn get_catalog(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
) -> Result<PublishedAgentCatalog, RegistryError> {
|
||||
self.refresh_if_stale(workspace_slug, agent_slug).await?;
|
||||
let guard = self.cached.read().await;
|
||||
Ok(guard
|
||||
guard
|
||||
.get(&CatalogKey::new(workspace_slug, agent_slug))
|
||||
.map(|entry| entry.tools.clone())
|
||||
.unwrap_or_default())
|
||||
.map(|entry| entry.catalog.clone())
|
||||
.ok_or_else(|| RegistryError::PublishedAgentNotFound {
|
||||
workspace_slug: workspace_slug.to_owned(),
|
||||
agent_slug: agent_slug.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn refresh_if_stale(
|
||||
@@ -106,42 +116,41 @@ impl PublishedToolCatalog {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some((tools, age)) = self.load_shared_snapshot(workspace_slug, agent_slug).await {
|
||||
log_catalog_analysis(workspace_slug, agent_slug, "shared_cache", &tools);
|
||||
if let Some((catalog, age)) = self.load_shared_snapshot(workspace_slug, agent_slug).await {
|
||||
log_catalog_analysis(workspace_slug, agent_slug, "shared_cache", &catalog.tools);
|
||||
let mut guard = self.cached.write().await;
|
||||
guard.insert(
|
||||
key,
|
||||
CachedCatalog {
|
||||
loaded_at: Instant::now().checked_sub(age),
|
||||
tools,
|
||||
catalog,
|
||||
},
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let tools = match self
|
||||
let catalog = match self
|
||||
.registry
|
||||
.get_published_agent_tools_by_slug(workspace_slug, agent_slug)
|
||||
.get_published_agent_catalog_by_slug(workspace_slug, agent_slug)
|
||||
.await
|
||||
{
|
||||
Ok(tools) => tools,
|
||||
Err(RegistryError::PublishedAgentNotFound { .. }) => Vec::new(),
|
||||
Ok(catalog) => catalog,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
log_catalog_analysis(workspace_slug, agent_slug, "postgres", &tools);
|
||||
self.store_shared_snapshot(workspace_slug, agent_slug, &tools)
|
||||
log_catalog_analysis(workspace_slug, agent_slug, "postgres", &catalog.tools);
|
||||
self.store_shared_snapshot(workspace_slug, agent_slug, &catalog)
|
||||
.await;
|
||||
let mut guard = self.cached.write().await;
|
||||
let previous_count = guard
|
||||
.get(&key)
|
||||
.map(|entry| entry.tools.len())
|
||||
.map(|entry| entry.catalog.tools.len())
|
||||
.unwrap_or_default();
|
||||
|
||||
guard.insert(
|
||||
key,
|
||||
CachedCatalog {
|
||||
loaded_at: Some(Instant::now()),
|
||||
tools,
|
||||
catalog,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -150,7 +159,7 @@ impl PublishedToolCatalog {
|
||||
agent_slug,
|
||||
published_tool_count = guard
|
||||
.get(&CatalogKey::new(workspace_slug, agent_slug))
|
||||
.map(|entry| entry.tools.len())
|
||||
.map(|entry| entry.catalog.tools.len())
|
||||
.unwrap_or_default(),
|
||||
previous_published_tool_count = previous_count,
|
||||
"published agent catalog refreshed"
|
||||
@@ -163,7 +172,7 @@ impl PublishedToolCatalog {
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
) -> Option<(Vec<PublishedAgentTool>, Duration)> {
|
||||
) -> Option<(PublishedAgentCatalog, Duration)> {
|
||||
if self.refresh_interval.is_zero() {
|
||||
return None;
|
||||
}
|
||||
@@ -179,21 +188,21 @@ impl PublishedToolCatalog {
|
||||
};
|
||||
let snapshot = serde_json::from_value::<CatalogSnapshot>(value.payload).ok()?;
|
||||
let age = Duration::from_millis(now_unix_ms().saturating_sub(snapshot.generated_at_ms));
|
||||
(age < self.refresh_interval).then_some((snapshot.tools, age))
|
||||
(age < self.refresh_interval).then_some((snapshot.catalog, age))
|
||||
}
|
||||
|
||||
async fn store_shared_snapshot(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
tools: &[PublishedAgentTool],
|
||||
catalog: &PublishedAgentCatalog,
|
||||
) {
|
||||
let Some(ttl) = catalog_snapshot_ttl(self.refresh_interval) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let payload = match serde_json::to_value(CatalogSnapshot {
|
||||
tools: tools.to_vec(),
|
||||
catalog: catalog.clone(),
|
||||
generated_at_ms: now_unix_ms(),
|
||||
}) {
|
||||
Ok(payload) => payload,
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod manifest;
|
||||
mod rate_limit;
|
||||
pub mod session;
|
||||
pub mod tool_error;
|
||||
mod tool_search;
|
||||
mod transport;
|
||||
|
||||
pub use app::{build_app, build_app_with_background_workers};
|
||||
|
||||
@@ -1,10 +1,110 @@
|
||||
use crank_core::{
|
||||
HttpMethod, OperationSafetyClass, OperationSafetyPolicy, Target, ToolCatalogAnalysis,
|
||||
ToolCatalogAnalysisError, ToolQualityCatalogTool, analyze_tool_catalog,
|
||||
HttpMethod, OperationSafetyClass, OperationSafetyPolicy, SearchableTool, Target,
|
||||
ToolAccessMode, ToolCatalogAnalysis, ToolCatalogAnalysisError, ToolQualityCatalogTool,
|
||||
ToolSelectionPolicy, analyze_tool_catalog,
|
||||
};
|
||||
use crank_registry::PublishedAgentTool;
|
||||
use crank_registry::{PublishedAgentCatalog, PublishedAgentTool};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub const SEARCH_TOOLS_NAME: &str = "search_tools";
|
||||
pub const CALL_TOOL_NAME: &str = "call_tool";
|
||||
|
||||
pub fn catalog_tool_definitions(catalog: &PublishedAgentCatalog) -> Vec<Value> {
|
||||
match catalog.tool_selection_policy.mode {
|
||||
ToolAccessMode::Direct => catalog.tools.iter().flat_map(tool_definitions).collect(),
|
||||
ToolAccessMode::Search => search_mode_tool_definitions(&catalog.tool_selection_policy),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn searchable_tools(catalog: &PublishedAgentCatalog) -> Vec<SearchableTool> {
|
||||
catalog
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| {
|
||||
let groups = catalog
|
||||
.tool_selection_policy
|
||||
.groups
|
||||
.iter()
|
||||
.filter(|group| group.tool_names.iter().any(|name| name == &tool.tool_name))
|
||||
.collect::<Vec<_>>();
|
||||
SearchableTool {
|
||||
name: tool.tool_name.clone(),
|
||||
title: tool.tool_title.clone(),
|
||||
description: tool.tool_description.clone(),
|
||||
input_schema: tool_definitions(tool)
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|definition| definition.get("inputSchema").cloned())
|
||||
.unwrap_or_else(|| json!({"type": "object"})),
|
||||
group_ids: groups.iter().map(|group| group.id.clone()).collect(),
|
||||
group_context: groups
|
||||
.iter()
|
||||
.map(|group| format!("{} {}", group.name, group.description))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn search_mode_tool_definitions(policy: &ToolSelectionPolicy) -> Vec<Value> {
|
||||
let group_catalog = if policy.groups.is_empty() {
|
||||
"Разделы каталога не заданы; выполняйте поиск по всему каталогу.".to_owned()
|
||||
} else {
|
||||
policy
|
||||
.groups
|
||||
.iter()
|
||||
.map(|group| format!("{} — {}: {}", group.id, group.name, group.description))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
|
||||
vec![
|
||||
tool_definition(
|
||||
SEARCH_TOOLS_NAME,
|
||||
"Подобрать инструменты",
|
||||
&format!(
|
||||
"Находит подходящие инструменты агента и возвращает их полные входные схемы. Доступные разделы:\n{group_catalog}"
|
||||
),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Краткое описание требуемого действия"
|
||||
},
|
||||
"group_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Необязательный список разделов каталога"
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 20,
|
||||
"default": policy.search.max_results
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}),
|
||||
),
|
||||
tool_definition(
|
||||
CALL_TOOL_NAME,
|
||||
"Вызвать найденный инструмент",
|
||||
"Вызывает инструмент, ранее найденный через search_tools. Передайте имя, аргументы по полученной схеме и версию каталога из результата поиска.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"arguments": {"type": "object"},
|
||||
"catalog_revision": {"type": "string"}
|
||||
},
|
||||
"required": ["name", "arguments", "catalog_revision"]
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn tool_definitions(tool: &PublishedAgentTool) -> Vec<Value> {
|
||||
let safety = effective_safety_policy(tool);
|
||||
let requires_confirmation = safety.class.requires_confirmation();
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
use std::{collections::BTreeSet, sync::Arc};
|
||||
|
||||
use axum::{http::StatusCode, response::Response};
|
||||
use crank_core::{ToolAccessMode, search_tool_catalog};
|
||||
use crank_registry::PublishedAgentCatalog;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
AppState, handle_tool_call, resolve_generated_tool, take_confirmation_token,
|
||||
tool_error_response,
|
||||
},
|
||||
auth::VerifiedMachineCredential,
|
||||
jsonrpc::{jsonrpc_error, jsonrpc_result, request_id},
|
||||
manifest::{CALL_TOOL_NAME, SEARCH_TOOLS_NAME, searchable_tools},
|
||||
session::SessionState,
|
||||
tool_error::generic_tool_error_contract,
|
||||
transport::{ResponseMode, transport_response},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SearchToolsArguments {
|
||||
query: String,
|
||||
#[serde(default)]
|
||||
group_ids: Vec<String>,
|
||||
max_results: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProxyToolCallArguments {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
arguments: Value,
|
||||
catalog_revision: String,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn handle_catalog_tool_call(
|
||||
state: Arc<AppState>,
|
||||
session: &SessionState,
|
||||
message: &Value,
|
||||
response_mode: ResponseMode,
|
||||
credential: &VerifiedMachineCredential,
|
||||
catalog: &PublishedAgentCatalog,
|
||||
tool_name: &str,
|
||||
arguments: Value,
|
||||
transport_request_id: &str,
|
||||
) -> Response {
|
||||
match catalog.tool_selection_policy.mode {
|
||||
ToolAccessMode::Direct => {
|
||||
execute_catalog_tool(
|
||||
state,
|
||||
session,
|
||||
message,
|
||||
response_mode,
|
||||
credential,
|
||||
catalog,
|
||||
tool_name,
|
||||
arguments,
|
||||
transport_request_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ToolAccessMode::Search if tool_name == SEARCH_TOOLS_NAME => {
|
||||
handle_search_tools(message, response_mode, session, catalog, arguments)
|
||||
}
|
||||
ToolAccessMode::Search if tool_name == CALL_TOOL_NAME => {
|
||||
let proxy: ProxyToolCallArguments = match serde_json::from_value(arguments) {
|
||||
Ok(proxy) => proxy,
|
||||
Err(error) => {
|
||||
return invalid_arguments_response(
|
||||
message,
|
||||
response_mode,
|
||||
&session.protocol_version,
|
||||
error.to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
if proxy.catalog_revision != catalog_revision(catalog) {
|
||||
return tool_error_response(
|
||||
message,
|
||||
response_mode,
|
||||
&session.protocol_version,
|
||||
generic_tool_error_contract(
|
||||
"catalog_revision_changed",
|
||||
format!(
|
||||
"catalog revision {} is no longer current",
|
||||
proxy.catalog_revision
|
||||
),
|
||||
transport_request_id,
|
||||
true,
|
||||
Some(
|
||||
"Повторите search_tools и вызовите инструмент с новой версией каталога.",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
execute_catalog_tool(
|
||||
state,
|
||||
session,
|
||||
message,
|
||||
response_mode,
|
||||
credential,
|
||||
catalog,
|
||||
&proxy.name,
|
||||
proxy.arguments,
|
||||
transport_request_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ToolAccessMode::Search => {
|
||||
tool_not_found_response(message, response_mode, &session.protocol_version, tool_name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn execute_catalog_tool(
|
||||
state: Arc<AppState>,
|
||||
session: &SessionState,
|
||||
message: &Value,
|
||||
response_mode: ResponseMode,
|
||||
credential: &VerifiedMachineCredential,
|
||||
catalog: &PublishedAgentCatalog,
|
||||
tool_name: &str,
|
||||
mut arguments: Value,
|
||||
transport_request_id: &str,
|
||||
) -> Response {
|
||||
let Some(resolved) = resolve_generated_tool(&catalog.tools, tool_name) else {
|
||||
return tool_not_found_response(
|
||||
message,
|
||||
response_mode,
|
||||
&session.protocol_version,
|
||||
tool_name,
|
||||
);
|
||||
};
|
||||
let confirmation_token = take_confirmation_token(&mut arguments);
|
||||
handle_tool_call(
|
||||
state,
|
||||
session,
|
||||
message,
|
||||
response_mode,
|
||||
credential,
|
||||
resolved,
|
||||
arguments,
|
||||
confirmation_token,
|
||||
transport_request_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn handle_search_tools(
|
||||
message: &Value,
|
||||
response_mode: ResponseMode,
|
||||
session: &SessionState,
|
||||
catalog: &PublishedAgentCatalog,
|
||||
arguments: Value,
|
||||
) -> Response {
|
||||
let search: SearchToolsArguments = match serde_json::from_value(arguments) {
|
||||
Ok(search) => search,
|
||||
Err(error) => {
|
||||
return invalid_arguments_response(
|
||||
message,
|
||||
response_mode,
|
||||
&session.protocol_version,
|
||||
error.to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
if search.query.trim().is_empty() {
|
||||
return invalid_arguments_response(
|
||||
message,
|
||||
response_mode,
|
||||
&session.protocol_version,
|
||||
"query must not be empty".to_owned(),
|
||||
);
|
||||
}
|
||||
let known_group_ids = catalog
|
||||
.tool_selection_policy
|
||||
.groups
|
||||
.iter()
|
||||
.map(|group| group.id.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
if let Some(group_id) = search
|
||||
.group_ids
|
||||
.iter()
|
||||
.find(|group_id| !known_group_ids.contains(group_id.as_str()))
|
||||
{
|
||||
return invalid_arguments_response(
|
||||
message,
|
||||
response_mode,
|
||||
&session.protocol_version,
|
||||
format!("unknown tool group {group_id}"),
|
||||
);
|
||||
}
|
||||
|
||||
let configured_limit = catalog.tool_selection_policy.search.max_results;
|
||||
let requested_limit = search.max_results.unwrap_or(configured_limit).clamp(1, 20);
|
||||
let tools = search_tool_catalog(
|
||||
&searchable_tools(catalog),
|
||||
&search.query,
|
||||
&search.group_ids,
|
||||
requested_limit.min(configured_limit),
|
||||
)
|
||||
.into_iter()
|
||||
.map(|found| {
|
||||
json!({
|
||||
"name": found.tool.name,
|
||||
"title": found.tool.title,
|
||||
"description": found.tool.description,
|
||||
"inputSchema": found.tool.input_schema,
|
||||
"group_ids": found.tool.group_ids,
|
||||
"score": found.score,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let result = json!({
|
||||
"catalog_revision": catalog_revision(catalog),
|
||||
"tools": tools,
|
||||
});
|
||||
let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string());
|
||||
|
||||
transport_response(
|
||||
StatusCode::OK,
|
||||
jsonrpc_result(
|
||||
request_id(message),
|
||||
json!({
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"structuredContent": result,
|
||||
"isError": false
|
||||
}),
|
||||
),
|
||||
response_mode,
|
||||
None,
|
||||
Some(&session.protocol_version),
|
||||
)
|
||||
}
|
||||
|
||||
fn catalog_revision(catalog: &PublishedAgentCatalog) -> String {
|
||||
format!("agent-version-{}", catalog.agent_version)
|
||||
}
|
||||
|
||||
fn invalid_arguments_response(
|
||||
message: &Value,
|
||||
response_mode: ResponseMode,
|
||||
protocol_version: &str,
|
||||
detail: String,
|
||||
) -> Response {
|
||||
transport_response(
|
||||
StatusCode::OK,
|
||||
jsonrpc_error(request_id(message), -32602, detail),
|
||||
response_mode,
|
||||
None,
|
||||
Some(protocol_version),
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_not_found_response(
|
||||
message: &Value,
|
||||
response_mode: ResponseMode,
|
||||
protocol_version: &str,
|
||||
tool_name: &str,
|
||||
) -> Response {
|
||||
transport_response(
|
||||
StatusCode::OK,
|
||||
jsonrpc_error(
|
||||
request_id(message),
|
||||
-32602,
|
||||
format!("tool {tool_name} was not found"),
|
||||
),
|
||||
response_mode,
|
||||
None,
|
||||
Some(protocol_version),
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod protocol;
|
||||
pub mod secret;
|
||||
pub mod tool_catalog;
|
||||
pub mod tool_quality;
|
||||
pub mod tool_search;
|
||||
pub mod workspace;
|
||||
|
||||
pub mod domain {
|
||||
@@ -19,7 +20,10 @@ pub mod domain {
|
||||
InvitationStatus, InvitationToken, Membership, MembershipRole, PlatformApiKey,
|
||||
PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
|
||||
};
|
||||
pub use crate::agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
|
||||
pub use crate::agent::{
|
||||
Agent, AgentOperationBinding, AgentStatus, AgentVersion, ToolAccessMode, ToolGroup,
|
||||
ToolSearchSettings, ToolSelectionPolicy, ToolSelectionPolicyError,
|
||||
};
|
||||
pub use crate::approval::{ApprovalRequest, ApprovalRequestStatus};
|
||||
pub use crate::auth::{
|
||||
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig,
|
||||
@@ -56,6 +60,7 @@ pub mod domain {
|
||||
ToolQualityCatalogTool, ToolQualityFinding, ToolQualityMappingRule, ToolQualityMappingSet,
|
||||
ToolQualityReport, ToolQualitySchemaKind, ToolQualitySchemaNode, ToolQualitySeverity,
|
||||
};
|
||||
pub use crate::tool_search::{SearchableTool, ToolSearchMatch, search_tool_catalog};
|
||||
pub use crate::workspace::{Workspace, WorkspaceStatus};
|
||||
}
|
||||
|
||||
@@ -92,7 +97,10 @@ pub use access::{
|
||||
InvitationStatus, InvitationToken, Membership, MembershipRole, PlatformApiKey,
|
||||
PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
|
||||
};
|
||||
pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
|
||||
pub use agent::{
|
||||
Agent, AgentOperationBinding, AgentStatus, AgentVersion, ToolAccessMode, ToolGroup,
|
||||
ToolSearchSettings, ToolSelectionPolicy, ToolSelectionPolicyError,
|
||||
};
|
||||
pub use approval::{ApprovalRequest, ApprovalRequestStatus};
|
||||
pub use auth::{
|
||||
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig,
|
||||
@@ -153,4 +161,5 @@ pub use tool_quality::{
|
||||
analyze_agent_tool_catalog_quality, analyze_tool_identity_quality,
|
||||
analyze_tool_response_projection_quality, analyze_tool_schema_quality,
|
||||
};
|
||||
pub use tool_search::{SearchableTool, ToolSearchMatch, search_tool_catalog};
|
||||
pub use workspace::{Workspace, WorkspaceStatus};
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SearchableTool {
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub input_schema: Value,
|
||||
#[serde(default)]
|
||||
pub group_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub group_context: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ToolSearchMatch {
|
||||
pub tool: SearchableTool,
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
pub fn search_tool_catalog(
|
||||
tools: &[SearchableTool],
|
||||
query: &str,
|
||||
group_ids: &[String],
|
||||
max_results: usize,
|
||||
) -> Vec<ToolSearchMatch> {
|
||||
let query_tokens = tokenize(query);
|
||||
if query_tokens.is_empty() || max_results == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let requested_groups = group_ids
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let candidates = tools
|
||||
.iter()
|
||||
.filter(|tool| {
|
||||
requested_groups.is_empty()
|
||||
|| tool
|
||||
.group_ids
|
||||
.iter()
|
||||
.any(|group_id| requested_groups.contains(group_id.as_str()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if candidates.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let documents = candidates
|
||||
.iter()
|
||||
.map(|tool| weighted_tokens(tool))
|
||||
.collect::<Vec<_>>();
|
||||
let average_length =
|
||||
documents.iter().map(Vec::len).sum::<usize>() as f64 / documents.len() as f64;
|
||||
let candidate_count = documents.len();
|
||||
let document_frequency = document_frequency(&documents, &query_tokens);
|
||||
let normalized_query = query.trim().to_lowercase();
|
||||
|
||||
let mut matches = candidates
|
||||
.into_iter()
|
||||
.zip(documents)
|
||||
.filter_map(|(tool, document)| {
|
||||
let score = bm25_score(
|
||||
&document,
|
||||
&query_tokens,
|
||||
&document_frequency,
|
||||
candidate_count,
|
||||
average_length,
|
||||
) + exact_match_bonus(tool, &normalized_query);
|
||||
(score > 0.0).then(|| ToolSearchMatch {
|
||||
tool: tool.clone(),
|
||||
score,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
matches.sort_by(|left, right| {
|
||||
right
|
||||
.score
|
||||
.total_cmp(&left.score)
|
||||
.then_with(|| left.tool.name.cmp(&right.tool.name))
|
||||
});
|
||||
matches.truncate(max_results);
|
||||
matches
|
||||
}
|
||||
|
||||
fn weighted_tokens(tool: &SearchableTool) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
for _ in 0..3 {
|
||||
tokens.extend(tokenize(&tool.name));
|
||||
tokens.extend(tokenize(&tool.title));
|
||||
}
|
||||
tokens.extend(tokenize(&tool.description));
|
||||
for _ in 0..2 {
|
||||
tokens.extend(tokenize(&tool.group_context));
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
fn document_frequency(
|
||||
documents: &[Vec<String>],
|
||||
query_tokens: &[String],
|
||||
) -> BTreeMap<String, usize> {
|
||||
let mut frequencies = BTreeMap::new();
|
||||
for query_token in query_tokens {
|
||||
let count = documents
|
||||
.iter()
|
||||
.filter(|document| document.iter().any(|token| token == query_token))
|
||||
.count();
|
||||
frequencies.insert(query_token.clone(), count);
|
||||
}
|
||||
frequencies
|
||||
}
|
||||
|
||||
fn bm25_score(
|
||||
document: &[String],
|
||||
query_tokens: &[String],
|
||||
document_frequency: &BTreeMap<String, usize>,
|
||||
document_count: usize,
|
||||
average_length: f64,
|
||||
) -> f64 {
|
||||
const K1: f64 = 1.2;
|
||||
const B: f64 = 0.75;
|
||||
|
||||
query_tokens.iter().fold(0.0, |score, token| {
|
||||
let term_frequency = document.iter().filter(|term| *term == token).count() as f64;
|
||||
if term_frequency == 0.0 {
|
||||
return score;
|
||||
}
|
||||
let frequency = document_frequency.get(token).copied().unwrap_or_default() as f64;
|
||||
let inverse_document_frequency =
|
||||
((document_count as f64 - frequency + 0.5) / (frequency + 0.5) + 1.0).ln();
|
||||
let length_ratio = if average_length > 0.0 {
|
||||
document.len() as f64 / average_length
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let normalized_frequency =
|
||||
term_frequency * (K1 + 1.0) / (term_frequency + K1 * (1.0 - B + B * length_ratio));
|
||||
score + inverse_document_frequency * normalized_frequency
|
||||
})
|
||||
}
|
||||
|
||||
fn exact_match_bonus(tool: &SearchableTool, query: &str) -> f64 {
|
||||
if query.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let name = tool.name.to_lowercase();
|
||||
let title = tool.title.to_lowercase();
|
||||
if name == query || title == query {
|
||||
8.0
|
||||
} else if name.contains(query) || title.contains(query) {
|
||||
3.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn tokenize(value: &str) -> Vec<String> {
|
||||
value
|
||||
.to_lowercase()
|
||||
.split(|character: char| !character.is_alphanumeric())
|
||||
.filter(|token| token.chars().count() >= 2)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{SearchableTool, search_tool_catalog};
|
||||
|
||||
fn tool(name: &str, title: &str, description: &str, group_ids: &[&str]) -> SearchableTool {
|
||||
SearchableTool {
|
||||
name: name.to_owned(),
|
||||
title: title.to_owned(),
|
||||
description: description.to_owned(),
|
||||
input_schema: json!({"type": "object"}),
|
||||
group_ids: group_ids.iter().map(|value| (*value).to_owned()).collect(),
|
||||
group_context: if group_ids.contains(&"finance") {
|
||||
"Finance invoices and payments".to_owned()
|
||||
} else {
|
||||
"Customer support tickets".to_owned()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranks_title_and_name_matches_above_description_matches() {
|
||||
let tools = vec![
|
||||
tool(
|
||||
"create_invoice",
|
||||
"Create invoice",
|
||||
"Issue a bill",
|
||||
&["finance"],
|
||||
),
|
||||
tool(
|
||||
"list_customers",
|
||||
"List customers",
|
||||
"Customers with invoices",
|
||||
&["support"],
|
||||
),
|
||||
];
|
||||
|
||||
let matches = search_tool_catalog(&tools, "create invoice", &[], 8);
|
||||
|
||||
assert_eq!(matches[0].tool.name, "create_invoice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_candidates_by_group() {
|
||||
let tools = vec![
|
||||
tool(
|
||||
"create_invoice",
|
||||
"Create invoice",
|
||||
"Issue a bill",
|
||||
&["finance"],
|
||||
),
|
||||
tool(
|
||||
"create_ticket",
|
||||
"Create ticket",
|
||||
"Open a support case",
|
||||
&["support"],
|
||||
),
|
||||
];
|
||||
|
||||
let matches = search_tool_catalog(&tools, "create", &["support".to_owned()], 8);
|
||||
|
||||
assert_eq!(matches.len(), 1);
|
||||
assert_eq!(matches[0].tool.name, "create_ticket");
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,9 @@ pub mod records {
|
||||
DescriptorMetadata, ImportJob, ImportJobId, ImportJobKind, ImportJobStatus,
|
||||
InvitationRecord, InvocationLogRecord, MembershipRecord, OperationAgentRef,
|
||||
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
|
||||
Page, PlatformApiKeyRecord, PublishedAgentTool, RegistryOperation, SampleKind,
|
||||
SecretRecord, SecretVersionRecord, SessionRecord, UsageAgentBreakdown, UsageBucket,
|
||||
UsageOperationBreakdown, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
||||
Page, PlatformApiKeyRecord, PublishedAgentCatalog, PublishedAgentTool, RegistryOperation,
|
||||
SampleKind, SecretRecord, SecretVersionRecord, SessionRecord, UsageAgentBreakdown,
|
||||
UsageBucket, UsageOperationBreakdown, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
||||
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId,
|
||||
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
};
|
||||
@@ -29,9 +29,9 @@ pub mod requests {
|
||||
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
|
||||
ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest,
|
||||
ListApprovalRequestsQuery, ListInvocationLogsQuery, PublishAgentRequest, PublishRequest,
|
||||
RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest,
|
||||
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest,
|
||||
UpdateWorkspaceRequest, UsageQuery,
|
||||
RotateSecretRequest, SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
SaveWorkspaceUpstreamRequest, UpdateWorkspaceRequest, UsageQuery,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,13 +50,14 @@ pub use model::{
|
||||
ImportJobKind, ImportJobStatus, InvitationRecord, InvocationLogRecord,
|
||||
ListApprovalRequestsQuery, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef,
|
||||
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord, Page,
|
||||
PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool,
|
||||
RegistryOperation, RotateSecretRequest, SampleKind, SaveAgentBindingsRequest,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord, SessionRecord,
|
||||
UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
|
||||
UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord,
|
||||
WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId, YamlImportJob,
|
||||
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentCatalog,
|
||||
PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind,
|
||||
SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest, SaveAuthProfileRequest,
|
||||
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest,
|
||||
SecretRecord, SecretVersionRecord, SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown,
|
||||
UsageBucket, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary,
|
||||
UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream,
|
||||
WorkspaceUpstreamId, YamlImportJob, YamlImportJobCompletion, YamlImportJobId,
|
||||
YamlImportJobStatus,
|
||||
};
|
||||
pub use postgres::{PostgresPoolConfig, PostgresPoolConfigError, PostgresRegistry};
|
||||
|
||||
@@ -3,8 +3,8 @@ use crank_core::{
|
||||
ApprovalRequestId, ApprovalRequestStatus, AuthProfile, DescriptorId, ExportMode,
|
||||
InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole, Operation,
|
||||
OperationId, OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId,
|
||||
Protocol, SampleId, Secret, SecretId, SecretVersion, UsagePeriod, UsageRollup, User,
|
||||
UserSessionId, Workspace, WorkspaceId,
|
||||
Protocol, SampleId, Secret, SecretId, SecretVersion, ToolSelectionPolicy, UsagePeriod,
|
||||
UsageRollup, User, UserSessionId, Workspace, WorkspaceId,
|
||||
};
|
||||
use crank_mapping::MappingSet;
|
||||
use crank_schema::Schema;
|
||||
@@ -208,6 +208,13 @@ pub struct PublishedAgentTool {
|
||||
pub tool_description: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PublishedAgentCatalog {
|
||||
pub agent_version: u32,
|
||||
pub tool_selection_policy: ToolSelectionPolicy,
|
||||
pub tools: Vec<PublishedAgentTool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OperationSummary {
|
||||
pub id: OperationId,
|
||||
@@ -499,6 +506,15 @@ pub struct SaveAgentBindingsRequest<'a> {
|
||||
pub bindings: &'a [AgentOperationBinding],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SaveAgentCatalogConfigRequest<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub agent_id: &'a AgentId,
|
||||
pub agent_version: u32,
|
||||
pub bindings: &'a [AgentOperationBinding],
|
||||
pub tool_selection_policy: &'a ToolSelectionPolicy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PublishAgentRequest<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
use super::*;
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn get_published_agent_catalog_by_slug(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
) -> Result<PublishedAgentCatalog, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
pa.version,
|
||||
av.tool_selection_policy_json
|
||||
from workspaces w
|
||||
join agents a on a.workspace_id = w.id
|
||||
join published_agents pa on pa.agent_id = a.id
|
||||
join agent_versions av on av.agent_id = a.id and av.version = pa.version
|
||||
where w.slug = $1 and a.slug = $2",
|
||||
)
|
||||
.bind(workspace_slug)
|
||||
.bind(agent_slug)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.ok_or_else(|| RegistryError::PublishedAgentNotFound {
|
||||
workspace_slug: workspace_slug.to_owned(),
|
||||
agent_slug: agent_slug.to_owned(),
|
||||
})?;
|
||||
let tools = self
|
||||
.get_published_agent_tools_by_slug(workspace_slug, agent_slug)
|
||||
.await?;
|
||||
|
||||
Ok(PublishedAgentCatalog {
|
||||
agent_version: from_db_version(row.try_get("version")?, "agent_version")?,
|
||||
tool_selection_policy: deserialize_json_value(
|
||||
row.try_get("tool_selection_policy_json")?,
|
||||
)?,
|
||||
tools,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_agents(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
@@ -254,6 +290,48 @@ impl PostgresRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn save_agent_catalog_config(
|
||||
&self,
|
||||
request: SaveAgentCatalogConfigRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
if self
|
||||
.get_agent_summary(request.workspace_id, request.agent_id)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(RegistryError::AgentNotFound {
|
||||
agent_id: request.agent_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let updated = sqlx::query(
|
||||
"update agent_versions
|
||||
set tool_selection_policy_json = $3
|
||||
where agent_id = $1 and version = $2",
|
||||
)
|
||||
.bind(request.agent_id.as_str())
|
||||
.bind(to_db_version(request.agent_version))
|
||||
.bind(Json(serialize_json_value(request.tool_selection_policy)?))
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if updated == 0 {
|
||||
return Err(RegistryError::AgentNotFound {
|
||||
agent_id: request.agent_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
replace_agent_bindings_rows(
|
||||
&mut tx,
|
||||
request.agent_id,
|
||||
request.agent_version,
|
||||
request.bindings,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_agent_summary(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
|
||||
@@ -39,13 +39,14 @@ use crate::{
|
||||
ImportJob, ImportJobId, InvitationRecord, InvocationLogRecord, ListApprovalRequestsQuery,
|
||||
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
|
||||
OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord,
|
||||
PublishAgentRequest, PublishRequest, PublishedAgentTool, RegistryOperation,
|
||||
RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest,
|
||||
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest,
|
||||
SecretRecord, SecretVersionRecord, SessionRecord, UpdateWorkspaceRequest,
|
||||
UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary,
|
||||
UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream,
|
||||
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
PublishAgentRequest, PublishRequest, PublishedAgentCatalog, PublishedAgentTool,
|
||||
RegistryOperation, RotateSecretRequest, SaveAgentBindingsRequest,
|
||||
SaveAgentCatalogConfigRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord,
|
||||
SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageOperationBreakdown,
|
||||
UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord,
|
||||
WorkspaceRecord, WorkspaceUpstream, YamlImportJob, YamlImportJobCompletion,
|
||||
YamlImportJobId, YamlImportJobStatus,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -154,7 +155,7 @@ async fn insert_agent_version_row(
|
||||
.bind(to_db_version(version.version))
|
||||
.bind(serialize_enum_text(&version.status, "status")?)
|
||||
.bind(Json(version.instructions.clone()))
|
||||
.bind(Json(version.tool_selection_policy.clone()))
|
||||
.bind(Json(serialize_json_value(&version.tool_selection_policy)?))
|
||||
.bind(version.created_at)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
@@ -498,7 +499,7 @@ fn build_agent_version_record(
|
||||
version,
|
||||
status,
|
||||
instructions: instructions_json,
|
||||
tool_selection_policy: tool_selection_policy_json,
|
||||
tool_selection_policy: deserialize_json_value(tool_selection_policy_json)?,
|
||||
created_at,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -192,10 +192,7 @@ pub(super) fn test_agent_version(
|
||||
"system": "triage tickets",
|
||||
"guardrails": ["don't mutate state"]
|
||||
}),
|
||||
tool_selection_policy: json!({
|
||||
"mode": "allow_list",
|
||||
"max_tools": 8
|
||||
}),
|
||||
tool_selection_policy: Default::default(),
|
||||
created_at: timestamp("2026-03-25T12:00:00Z"),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user