агенты: добавить поиск инструментов по каталогу
This commit is contained in:
@@ -12,7 +12,7 @@ use crate::{
|
|||||||
agents::{
|
agents::{
|
||||||
archive_agent, create_agent, create_agent_platform_api_key, delete_agent,
|
archive_agent, create_agent, create_agent_platform_api_key, delete_agent,
|
||||||
delete_agent_platform_api_key, get_agent, get_agent_version,
|
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,
|
revoke_agent_platform_api_key, save_agent_bindings, unpublish_agent, update_agent,
|
||||||
},
|
},
|
||||||
auth::{change_password, get_profile, get_session, login, logout, update_profile},
|
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("/operations/{operation_id}/export", get(export_operation))
|
||||||
.route("/agents", get(list_agents).post(create_agent))
|
.route("/agents", get(list_agents).post(create_agent))
|
||||||
|
.route("/agents/tool-search/preview", post(preview_tool_search))
|
||||||
.route(
|
.route(
|
||||||
"/agents/{agent_id}",
|
"/agents/{agent_id}",
|
||||||
get(get_agent).patch(update_agent).delete(delete_agent),
|
get(get_agent).patch(update_agent).delete(delete_agent),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use crank_core::{
|
|||||||
AgentId, AgentStatus, ApprovalRequestStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode,
|
AgentId, AgentStatus, ApprovalRequestStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode,
|
||||||
GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel,
|
GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel,
|
||||||
OperationStatus, PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target,
|
OperationStatus, PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target,
|
||||||
UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus,
|
ToolSelectionPolicy, UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus,
|
||||||
};
|
};
|
||||||
use crank_mapping::MappingSet;
|
use crank_mapping::MappingSet;
|
||||||
use crank_registry::{
|
use crank_registry::{
|
||||||
@@ -144,7 +144,7 @@ pub struct AgentPayload {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub instructions: Value,
|
pub instructions: Value,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tool_selection_policy: Value,
|
pub tool_selection_policy: ToolSelectionPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
@@ -165,6 +165,43 @@ pub struct AgentBindingPayload {
|
|||||||
pub enabled: bool,
|
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)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct CreatedAgentResponse {
|
pub struct CreatedAgentResponse {
|
||||||
pub agent_id: String,
|
pub agent_id: String,
|
||||||
@@ -196,6 +233,7 @@ pub struct AgentSummaryView {
|
|||||||
pub published_at: Option<String>,
|
pub published_at: Option<String>,
|
||||||
pub operation_count: usize,
|
pub operation_count: usize,
|
||||||
pub operation_ids: Vec<String>,
|
pub operation_ids: Vec<String>,
|
||||||
|
pub tool_selection_policy: ToolSelectionPolicy,
|
||||||
pub key_count: usize,
|
pub key_count: usize,
|
||||||
pub calls_today: u64,
|
pub calls_today: u64,
|
||||||
pub mcp_endpoint: String,
|
pub mcp_endpoint: String,
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use serde_json::{Value, json};
|
|||||||
use crate::{
|
use crate::{
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
service::{
|
service::{
|
||||||
AgentBindingPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
|
AgentCatalogPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
|
||||||
UpdateAgentPayload,
|
ToolSearchPreviewPayload, UpdateAgentPayload,
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
@@ -50,6 +50,18 @@ pub async fn list_agents(
|
|||||||
Ok(Json(json!({ "items": items })))
|
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(
|
pub async fn create_agent(
|
||||||
Path(path): Path<WorkspacePath>,
|
Path(path): Path<WorkspacePath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -124,7 +136,7 @@ pub async fn get_agent_version(
|
|||||||
pub async fn save_agent_bindings(
|
pub async fn save_agent_bindings(
|
||||||
Path(path): Path<WorkspaceAgentPath>,
|
Path(path): Path<WorkspaceAgentPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<Vec<AgentBindingPayload>>,
|
Json(payload): Json<AgentCatalogPayload>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let record = state
|
let record = state
|
||||||
.service
|
.service
|
||||||
|
|||||||
@@ -698,6 +698,7 @@ fn map_agent_summary_view(summary: AgentSummary) -> AgentSummaryView {
|
|||||||
published_at: summary.published_at.map(format_timestamp),
|
published_at: summary.published_at.map(format_timestamp),
|
||||||
operation_count: 0,
|
operation_count: 0,
|
||||||
operation_ids: Vec::new(),
|
operation_ids: Vec::new(),
|
||||||
|
tool_selection_policy: Default::default(),
|
||||||
key_count: 0,
|
key_count: 0,
|
||||||
calls_today: 0,
|
calls_today: 0,
|
||||||
mcp_endpoint: String::new(),
|
mcp_endpoint: String::new(),
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use crank_core::{
|
use crank_core::{
|
||||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, OperationId, UsagePeriod,
|
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, OperationId, SearchableTool,
|
||||||
WorkspaceId,
|
ToolAccessMode, ToolSelectionPolicy, UsagePeriod, WorkspaceId, search_tool_catalog,
|
||||||
};
|
};
|
||||||
use crank_registry::{
|
use crank_registry::{
|
||||||
AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest, PublishAgentRequest,
|
AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest, PublishAgentRequest,
|
||||||
SaveAgentBindingsRequest, UsageBucket, UsageQuery,
|
SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest, UsageBucket, UsageQuery,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
@@ -15,13 +15,93 @@ use tracing::{info, instrument};
|
|||||||
use crate::{
|
use crate::{
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
service::{
|
service::{
|
||||||
AdminService, AgentBindingPayload, AgentMutationResult, AgentPayload, AgentSummaryView,
|
AdminService, AgentCatalogPayload, AgentMutationResult, AgentPayload, AgentSummaryView,
|
||||||
CreatedAgentResponse, PublishAgentResponse, UpdateAgentPayload, agent_mcp_endpoint,
|
CreatedAgentResponse, PublishAgentResponse, ToolSearchPreviewPayload, UpdateAgentPayload,
|
||||||
format_timestamp, map_agent_summary_view, new_prefixed_id, today_start_utc,
|
agent_mcp_endpoint, format_timestamp, map_agent_summary_view, new_prefixed_id,
|
||||||
|
today_start_utc,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
impl AdminService {
|
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))]
|
#[instrument(skip(self))]
|
||||||
pub async fn list_agents(
|
pub async fn list_agents(
|
||||||
&self,
|
&self,
|
||||||
@@ -69,6 +149,7 @@ impl AdminService {
|
|||||||
items.push(AgentSummaryView {
|
items.push(AgentSummaryView {
|
||||||
operation_count: operation_ids.len(),
|
operation_count: operation_ids.len(),
|
||||||
operation_ids,
|
operation_ids,
|
||||||
|
tool_selection_policy: version.snapshot.tool_selection_policy,
|
||||||
key_count: key_counts.get(summary.id.as_str()).copied().unwrap_or(0),
|
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),
|
calls_today: calls_today.get(summary.id.as_str()).copied().unwrap_or(0),
|
||||||
mcp_endpoint: agent_mcp_endpoint(
|
mcp_endpoint: agent_mcp_endpoint(
|
||||||
@@ -130,6 +211,7 @@ impl AdminService {
|
|||||||
Ok(AgentSummaryView {
|
Ok(AgentSummaryView {
|
||||||
operation_count: operation_ids.len(),
|
operation_count: operation_ids.len(),
|
||||||
operation_ids,
|
operation_ids,
|
||||||
|
tool_selection_policy: version.snapshot.tool_selection_policy,
|
||||||
key_count,
|
key_count,
|
||||||
calls_today: usage.map(|item| item.rollup.calls_total).unwrap_or(0),
|
calls_today: usage.map(|item| item.rollup.calls_total).unwrap_or(0),
|
||||||
mcp_endpoint: agent_mcp_endpoint(
|
mcp_endpoint: agent_mcp_endpoint(
|
||||||
@@ -304,9 +386,12 @@ impl AdminService {
|
|||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
workspace_id: &WorkspaceId,
|
||||||
agent_id: &AgentId,
|
agent_id: &AgentId,
|
||||||
payload: Vec<AgentBindingPayload>,
|
payload: AgentCatalogPayload,
|
||||||
) -> Result<AgentVersionRecord, ApiError> {
|
) -> 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
|
let bindings = payload
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|binding| AgentOperationBinding {
|
.map(|binding| AgentOperationBinding {
|
||||||
@@ -318,23 +403,62 @@ impl AdminService {
|
|||||||
enabled: binding.enabled,
|
enabled: binding.enabled,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.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
|
self.registry
|
||||||
.save_agent_bindings(SaveAgentBindingsRequest {
|
.save_agent_catalog_config(SaveAgentCatalogConfigRequest {
|
||||||
workspace_id,
|
workspace_id,
|
||||||
agent_id,
|
agent_id,
|
||||||
agent_version: agent.current_draft_version,
|
agent_version: current_version.version,
|
||||||
bindings: &bindings,
|
bindings: &bindings,
|
||||||
|
tool_selection_policy: &tool_selection_policy,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
info!(
|
info!(
|
||||||
agent_id = %agent_id.as_str(),
|
agent_id = %agent_id.as_str(),
|
||||||
version = agent.current_draft_version,
|
version = current_version.version,
|
||||||
binding_count = bindings.len(),
|
binding_count = bindings.len(),
|
||||||
"agent bindings saved"
|
"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: ¤t.bindings,
|
||||||
|
updated_at: &now,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
self.get_agent_version(workspace_id, agent_id, draft.version)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +475,10 @@ impl AdminService {
|
|||||||
let published_bindings = self
|
let published_bindings = self
|
||||||
.published_agent_bindings(workspace_id, &agent_version.bindings)
|
.published_agent_bindings(workspace_id, &agent_version.bindings)
|
||||||
.await?;
|
.await?;
|
||||||
|
validate_tool_selection_policy(
|
||||||
|
&agent_version.snapshot.tool_selection_policy,
|
||||||
|
&published_bindings,
|
||||||
|
)?;
|
||||||
|
|
||||||
if published_bindings.is_empty() {
|
if published_bindings.is_empty() {
|
||||||
return Err(ApiError::conflict_with_context(
|
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"}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ impl AdminService {
|
|||||||
publish: bool,
|
publish: bool,
|
||||||
) -> Result<(), ApiError> {
|
) -> Result<(), ApiError> {
|
||||||
let summary = self.get_agent(workspace_id, agent_id).await?;
|
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?;
|
.await?;
|
||||||
if publish && summary.latest_published_version.is_none() {
|
if publish && summary.latest_published_version.is_none() {
|
||||||
self.publish_agent(workspace_id, agent_id, summary.current_draft_version)
|
self.publish_agent(workspace_id, agent_id, summary.current_draft_version)
|
||||||
@@ -348,10 +348,7 @@ fn demo_currency_agent_payload() -> AgentPayload {
|
|||||||
instructions: json!({
|
instructions: json!({
|
||||||
"system": "Используй инструменты Frankfurter только для запросов о курсах валют."
|
"system": "Используй инструменты Frankfurter только для запросов о курсах валют."
|
||||||
}),
|
}),
|
||||||
tool_selection_policy: json!({
|
tool_selection_policy: Default::default(),
|
||||||
"max_tools": 4,
|
|
||||||
"prefer_tag": ["currency", "exchange-rate"]
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -464,7 +464,7 @@ async fn seeds_demo_assets_for_live_ui() {
|
|||||||
display_name: "Legacy Smoke Agent".to_owned(),
|
display_name: "Legacy Smoke Agent".to_owned(),
|
||||||
description: "Keeps a legacy smoke operation published".to_owned(),
|
description: "Keeps a legacy smoke operation published".to_owned(),
|
||||||
instructions: json!({}),
|
instructions: json!({}),
|
||||||
tool_selection_policy: json!({}),
|
tool_selection_policy: Default::default(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -481,7 +481,8 @@ async fn seeds_demo_assets_for_live_ui() {
|
|||||||
tool_title: "Legacy health smoke".to_owned(),
|
tool_title: "Legacy health smoke".to_owned(),
|
||||||
tool_description_override: None,
|
tool_description_override: None,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}],
|
}]
|
||||||
|
.into(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -398,6 +398,116 @@ async fn creates_binds_and_publishes_agent() {
|
|||||||
assert_eq!(published["published_version"], 1);
|
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")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
|
async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
mod integration {
|
mod integration {
|
||||||
mod catalog_access;
|
mod catalog_access;
|
||||||
mod common;
|
mod common;
|
||||||
|
mod tool_search;
|
||||||
mod transport_protocol;
|
mod transport_protocol;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use crank_core::{
|
|||||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod,
|
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod,
|
||||||
Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind,
|
Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind,
|
||||||
PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription,
|
PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription,
|
||||||
WorkspaceId,
|
ToolSelectionPolicy, WorkspaceId,
|
||||||
};
|
};
|
||||||
use crank_mapping::{MappingRule, MappingSet};
|
use crank_mapping::{MappingRule, MappingSet};
|
||||||
use crank_registry::{
|
use crank_registry::{
|
||||||
@@ -45,7 +45,7 @@ use crank_community_mcp::{
|
|||||||
session::{InMemorySessionStore, SharedSessionStore, TransportSessionStore},
|
session::{InMemorySessionStore, SharedSessionStore, TransportSessionStore},
|
||||||
};
|
};
|
||||||
|
|
||||||
fn test_workspace_id() -> WorkspaceId {
|
pub(super) fn test_workspace_id() -> WorkspaceId {
|
||||||
WorkspaceId::new("ws_default")
|
WorkspaceId::new("ws_default")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ fn test_agent_id(agent_slug: &str) -> AgentId {
|
|||||||
AgentId::new(format!("agent_{agent_slug}"))
|
AgentId::new(format!("agent_{agent_slug}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_test_app(
|
pub(super) fn build_test_app(
|
||||||
registry: PostgresRegistry,
|
registry: PostgresRegistry,
|
||||||
refresh_interval: Duration,
|
refresh_interval: Duration,
|
||||||
public_base_url: Option<String>,
|
public_base_url: Option<String>,
|
||||||
@@ -363,6 +363,21 @@ pub(super) async fn publish_agent_with_bindings(
|
|||||||
registry: &PostgresRegistry,
|
registry: &PostgresRegistry,
|
||||||
agent_slug: &str,
|
agent_slug: &str,
|
||||||
bindings: Vec<AgentOperationBinding>,
|
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_id = AgentId::new(format!("agent_{agent_slug}"));
|
||||||
let agent = Agent {
|
let agent = Agent {
|
||||||
@@ -383,7 +398,7 @@ pub(super) async fn publish_agent_with_bindings(
|
|||||||
version: 1,
|
version: 1,
|
||||||
status: AgentStatus::Draft,
|
status: AgentStatus::Draft,
|
||||||
instructions: json!({}),
|
instructions: json!({}),
|
||||||
tool_selection_policy: json!({}),
|
tool_selection_policy,
|
||||||
created_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
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(
|
||||||
|
®istry,
|
||||||
|
"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(
|
||||||
|
®istry,
|
||||||
|
"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::{
|
use crank_core::{
|
||||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod,
|
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod,
|
||||||
Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
|
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_mapping::{MappingRule, MappingSet};
|
||||||
use crank_registry::{
|
use crank_registry::{
|
||||||
|
|||||||
@@ -1403,6 +1403,177 @@
|
|||||||
.agents-rec-callout svg { flex-shrink: 0; color: #d2991f; margin-top: 1px; }
|
.agents-rec-callout svg { flex-shrink: 0; color: #d2991f; margin-top: 1px; }
|
||||||
.agents-rec-callout strong { color: var(--text-primary); }
|
.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
|
Settings — Members enhanced
|
||||||
|
|||||||
+101
-3
@@ -359,24 +359,122 @@
|
|||||||
<!-- Footer count -->
|
<!-- Footer count -->
|
||||||
<div class="ops-picker-footer" x-show="form.selectedOps.length > 0">
|
<div class="ops-picker-footer" x-show="form.selectedOps.length > 0">
|
||||||
<span x-text="tfKey('agents.drawer.ops_selected', { count: form.selectedOps.length })"></span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recommendation callout -->
|
<!-- 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>
|
<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>
|
<span x-text="tfKey('agents.drawer.recommendation', { count: form.selectedOps.length })">You've selected tools.</span>
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
</div><!-- /drawer-body -->
|
||||||
|
|
||||||
<!-- Drawer footer -->
|
<!-- Drawer footer -->
|
||||||
<div class="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-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;"
|
<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()"
|
@click="saveAgent()"
|
||||||
x-text="drawerMode === 'create' ? tKey('agents.drawer.create') : tKey('agents.drawer.save')">
|
x-text="drawerMode === 'create' ? tKey('agents.drawer.create') : tKey('agents.drawer.save')">
|
||||||
Create agent
|
Create agent
|
||||||
|
|||||||
+192
-20
@@ -14,6 +14,7 @@ function mapAgent(agent) {
|
|||||||
raw_status: agent.status,
|
raw_status: agent.status,
|
||||||
operation_count: agent.operation_count || 0,
|
operation_count: agent.operation_count || 0,
|
||||||
operation_ids: agent.operation_ids || [],
|
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,
|
key_count: agent.key_count || 0,
|
||||||
calls_today: agent.calls_today || 0,
|
calls_today: agent.calls_today || 0,
|
||||||
created_at: agent.created_at,
|
created_at: agent.created_at,
|
||||||
@@ -66,10 +67,18 @@ document.addEventListener('alpine:init', function() {
|
|||||||
description: '',
|
description: '',
|
||||||
status: 'published',
|
status: 'published',
|
||||||
selectedOps: [],
|
selectedOps: [],
|
||||||
|
accessMode: 'direct',
|
||||||
|
groups: [],
|
||||||
|
searchMaxResults: 8,
|
||||||
},
|
},
|
||||||
|
|
||||||
opSearch: '',
|
opSearch: '',
|
||||||
slugManuallyEdited: false,
|
slugManuallyEdited: false,
|
||||||
|
searchPreviewQuery: '',
|
||||||
|
searchPreviewGroup: '',
|
||||||
|
searchPreviewItems: [],
|
||||||
|
searchPreviewLoading: false,
|
||||||
|
searchPreviewRan: false,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
var self = this;
|
var self = this;
|
||||||
@@ -205,7 +214,7 @@ document.addEventListener('alpine:init', function() {
|
|||||||
get agentToolFindings() {
|
get agentToolFindings() {
|
||||||
var findings = [];
|
var findings = [];
|
||||||
var selected = this.selectedOperations;
|
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'));
|
findings.push(this.tKey('agents.drawer.finding.too_many_tools'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,13 +251,18 @@ document.addEventListener('alpine:init', function() {
|
|||||||
description: '',
|
description: '',
|
||||||
status: 'published',
|
status: 'published',
|
||||||
selectedOps: [],
|
selectedOps: [],
|
||||||
|
accessMode: 'direct',
|
||||||
|
groups: [],
|
||||||
|
searchMaxResults: 8,
|
||||||
};
|
};
|
||||||
this.opSearch = '';
|
this.opSearch = '';
|
||||||
this.slugManuallyEdited = false;
|
this.slugManuallyEdited = false;
|
||||||
|
this.resetSearchPreview();
|
||||||
this.drawerOpen = true;
|
this.drawerOpen = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
openEdit(agent) {
|
openEdit(agent) {
|
||||||
|
var policy = agent.tool_selection_policy || {};
|
||||||
this.drawerMode = 'edit';
|
this.drawerMode = 'edit';
|
||||||
this.editingId = agent.id;
|
this.editingId = agent.id;
|
||||||
this.form = {
|
this.form = {
|
||||||
@@ -257,9 +271,22 @@ document.addEventListener('alpine:init', function() {
|
|||||||
description: agent.description,
|
description: agent.description,
|
||||||
status: agent.raw_status || agent.status || 'draft',
|
status: agent.raw_status || agent.status || 'draft',
|
||||||
selectedOps: [].concat(agent.operation_ids || []),
|
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.opSearch = '';
|
||||||
this.slugManuallyEdited = true;
|
this.slugManuallyEdited = true;
|
||||||
|
this.resetSearchPreview();
|
||||||
this.drawerOpen = true;
|
this.drawerOpen = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -289,13 +316,170 @@ document.addEventListener('alpine:init', function() {
|
|||||||
this.form.selectedOps.push(operationId);
|
this.form.selectedOps.push(operationId);
|
||||||
} else {
|
} else {
|
||||||
this.form.selectedOps.splice(index, 1);
|
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) {
|
isOpSelected(operationId) {
|
||||||
return this.form.selectedOps.includes(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) {
|
operationsLookSimilar(left, right) {
|
||||||
var leftTokens = this.operationTokens(left);
|
var leftTokens = this.operationTokens(left);
|
||||||
var rightTokens = this.operationTokens(right);
|
var rightTokens = this.operationTokens(right);
|
||||||
@@ -345,7 +529,7 @@ document.addEventListener('alpine:init', function() {
|
|||||||
display_name: this.form.display_name,
|
display_name: this.form.display_name,
|
||||||
description: this.form.description,
|
description: this.form.description,
|
||||||
instructions: {},
|
instructions: {},
|
||||||
tool_selection_policy: {},
|
tool_selection_policy: this.toolSelectionPolicy(),
|
||||||
});
|
});
|
||||||
agentId = created.agent_id;
|
agentId = created.agent_id;
|
||||||
currentVersion = created.version || 1;
|
currentVersion = created.version || 1;
|
||||||
@@ -359,27 +543,15 @@ document.addEventListener('alpine:init', function() {
|
|||||||
currentVersion = agent.current_draft_version || 1;
|
currentVersion = agent.current_draft_version || 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
await window.CrankApi.saveAgentBindings(
|
var savedVersion = await window.CrankApi.saveAgentBindings(
|
||||||
this.workspaceId,
|
this.workspaceId,
|
||||||
agentId,
|
agentId,
|
||||||
this.form.selectedOps.map(function(operationId) {
|
{
|
||||||
var operation = self.operations.find(function(item) {
|
bindings: this.agentBindings(),
|
||||||
return item.id === operationId;
|
tool_selection_policy: this.toolSelectionPolicy(),
|
||||||
});
|
},
|
||||||
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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
currentVersion = savedVersion.version || currentVersion;
|
||||||
|
|
||||||
if (this.form.status === 'published') {
|
if (this.form.status === 'published') {
|
||||||
await window.CrankApi.publishAgent(this.workspaceId, agentId, {
|
await window.CrankApi.publishAgent(this.workspaceId, agentId, {
|
||||||
|
|||||||
@@ -262,6 +262,9 @@
|
|||||||
saveAgentBindings: function(workspaceId, agentId, payload) {
|
saveAgentBindings: function(workspaceId, agentId, payload) {
|
||||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/bindings', 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) {
|
publishAgent: function(workspaceId, agentId, payload) {
|
||||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/publish', payload);
|
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/publish', payload);
|
||||||
},
|
},
|
||||||
|
|||||||
+60
-4
@@ -819,13 +819,41 @@ var TRANSLATIONS = {
|
|||||||
'agents.drawer.operations_sub': 'Select the MCP tools available to this agent.',
|
'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.operations_sub_community': 'Select the MCP tools available to this agent.',
|
||||||
'agents.drawer.finding.title': 'Recommendation',
|
'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.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.filter_ops': 'Filter operations…',
|
||||||
'agents.drawer.ops_no_match': 'No operations match "{query}"',
|
'agents.drawer.ops_no_match': 'No operations match "{query}"',
|
||||||
'agents.drawer.ops_selected': '{count} operations selected',
|
'agents.drawer.ops_selected': '{count} operations selected',
|
||||||
'agents.drawer.clear_all': 'Clear all',
|
'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.cancel': 'Cancel',
|
||||||
'agents.drawer.create': 'Create agent',
|
'agents.drawer.create': 'Create agent',
|
||||||
'agents.drawer.save': 'Save changes',
|
'agents.drawer.save': 'Save changes',
|
||||||
@@ -1683,13 +1711,41 @@ var TRANSLATIONS = {
|
|||||||
'agents.drawer.operations_sub': 'Выберите MCP инструменты, которые будут доступны для этого агента.',
|
'agents.drawer.operations_sub': 'Выберите MCP инструменты, которые будут доступны для этого агента.',
|
||||||
'agents.drawer.operations_sub_community': 'Выберите MCP инструменты, которые будут доступны для этого агента.',
|
'agents.drawer.operations_sub_community': 'Выберите MCP инструменты, которые будут доступны для этого агента.',
|
||||||
'agents.drawer.finding.title': 'Рекомендация',
|
'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.finding.similar_tools': 'Инструменты «{left}» и «{right}» похожи. Переименуйте их точнее или оставьте один вариант.',
|
||||||
'agents.drawer.filter_ops': 'Фильтр операций…',
|
'agents.drawer.filter_ops': 'Фильтр операций…',
|
||||||
'agents.drawer.ops_no_match': 'Нет операций по запросу "{query}"',
|
'agents.drawer.ops_no_match': 'Нет операций по запросу "{query}"',
|
||||||
'agents.drawer.ops_selected': 'Выбрано операций: {count}',
|
'agents.drawer.ops_selected': 'Выбрано операций: {count}',
|
||||||
'agents.drawer.clear_all': 'Очистить все',
|
'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.cancel': 'Отмена',
|
||||||
'agents.drawer.create': 'Создать агента',
|
'agents.drawer.create': 'Создать агента',
|
||||||
'agents.drawer.save': 'Сохранить изменения',
|
'agents.drawer.save': 'Сохранить изменения',
|
||||||
|
|||||||
@@ -21,3 +21,26 @@ test('agents page shows demo cards and edit drawer opens', async ({ page }) => {
|
|||||||
);
|
);
|
||||||
await expect(page.locator('.drawer')).toContainText(/mcp/i);
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ use crate::{
|
|||||||
DEFAULT_PROTOCOL_VERSION, is_notification, is_request, is_response, jsonrpc_error,
|
DEFAULT_PROTOCOL_VERSION, is_notification, is_request, is_response, jsonrpc_error,
|
||||||
jsonrpc_result, method_name, negotiated_protocol_version, params, request_id,
|
jsonrpc_result, method_name, negotiated_protocol_version, params, request_id,
|
||||||
},
|
},
|
||||||
manifest::tool_definitions,
|
manifest::catalog_tool_definitions,
|
||||||
rate_limit::{
|
rate_limit::{
|
||||||
enforce_post_rate_limit, enforce_transport_rate_limit, rate_limited_jsonrpc_response,
|
enforce_post_rate_limit, enforce_transport_rate_limit, rate_limited_jsonrpc_response,
|
||||||
rate_limited_status_response,
|
rate_limited_status_response,
|
||||||
@@ -53,6 +53,7 @@ use crate::{
|
|||||||
ToolErrorContract, generic_tool_error_contract, runtime_error_code,
|
ToolErrorContract, generic_tool_error_contract, runtime_error_code,
|
||||||
tool_error_contract_from_runtime, tool_error_text, tool_error_value,
|
tool_error_contract_from_runtime, tool_error_text, tool_error_value,
|
||||||
},
|
},
|
||||||
|
tool_search::handle_catalog_tool_call,
|
||||||
transport::{
|
transport::{
|
||||||
AllowedOrigins, HEADER_MCP_SESSION_ID, ResponseMode, json_response,
|
AllowedOrigins, HEADER_MCP_SESSION_ID, ResponseMode, json_response,
|
||||||
negotiate_post_response_mode, protocol_version_from_headers, resolve_request_id,
|
negotiate_post_response_mode, protocol_version_from_headers, resolve_request_id,
|
||||||
@@ -98,7 +99,7 @@ struct ApprovalDecisionPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ResolvedToolCall {
|
pub(super) struct ResolvedToolCall {
|
||||||
tool: PublishedAgentTool,
|
tool: PublishedAgentTool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,11 +680,11 @@ async fn mcp_post(
|
|||||||
|
|
||||||
match state
|
match state
|
||||||
.catalog
|
.catalog
|
||||||
.list_tools(&session.workspace_slug, &session.agent_slug)
|
.get_catalog(&session.workspace_slug, &session.agent_slug)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(tools) => {
|
Ok(catalog) => {
|
||||||
let definitions = tools.iter().flat_map(tool_definitions).collect::<Vec<_>>();
|
let definitions = catalog_tool_definitions(&catalog);
|
||||||
|
|
||||||
transport_response(
|
transport_response(
|
||||||
StatusCode::OK,
|
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!({})
|
json!({})
|
||||||
} else {
|
} else {
|
||||||
tool_call_params.arguments
|
tool_call_params.arguments
|
||||||
};
|
};
|
||||||
let confirmation_token = take_confirmation_token(&mut arguments);
|
|
||||||
|
|
||||||
match state
|
match state
|
||||||
.catalog
|
.catalog
|
||||||
.list_tools(&session.workspace_slug, &session.agent_slug)
|
.get_catalog(&session.workspace_slug, &session.agent_slug)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(tools) => match resolve_generated_tool(&tools, &tool_call_params.name) {
|
Ok(catalog) => {
|
||||||
Some(resolved) => {
|
handle_catalog_tool_call(
|
||||||
handle_tool_call(
|
|
||||||
state.clone(),
|
state.clone(),
|
||||||
&session,
|
&session,
|
||||||
&message,
|
&message,
|
||||||
response_mode,
|
response_mode,
|
||||||
&credential,
|
&credential,
|
||||||
resolved,
|
&catalog,
|
||||||
|
&tool_call_params.name,
|
||||||
arguments,
|
arguments,
|
||||||
confirmation_token,
|
|
||||||
&transport_request_id,
|
&transport_request_id,
|
||||||
)
|
)
|
||||||
.await
|
.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),
|
Err(error) => internal_jsonrpc_error(&message, error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -785,7 +772,7 @@ async fn mcp_post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn handle_tool_call(
|
pub(super) async fn handle_tool_call(
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
session: &SessionState,
|
session: &SessionState,
|
||||||
message: &Value,
|
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 {
|
let Value::Object(object) = arguments else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
@@ -1479,7 +1466,7 @@ fn success_tool_response(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tool_error_response(
|
pub(super) fn tool_error_response(
|
||||||
message: &Value,
|
message: &Value,
|
||||||
response_mode: ResponseMode,
|
response_mode: ResponseMode,
|
||||||
protocol_version: &str,
|
protocol_version: &str,
|
||||||
@@ -1517,7 +1504,7 @@ fn add_millis(timestamp: OffsetDateTime, millis: u64) -> OffsetDateTime {
|
|||||||
timestamp + delta
|
timestamp + delta
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_generated_tool(
|
pub(super) fn resolve_generated_tool(
|
||||||
tools: &[PublishedAgentTool],
|
tools: &[PublishedAgentTool],
|
||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
) -> Option<ResolvedToolCall> {
|
) -> Option<ResolvedToolCall> {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crank_core::{CacheScope, CoordinationStateStore, CoordinationStateValue};
|
use crank_core::{CacheScope, CoordinationStateStore, CoordinationStateValue};
|
||||||
use crank_registry::{PostgresRegistry, PublishedAgentTool, RegistryError};
|
use crank_registry::{PostgresRegistry, PublishedAgentCatalog, PublishedAgentTool, RegistryError};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::{Mutex, RwLock};
|
use tokio::sync::{Mutex, RwLock};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
@@ -27,15 +27,14 @@ struct CatalogKey {
|
|||||||
agent_slug: String,
|
agent_slug: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct CachedCatalog {
|
struct CachedCatalog {
|
||||||
loaded_at: Option<Instant>,
|
loaded_at: Option<Instant>,
|
||||||
tools: Vec<PublishedAgentTool>,
|
catalog: PublishedAgentCatalog,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
struct CatalogSnapshot {
|
struct CatalogSnapshot {
|
||||||
tools: Vec<PublishedAgentTool>,
|
catalog: PublishedAgentCatalog,
|
||||||
generated_at_ms: u64,
|
generated_at_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,12 +58,23 @@ impl PublishedToolCatalog {
|
|||||||
workspace_slug: &str,
|
workspace_slug: &str,
|
||||||
agent_slug: &str,
|
agent_slug: &str,
|
||||||
) -> Result<Vec<PublishedAgentTool>, RegistryError> {
|
) -> 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?;
|
self.refresh_if_stale(workspace_slug, agent_slug).await?;
|
||||||
let guard = self.cached.read().await;
|
let guard = self.cached.read().await;
|
||||||
Ok(guard
|
guard
|
||||||
.get(&CatalogKey::new(workspace_slug, agent_slug))
|
.get(&CatalogKey::new(workspace_slug, agent_slug))
|
||||||
.map(|entry| entry.tools.clone())
|
.map(|entry| entry.catalog.clone())
|
||||||
.unwrap_or_default())
|
.ok_or_else(|| RegistryError::PublishedAgentNotFound {
|
||||||
|
workspace_slug: workspace_slug.to_owned(),
|
||||||
|
agent_slug: agent_slug.to_owned(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn refresh_if_stale(
|
async fn refresh_if_stale(
|
||||||
@@ -106,42 +116,41 @@ impl PublishedToolCatalog {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((tools, age)) = self.load_shared_snapshot(workspace_slug, agent_slug).await {
|
if let Some((catalog, age)) = self.load_shared_snapshot(workspace_slug, agent_slug).await {
|
||||||
log_catalog_analysis(workspace_slug, agent_slug, "shared_cache", &tools);
|
log_catalog_analysis(workspace_slug, agent_slug, "shared_cache", &catalog.tools);
|
||||||
let mut guard = self.cached.write().await;
|
let mut guard = self.cached.write().await;
|
||||||
guard.insert(
|
guard.insert(
|
||||||
key,
|
key,
|
||||||
CachedCatalog {
|
CachedCatalog {
|
||||||
loaded_at: Instant::now().checked_sub(age),
|
loaded_at: Instant::now().checked_sub(age),
|
||||||
tools,
|
catalog,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let tools = match self
|
let catalog = match self
|
||||||
.registry
|
.registry
|
||||||
.get_published_agent_tools_by_slug(workspace_slug, agent_slug)
|
.get_published_agent_catalog_by_slug(workspace_slug, agent_slug)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(tools) => tools,
|
Ok(catalog) => catalog,
|
||||||
Err(RegistryError::PublishedAgentNotFound { .. }) => Vec::new(),
|
|
||||||
Err(error) => return Err(error),
|
Err(error) => return Err(error),
|
||||||
};
|
};
|
||||||
log_catalog_analysis(workspace_slug, agent_slug, "postgres", &tools);
|
log_catalog_analysis(workspace_slug, agent_slug, "postgres", &catalog.tools);
|
||||||
self.store_shared_snapshot(workspace_slug, agent_slug, &tools)
|
self.store_shared_snapshot(workspace_slug, agent_slug, &catalog)
|
||||||
.await;
|
.await;
|
||||||
let mut guard = self.cached.write().await;
|
let mut guard = self.cached.write().await;
|
||||||
let previous_count = guard
|
let previous_count = guard
|
||||||
.get(&key)
|
.get(&key)
|
||||||
.map(|entry| entry.tools.len())
|
.map(|entry| entry.catalog.tools.len())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
guard.insert(
|
guard.insert(
|
||||||
key,
|
key,
|
||||||
CachedCatalog {
|
CachedCatalog {
|
||||||
loaded_at: Some(Instant::now()),
|
loaded_at: Some(Instant::now()),
|
||||||
tools,
|
catalog,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -150,7 +159,7 @@ impl PublishedToolCatalog {
|
|||||||
agent_slug,
|
agent_slug,
|
||||||
published_tool_count = guard
|
published_tool_count = guard
|
||||||
.get(&CatalogKey::new(workspace_slug, agent_slug))
|
.get(&CatalogKey::new(workspace_slug, agent_slug))
|
||||||
.map(|entry| entry.tools.len())
|
.map(|entry| entry.catalog.tools.len())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
previous_published_tool_count = previous_count,
|
previous_published_tool_count = previous_count,
|
||||||
"published agent catalog refreshed"
|
"published agent catalog refreshed"
|
||||||
@@ -163,7 +172,7 @@ impl PublishedToolCatalog {
|
|||||||
&self,
|
&self,
|
||||||
workspace_slug: &str,
|
workspace_slug: &str,
|
||||||
agent_slug: &str,
|
agent_slug: &str,
|
||||||
) -> Option<(Vec<PublishedAgentTool>, Duration)> {
|
) -> Option<(PublishedAgentCatalog, Duration)> {
|
||||||
if self.refresh_interval.is_zero() {
|
if self.refresh_interval.is_zero() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -179,21 +188,21 @@ impl PublishedToolCatalog {
|
|||||||
};
|
};
|
||||||
let snapshot = serde_json::from_value::<CatalogSnapshot>(value.payload).ok()?;
|
let snapshot = serde_json::from_value::<CatalogSnapshot>(value.payload).ok()?;
|
||||||
let age = Duration::from_millis(now_unix_ms().saturating_sub(snapshot.generated_at_ms));
|
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(
|
async fn store_shared_snapshot(
|
||||||
&self,
|
&self,
|
||||||
workspace_slug: &str,
|
workspace_slug: &str,
|
||||||
agent_slug: &str,
|
agent_slug: &str,
|
||||||
tools: &[PublishedAgentTool],
|
catalog: &PublishedAgentCatalog,
|
||||||
) {
|
) {
|
||||||
let Some(ttl) = catalog_snapshot_ttl(self.refresh_interval) else {
|
let Some(ttl) = catalog_snapshot_ttl(self.refresh_interval) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let payload = match serde_json::to_value(CatalogSnapshot {
|
let payload = match serde_json::to_value(CatalogSnapshot {
|
||||||
tools: tools.to_vec(),
|
catalog: catalog.clone(),
|
||||||
generated_at_ms: now_unix_ms(),
|
generated_at_ms: now_unix_ms(),
|
||||||
}) {
|
}) {
|
||||||
Ok(payload) => payload,
|
Ok(payload) => payload,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub mod manifest;
|
|||||||
mod rate_limit;
|
mod rate_limit;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod tool_error;
|
pub mod tool_error;
|
||||||
|
mod tool_search;
|
||||||
mod transport;
|
mod transport;
|
||||||
|
|
||||||
pub use app::{build_app, build_app_with_background_workers};
|
pub use app::{build_app, build_app_with_background_workers};
|
||||||
|
|||||||
@@ -1,10 +1,110 @@
|
|||||||
use crank_core::{
|
use crank_core::{
|
||||||
HttpMethod, OperationSafetyClass, OperationSafetyPolicy, Target, ToolCatalogAnalysis,
|
HttpMethod, OperationSafetyClass, OperationSafetyPolicy, SearchableTool, Target,
|
||||||
ToolCatalogAnalysisError, ToolQualityCatalogTool, analyze_tool_catalog,
|
ToolAccessMode, ToolCatalogAnalysis, ToolCatalogAnalysisError, ToolQualityCatalogTool,
|
||||||
|
ToolSelectionPolicy, analyze_tool_catalog,
|
||||||
};
|
};
|
||||||
use crank_registry::PublishedAgentTool;
|
use crank_registry::{PublishedAgentCatalog, PublishedAgentTool};
|
||||||
use serde_json::{Value, json};
|
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> {
|
pub fn tool_definitions(tool: &PublishedAgentTool) -> Vec<Value> {
|
||||||
let safety = effective_safety_policy(tool);
|
let safety = effective_safety_policy(tool);
|
||||||
let requires_confirmation = safety.class.requires_confirmation();
|
let requires_confirmation = safety.class.requires_confirmation();
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
use std::{collections::BTreeSet, sync::Arc};
|
||||||
|
|
||||||
|
use axum::{http::StatusCode, response::Response};
|
||||||
|
use crank_core::{ToolAccessMode, search_tool_catalog};
|
||||||
|
use crank_registry::PublishedAgentCatalog;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::{
|
||||||
|
AppState, handle_tool_call, resolve_generated_tool, take_confirmation_token,
|
||||||
|
tool_error_response,
|
||||||
|
},
|
||||||
|
auth::VerifiedMachineCredential,
|
||||||
|
jsonrpc::{jsonrpc_error, jsonrpc_result, request_id},
|
||||||
|
manifest::{CALL_TOOL_NAME, SEARCH_TOOLS_NAME, searchable_tools},
|
||||||
|
session::SessionState,
|
||||||
|
tool_error::generic_tool_error_contract,
|
||||||
|
transport::{ResponseMode, transport_response},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SearchToolsArguments {
|
||||||
|
query: String,
|
||||||
|
#[serde(default)]
|
||||||
|
group_ids: Vec<String>,
|
||||||
|
max_results: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ProxyToolCallArguments {
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
arguments: Value,
|
||||||
|
catalog_revision: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) async fn handle_catalog_tool_call(
|
||||||
|
state: Arc<AppState>,
|
||||||
|
session: &SessionState,
|
||||||
|
message: &Value,
|
||||||
|
response_mode: ResponseMode,
|
||||||
|
credential: &VerifiedMachineCredential,
|
||||||
|
catalog: &PublishedAgentCatalog,
|
||||||
|
tool_name: &str,
|
||||||
|
arguments: Value,
|
||||||
|
transport_request_id: &str,
|
||||||
|
) -> Response {
|
||||||
|
match catalog.tool_selection_policy.mode {
|
||||||
|
ToolAccessMode::Direct => {
|
||||||
|
execute_catalog_tool(
|
||||||
|
state,
|
||||||
|
session,
|
||||||
|
message,
|
||||||
|
response_mode,
|
||||||
|
credential,
|
||||||
|
catalog,
|
||||||
|
tool_name,
|
||||||
|
arguments,
|
||||||
|
transport_request_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
ToolAccessMode::Search if tool_name == SEARCH_TOOLS_NAME => {
|
||||||
|
handle_search_tools(message, response_mode, session, catalog, arguments)
|
||||||
|
}
|
||||||
|
ToolAccessMode::Search if tool_name == CALL_TOOL_NAME => {
|
||||||
|
let proxy: ProxyToolCallArguments = match serde_json::from_value(arguments) {
|
||||||
|
Ok(proxy) => proxy,
|
||||||
|
Err(error) => {
|
||||||
|
return invalid_arguments_response(
|
||||||
|
message,
|
||||||
|
response_mode,
|
||||||
|
&session.protocol_version,
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if proxy.catalog_revision != catalog_revision(catalog) {
|
||||||
|
return tool_error_response(
|
||||||
|
message,
|
||||||
|
response_mode,
|
||||||
|
&session.protocol_version,
|
||||||
|
generic_tool_error_contract(
|
||||||
|
"catalog_revision_changed",
|
||||||
|
format!(
|
||||||
|
"catalog revision {} is no longer current",
|
||||||
|
proxy.catalog_revision
|
||||||
|
),
|
||||||
|
transport_request_id,
|
||||||
|
true,
|
||||||
|
Some(
|
||||||
|
"Повторите search_tools и вызовите инструмент с новой версией каталога.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
execute_catalog_tool(
|
||||||
|
state,
|
||||||
|
session,
|
||||||
|
message,
|
||||||
|
response_mode,
|
||||||
|
credential,
|
||||||
|
catalog,
|
||||||
|
&proxy.name,
|
||||||
|
proxy.arguments,
|
||||||
|
transport_request_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
ToolAccessMode::Search => {
|
||||||
|
tool_not_found_response(message, response_mode, &session.protocol_version, tool_name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn execute_catalog_tool(
|
||||||
|
state: Arc<AppState>,
|
||||||
|
session: &SessionState,
|
||||||
|
message: &Value,
|
||||||
|
response_mode: ResponseMode,
|
||||||
|
credential: &VerifiedMachineCredential,
|
||||||
|
catalog: &PublishedAgentCatalog,
|
||||||
|
tool_name: &str,
|
||||||
|
mut arguments: Value,
|
||||||
|
transport_request_id: &str,
|
||||||
|
) -> Response {
|
||||||
|
let Some(resolved) = resolve_generated_tool(&catalog.tools, tool_name) else {
|
||||||
|
return tool_not_found_response(
|
||||||
|
message,
|
||||||
|
response_mode,
|
||||||
|
&session.protocol_version,
|
||||||
|
tool_name,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let confirmation_token = take_confirmation_token(&mut arguments);
|
||||||
|
handle_tool_call(
|
||||||
|
state,
|
||||||
|
session,
|
||||||
|
message,
|
||||||
|
response_mode,
|
||||||
|
credential,
|
||||||
|
resolved,
|
||||||
|
arguments,
|
||||||
|
confirmation_token,
|
||||||
|
transport_request_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_search_tools(
|
||||||
|
message: &Value,
|
||||||
|
response_mode: ResponseMode,
|
||||||
|
session: &SessionState,
|
||||||
|
catalog: &PublishedAgentCatalog,
|
||||||
|
arguments: Value,
|
||||||
|
) -> Response {
|
||||||
|
let search: SearchToolsArguments = match serde_json::from_value(arguments) {
|
||||||
|
Ok(search) => search,
|
||||||
|
Err(error) => {
|
||||||
|
return invalid_arguments_response(
|
||||||
|
message,
|
||||||
|
response_mode,
|
||||||
|
&session.protocol_version,
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if search.query.trim().is_empty() {
|
||||||
|
return invalid_arguments_response(
|
||||||
|
message,
|
||||||
|
response_mode,
|
||||||
|
&session.protocol_version,
|
||||||
|
"query must not be empty".to_owned(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let known_group_ids = catalog
|
||||||
|
.tool_selection_policy
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.map(|group| group.id.as_str())
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
if let Some(group_id) = search
|
||||||
|
.group_ids
|
||||||
|
.iter()
|
||||||
|
.find(|group_id| !known_group_ids.contains(group_id.as_str()))
|
||||||
|
{
|
||||||
|
return invalid_arguments_response(
|
||||||
|
message,
|
||||||
|
response_mode,
|
||||||
|
&session.protocol_version,
|
||||||
|
format!("unknown tool group {group_id}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let configured_limit = catalog.tool_selection_policy.search.max_results;
|
||||||
|
let requested_limit = search.max_results.unwrap_or(configured_limit).clamp(1, 20);
|
||||||
|
let tools = search_tool_catalog(
|
||||||
|
&searchable_tools(catalog),
|
||||||
|
&search.query,
|
||||||
|
&search.group_ids,
|
||||||
|
requested_limit.min(configured_limit),
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.map(|found| {
|
||||||
|
json!({
|
||||||
|
"name": found.tool.name,
|
||||||
|
"title": found.tool.title,
|
||||||
|
"description": found.tool.description,
|
||||||
|
"inputSchema": found.tool.input_schema,
|
||||||
|
"group_ids": found.tool.group_ids,
|
||||||
|
"score": found.score,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let result = json!({
|
||||||
|
"catalog_revision": catalog_revision(catalog),
|
||||||
|
"tools": tools,
|
||||||
|
});
|
||||||
|
let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string());
|
||||||
|
|
||||||
|
transport_response(
|
||||||
|
StatusCode::OK,
|
||||||
|
jsonrpc_result(
|
||||||
|
request_id(message),
|
||||||
|
json!({
|
||||||
|
"content": [{"type": "text", "text": text}],
|
||||||
|
"structuredContent": result,
|
||||||
|
"isError": false
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
response_mode,
|
||||||
|
None,
|
||||||
|
Some(&session.protocol_version),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn catalog_revision(catalog: &PublishedAgentCatalog) -> String {
|
||||||
|
format!("agent-version-{}", catalog.agent_version)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_arguments_response(
|
||||||
|
message: &Value,
|
||||||
|
response_mode: ResponseMode,
|
||||||
|
protocol_version: &str,
|
||||||
|
detail: String,
|
||||||
|
) -> Response {
|
||||||
|
transport_response(
|
||||||
|
StatusCode::OK,
|
||||||
|
jsonrpc_error(request_id(message), -32602, detail),
|
||||||
|
response_mode,
|
||||||
|
None,
|
||||||
|
Some(protocol_version),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_not_found_response(
|
||||||
|
message: &Value,
|
||||||
|
response_mode: ResponseMode,
|
||||||
|
protocol_version: &str,
|
||||||
|
tool_name: &str,
|
||||||
|
) -> Response {
|
||||||
|
transport_response(
|
||||||
|
StatusCode::OK,
|
||||||
|
jsonrpc_error(
|
||||||
|
request_id(message),
|
||||||
|
-32602,
|
||||||
|
format!("tool {tool_name} was not found"),
|
||||||
|
),
|
||||||
|
response_mode,
|
||||||
|
None,
|
||||||
|
Some(protocol_version),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use thiserror::Error;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use crate::ids::{AgentId, OperationId, WorkspaceId};
|
use crate::ids::{AgentId, OperationId, WorkspaceId};
|
||||||
@@ -36,11 +37,149 @@ pub struct AgentVersion {
|
|||||||
pub version: u32,
|
pub version: u32,
|
||||||
pub status: AgentStatus,
|
pub status: AgentStatus,
|
||||||
pub instructions: Value,
|
pub instructions: Value,
|
||||||
pub tool_selection_policy: Value,
|
#[serde(default)]
|
||||||
|
pub tool_selection_policy: ToolSelectionPolicy,
|
||||||
#[serde(with = "time::serde::rfc3339")]
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
pub created_at: OffsetDateTime,
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct AgentOperationBinding {
|
pub struct AgentOperationBinding {
|
||||||
pub operation_id: OperationId,
|
pub operation_id: OperationId,
|
||||||
@@ -56,7 +195,10 @@ mod tests {
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
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};
|
use crate::ids::{AgentId, WorkspaceId};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -98,5 +240,28 @@ mod tests {
|
|||||||
version.created_at,
|
version.created_at,
|
||||||
OffsetDateTime::parse("2026-03-25T12:00:00Z", &Rfc3339).unwrap()
|
OffsetDateTime::parse("2026-03-25T12:00:00Z", &Rfc3339).unwrap()
|
||||||
);
|
);
|
||||||
|
assert_eq!(version.tool_selection_policy.mode, ToolAccessMode::Direct);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_policy_validates_groups_against_published_tools() {
|
||||||
|
let policy = ToolSelectionPolicy {
|
||||||
|
mode: ToolAccessMode::Search,
|
||||||
|
groups: vec![ToolGroup {
|
||||||
|
id: "finance".to_owned(),
|
||||||
|
name: "Finance".to_owned(),
|
||||||
|
description: "Invoices and payments".to_owned(),
|
||||||
|
tool_names: vec!["create_invoice".to_owned()],
|
||||||
|
}],
|
||||||
|
..ToolSelectionPolicy::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
policy.validate_for_tools(["list_invoices"]),
|
||||||
|
Err(ToolSelectionPolicyError::UnknownTool {
|
||||||
|
group_id: "finance".to_owned(),
|
||||||
|
tool_name: "create_invoice".to_owned(),
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod protocol;
|
|||||||
pub mod secret;
|
pub mod secret;
|
||||||
pub mod tool_catalog;
|
pub mod tool_catalog;
|
||||||
pub mod tool_quality;
|
pub mod tool_quality;
|
||||||
|
pub mod tool_search;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
||||||
pub mod domain {
|
pub mod domain {
|
||||||
@@ -19,7 +20,10 @@ pub mod domain {
|
|||||||
InvitationStatus, InvitationToken, Membership, MembershipRole, PlatformApiKey,
|
InvitationStatus, InvitationToken, Membership, MembershipRole, PlatformApiKey,
|
||||||
PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
|
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::approval::{ApprovalRequest, ApprovalRequestStatus};
|
||||||
pub use crate::auth::{
|
pub use crate::auth::{
|
||||||
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig,
|
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig,
|
||||||
@@ -56,6 +60,7 @@ pub mod domain {
|
|||||||
ToolQualityCatalogTool, ToolQualityFinding, ToolQualityMappingRule, ToolQualityMappingSet,
|
ToolQualityCatalogTool, ToolQualityFinding, ToolQualityMappingRule, ToolQualityMappingSet,
|
||||||
ToolQualityReport, ToolQualitySchemaKind, ToolQualitySchemaNode, ToolQualitySeverity,
|
ToolQualityReport, ToolQualitySchemaKind, ToolQualitySchemaNode, ToolQualitySeverity,
|
||||||
};
|
};
|
||||||
|
pub use crate::tool_search::{SearchableTool, ToolSearchMatch, search_tool_catalog};
|
||||||
pub use crate::workspace::{Workspace, WorkspaceStatus};
|
pub use crate::workspace::{Workspace, WorkspaceStatus};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +97,10 @@ pub use access::{
|
|||||||
InvitationStatus, InvitationToken, Membership, MembershipRole, PlatformApiKey,
|
InvitationStatus, InvitationToken, Membership, MembershipRole, PlatformApiKey,
|
||||||
PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
|
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 approval::{ApprovalRequest, ApprovalRequestStatus};
|
||||||
pub use auth::{
|
pub use auth::{
|
||||||
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig,
|
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig,
|
||||||
@@ -153,4 +161,5 @@ pub use tool_quality::{
|
|||||||
analyze_agent_tool_catalog_quality, analyze_tool_identity_quality,
|
analyze_agent_tool_catalog_quality, analyze_tool_identity_quality,
|
||||||
analyze_tool_response_projection_quality, analyze_tool_schema_quality,
|
analyze_tool_response_projection_quality, analyze_tool_schema_quality,
|
||||||
};
|
};
|
||||||
|
pub use tool_search::{SearchableTool, ToolSearchMatch, search_tool_catalog};
|
||||||
pub use workspace::{Workspace, WorkspaceStatus};
|
pub use workspace::{Workspace, WorkspaceStatus};
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct SearchableTool {
|
||||||
|
pub name: String,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub input_schema: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub group_ids: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub group_context: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ToolSearchMatch {
|
||||||
|
pub tool: SearchableTool,
|
||||||
|
pub score: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn search_tool_catalog(
|
||||||
|
tools: &[SearchableTool],
|
||||||
|
query: &str,
|
||||||
|
group_ids: &[String],
|
||||||
|
max_results: usize,
|
||||||
|
) -> Vec<ToolSearchMatch> {
|
||||||
|
let query_tokens = tokenize(query);
|
||||||
|
if query_tokens.is_empty() || max_results == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let requested_groups = group_ids
|
||||||
|
.iter()
|
||||||
|
.map(String::as_str)
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
let candidates = tools
|
||||||
|
.iter()
|
||||||
|
.filter(|tool| {
|
||||||
|
requested_groups.is_empty()
|
||||||
|
|| tool
|
||||||
|
.group_ids
|
||||||
|
.iter()
|
||||||
|
.any(|group_id| requested_groups.contains(group_id.as_str()))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let documents = candidates
|
||||||
|
.iter()
|
||||||
|
.map(|tool| weighted_tokens(tool))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let average_length =
|
||||||
|
documents.iter().map(Vec::len).sum::<usize>() as f64 / documents.len() as f64;
|
||||||
|
let candidate_count = documents.len();
|
||||||
|
let document_frequency = document_frequency(&documents, &query_tokens);
|
||||||
|
let normalized_query = query.trim().to_lowercase();
|
||||||
|
|
||||||
|
let mut matches = candidates
|
||||||
|
.into_iter()
|
||||||
|
.zip(documents)
|
||||||
|
.filter_map(|(tool, document)| {
|
||||||
|
let score = bm25_score(
|
||||||
|
&document,
|
||||||
|
&query_tokens,
|
||||||
|
&document_frequency,
|
||||||
|
candidate_count,
|
||||||
|
average_length,
|
||||||
|
) + exact_match_bonus(tool, &normalized_query);
|
||||||
|
(score > 0.0).then(|| ToolSearchMatch {
|
||||||
|
tool: tool.clone(),
|
||||||
|
score,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
matches.sort_by(|left, right| {
|
||||||
|
right
|
||||||
|
.score
|
||||||
|
.total_cmp(&left.score)
|
||||||
|
.then_with(|| left.tool.name.cmp(&right.tool.name))
|
||||||
|
});
|
||||||
|
matches.truncate(max_results);
|
||||||
|
matches
|
||||||
|
}
|
||||||
|
|
||||||
|
fn weighted_tokens(tool: &SearchableTool) -> Vec<String> {
|
||||||
|
let mut tokens = Vec::new();
|
||||||
|
for _ in 0..3 {
|
||||||
|
tokens.extend(tokenize(&tool.name));
|
||||||
|
tokens.extend(tokenize(&tool.title));
|
||||||
|
}
|
||||||
|
tokens.extend(tokenize(&tool.description));
|
||||||
|
for _ in 0..2 {
|
||||||
|
tokens.extend(tokenize(&tool.group_context));
|
||||||
|
}
|
||||||
|
tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
fn document_frequency(
|
||||||
|
documents: &[Vec<String>],
|
||||||
|
query_tokens: &[String],
|
||||||
|
) -> BTreeMap<String, usize> {
|
||||||
|
let mut frequencies = BTreeMap::new();
|
||||||
|
for query_token in query_tokens {
|
||||||
|
let count = documents
|
||||||
|
.iter()
|
||||||
|
.filter(|document| document.iter().any(|token| token == query_token))
|
||||||
|
.count();
|
||||||
|
frequencies.insert(query_token.clone(), count);
|
||||||
|
}
|
||||||
|
frequencies
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bm25_score(
|
||||||
|
document: &[String],
|
||||||
|
query_tokens: &[String],
|
||||||
|
document_frequency: &BTreeMap<String, usize>,
|
||||||
|
document_count: usize,
|
||||||
|
average_length: f64,
|
||||||
|
) -> f64 {
|
||||||
|
const K1: f64 = 1.2;
|
||||||
|
const B: f64 = 0.75;
|
||||||
|
|
||||||
|
query_tokens.iter().fold(0.0, |score, token| {
|
||||||
|
let term_frequency = document.iter().filter(|term| *term == token).count() as f64;
|
||||||
|
if term_frequency == 0.0 {
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
let frequency = document_frequency.get(token).copied().unwrap_or_default() as f64;
|
||||||
|
let inverse_document_frequency =
|
||||||
|
((document_count as f64 - frequency + 0.5) / (frequency + 0.5) + 1.0).ln();
|
||||||
|
let length_ratio = if average_length > 0.0 {
|
||||||
|
document.len() as f64 / average_length
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
let normalized_frequency =
|
||||||
|
term_frequency * (K1 + 1.0) / (term_frequency + K1 * (1.0 - B + B * length_ratio));
|
||||||
|
score + inverse_document_frequency * normalized_frequency
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exact_match_bonus(tool: &SearchableTool, query: &str) -> f64 {
|
||||||
|
if query.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let name = tool.name.to_lowercase();
|
||||||
|
let title = tool.title.to_lowercase();
|
||||||
|
if name == query || title == query {
|
||||||
|
8.0
|
||||||
|
} else if name.contains(query) || title.contains(query) {
|
||||||
|
3.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tokenize(value: &str) -> Vec<String> {
|
||||||
|
value
|
||||||
|
.to_lowercase()
|
||||||
|
.split(|character: char| !character.is_alphanumeric())
|
||||||
|
.filter(|token| token.chars().count() >= 2)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::{SearchableTool, search_tool_catalog};
|
||||||
|
|
||||||
|
fn tool(name: &str, title: &str, description: &str, group_ids: &[&str]) -> SearchableTool {
|
||||||
|
SearchableTool {
|
||||||
|
name: name.to_owned(),
|
||||||
|
title: title.to_owned(),
|
||||||
|
description: description.to_owned(),
|
||||||
|
input_schema: json!({"type": "object"}),
|
||||||
|
group_ids: group_ids.iter().map(|value| (*value).to_owned()).collect(),
|
||||||
|
group_context: if group_ids.contains(&"finance") {
|
||||||
|
"Finance invoices and payments".to_owned()
|
||||||
|
} else {
|
||||||
|
"Customer support tickets".to_owned()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ranks_title_and_name_matches_above_description_matches() {
|
||||||
|
let tools = vec![
|
||||||
|
tool(
|
||||||
|
"create_invoice",
|
||||||
|
"Create invoice",
|
||||||
|
"Issue a bill",
|
||||||
|
&["finance"],
|
||||||
|
),
|
||||||
|
tool(
|
||||||
|
"list_customers",
|
||||||
|
"List customers",
|
||||||
|
"Customers with invoices",
|
||||||
|
&["support"],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let matches = search_tool_catalog(&tools, "create invoice", &[], 8);
|
||||||
|
|
||||||
|
assert_eq!(matches[0].tool.name, "create_invoice");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filters_candidates_by_group() {
|
||||||
|
let tools = vec![
|
||||||
|
tool(
|
||||||
|
"create_invoice",
|
||||||
|
"Create invoice",
|
||||||
|
"Issue a bill",
|
||||||
|
&["finance"],
|
||||||
|
),
|
||||||
|
tool(
|
||||||
|
"create_ticket",
|
||||||
|
"Create ticket",
|
||||||
|
"Open a support case",
|
||||||
|
&["support"],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let matches = search_tool_catalog(&tools, "create", &["support".to_owned()], 8);
|
||||||
|
|
||||||
|
assert_eq!(matches.len(), 1);
|
||||||
|
assert_eq!(matches[0].tool.name, "create_ticket");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,9 +13,9 @@ pub mod records {
|
|||||||
DescriptorMetadata, ImportJob, ImportJobId, ImportJobKind, ImportJobStatus,
|
DescriptorMetadata, ImportJob, ImportJobId, ImportJobKind, ImportJobStatus,
|
||||||
InvitationRecord, InvocationLogRecord, MembershipRecord, OperationAgentRef,
|
InvitationRecord, InvocationLogRecord, MembershipRecord, OperationAgentRef,
|
||||||
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
|
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
|
||||||
Page, PlatformApiKeyRecord, PublishedAgentTool, RegistryOperation, SampleKind,
|
Page, PlatformApiKeyRecord, PublishedAgentCatalog, PublishedAgentTool, RegistryOperation,
|
||||||
SecretRecord, SecretVersionRecord, SessionRecord, UsageAgentBreakdown, UsageBucket,
|
SampleKind, SecretRecord, SecretVersionRecord, SessionRecord, UsageAgentBreakdown,
|
||||||
UsageOperationBreakdown, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
UsageBucket, UsageOperationBreakdown, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
||||||
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId,
|
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId,
|
||||||
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||||
};
|
};
|
||||||
@@ -29,9 +29,9 @@ pub mod requests {
|
|||||||
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
|
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
|
||||||
ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest,
|
ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest,
|
||||||
ListApprovalRequestsQuery, ListInvocationLogsQuery, PublishAgentRequest, PublishRequest,
|
ListApprovalRequestsQuery, ListInvocationLogsQuery, PublishAgentRequest, PublishRequest,
|
||||||
RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest,
|
RotateSecretRequest, SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest,
|
||||||
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest,
|
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||||
UpdateWorkspaceRequest, UsageQuery,
|
SaveWorkspaceUpstreamRequest, UpdateWorkspaceRequest, UsageQuery,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,13 +50,14 @@ pub use model::{
|
|||||||
ImportJobKind, ImportJobStatus, InvitationRecord, InvocationLogRecord,
|
ImportJobKind, ImportJobStatus, InvitationRecord, InvocationLogRecord,
|
||||||
ListApprovalRequestsQuery, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef,
|
ListApprovalRequestsQuery, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef,
|
||||||
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord, Page,
|
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord, Page,
|
||||||
PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool,
|
PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentCatalog,
|
||||||
RegistryOperation, RotateSecretRequest, SampleKind, SaveAgentBindingsRequest,
|
PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind,
|
||||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest, SaveAuthProfileRequest,
|
||||||
SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord, SessionRecord,
|
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest,
|
||||||
UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
|
SecretRecord, SecretVersionRecord, SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown,
|
||||||
UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord,
|
UsageBucket, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary,
|
||||||
WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId, YamlImportJob,
|
UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream,
|
||||||
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
WorkspaceUpstreamId, YamlImportJob, YamlImportJobCompletion, YamlImportJobId,
|
||||||
|
YamlImportJobStatus,
|
||||||
};
|
};
|
||||||
pub use postgres::{PostgresPoolConfig, PostgresPoolConfigError, PostgresRegistry};
|
pub use postgres::{PostgresPoolConfig, PostgresPoolConfigError, PostgresRegistry};
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use crank_core::{
|
|||||||
ApprovalRequestId, ApprovalRequestStatus, AuthProfile, DescriptorId, ExportMode,
|
ApprovalRequestId, ApprovalRequestStatus, AuthProfile, DescriptorId, ExportMode,
|
||||||
InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole, Operation,
|
InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole, Operation,
|
||||||
OperationId, OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId,
|
OperationId, OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId,
|
||||||
Protocol, SampleId, Secret, SecretId, SecretVersion, UsagePeriod, UsageRollup, User,
|
Protocol, SampleId, Secret, SecretId, SecretVersion, ToolSelectionPolicy, UsagePeriod,
|
||||||
UserSessionId, Workspace, WorkspaceId,
|
UsageRollup, User, UserSessionId, Workspace, WorkspaceId,
|
||||||
};
|
};
|
||||||
use crank_mapping::MappingSet;
|
use crank_mapping::MappingSet;
|
||||||
use crank_schema::Schema;
|
use crank_schema::Schema;
|
||||||
@@ -208,6 +208,13 @@ pub struct PublishedAgentTool {
|
|||||||
pub tool_description: String,
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct OperationSummary {
|
pub struct OperationSummary {
|
||||||
pub id: OperationId,
|
pub id: OperationId,
|
||||||
@@ -499,6 +506,15 @@ pub struct SaveAgentBindingsRequest<'a> {
|
|||||||
pub bindings: &'a [AgentOperationBinding],
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct PublishAgentRequest<'a> {
|
pub struct PublishAgentRequest<'a> {
|
||||||
pub workspace_id: &'a WorkspaceId,
|
pub workspace_id: &'a WorkspaceId,
|
||||||
|
|||||||
@@ -1,6 +1,42 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
impl PostgresRegistry {
|
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(
|
pub async fn list_agents(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
workspace_id: &WorkspaceId,
|
||||||
@@ -254,6 +290,48 @@ impl PostgresRegistry {
|
|||||||
Ok(())
|
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(
|
pub async fn update_agent_summary(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
workspace_id: &WorkspaceId,
|
||||||
|
|||||||
@@ -39,13 +39,14 @@ use crate::{
|
|||||||
ImportJob, ImportJobId, InvitationRecord, InvocationLogRecord, ListApprovalRequestsQuery,
|
ImportJob, ImportJobId, InvitationRecord, InvocationLogRecord, ListApprovalRequestsQuery,
|
||||||
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
|
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
|
||||||
OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord,
|
OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord,
|
||||||
PublishAgentRequest, PublishRequest, PublishedAgentTool, RegistryOperation,
|
PublishAgentRequest, PublishRequest, PublishedAgentCatalog, PublishedAgentTool,
|
||||||
RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest,
|
RegistryOperation, RotateSecretRequest, SaveAgentBindingsRequest,
|
||||||
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest,
|
SaveAgentCatalogConfigRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||||
SecretRecord, SecretVersionRecord, SessionRecord, UpdateWorkspaceRequest,
|
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord,
|
||||||
UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary,
|
SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageOperationBreakdown,
|
||||||
UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream,
|
UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord,
|
||||||
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
WorkspaceRecord, WorkspaceUpstream, YamlImportJob, YamlImportJobCompletion,
|
||||||
|
YamlImportJobId, YamlImportJobStatus,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -154,7 +155,7 @@ async fn insert_agent_version_row(
|
|||||||
.bind(to_db_version(version.version))
|
.bind(to_db_version(version.version))
|
||||||
.bind(serialize_enum_text(&version.status, "status")?)
|
.bind(serialize_enum_text(&version.status, "status")?)
|
||||||
.bind(Json(version.instructions.clone()))
|
.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)
|
.bind(version.created_at)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -498,7 +499,7 @@ fn build_agent_version_record(
|
|||||||
version,
|
version,
|
||||||
status,
|
status,
|
||||||
instructions: instructions_json,
|
instructions: instructions_json,
|
||||||
tool_selection_policy: tool_selection_policy_json,
|
tool_selection_policy: deserialize_json_value(tool_selection_policy_json)?,
|
||||||
created_at,
|
created_at,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -192,10 +192,7 @@ pub(super) fn test_agent_version(
|
|||||||
"system": "triage tickets",
|
"system": "triage tickets",
|
||||||
"guardrails": ["don't mutate state"]
|
"guardrails": ["don't mutate state"]
|
||||||
}),
|
}),
|
||||||
tool_selection_policy: json!({
|
tool_selection_policy: Default::default(),
|
||||||
"mode": "allow_list",
|
|
||||||
"max_tools": 8
|
|
||||||
}),
|
|
||||||
created_at: timestamp("2026-03-25T12:00:00Z"),
|
created_at: timestamp("2026-03-25T12:00:00Z"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}/unpublish`
|
||||||
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/archive`
|
- `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/{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}`
|
- `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
|
## Agent API keys
|
||||||
|
|
||||||
- `GET /api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys`
|
- `GET /api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys`
|
||||||
|
|||||||
@@ -122,6 +122,17 @@ curl https://crank.example.com/mcp/v1/default/currency-rates \
|
|||||||
frankfurter_latest_rate
|
frankfurter_latest_rate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Если опубликованная версия агента использует подбор по запросу, `tools/list` содержит только:
|
||||||
|
|
||||||
|
```text
|
||||||
|
search_tools
|
||||||
|
call_tool
|
||||||
|
```
|
||||||
|
|
||||||
|
`search_tools` принимает текст задачи, необязательные идентификаторы разделов и предел результатов. Ответ содержит полные входные схемы найденных инструментов и `catalog_revision`.
|
||||||
|
|
||||||
|
`call_tool` принимает имя найденного инструмента, его аргументы и полученную `catalog_revision`. Если за время между поиском и вызовом опубликована новая версия агента, вызов отклоняется с кодом `catalog_revision_changed`: клиент должен повторить поиск.
|
||||||
|
|
||||||
## Пример `tools/call`
|
## Пример `tools/call`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -226,6 +237,10 @@ curl https://crank.example.com/mcp/v1/default/sales/approvals/<approval_id>/deny
|
|||||||
|
|
||||||
MCP-клиент видит только опубликованные операции, которые привязаны к опубликованному агенту.
|
MCP-клиент видит только опубликованные операции, которые привязаны к опубликованному агенту.
|
||||||
|
|
||||||
|
Способ выдачи, разделы и предел результатов входят в версионируемый контракт агента. Прямой режим не меняет обычный MCP-поток. Режим подбора скрывает исходные инструменты за совместимыми метаинструментами `search_tools` и `call_tool`; динамическое изменение `tools/list` и специальные расширения клиента не требуются.
|
||||||
|
|
||||||
|
Базовый поиск использует BM25 по имени, заголовку, описанию инструмента и описаниям его разделов. Фильтр по разделу применяется до ранжирования. По умолчанию возвращается не более восьми результатов, допустимый предел — от одного до двадцати.
|
||||||
|
|
||||||
Черновики операций не попадают в MCP-каталог. Если два пользователя работают в одном workspace, один может редактировать черновик, а второй публиковать агента. В опубликованный каталог попадут только опубликованные версии операций.
|
Черновики операций не попадают в MCP-каталог. Если два пользователя работают в одном workspace, один может редактировать черновик, а второй публиковать агента. В опубликованный каталог попадут только опубликованные версии операций.
|
||||||
|
|
||||||
При каждом обновлении каталога Crank анализирует фактические определения `tools/list` и записывает в структурированный журнал:
|
При каждом обновлении каталога Crank анализирует фактические определения `tools/list` и записывает в структурированный журнал:
|
||||||
|
|||||||
+1
-1
@@ -156,7 +156,7 @@ Crank возвращает структурированные ошибки. MCP-
|
|||||||
|
|
||||||
Crank измеряет опубликованный каталог по тому же компактному JSON, который возвращается в `tools/list`. В расчёт входят имя, заголовок, описание, входная JSON-схема и добавляемое Crank описание подтверждения опасной операции. Для сравнения используется независимая от конкретной модели консервативная оценка: один токен на три байта UTF-8. Это не счётчик токенов конкретного поставщика, а стабильная инженерная метрика для поиска регрессий.
|
Crank измеряет опубликованный каталог по тому же компактному JSON, который возвращается в `tools/list`. В расчёт входят имя, заголовок, описание, входная JSON-схема и добавляемое Crank описание подтверждения опасной операции. Для сравнения используется независимая от конкретной модели консервативная оценка: один токен на три байта UTF-8. Это не счётчик токенов конкретного поставщика, а стабильная инженерная метрика для поиска регрессий.
|
||||||
|
|
||||||
Рекомендуемый бюджет одного агентского каталога — не более 4096 оценочных токенов. Превышение не блокирует публикацию, потому что допустимый объём зависит от модели, но создаёт предупреждение. Сначала сокращайте лишние описания и схемы. Если инструменты решают разные задачи, разделяйте их между специализированными агентами. Выбор нужного агента и постепенное раскрытие каталогов выполняет оркестратор Drivetrain, а не Crank.
|
Рекомендуемый бюджет одного агентского каталога — не более 4096 оценочных токенов. Превышение не блокирует публикацию, потому что допустимый объём зависит от модели, но создаёт предупреждение. Сначала сокращайте лишние описания и схемы. Если инструменты решают разные задачи, разделяйте их между специализированными агентами. Если большой каталог является осознанным бизнес-требованием, включайте в версии агента режим подбора по запросу и проверяйте его на характерных задачах. Drivetrain выбирает нужного агента и строит связи между агентами, но не подменяет внутренний поиск по каталогу одного агента.
|
||||||
|
|
||||||
## Проверочный список
|
## Проверочный список
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,15 @@
|
|||||||
- делать названия и описания инструментов однозначными;
|
- делать названия и описания инструментов однозначными;
|
||||||
- публиковать агента только после проверки операций.
|
- публиковать агента только после проверки операций.
|
||||||
|
|
||||||
|
В карточке агента явно выбирается способ доступа к каталогу:
|
||||||
|
|
||||||
|
- **Показывать сразу** — `tools/list` возвращает все привязанные инструменты. Это основной режим для небольшого каталога.
|
||||||
|
- **Подбирать по запросу** — `tools/list` возвращает только `search_tools` и `call_tool`. Модель сначала находит несколько подходящих инструментов, затем вызывает выбранный через прокси-инструмент.
|
||||||
|
|
||||||
|
В режиме подбора каталог можно разделить на именованные разделы. Название и описание раздела видит модель, а один инструмент можно включить в несколько разделов. Раздел ограничивает область поиска, но поиск без раздела всегда охватывает весь каталог.
|
||||||
|
|
||||||
|
Перед сохранением можно ввести пробную задачу и проверить результат подбора. Эта проверка использует тот же алгоритм, что и MCP-сервер.
|
||||||
|
|
||||||
## API ключи
|
## API ключи
|
||||||
|
|
||||||
API-ключ выдается на конкретного агента. В Community есть два режима ключей.
|
API-ключ выдается на конкретного агента. В Community есть два режима ключей.
|
||||||
|
|||||||
Reference in New Issue
Block a user