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

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();
});