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

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
+2 -1
View File
@@ -12,7 +12,7 @@ use crate::{
agents::{
archive_agent, create_agent, create_agent_platform_api_key, delete_agent,
delete_agent_platform_api_key, get_agent, get_agent_version,
list_agent_platform_api_keys, list_agents, publish_agent,
list_agent_platform_api_keys, list_agents, preview_tool_search, publish_agent,
revoke_agent_platform_api_key, save_agent_bindings, unpublish_agent, update_agent,
},
auth::{change_password, get_profile, get_session, login, logout, update_profile},
@@ -83,6 +83,7 @@ pub fn build_app(state: AppState) -> Router {
)
.route("/operations/{operation_id}/export", get(export_operation))
.route("/agents", get(list_agents).post(create_agent))
.route("/agents/tool-search/preview", post(preview_tool_search))
.route(
"/agents/{agent_id}",
get(get_agent).patch(update_agent).delete(delete_agent),
+40 -2
View File
@@ -2,7 +2,7 @@ use crank_core::{
AgentId, AgentStatus, ApprovalRequestStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode,
GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel,
OperationStatus, PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target,
UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus,
ToolSelectionPolicy, UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus,
};
use crank_mapping::MappingSet;
use crank_registry::{
@@ -144,7 +144,7 @@ pub struct AgentPayload {
#[serde(default)]
pub instructions: Value,
#[serde(default)]
pub tool_selection_policy: Value,
pub tool_selection_policy: ToolSelectionPolicy,
}
#[derive(Clone, Debug, Deserialize)]
@@ -165,6 +165,43 @@ pub struct AgentBindingPayload {
pub enabled: bool,
}
#[derive(Clone, Debug, Deserialize)]
pub struct ToolSearchPreviewPayload {
pub query: String,
#[serde(default)]
pub group_ids: Vec<String>,
pub bindings: Vec<AgentBindingPayload>,
pub tool_selection_policy: ToolSelectionPolicy,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
pub enum AgentCatalogPayload {
Bindings(Vec<AgentBindingPayload>),
Config {
bindings: Vec<AgentBindingPayload>,
tool_selection_policy: ToolSelectionPolicy,
},
}
impl AgentCatalogPayload {
pub fn into_parts(self) -> (Vec<AgentBindingPayload>, Option<ToolSelectionPolicy>) {
match self {
Self::Bindings(bindings) => (bindings, None),
Self::Config {
bindings,
tool_selection_policy,
} => (bindings, Some(tool_selection_policy)),
}
}
}
impl From<Vec<AgentBindingPayload>> for AgentCatalogPayload {
fn from(bindings: Vec<AgentBindingPayload>) -> Self {
Self::Bindings(bindings)
}
}
#[derive(Clone, Debug, Serialize)]
pub struct CreatedAgentResponse {
pub agent_id: String,
@@ -196,6 +233,7 @@ pub struct AgentSummaryView {
pub published_at: Option<String>,
pub operation_count: usize,
pub operation_ids: Vec<String>,
pub tool_selection_policy: ToolSelectionPolicy,
pub key_count: usize,
pub calls_today: u64,
pub mcp_endpoint: String,
+15 -3
View File
@@ -8,8 +8,8 @@ use serde_json::{Value, json};
use crate::{
error::ApiError,
service::{
AgentBindingPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
UpdateAgentPayload,
AgentCatalogPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
ToolSearchPreviewPayload, UpdateAgentPayload,
},
state::AppState,
};
@@ -50,6 +50,18 @@ pub async fn list_agents(
Ok(Json(json!({ "items": items })))
}
pub async fn preview_tool_search(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
Json(payload): Json<ToolSearchPreviewPayload>,
) -> Result<Json<Value>, ApiError> {
let items = state
.service
.preview_tool_search(&path.workspace_id.as_str().into(), payload)
.await?;
Ok(Json(json!({"items": items})))
}
pub async fn create_agent(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
@@ -124,7 +136,7 @@ pub async fn get_agent_version(
pub async fn save_agent_bindings(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
Json(payload): Json<Vec<AgentBindingPayload>>,
Json(payload): Json<AgentCatalogPayload>,
) -> Result<Json<Value>, ApiError> {
let record = state
.service
+1
View File
@@ -698,6 +698,7 @@ fn map_agent_summary_view(summary: AgentSummary) -> AgentSummaryView {
published_at: summary.published_at.map(format_timestamp),
operation_count: 0,
operation_ids: Vec::new(),
tool_selection_policy: Default::default(),
key_count: 0,
calls_today: 0,
mcp_endpoint: String::new(),
+159 -12
View File
@@ -1,12 +1,12 @@
use std::collections::BTreeMap;
use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, OperationId, UsagePeriod,
WorkspaceId,
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, OperationId, SearchableTool,
ToolAccessMode, ToolSelectionPolicy, UsagePeriod, WorkspaceId, search_tool_catalog,
};
use crank_registry::{
AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest, PublishAgentRequest,
SaveAgentBindingsRequest, UsageBucket, UsageQuery,
SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest, UsageBucket, UsageQuery,
};
use serde_json::json;
use time::OffsetDateTime;
@@ -15,13 +15,93 @@ use tracing::{info, instrument};
use crate::{
error::ApiError,
service::{
AdminService, AgentBindingPayload, AgentMutationResult, AgentPayload, AgentSummaryView,
CreatedAgentResponse, PublishAgentResponse, UpdateAgentPayload, agent_mcp_endpoint,
format_timestamp, map_agent_summary_view, new_prefixed_id, today_start_utc,
AdminService, AgentCatalogPayload, AgentMutationResult, AgentPayload, AgentSummaryView,
CreatedAgentResponse, PublishAgentResponse, ToolSearchPreviewPayload, UpdateAgentPayload,
agent_mcp_endpoint, format_timestamp, map_agent_summary_view, new_prefixed_id,
today_start_utc,
},
};
impl AdminService {
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str()))]
pub async fn preview_tool_search(
&self,
workspace_id: &WorkspaceId,
payload: ToolSearchPreviewPayload,
) -> Result<Vec<crank_core::ToolSearchMatch>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
if payload.tool_selection_policy.mode != ToolAccessMode::Search {
return Err(ApiError::validation_with_context(
"tool search preview requires search mode",
json!({"field": "tool_selection_policy.mode"}),
));
}
let bindings = payload
.bindings
.iter()
.map(|binding| AgentOperationBinding {
operation_id: OperationId::new(binding.operation_id.clone()),
operation_version: binding.operation_version,
tool_name: binding.tool_name.clone(),
tool_title: binding.tool_title.clone(),
tool_description_override: binding.tool_description_override.clone(),
enabled: binding.enabled,
})
.collect::<Vec<_>>();
validate_tool_selection_policy(&payload.tool_selection_policy, &bindings)?;
let mut tools = Vec::new();
for binding in bindings.iter().filter(|binding| binding.enabled) {
let version = self
.registry
.get_operation_version(
workspace_id,
&binding.operation_id,
binding.operation_version,
)
.await?
.ok_or_else(|| {
ApiError::not_found_with_context(
format!("operation {} was not found", binding.operation_id.as_str()),
json!({"operation_id": binding.operation_id.as_str()}),
)
})?;
let groups = payload
.tool_selection_policy
.groups
.iter()
.filter(|group| {
group
.tool_names
.iter()
.any(|name| name == &binding.tool_name)
})
.collect::<Vec<_>>();
tools.push(SearchableTool {
name: binding.tool_name.clone(),
title: binding.tool_title.clone(),
description: binding
.tool_description_override
.clone()
.unwrap_or(version.snapshot.tool_description.description),
input_schema: serde_json::Value::Null,
group_ids: groups.iter().map(|group| group.id.clone()).collect(),
group_context: groups
.iter()
.map(|group| format!("{} {}", group.name, group.description))
.collect::<Vec<_>>()
.join(" "),
});
}
let max_results = payload.tool_selection_policy.search.max_results;
Ok(search_tool_catalog(
&tools,
&payload.query,
&payload.group_ids,
max_results,
))
}
#[instrument(skip(self))]
pub async fn list_agents(
&self,
@@ -69,6 +149,7 @@ impl AdminService {
items.push(AgentSummaryView {
operation_count: operation_ids.len(),
operation_ids,
tool_selection_policy: version.snapshot.tool_selection_policy,
key_count: key_counts.get(summary.id.as_str()).copied().unwrap_or(0),
calls_today: calls_today.get(summary.id.as_str()).copied().unwrap_or(0),
mcp_endpoint: agent_mcp_endpoint(
@@ -130,6 +211,7 @@ impl AdminService {
Ok(AgentSummaryView {
operation_count: operation_ids.len(),
operation_ids,
tool_selection_policy: version.snapshot.tool_selection_policy,
key_count,
calls_today: usage.map(|item| item.rollup.calls_total).unwrap_or(0),
mcp_endpoint: agent_mcp_endpoint(
@@ -304,9 +386,12 @@ impl AdminService {
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
payload: Vec<AgentBindingPayload>,
payload: AgentCatalogPayload,
) -> Result<AgentVersionRecord, ApiError> {
let agent = self.get_agent(workspace_id, agent_id).await?;
let current_version = self
.ensure_editable_agent_version(workspace_id, agent_id)
.await?;
let (payload, requested_policy) = payload.into_parts();
let bindings = payload
.into_iter()
.map(|binding| AgentOperationBinding {
@@ -318,23 +403,62 @@ impl AdminService {
enabled: binding.enabled,
})
.collect::<Vec<_>>();
let tool_selection_policy = requested_policy
.unwrap_or_else(|| current_version.snapshot.tool_selection_policy.clone());
validate_tool_selection_policy(&tool_selection_policy, &bindings)?;
self.registry
.save_agent_bindings(SaveAgentBindingsRequest {
.save_agent_catalog_config(SaveAgentCatalogConfigRequest {
workspace_id,
agent_id,
agent_version: agent.current_draft_version,
agent_version: current_version.version,
bindings: &bindings,
tool_selection_policy: &tool_selection_policy,
})
.await?;
info!(
agent_id = %agent_id.as_str(),
version = agent.current_draft_version,
version = current_version.version,
binding_count = bindings.len(),
"agent bindings saved"
);
self.get_agent_version(workspace_id, agent_id, agent.current_draft_version)
self.get_agent_version(workspace_id, agent_id, current_version.version)
.await
}
async fn ensure_editable_agent_version(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
) -> Result<AgentVersionRecord, ApiError> {
let agent = self.get_agent(workspace_id, agent_id).await?;
let current = self
.get_agent_version(workspace_id, agent_id, agent.current_draft_version)
.await?;
if agent.latest_published_version != Some(agent.current_draft_version) {
return Ok(current);
}
let now = OffsetDateTime::now_utc();
let draft = AgentVersion {
agent_id: agent_id.clone(),
version: current.version + 1,
status: AgentStatus::Draft,
instructions: current.snapshot.instructions.clone(),
tool_selection_policy: current.snapshot.tool_selection_policy.clone(),
created_at: now,
};
self.registry
.create_agent_draft_version(CreateAgentDraftVersionRequest {
workspace_id,
agent_id,
version: &draft,
bindings: &current.bindings,
updated_at: &now,
})
.await?;
self.get_agent_version(workspace_id, agent_id, draft.version)
.await
}
@@ -351,6 +475,10 @@ impl AdminService {
let published_bindings = self
.published_agent_bindings(workspace_id, &agent_version.bindings)
.await?;
validate_tool_selection_policy(
&agent_version.snapshot.tool_selection_policy,
&published_bindings,
)?;
if published_bindings.is_empty() {
return Err(ApiError::conflict_with_context(
@@ -490,3 +618,22 @@ impl AdminService {
})
}
}
fn validate_tool_selection_policy(
policy: &ToolSelectionPolicy,
bindings: &[AgentOperationBinding],
) -> Result<(), ApiError> {
policy
.validate_for_tools(
bindings
.iter()
.filter(|binding| binding.enabled)
.map(|binding| binding.tool_name.as_str()),
)
.map_err(|error| {
ApiError::validation_with_context(
error.to_string(),
json!({"field": "tool_selection_policy"}),
)
})
}
+2 -5
View File
@@ -272,7 +272,7 @@ impl AdminService {
publish: bool,
) -> Result<(), ApiError> {
let summary = self.get_agent(workspace_id, agent_id).await?;
self.save_agent_bindings(workspace_id, agent_id, bindings)
self.save_agent_bindings(workspace_id, agent_id, bindings.into())
.await?;
if publish && summary.latest_published_version.is_none() {
self.publish_agent(workspace_id, agent_id, summary.current_draft_version)
@@ -348,10 +348,7 @@ fn demo_currency_agent_payload() -> AgentPayload {
instructions: json!({
"system": "Используй инструменты Frankfurter только для запросов о курсах валют."
}),
tool_selection_policy: json!({
"max_tools": 4,
"prefer_tag": ["currency", "exchange-rate"]
}),
tool_selection_policy: Default::default(),
}
}
@@ -464,7 +464,7 @@ async fn seeds_demo_assets_for_live_ui() {
display_name: "Legacy Smoke Agent".to_owned(),
description: "Keeps a legacy smoke operation published".to_owned(),
instructions: json!({}),
tool_selection_policy: json!({}),
tool_selection_policy: Default::default(),
},
)
.await
@@ -481,7 +481,8 @@ async fn seeds_demo_assets_for_live_ui() {
tool_title: "Legacy health smoke".to_owned(),
tool_description_override: None,
enabled: true,
}],
}]
.into(),
)
.await
.unwrap();
@@ -398,6 +398,116 @@ async fn creates_binds_and_publishes_agent() {
assert_eq!(published["published_version"], 1);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn saves_and_previews_versioned_agent_tool_search_policy() {
let registry = test_registry().await;
let storage_root = test_storage_root("agent_tool_search");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let operation = assert_success_json(
client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"finance_create_invoice",
))
.send()
.await
.unwrap(),
)
.await;
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
assert_success_json(
client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.json(&json!({"version": 1}))
.send()
.await
.unwrap(),
)
.await;
let agent = assert_success_json(
client
.post(format!("{base_url}/agents"))
.json(&json!({
"slug": "finance-agent",
"display_name": "Finance Agent",
"description": "Finance workflows",
"instructions": {},
"tool_selection_policy": {}
}))
.send()
.await
.unwrap(),
)
.await;
let agent_id = agent["agent_id"].as_str().unwrap().to_owned();
let catalog = json!({
"bindings": [{
"operation_id": operation_id,
"operation_version": 1,
"tool_name": "finance_create_invoice",
"tool_title": "Create Lead",
"tool_description_override": "Creates an invoice for a customer",
"enabled": true
}],
"tool_selection_policy": {
"mode": "search",
"groups": [{
"id": "finance",
"name": "Finance",
"description": "Invoices and payments",
"tool_names": ["finance_create_invoice"]
}],
"search": {"max_results": 5}
}
});
let saved = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.json(&catalog)
.send()
.await
.unwrap(),
)
.await;
assert_eq!(saved["snapshot"]["tool_selection_policy"]["mode"], "search");
let preview = assert_success_json(
client
.post(format!("{base_url}/agents/tool-search/preview"))
.json(&json!({
"query": "create invoice",
"group_ids": ["finance"],
"bindings": catalog["bindings"],
"tool_selection_policy": catalog["tool_selection_policy"]
}))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(
preview["items"][0]["tool"]["name"],
"finance_create_invoice"
);
let published = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/publish"))
.json(&json!({"version": 1}))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(published["published_version"], 1);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
+1
View File
@@ -1,5 +1,6 @@
mod integration {
mod catalog_access;
mod common;
mod tool_search;
mod transport_protocol;
}
+19 -4
View File
@@ -18,7 +18,7 @@ use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod,
Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind,
PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription,
WorkspaceId,
ToolSelectionPolicy, WorkspaceId,
};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::{
@@ -45,7 +45,7 @@ use crank_community_mcp::{
session::{InMemorySessionStore, SharedSessionStore, TransportSessionStore},
};
fn test_workspace_id() -> WorkspaceId {
pub(super) fn test_workspace_id() -> WorkspaceId {
WorkspaceId::new("ws_default")
}
@@ -93,7 +93,7 @@ fn test_agent_id(agent_slug: &str) -> AgentId {
AgentId::new(format!("agent_{agent_slug}"))
}
fn build_test_app(
pub(super) fn build_test_app(
registry: PostgresRegistry,
refresh_interval: Duration,
public_base_url: Option<String>,
@@ -363,6 +363,21 @@ pub(super) async fn publish_agent_with_bindings(
registry: &PostgresRegistry,
agent_slug: &str,
bindings: Vec<AgentOperationBinding>,
) {
publish_agent_with_policy(
registry,
agent_slug,
bindings,
ToolSelectionPolicy::default(),
)
.await;
}
pub(super) async fn publish_agent_with_policy(
registry: &PostgresRegistry,
agent_slug: &str,
bindings: Vec<AgentOperationBinding>,
tool_selection_policy: ToolSelectionPolicy,
) {
let agent_id = AgentId::new(format!("agent_{agent_slug}"));
let agent = Agent {
@@ -383,7 +398,7 @@ pub(super) async fn publish_agent_with_bindings(
version: 1,
status: AgentStatus::Draft,
instructions: json!({}),
tool_selection_policy: json!({}),
tool_selection_policy,
created_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
};
@@ -0,0 +1,154 @@
use super::common::*;
use std::time::Duration;
use crank_core::{
PlatformApiKeyScope, ToolAccessMode, ToolGroup, ToolSearchSettings, ToolSelectionPolicy,
};
use crank_registry::PublishRequest;
use serde_json::json;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
#[tokio::test]
async fn search_mode_discovers_and_calls_tools_through_meta_tools() {
let registry = test_registry().await;
let upstream_base_url = spawn_upstream_server().await;
let invoice = test_operation(&upstream_base_url, "create_invoice");
let ticket = test_operation(&upstream_base_url, "create_support_ticket");
for operation in [&invoice, &ticket] {
registry
.create_operation(&test_workspace_id(), operation, Some("alice"))
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &test_workspace_id(),
operation_id: &operation.id,
version: 1,
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
published_by: Some("alice"),
})
.await
.unwrap();
}
publish_agent_with_policy(
&registry,
"business-search",
vec![
binding_for_operation(&invoice),
binding_for_operation(&ticket),
],
ToolSelectionPolicy {
mode: ToolAccessMode::Search,
groups: vec![
ToolGroup {
id: "finance".to_owned(),
name: "Finance".to_owned(),
description: "Invoices and payments".to_owned(),
tool_names: vec![invoice.name.clone()],
},
ToolGroup {
id: "support".to_owned(),
name: "Support".to_owned(),
description: "Customer support tickets".to_owned(),
tool_names: vec![ticket.name.clone()],
},
],
search: ToolSearchSettings { max_results: 5 },
},
)
.await;
let api_key = create_platform_api_key(
&registry,
"business-search",
"mcp-search",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
.await;
let base_url = spawn_mcp_server(build_test_app(
registry,
Duration::from_millis(0),
Some("https://crank.example.com".to_owned()),
))
.await;
let client = reqwest::Client::new();
let mcp_url = agent_mcp_url(&base_url, "business-search");
let session = initialize_session(&client, &mcp_url, &api_key).await;
let listed = post_jsonrpc(
&client,
&mcp_url,
&api_key,
Some(&session),
json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}),
)
.await;
assert_eq!(
listed["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|tool| tool["name"].as_str().unwrap())
.collect::<Vec<_>>(),
vec!["search_tools", "call_tool"]
);
let search = post_jsonrpc(
&client,
&mcp_url,
&api_key,
Some(&session),
json!({
"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"search_tools","arguments":{"query":"invoice","group_ids":["finance"]}}
}),
)
.await;
assert_eq!(
search["result"]["structuredContent"]["tools"][0]["name"],
"create_invoice"
);
assert_eq!(
search["result"]["structuredContent"]["catalog_revision"],
"agent-version-1"
);
let stale_call = post_jsonrpc(
&client,
&mcp_url,
&api_key,
Some(&session),
json!({
"jsonrpc":"2.0","id":4,"method":"tools/call",
"params":{"name":"call_tool","arguments":{
"name":"create_invoice",
"arguments":{"email":"user@example.com"},
"catalog_revision":"agent-version-0"
}}
}),
)
.await;
assert_eq!(stale_call["result"]["isError"], true);
assert_eq!(
stale_call["result"]["structuredContent"]["error"]["code"],
"catalog_revision_changed"
);
let call = post_jsonrpc(
&client,
&mcp_url,
&api_key,
Some(&session),
json!({
"jsonrpc":"2.0","id":5,"method":"tools/call",
"params":{"name":"call_tool","arguments":{
"name":"create_invoice",
"arguments":{"email":"user@example.com"},
"catalog_revision":"agent-version-1"
}}
}),
)
.await;
assert_eq!(call["result"]["isError"], false);
assert_eq!(call["result"]["structuredContent"]["id"], "lead_123");
}
@@ -19,7 +19,8 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod,
Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, WorkspaceId,
PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolAccessMode, ToolDescription, ToolGroup,
ToolSearchSettings, ToolSelectionPolicy, WorkspaceId,
};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::{
+171
View File
@@ -1403,6 +1403,177 @@
.agents-rec-callout svg { flex-shrink: 0; color: #d2991f; margin-top: 1px; }
.agents-rec-callout strong { color: var(--text-primary); }
.tool-access-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.tool-access-option {
min-height: 112px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg-canvas);
color: var(--text-secondary);
text-align: left;
cursor: pointer;
}
.tool-access-option:hover { border-color: var(--border-muted, #444c56); }
.tool-access-option.active {
border-color: var(--accent);
background: rgba(45, 212, 191, 0.07);
}
.tool-access-option-title {
display: block;
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
line-height: 1.35;
}
.tool-access-option.active .tool-access-option-title { color: var(--accent); }
.tool-access-option-body {
display: block;
margin-top: 7px;
font-size: 11px;
line-height: 1.5;
}
.tool-search-config {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--border-subtle);
}
.tool-search-config-header,
.tool-group-card-header,
.tool-search-preview-controls,
.tool-search-result,
.tool-group-assignment-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.tool-search-config-title {
color: var(--text-primary);
font-size: 12px;
font-weight: 600;
}
.tool-search-config-header .drawer-section-sub { margin: 3px 0 0; }
.tool-search-config-header .btn-ghost-sm { white-space: nowrap; }
.tool-group-empty {
padding: 12px;
border: 1px dashed var(--border);
border-radius: 7px;
color: var(--text-muted);
font-size: 11px;
line-height: 1.5;
}
.tool-group-card,
.tool-search-preview {
padding: 12px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg-canvas);
}
.tool-group-card-header { margin-bottom: 10px; }
.tool-group-card-header strong { font-size: 12px; color: var(--text-primary); }
.tool-group-card-header .agent-action-btn img { width: 13px; height: 13px; }
.tool-group-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
}
.tool-group-fields label,
.tool-group-description,
.tool-search-limit {
display: flex;
flex-direction: column;
gap: 5px;
color: var(--text-secondary);
font-size: 11px;
}
.tool-group-description { margin-top: 9px; }
.tool-group-assignments {
border: 1px solid var(--border);
border-radius: 7px;
overflow: hidden;
}
.tool-group-assignments > .tool-search-config-title {
padding: 10px 12px;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-canvas);
}
.tool-group-assignment-row {
align-items: flex-start;
padding: 10px 12px;
border-bottom: 1px solid var(--border-subtle);
}
.tool-group-assignment-row:last-child { border-bottom: 0; }
.tool-group-assignment-tool { min-width: 130px; }
.tool-group-assignment-tool strong,
.tool-group-assignment-tool code,
.tool-search-result strong,
.tool-search-result code {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-group-assignment-tool strong,
.tool-search-result strong { color: var(--text-primary); font-size: 11px; }
.tool-group-assignment-tool code,
.tool-search-result code { margin-top: 3px; color: var(--text-muted); font-size: 10px; }
.tool-group-chips {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 5px;
}
.tool-group-chip {
max-width: 150px;
padding: 4px 7px;
border: 1px solid var(--border);
border-radius: 5px;
background: transparent;
color: var(--text-muted);
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.tool-group-chip.active {
border-color: var(--accent);
background: rgba(45, 212, 191, 0.08);
color: var(--accent);
}
.tool-search-limit { max-width: 190px; }
.tool-search-preview .drawer-section-sub { margin-top: 4px; }
.tool-search-preview-controls {
display: grid;
grid-template-columns: minmax(0, 1fr) 132px auto;
}
.tool-search-results {
margin-top: 10px;
border-top: 1px solid var(--border-subtle);
}
.tool-search-result { padding: 9px 0; border-bottom: 1px solid var(--border-subtle); }
.tool-search-result:last-child { border-bottom: 0; }
.tool-search-result > div { min-width: 0; }
.tool-search-result > span { color: var(--text-muted); font-size: 10px; }
.tool-search-preview > .tool-group-empty { margin-top: 10px; }
@media (max-width: 540px) {
.tool-access-options,
.tool-group-fields,
.tool-search-preview-controls { grid-template-columns: 1fr; }
.tool-group-assignment-row { flex-direction: column; }
.tool-group-chips { justify-content: flex-start; }
.tool-search-limit { max-width: none; }
}
/*
Settings Members enhanced
+101 -3
View File
@@ -359,24 +359,122 @@
<!-- Footer count -->
<div class="ops-picker-footer" x-show="form.selectedOps.length > 0">
<span x-text="tfKey('agents.drawer.ops_selected', { count: form.selectedOps.length })"></span>
<button @click="form.selectedOps = []" style="background:none;border:none;color:var(--text-muted);font-size:12px;cursor:pointer;margin-left:8px;" data-i18n="agents.drawer.clear_all">Clear all</button>
<button @click="clearSelectedOperations()" style="background:none;border:none;color:var(--text-muted);font-size:12px;cursor:pointer;margin-left:8px;" data-i18n="agents.drawer.clear_all">Clear all</button>
</div>
</div>
<!-- Recommendation callout -->
<div class="agents-rec-callout" x-show="form.selectedOps.length > 15">
<div class="agents-rec-callout" x-show="form.selectedOps.length > 15 && form.accessMode === 'direct'">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><polygon points="8,1 15,14 1,14" fill="none"/><path d="M8 6v4M8 11.5v.5"/></svg>
<span x-text="tfKey('agents.drawer.recommendation', { count: form.selectedOps.length })">You've selected tools.</span>
</div>
</div>
<div class="drawer-section">
<div class="drawer-section-title" data-i18n="agents.drawer.access.title">Tool access</div>
<div class="drawer-section-sub" data-i18n="agents.drawer.access.subtitle">Choose how the model receives this agent's tool catalog.</div>
<div class="tool-access-options">
<button class="tool-access-option" :class="{ active: form.accessMode === 'direct' }" @click="setAccessMode('direct')">
<span class="tool-access-option-title" data-i18n="agents.drawer.access.direct">Show tools immediately</span>
<span class="tool-access-option-body" data-i18n="agents.drawer.access.direct_hint">Best for a small curated catalog. MCP clients receive every tool in tools/list.</span>
</button>
<button class="tool-access-option" :class="{ active: form.accessMode === 'search' }" @click="setAccessMode('search')">
<span class="tool-access-option-title" data-i18n="agents.drawer.access.search">Select tools on demand</span>
<span class="tool-access-option-body" data-i18n="agents.drawer.access.search_hint">The model sees search_tools and call_tool, then discovers only relevant schemas.</span>
</button>
</div>
<div class="tool-search-config" x-show="form.accessMode === 'search'" style="display:none">
<div class="tool-search-config-header">
<div>
<div class="tool-search-config-title" data-i18n="agents.drawer.groups.title">Catalog sections</div>
<div class="drawer-section-sub" data-i18n="agents.drawer.groups.subtitle">Sections help the model narrow a search without blocking catalog-wide discovery.</div>
</div>
<button class="btn-ghost-sm" @click="addToolGroup()" data-i18n="agents.drawer.groups.add">Add section</button>
</div>
<div class="tool-group-empty" x-show="form.groups.length === 0" data-i18n="agents.drawer.groups.empty">No sections yet. Search will use the entire selected catalog.</div>
<template x-for="(group, groupIndex) in form.groups" :key="groupIndex">
<div class="tool-group-card">
<div class="tool-group-card-header">
<strong x-text="group.name || tKey('agents.drawer.groups.untitled')"></strong>
<button class="agent-action-btn danger" @click="removeToolGroup(groupIndex)" :title="tKey('agents.drawer.groups.remove')">
<img src="/icons/general/trash.svg" alt="">
</button>
</div>
<div class="tool-group-fields">
<label>
<span data-i18n="agents.drawer.groups.name">Name</span>
<input class="form-input" type="text" :value="group.name" @input="onToolGroupName(groupIndex, $event.target.value)" data-i18n-ph="agents.drawer.groups.name_placeholder" placeholder="Finance">
</label>
<label>
<span data-i18n="agents.drawer.groups.id">Identifier</span>
<input class="form-input input-mono" type="text" :value="group.id" @input="onToolGroupId(groupIndex, $event.target.value)" placeholder="finance">
</label>
</div>
<label class="tool-group-description">
<span data-i18n="agents.drawer.groups.description">Description for the model</span>
<textarea class="form-textarea" rows="2" x-model="group.description" @input="resetSearchPreview()" data-i18n-ph="agents.drawer.groups.description_placeholder" placeholder="Invoices, payments and refunds"></textarea>
</label>
</div>
</template>
<div class="tool-group-assignments" x-show="form.groups.length > 0 && selectedOperations.length > 0">
<div class="tool-search-config-title" data-i18n="agents.drawer.groups.assign">Assign tools to sections</div>
<template x-for="operation in selectedOperations" :key="operation.id">
<div class="tool-group-assignment-row">
<div class="tool-group-assignment-tool">
<strong x-text="operation.display_name || operation.name"></strong>
<code x-text="operation.name"></code>
</div>
<div class="tool-group-chips">
<template x-for="(group, groupIndex) in form.groups" :key="groupIndex">
<button class="tool-group-chip" :class="{ active: toolInGroup(groupIndex, operation.name) }" @click="toggleToolGroup(groupIndex, operation.name)" x-text="group.name || group.id || '—'"></button>
</template>
</div>
</div>
</template>
</div>
<label class="tool-search-limit">
<span data-i18n="agents.drawer.search.limit">Maximum results per search</span>
<input class="form-input" type="number" min="1" max="20" x-model.number="form.searchMaxResults" @input="resetSearchPreview()">
</label>
<div class="tool-search-preview">
<div class="tool-search-config-title" data-i18n="agents.drawer.search.preview_title">Test tool selection</div>
<div class="drawer-section-sub" data-i18n="agents.drawer.search.preview_subtitle">Enter a task and verify which tools the model will receive.</div>
<div class="tool-search-preview-controls">
<input class="form-input" type="text" x-model="searchPreviewQuery" @keydown.enter.prevent="previewToolSearch()" data-i18n-ph="agents.drawer.search.query_placeholder" placeholder="Create an invoice for a customer">
<select class="form-select" x-model="searchPreviewGroup">
<option value="" data-i18n="agents.drawer.search.all_groups">All sections</option>
<template x-for="group in form.groups" :key="group.id">
<option :value="group.id" x-text="group.name || group.id"></option>
</template>
</select>
<button class="btn-primary-sm" :disabled="searchPreviewLoading || !searchPreviewQuery.trim() || !catalogConfigValid" @click="previewToolSearch()" x-text="searchPreviewLoading ? tKey('agents.drawer.search.testing') : tKey('agents.drawer.search.test')">Test</button>
</div>
<div class="tool-search-results" x-show="searchPreviewItems.length > 0">
<template x-for="item in searchPreviewItems" :key="item.tool.name">
<div class="tool-search-result">
<div><strong x-text="item.tool.title"></strong><code x-text="item.tool.name"></code></div>
<span x-text="item.tool.group_ids.join(', ')"></span>
</div>
</template>
</div>
<div class="tool-group-empty" x-show="searchPreviewRan && !searchPreviewLoading && searchPreviewItems.length === 0" data-i18n="agents.drawer.search.no_results">No tools matched this task.</div>
</div>
</div>
</div>
</div><!-- /drawer-body -->
<!-- Drawer footer -->
<div class="drawer-footer">
<button class="btn-ghost-sm" style="padding: 8px 16px; font-size: 13px;" @click="closeDrawer()" data-i18n="agents.drawer.cancel">Cancel</button>
<button class="btn-primary-sm" style="padding: 8px 20px; font-size: 13px;"
:disabled="!form.display_name.trim() || !form.slug.trim()"
:disabled="!form.display_name.trim() || !form.slug.trim() || !catalogConfigValid"
@click="saveAgent()"
x-text="drawerMode === 'create' ? tKey('agents.drawer.create') : tKey('agents.drawer.save')">
Create agent
+192 -20
View File
@@ -14,6 +14,7 @@ function mapAgent(agent) {
raw_status: agent.status,
operation_count: agent.operation_count || 0,
operation_ids: agent.operation_ids || [],
tool_selection_policy: agent.tool_selection_policy || { mode: 'direct', groups: [], search: { max_results: 8 } },
key_count: agent.key_count || 0,
calls_today: agent.calls_today || 0,
created_at: agent.created_at,
@@ -66,10 +67,18 @@ document.addEventListener('alpine:init', function() {
description: '',
status: 'published',
selectedOps: [],
accessMode: 'direct',
groups: [],
searchMaxResults: 8,
},
opSearch: '',
slugManuallyEdited: false,
searchPreviewQuery: '',
searchPreviewGroup: '',
searchPreviewItems: [],
searchPreviewLoading: false,
searchPreviewRan: false,
async init() {
var self = this;
@@ -205,7 +214,7 @@ document.addEventListener('alpine:init', function() {
get agentToolFindings() {
var findings = [];
var selected = this.selectedOperations;
if (selected.length > 8) {
if (selected.length > 8 && this.form.accessMode === 'direct') {
findings.push(this.tKey('agents.drawer.finding.too_many_tools'));
}
@@ -242,13 +251,18 @@ document.addEventListener('alpine:init', function() {
description: '',
status: 'published',
selectedOps: [],
accessMode: 'direct',
groups: [],
searchMaxResults: 8,
};
this.opSearch = '';
this.slugManuallyEdited = false;
this.resetSearchPreview();
this.drawerOpen = true;
},
openEdit(agent) {
var policy = agent.tool_selection_policy || {};
this.drawerMode = 'edit';
this.editingId = agent.id;
this.form = {
@@ -257,9 +271,22 @@ document.addEventListener('alpine:init', function() {
description: agent.description,
status: agent.raw_status || agent.status || 'draft',
selectedOps: [].concat(agent.operation_ids || []),
accessMode: policy.mode === 'search' ? 'search' : 'direct',
groups: (policy.groups || []).map(function(group) {
return {
id: group.id || '',
name: group.name || '',
description: group.description || '',
tool_names: [].concat(group.tool_names || []),
};
}),
searchMaxResults: policy.search && policy.search.max_results
? policy.search.max_results
: 8,
};
this.opSearch = '';
this.slugManuallyEdited = true;
this.resetSearchPreview();
this.drawerOpen = true;
},
@@ -289,13 +316,170 @@ document.addEventListener('alpine:init', function() {
this.form.selectedOps.push(operationId);
} else {
this.form.selectedOps.splice(index, 1);
var operation = this.operations.find(function(item) { return item.id === operationId; });
if (operation) {
this.form.groups.forEach(function(group) {
group.tool_names = group.tool_names.filter(function(name) {
return name !== operation.name;
});
});
}
}
this.resetSearchPreview();
},
isOpSelected(operationId) {
return this.form.selectedOps.includes(operationId);
},
clearSelectedOperations() {
this.form.selectedOps = [];
this.form.groups.forEach(function(group) { group.tool_names = []; });
this.resetSearchPreview();
},
setAccessMode(mode) {
this.form.accessMode = mode === 'search' ? 'search' : 'direct';
this.resetSearchPreview();
},
addToolGroup() {
this.form.groups.push({ id: '', name: '', description: '', tool_names: [] });
this.resetSearchPreview();
},
removeToolGroup(index) {
this.form.groups.splice(index, 1);
this.resetSearchPreview();
},
onToolGroupName(index, value) {
var group = this.form.groups[index];
if (!group) return;
var previousSlug = this.slugifyGroupName(group.name);
group.name = value;
if (!group.id || group.id === previousSlug) {
group.id = this.slugifyGroupName(value);
}
this.resetSearchPreview();
},
onToolGroupId(index, value) {
var group = this.form.groups[index];
if (!group) return;
group.id = this.slugifyGroupName(value);
this.resetSearchPreview();
},
slugifyGroupName(value) {
return String(value || '')
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
},
toggleToolGroup(groupIndex, toolName) {
var group = this.form.groups[groupIndex];
if (!group) return;
var index = group.tool_names.indexOf(toolName);
if (index === -1) group.tool_names.push(toolName);
else group.tool_names.splice(index, 1);
this.resetSearchPreview();
},
toolInGroup(groupIndex, toolName) {
var group = this.form.groups[groupIndex];
return Boolean(group && group.tool_names.includes(toolName));
},
toolSelectionPolicy() {
return {
mode: this.form.accessMode,
groups: this.form.accessMode === 'search'
? this.form.groups.map(function(group) {
return {
id: group.id.trim(),
name: group.name.trim(),
description: group.description.trim(),
tool_names: [].concat(group.tool_names || []),
};
})
: [],
search: {
max_results: Math.max(1, Math.min(20, Number(this.form.searchMaxResults) || 8)),
},
};
},
get catalogConfigValid() {
if (this.form.accessMode !== 'search') return true;
var ids = [];
for (var index = 0; index < this.form.groups.length; index += 1) {
var group = this.form.groups[index];
if (!group.id.trim() || !group.name.trim() || !group.description.trim()) return false;
if (ids.includes(group.id.trim())) return false;
ids.push(group.id.trim());
}
return true;
},
agentBindings() {
var self = this;
return this.form.selectedOps.map(function(operationId) {
var operation = self.operations.find(function(item) { return item.id === operationId; });
return {
operation_id: operationId,
operation_version: operation && operation.latest_published_version
? operation.latest_published_version
: operation && operation.current_draft_version
? operation.current_draft_version
: 1,
tool_name: operation ? operation.name : operationId,
tool_title: operation ? (operation.display_name || operation.name) : operationId,
tool_description_override: null,
enabled: true,
};
});
},
resetSearchPreview() {
this.searchPreviewItems = [];
this.searchPreviewRan = false;
},
async previewToolSearch() {
if (
this.searchPreviewLoading
|| !this.workspaceId
|| !this.searchPreviewQuery.trim()
|| this.form.accessMode !== 'search'
) return;
this.searchPreviewLoading = true;
this.searchPreviewRan = false;
try {
var response = await window.CrankApi.previewAgentToolSearch(this.workspaceId, {
query: this.searchPreviewQuery.trim(),
group_ids: this.searchPreviewGroup ? [this.searchPreviewGroup] : [],
bindings: this.agentBindings(),
tool_selection_policy: this.toolSelectionPolicy(),
});
this.searchPreviewItems = response.items || [];
this.searchPreviewRan = true;
} catch (error) {
this.searchPreviewItems = [];
if (window.CrankUi) {
window.CrankUi.error(
error.message || this.tKey('agents.drawer.search.preview_error'),
this.tKey('agents.drawer.search.preview_error_title')
);
}
} finally {
this.searchPreviewLoading = false;
}
},
operationsLookSimilar(left, right) {
var leftTokens = this.operationTokens(left);
var rightTokens = this.operationTokens(right);
@@ -345,7 +529,7 @@ document.addEventListener('alpine:init', function() {
display_name: this.form.display_name,
description: this.form.description,
instructions: {},
tool_selection_policy: {},
tool_selection_policy: this.toolSelectionPolicy(),
});
agentId = created.agent_id;
currentVersion = created.version || 1;
@@ -359,27 +543,15 @@ document.addEventListener('alpine:init', function() {
currentVersion = agent.current_draft_version || 1;
}
await window.CrankApi.saveAgentBindings(
var savedVersion = await window.CrankApi.saveAgentBindings(
this.workspaceId,
agentId,
this.form.selectedOps.map(function(operationId) {
var operation = self.operations.find(function(item) {
return item.id === operationId;
});
return {
operation_id: operationId,
operation_version: operation && operation.latest_published_version
? operation.latest_published_version
: operation && operation.current_draft_version
? operation.current_draft_version
: 1,
tool_name: operation ? operation.name : operationId,
tool_title: operation ? (operation.display_name || operation.name) : operationId,
tool_description_override: null,
enabled: true,
};
}),
{
bindings: this.agentBindings(),
tool_selection_policy: this.toolSelectionPolicy(),
},
);
currentVersion = savedVersion.version || currentVersion;
if (this.form.status === 'published') {
await window.CrankApi.publishAgent(this.workspaceId, agentId, {
+3
View File
@@ -262,6 +262,9 @@
saveAgentBindings: function(workspaceId, agentId, payload) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/bindings', payload);
},
previewAgentToolSearch: function(workspaceId, payload) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/tool-search/preview', payload);
},
publishAgent: function(workspaceId, agentId, payload) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/publish', payload);
},
+60 -4
View File
@@ -819,13 +819,41 @@ var TRANSLATIONS = {
'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.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.too_many_tools': 'This agent has many tools. Use on-demand selection or 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.ops_no_match': 'No operations match "{query}"',
'agents.drawer.ops_selected': '{count} operations selected',
'agents.drawer.clear_all': 'Clear all',
'agents.drawer.recommendation': "You've selected {count} tools. LLMs usually work best when an agent has fewer than 15 tools. Consider splitting this into separate agents by use case.",
'agents.drawer.recommendation': "You've selected {count} tools. Switch to on-demand selection so the model receives only relevant schemas.",
'agents.drawer.access.title': 'Tool access',
'agents.drawer.access.subtitle': "Choose how the model receives this agent's tool catalog.",
'agents.drawer.access.direct': 'Show tools immediately',
'agents.drawer.access.direct_hint': 'Best for a small curated catalog. MCP clients receive every tool in tools/list.',
'agents.drawer.access.search': 'Select tools on demand',
'agents.drawer.access.search_hint': 'The model sees search_tools and call_tool, then discovers only relevant schemas.',
'agents.drawer.groups.title': 'Catalog sections',
'agents.drawer.groups.subtitle': 'Sections help the model narrow a search without blocking catalog-wide discovery.',
'agents.drawer.groups.add': 'Add section',
'agents.drawer.groups.empty': 'No sections yet. Search will use the entire selected catalog.',
'agents.drawer.groups.untitled': 'Untitled section',
'agents.drawer.groups.remove': 'Remove section',
'agents.drawer.groups.name': 'Name',
'agents.drawer.groups.name_placeholder': 'Finance',
'agents.drawer.groups.id': 'Identifier',
'agents.drawer.groups.description': 'Description for the model',
'agents.drawer.groups.description_placeholder': 'Invoices, payments and refunds',
'agents.drawer.groups.assign': 'Assign tools to sections',
'agents.drawer.search.limit': 'Maximum results per search',
'agents.drawer.search.preview_title': 'Test tool selection',
'agents.drawer.search.preview_subtitle': 'Enter a task and verify which tools the model will receive.',
'agents.drawer.search.query_placeholder': 'Create an invoice for a customer',
'agents.drawer.search.all_groups': 'All sections',
'agents.drawer.search.test': 'Test',
'agents.drawer.search.testing': 'Testing…',
'agents.drawer.search.no_results': 'No preview results yet.',
'agents.drawer.search.preview_error': 'Failed to test tool selection',
'agents.drawer.search.preview_error_title': 'Tool selection test failed',
'agents.drawer.cancel': 'Cancel',
'agents.drawer.create': 'Create agent',
'agents.drawer.save': 'Save changes',
@@ -1683,13 +1711,41 @@ var TRANSLATIONS = {
'agents.drawer.operations_sub': 'Выберите MCP инструменты, которые будут доступны для этого агента.',
'agents.drawer.operations_sub_community': 'Выберите MCP инструменты, которые будут доступны для этого агента.',
'agents.drawer.finding.title': 'Рекомендация',
'agents.drawer.finding.too_many_tools': 'У агента выбрано много инструментов. Оставьте только те, которые нужны для одной конкретной задачи.',
'agents.drawer.finding.too_many_tools': 'У агента выбрано много инструментов. Включите подбор по запросу или оставьте только инструменты для одной конкретной задачи.',
'agents.drawer.finding.similar_tools': 'Инструменты «{left}» и «{right}» похожи. Переименуйте их точнее или оставьте один вариант.',
'agents.drawer.filter_ops': 'Фильтр операций…',
'agents.drawer.ops_no_match': 'Нет операций по запросу "{query}"',
'agents.drawer.ops_selected': 'Выбрано операций: {count}',
'agents.drawer.clear_all': 'Очистить все',
'agents.drawer.recommendation': 'Сейчас выбрано {count} инструментов. LLM лучше работает, когда у агента меньше 15 инструментов. Подумайте о разбиении по сценариям.',
'agents.drawer.recommendation': 'Сейчас выбрано {count} инструментов. Включите подбор по запросу, чтобы модель получала только подходящие схемы.',
'agents.drawer.access.title': 'Доступ к инструментам',
'agents.drawer.access.subtitle': 'Выберите, как модель будет получать каталог инструментов этого агента.',
'agents.drawer.access.direct': 'Показывать сразу',
'agents.drawer.access.direct_hint': 'Для небольшого отобранного каталога. MCP-клиент получает все инструменты через tools/list.',
'agents.drawer.access.search': 'Подбирать по запросу',
'agents.drawer.access.search_hint': 'Модель видит search_tools и call_tool, а затем получает только подходящие схемы.',
'agents.drawer.groups.title': 'Разделы каталога',
'agents.drawer.groups.subtitle': 'Разделы сужают область поиска, но не мешают искать по всему каталогу.',
'agents.drawer.groups.add': 'Добавить раздел',
'agents.drawer.groups.empty': 'Разделов пока нет. Поиск будет выполняться по всему выбранному каталогу.',
'agents.drawer.groups.untitled': 'Раздел без названия',
'agents.drawer.groups.remove': 'Удалить раздел',
'agents.drawer.groups.name': 'Название',
'agents.drawer.groups.name_placeholder': 'Расчёты',
'agents.drawer.groups.id': 'Идентификатор',
'agents.drawer.groups.description': 'Описание для модели',
'agents.drawer.groups.description_placeholder': 'Счета, платежи и возвраты',
'agents.drawer.groups.assign': 'Распределение инструментов по разделам',
'agents.drawer.search.limit': 'Максимум результатов за один поиск',
'agents.drawer.search.preview_title': 'Проверка подбора',
'agents.drawer.search.preview_subtitle': 'Введите задачу и проверьте, какие инструменты получит модель.',
'agents.drawer.search.query_placeholder': 'Создать счёт для клиента',
'agents.drawer.search.all_groups': 'Все разделы',
'agents.drawer.search.test': 'Проверить',
'agents.drawer.search.testing': 'Проверяем…',
'agents.drawer.search.no_results': 'Результатов проверки пока нет.',
'agents.drawer.search.preview_error': 'Не удалось проверить подбор инструментов',
'agents.drawer.search.preview_error_title': 'Ошибка проверки подбора',
'agents.drawer.cancel': 'Отмена',
'agents.drawer.create': 'Создать агента',
'agents.drawer.save': 'Сохранить изменения',
+23
View File
@@ -21,3 +21,26 @@ test('agents page shows demo cards and edit drawer opens', async ({ page }) => {
);
await expect(page.locator('.drawer')).toContainText(/mcp/i);
});
test('agent drawer configures on-demand tool discovery and catalog sections', async ({ page }) => {
await login(page);
await page.goto('/agents');
await page.getByRole('button', { name: localized('New agent', 'Новый агент') }).click();
await page.locator('.ops-picker-item').first().click();
await page.locator('.tool-access-option').nth(1).click();
await expect(page.locator('.tool-search-config')).toBeVisible();
await page.getByRole('button', { name: localized('Add section', 'Добавить раздел') }).click();
const group = page.locator('.tool-group-card').first();
await group.locator('input').nth(0).fill('Finance');
await group.locator('input').nth(1).fill('finance');
await group.locator('textarea').fill('Invoices, payments and refunds');
await expect(group.locator('input').nth(1)).toHaveValue('finance');
await expect(page.locator('.tool-search-preview')).toBeVisible();
await page.locator('.tool-group-chip').first().click();
await page.locator('.tool-search-preview input').fill('currency rate');
await page.locator('.tool-search-preview .btn-primary-sm').click();
await expect(page.locator('.tool-search-result').first()).toBeVisible();
});
+16 -29
View File
@@ -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(
Ok(catalog) => {
handle_catalog_tool_call(
state.clone(),
&session,
&message,
response_mode,
&credential,
resolved,
&catalog,
&tool_call_params.name,
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),
),
response_mode,
None,
Some(&session.protocol_version),
),
},
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> {
+32 -23
View File
@@ -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,
+1
View File
@@ -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};
+103 -3
View File
@@ -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),
)
}
+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(),
})
);
}
}
+11 -2
View File
@@ -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};
+237
View File
@@ -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");
}
}
+15 -14
View File
@@ -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};
+18 -2
View File
@@ -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,
+10 -9
View File
@@ -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"),
}
}
+31
View File
@@ -206,6 +206,7 @@ Auth profile хранит ссылки на secrets и способ примен
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/unpublish`
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/archive`
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings`
- `POST /api/admin/workspaces/{workspace_id}/agents/tool-search/preview`
- `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings/{operation_id}`
Пример создания агента:
@@ -223,6 +224,36 @@ curl https://crank.example.com/api/admin/workspaces/ws_default/agents \
}'
```
Привязки и политика доступа сохраняются одной атомарной операцией:
```json
{
"bindings": [
{
"operation_id": "op_01",
"operation_version": 1,
"tool_name": "create_invoice",
"tool_title": "Создать счёт",
"enabled": true
}
],
"tool_selection_policy": {
"mode": "search",
"groups": [
{
"id": "finance",
"name": "Расчёты",
"description": "Счета, платежи и возвраты",
"tool_names": ["create_invoice"]
}
],
"search": { "max_results": 8 }
}
}
```
Старый формат тела из одного массива привязок поддерживается и сохраняет текущую политику агента. Предварительная проверка принимает те же `bindings` и `tool_selection_policy`, а также `query` и необязательный `group_ids`.
## Agent API keys
- `GET /api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys`
+15
View File
@@ -122,6 +122,17 @@ curl https://crank.example.com/mcp/v1/default/currency-rates \
frankfurter_latest_rate
```
Если опубликованная версия агента использует подбор по запросу, `tools/list` содержит только:
```text
search_tools
call_tool
```
`search_tools` принимает текст задачи, необязательные идентификаторы разделов и предел результатов. Ответ содержит полные входные схемы найденных инструментов и `catalog_revision`.
`call_tool` принимает имя найденного инструмента, его аргументы и полученную `catalog_revision`. Если за время между поиском и вызовом опубликована новая версия агента, вызов отклоняется с кодом `catalog_revision_changed`: клиент должен повторить поиск.
## Пример `tools/call`
```bash
@@ -226,6 +237,10 @@ curl https://crank.example.com/mcp/v1/default/sales/approvals/<approval_id>/deny
MCP-клиент видит только опубликованные операции, которые привязаны к опубликованному агенту.
Способ выдачи, разделы и предел результатов входят в версионируемый контракт агента. Прямой режим не меняет обычный MCP-поток. Режим подбора скрывает исходные инструменты за совместимыми метаинструментами `search_tools` и `call_tool`; динамическое изменение `tools/list` и специальные расширения клиента не требуются.
Базовый поиск использует BM25 по имени, заголовку, описанию инструмента и описаниям его разделов. Фильтр по разделу применяется до ранжирования. По умолчанию возвращается не более восьми результатов, допустимый предел — от одного до двадцати.
Черновики операций не попадают в MCP-каталог. Если два пользователя работают в одном workspace, один может редактировать черновик, а второй публиковать агента. В опубликованный каталог попадут только опубликованные версии операций.
При каждом обновлении каталога Crank анализирует фактические определения `tools/list` и записывает в структурированный журнал:
+1 -1
View File
@@ -156,7 +156,7 @@ Crank возвращает структурированные ошибки. MCP-
Crank измеряет опубликованный каталог по тому же компактному JSON, который возвращается в `tools/list`. В расчёт входят имя, заголовок, описание, входная JSON-схема и добавляемое Crank описание подтверждения опасной операции. Для сравнения используется независимая от конкретной модели консервативная оценка: один токен на три байта UTF-8. Это не счётчик токенов конкретного поставщика, а стабильная инженерная метрика для поиска регрессий.
Рекомендуемый бюджет одного агентского каталога — не более 4096 оценочных токенов. Превышение не блокирует публикацию, потому что допустимый объём зависит от модели, но создаёт предупреждение. Сначала сокращайте лишние описания и схемы. Если инструменты решают разные задачи, разделяйте их между специализированными агентами. Выбор нужного агента и постепенное раскрытие каталогов выполняет оркестратор Drivetrain, а не Crank.
Рекомендуемый бюджет одного агентского каталога — не более 4096 оценочных токенов. Превышение не блокирует публикацию, потому что допустимый объём зависит от модели, но создаёт предупреждение. Сначала сокращайте лишние описания и схемы. Если инструменты решают разные задачи, разделяйте их между специализированными агентами. Если большой каталог является осознанным бизнес-требованием, включайте в версии агента режим подбора по запросу и проверяйте его на характерных задачах. Drivetrain выбирает нужного агента и строит связи между агентами, но не подменяет внутренний поиск по каталогу одного агента.
## Проверочный список
+9
View File
@@ -58,6 +58,15 @@
- делать названия и описания инструментов однозначными;
- публиковать агента только после проверки операций.
В карточке агента явно выбирается способ доступа к каталогу:
- **Показывать сразу**`tools/list` возвращает все привязанные инструменты. Это основной режим для небольшого каталога.
- **Подбирать по запросу**`tools/list` возвращает только `search_tools` и `call_tool`. Модель сначала находит несколько подходящих инструментов, затем вызывает выбранный через прокси-инструмент.
В режиме подбора каталог можно разделить на именованные разделы. Название и описание раздела видит модель, а один инструмент можно включить в несколько разделов. Раздел ограничивает область поиска, но поиск без раздела всегда охватывает весь каталог.
Перед сохранением можно ввести пробную задачу и проверить результат подбора. Эта проверка использует тот же алгоритм, что и MCP-сервер.
## API ключи
API-ключ выдается на конкретного агента. В Community есть два режима ключей.