tool card UI redesign: semantic icons, inline diff persistence, tool detail page, MCP-friendly titles
Nightly Build / build (push) Successful in 6m38s
Nightly Build / build (push) Successful in 6m38s
This commit is contained in:
@@ -11,6 +11,13 @@ pub struct LlmToolCall {
|
||||
/// payload, e.g. MCP `structuredContent`). Drives frontend rendering.
|
||||
pub result_type: String,
|
||||
pub status: String,
|
||||
/// For a file-write tool: the file content **before**/**after** the write,
|
||||
/// captured at execution time so the diff renders inline in the chat card and
|
||||
/// survives a page reload (it was previously only on the transient `PendingWrite`
|
||||
/// event). `None` for non-write tools, an unreadable path, or content over the
|
||||
/// size cap (no diff shown then). Only populated by `for_message` (history).
|
||||
pub preview_old: Option<String>,
|
||||
pub preview_new: Option<String>,
|
||||
}
|
||||
|
||||
/// Inserts a tool call in `running` state and returns its id.
|
||||
@@ -55,6 +62,25 @@ pub async fn complete(pool: &SqlitePool, id: i64, result: &str, result_type: &st
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persists a file-write tool's before/after snapshot (the diff preview) on its row.
|
||||
/// Both `None` is a valid no-op state (non-write tool, unreadable path, or content
|
||||
/// over the size cap). Separate from [`complete`] so the status/result write and the
|
||||
/// preview write stay independent, and so it can run for both the live and resume paths.
|
||||
pub async fn set_preview(
|
||||
pool: &SqlitePool,
|
||||
id: i64,
|
||||
old: Option<&str>,
|
||||
new: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query("UPDATE chat_llm_tools SET preview_old = ?, preview_new = ? WHERE id = ?")
|
||||
.bind(old)
|
||||
.bind(new)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fail(pool: &SqlitePool, id: i64, error: &str) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE chat_llm_tools SET result = ?, status = 'failed' WHERE id = ?",
|
||||
@@ -117,13 +143,15 @@ pub async fn pending_for_stack(
|
||||
Ok(rows.into_iter().map(row_to_tool).collect())
|
||||
}
|
||||
|
||||
/// All tool calls for a single assistant message, ordered chronologically.
|
||||
/// All tool calls for a single assistant message, ordered chronologically. Unlike
|
||||
/// [`pending_for_stack`], this also reads the diff-preview columns, so the history
|
||||
/// projection can re-render a write's diff after a page reload.
|
||||
pub async fn for_message(
|
||||
pool: &SqlitePool,
|
||||
message_id: i64,
|
||||
) -> anyhow::Result<Vec<LlmToolCall>> {
|
||||
let rows = sqlx::query_as::<_, (i64, i64, String, Option<String>, Option<String>, String, String)>(
|
||||
"SELECT id, message_id, name, arguments, result, result_type, status
|
||||
let rows = sqlx::query_as::<_, (i64, i64, String, Option<String>, Option<String>, String, String, Option<String>, Option<String>)>(
|
||||
"SELECT id, message_id, name, arguments, result, result_type, status, preview_old, preview_new
|
||||
FROM chat_llm_tools
|
||||
WHERE message_id = ?
|
||||
ORDER BY id ASC",
|
||||
@@ -132,7 +160,28 @@ pub async fn for_message(
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(row_to_tool).collect())
|
||||
Ok(rows.into_iter()
|
||||
.map(|(id, message_id, name, arguments, result, result_type, status, preview_old, preview_new)| {
|
||||
LlmToolCall { id, message_id, name, arguments, result, result_type, status, preview_old, preview_new }
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// A single tool call by id, with its diff-preview columns. Backs the tool-detail
|
||||
/// page (`GET /api/tools/{id}`). Returns `None` when the id is unknown in this pool.
|
||||
pub async fn get(pool: &SqlitePool, id: i64) -> anyhow::Result<Option<LlmToolCall>> {
|
||||
let row = sqlx::query_as::<_, (i64, i64, String, Option<String>, Option<String>, String, String, Option<String>, Option<String>)>(
|
||||
"SELECT id, message_id, name, arguments, result, result_type, status, preview_old, preview_new
|
||||
FROM chat_llm_tools
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|(id, message_id, name, arguments, result, result_type, status, preview_old, preview_new)| {
|
||||
LlmToolCall { id, message_id, name, arguments, result, result_type, status, preview_old, preview_new }
|
||||
}))
|
||||
}
|
||||
|
||||
fn row_to_tool(
|
||||
@@ -140,5 +189,6 @@ fn row_to_tool(
|
||||
i64, i64, String, Option<String>, Option<String>, String, String,
|
||||
),
|
||||
) -> LlmToolCall {
|
||||
LlmToolCall { id, message_id, name, arguments, result, result_type, status }
|
||||
// The resume path (`pending_for_stack`) never needs the diff preview.
|
||||
LlmToolCall { id, message_id, name, arguments, result, result_type, status, preview_old: None, preview_new: None }
|
||||
}
|
||||
|
||||
@@ -54,6 +54,11 @@ pub struct McpCatalogRow {
|
||||
pub icon_large_path: Option<String>,
|
||||
pub friendly_name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
/// Manifest-declared friendly tool names: a JSON array of `{name, display_name}`
|
||||
/// snapshotting the connector's `tools[]` block. Drives the UI card title for an
|
||||
/// `mcp__<server>__<tool>` call (override > live MCP `title` > prettified name).
|
||||
/// NULL when the manifest declares none.
|
||||
pub tool_meta_json: Option<String>,
|
||||
/// Marketplace build number — the **comparison key** for updates (a feed entry
|
||||
/// with a higher `version` than this installed one is "update available").
|
||||
/// Monotonic per connector; `version_string`/`version_release_date` are display
|
||||
@@ -105,9 +110,26 @@ const SELECT: &str =
|
||||
script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json, \
|
||||
deliver_json, role_filter, verify_command, \
|
||||
verify_script_path, icon_small_path, icon_large_path, friendly_name, \
|
||||
description, version, version_string, version_release_date, created_at \
|
||||
description, tool_meta_json, version, version_string, version_release_date, created_at \
|
||||
FROM mcp_catalog";
|
||||
|
||||
/// Parses a catalog row's `tool_meta_json` (a `[{name, display_name}]` array) into
|
||||
/// the `tool name → display title` override map the runtime feeds into
|
||||
/// [`McpServerSpec::tool_titles`]. Empty on NULL or malformed JSON — the runtime then
|
||||
/// falls back to the server's live MCP `title` and finally a prettified raw name.
|
||||
pub fn parse_tool_titles(tool_meta_json: Option<&str>) -> HashMap<String, String> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ToolMeta { name: String, display_name: Option<String> }
|
||||
tool_meta_json
|
||||
.and_then(|s| serde_json::from_str::<Vec<ToolMeta>>(s).ok())
|
||||
.map(|metas| {
|
||||
metas.into_iter()
|
||||
.filter_map(|m| m.display_name.map(|dn| (m.name, dn)))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn list(pool: &SqlitePool) -> Result<Vec<McpCatalogRow>> {
|
||||
@@ -167,6 +189,8 @@ pub struct UpsertCatalog<'a> {
|
||||
pub icon_large_path: Option<&'a str>,
|
||||
pub friendly_name: Option<&'a str>,
|
||||
pub description: Option<&'a str>,
|
||||
/// JSON array of `{name, display_name}` snapshotting the manifest's `tools[]`.
|
||||
pub tool_meta_json: Option<String>,
|
||||
/// Versioning (from the feed). All three `None` for the admin's manual form,
|
||||
/// which COALESCEs them away rather than blanking an installed entry's version.
|
||||
pub version: Option<i64>,
|
||||
@@ -181,8 +205,8 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
||||
script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json,
|
||||
deliver_json, role_filter, verify_command,
|
||||
verify_script_path, icon_small_path, icon_large_path, friendly_name, description,
|
||||
version, version_string, version_release_date)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)
|
||||
tool_meta_json, version, version_string, version_release_date)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
scope = excluded.scope,
|
||||
source = excluded.source,
|
||||
@@ -208,6 +232,9 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
||||
icon_large_path = COALESCE(excluded.icon_large_path, mcp_catalog.icon_large_path),
|
||||
friendly_name = excluded.friendly_name,
|
||||
description = excluded.description,
|
||||
-- Manifest tool titles are installer-owned like icons: COALESCE so the
|
||||
-- admin's manual catalog form (which never sends them) can't blank them.
|
||||
tool_meta_json = COALESCE(excluded.tool_meta_json, mcp_catalog.tool_meta_json),
|
||||
-- Version fields come from the feed on (re)install; the admin's manual
|
||||
-- form passes NULL, so COALESCE keeps the installed version rather than
|
||||
-- wiping it (same rationale as icons above).
|
||||
@@ -237,6 +264,7 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
||||
.bind(e.icon_large_path)
|
||||
.bind(e.friendly_name)
|
||||
.bind(e.description)
|
||||
.bind(e.tool_meta_json)
|
||||
.bind(e.version)
|
||||
.bind(e.version_string)
|
||||
.bind(e.version_release_date)
|
||||
|
||||
@@ -557,6 +557,7 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
icon_large_path TEXT,
|
||||
friendly_name TEXT,
|
||||
description TEXT,
|
||||
tool_meta_json TEXT, -- [{name,display_name}] friendly tool names from the manifest
|
||||
version INTEGER, -- marketplace build number: the update-comparison key
|
||||
version_string TEXT, -- semver, display only
|
||||
version_release_date TEXT, -- ISO date, display only
|
||||
@@ -569,6 +570,8 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
ensure_column(pool, "mcp_catalog", "oauth_provider", "TEXT").await?;
|
||||
ensure_column(pool, "mcp_catalog", "oauth_scopes_json", "TEXT").await?;
|
||||
ensure_column(pool, "mcp_catalog", "deliver_json", "TEXT").await?;
|
||||
// Manifest-declared friendly tool names (UI card titles) — additive.
|
||||
ensure_column(pool, "mcp_catalog", "tool_meta_json", "TEXT").await?;
|
||||
// Versioning columns are additive: the installed `version` integer is compared
|
||||
// against the feed's to surface "update available" in the marketplace UI.
|
||||
ensure_column(pool, "mcp_catalog", "version", "INTEGER").await?;
|
||||
@@ -745,11 +748,16 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
|
||||
result TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running', 'pending', 'done', 'failed', 'cancelled', 'rejected')),
|
||||
result_type TEXT NOT NULL DEFAULT 'string' CHECK(result_type IN ('string', 'json')),
|
||||
preview_old TEXT, -- file-write diff: content before the write
|
||||
preview_new TEXT, -- file-write diff: content after the write
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
// Diff-preview columns are additive — reach an already-created table in place.
|
||||
ensure_column(pool, "chat_llm_tools", "preview_old", "TEXT").await?;
|
||||
ensure_column(pool, "chat_llm_tools", "preview_new", "TEXT").await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_stack_session ON chat_sessions_stack(session_id)",
|
||||
|
||||
@@ -44,6 +44,10 @@ pub struct McpManager {
|
||||
servers: RwLock<HashMap<String, Arc<dyn McpServerClient>>>,
|
||||
errors: RwLock<HashMap<String, String>>,
|
||||
descriptions: RwLock<HashMap<String, Option<String>>>,
|
||||
/// Per-server manifest-declared friendly tool names (`server → tool → title`),
|
||||
/// the authoritative override for a tool's UI display name (`tool_display_name`).
|
||||
/// Populated from each spec's `tool_titles` at connect, forgotten on stop.
|
||||
titles: RwLock<HashMap<String, HashMap<String, String>>>,
|
||||
notification_tx: mpsc::UnboundedSender<McpNotification>,
|
||||
/// Feeds per-server diagnostic lines (stderr, `notifications/message`,
|
||||
/// lifecycle) to the `logs::log_consumer`, which writes `logs/mcp/<name>.log`.
|
||||
@@ -69,6 +73,7 @@ impl McpManager {
|
||||
servers: RwLock::new(HashMap::new()),
|
||||
errors: RwLock::new(HashMap::new()),
|
||||
descriptions: RwLock::new(HashMap::new()),
|
||||
titles: RwLock::new(HashMap::new()),
|
||||
notification_tx,
|
||||
log_tx,
|
||||
elicitation_handler: RwLock::new(None),
|
||||
@@ -179,8 +184,10 @@ impl McpManager {
|
||||
}
|
||||
{
|
||||
let mut descs = self.descriptions.write().unwrap();
|
||||
let mut titles = self.titles.write().unwrap();
|
||||
for spec in &specs {
|
||||
descs.insert(spec.config.name.clone(), spec.description.clone());
|
||||
titles.insert(spec.config.name.clone(), spec.tool_titles.clone());
|
||||
}
|
||||
}
|
||||
if boot {
|
||||
@@ -256,6 +263,7 @@ impl McpManager {
|
||||
self.log_lifecycle(&name, format!("connected — {} tool(s)", tool_names.len()));
|
||||
self.errors.write().unwrap().remove(&name);
|
||||
self.descriptions.write().unwrap().insert(name.clone(), spec.description);
|
||||
self.titles.write().unwrap().insert(name.clone(), spec.tool_titles);
|
||||
self.servers.write().unwrap().insert(name, client);
|
||||
Ok(tool_names)
|
||||
}
|
||||
@@ -266,6 +274,7 @@ impl McpManager {
|
||||
self.servers.write().unwrap().remove(name);
|
||||
self.errors.write().unwrap().remove(name);
|
||||
self.descriptions.write().unwrap().remove(name);
|
||||
self.titles.write().unwrap().remove(name);
|
||||
}
|
||||
|
||||
/// Stops **every** running server (each dropped client → `kill_on_drop` kills
|
||||
@@ -277,6 +286,7 @@ impl McpManager {
|
||||
self.servers.write().unwrap().clear();
|
||||
self.errors.write().unwrap().clear();
|
||||
self.descriptions.write().unwrap().clear();
|
||||
self.titles.write().unwrap().clear();
|
||||
}
|
||||
|
||||
/// Whether a server by this name currently has a live connection in the
|
||||
@@ -299,6 +309,18 @@ impl McpManager {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Best friendly name for a tool for UI display: the manifest-declared override
|
||||
/// (`tool_titles`) wins, else the server's live MCP `title` (2025-06-18+), else
|
||||
/// `None` — the caller falls back to a prettified raw name. Cheap: an O(tools)
|
||||
/// scan per call, run once per tool-call event.
|
||||
pub fn tool_display_name(&self, server: &str, tool: &str) -> Option<String> {
|
||||
if let Some(t) = self.titles.read().unwrap().get(server).and_then(|m| m.get(tool)) {
|
||||
return Some(t.clone());
|
||||
}
|
||||
self.servers.read().unwrap().get(server)
|
||||
.and_then(|s| s.tools().iter().find(|t| t.name == tool).and_then(|t| t.title.clone()))
|
||||
}
|
||||
|
||||
pub fn server_descriptions(&self) -> HashMap<String, Option<String>> {
|
||||
self.descriptions.read().unwrap().clone()
|
||||
}
|
||||
@@ -407,6 +429,11 @@ impl McpManager {
|
||||
pub struct McpServerSpec {
|
||||
pub config: McpServerConfig,
|
||||
pub description: Option<String>,
|
||||
/// Manifest-declared friendly tool names (`tool name → display title`), the
|
||||
/// authoritative override for a connector's UI display names. Empty for globals
|
||||
/// and for per-user rows with no catalog `tool_meta_json`; the runtime then
|
||||
/// falls back to the server's live MCP `title` and finally a prettified name.
|
||||
pub tool_titles: HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn transport_of(s: &str) -> McpTransport {
|
||||
@@ -532,6 +559,7 @@ pub fn global_row_spec(row: &crate::db::mcp_global_servers::McpGlobalServerRow)
|
||||
launch_in: None,
|
||||
},
|
||||
description: row.description.clone(),
|
||||
tool_titles: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,6 +644,9 @@ pub fn user_row_spec(
|
||||
// A per-user connector's description falls back to its catalog name; the
|
||||
// catalog's friendly description can be injected by the caller if richer.
|
||||
description: row.catalog_name.clone(),
|
||||
// Manifest tool titles are loaded from the catalog by `user_row_spec_resolved`,
|
||||
// which has registry access; the bare sync builder leaves them empty.
|
||||
tool_titles: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,6 +663,14 @@ pub async fn user_row_spec_resolved(
|
||||
registry: &SqlitePool,
|
||||
) -> McpServerSpec {
|
||||
let mut spec = user_row_spec(row, container);
|
||||
// Manifest-declared friendly tool names (UI card titles): snapshot the catalog's
|
||||
// `tool_meta_json` for this activation so `tool_display_name` can override the raw
|
||||
// name. Best-effort — a missing/failed lookup just leaves the live `title` path.
|
||||
if let Some(catalog_name) = row.catalog_name.as_deref() {
|
||||
if let Ok(Some(entry)) = crate::db::mcp_catalog::get_by_name(registry, catalog_name).await {
|
||||
spec.tool_titles = crate::db::mcp_catalog::parse_tool_titles(entry.tool_meta_json.as_deref());
|
||||
}
|
||||
}
|
||||
if let (Some(provider), Some(deliver), Some(refresh)) =
|
||||
(row.oauth_provider.as_deref(), row.deliver(), row.api_key.as_deref())
|
||||
{
|
||||
|
||||
@@ -25,6 +25,10 @@ pub trait McpProvider: Send + Sync {
|
||||
fn tools_for(&self, names: &[String]) -> Vec<McpTool>;
|
||||
fn server_descriptions(&self) -> HashMap<String, Option<String>>;
|
||||
fn server_infos(&self) -> Vec<Value>;
|
||||
/// Best friendly name for a `server`/`tool` pair for the chat card (manifest
|
||||
/// override > live MCP `title` > `None`, the caller then prettifies the raw
|
||||
/// name). Routed to whichever runtime owns the server.
|
||||
fn tool_display_name(&self, server: &str, tool: &str) -> Option<String>;
|
||||
async fn call(&self, server: &str, tool: &str, args: Value) -> Result<ToolResult>;
|
||||
}
|
||||
|
||||
@@ -34,6 +38,9 @@ impl McpProvider for McpManager {
|
||||
fn tools_for(&self, names: &[String]) -> Vec<McpTool> { McpManager::tools_for(self, names) }
|
||||
fn server_descriptions(&self) -> HashMap<String, Option<String>> { McpManager::server_descriptions(self) }
|
||||
fn server_infos(&self) -> Vec<Value> { McpManager::server_infos(self) }
|
||||
fn tool_display_name(&self, server: &str, tool: &str) -> Option<String> {
|
||||
McpManager::tool_display_name(self, server, tool)
|
||||
}
|
||||
async fn call(&self, server: &str, tool: &str, args: Value) -> Result<ToolResult> {
|
||||
McpManager::call(self, server, tool, args).await
|
||||
}
|
||||
@@ -97,6 +104,14 @@ impl McpProvider for UserMcpView {
|
||||
v
|
||||
}
|
||||
|
||||
fn tool_display_name(&self, server: &str, tool: &str) -> Option<String> {
|
||||
if self.accessible_global.contains(server) {
|
||||
self.global.tool_display_name(server, tool)
|
||||
} else {
|
||||
self.user.tool_display_name(server, tool)
|
||||
}
|
||||
}
|
||||
|
||||
async fn call(&self, server: &str, tool: &str, args: Value) -> Result<ToolResult> {
|
||||
if self.accessible_global.contains(server) {
|
||||
self.global.call(server, tool, args).await
|
||||
|
||||
@@ -12,11 +12,30 @@ use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::events::ServerEvent;
|
||||
use crate::tools::{drive_execution, tool_names as tn, ExecutionOutcome, ToolResult};
|
||||
use crate::tools::{drive_execution, is_file_write_tool, tool_names as tn, ExecutionOutcome, ToolResult};
|
||||
|
||||
use super::ChatSessionHandler;
|
||||
use super::interface_tools::AgentRunConfig;
|
||||
|
||||
/// Max bytes captured per side of a file-write diff preview. Beyond this the side is
|
||||
/// dropped (`None`) so a huge file never bloats a row or the WS payload — the detail
|
||||
/// page then shows no diff for it.
|
||||
const MAX_PREVIEW_BYTES: usize = 256 * 1024;
|
||||
|
||||
/// A file-write tool's before/after snapshot, captured by `execute_tool_call` around
|
||||
/// the write so the diff renders inline and survives a reload (Phase 2). `None` sides
|
||||
/// mean unreadable / new file / over the cap.
|
||||
pub(super) struct WritePreview {
|
||||
pub old: Option<String>,
|
||||
pub new: Option<String>,
|
||||
}
|
||||
|
||||
/// Drops a captured snapshot over the size cap (a truncated snapshot would render a
|
||||
/// misleading diff, so omit it entirely).
|
||||
fn cap_preview(s: Option<String>) -> Option<String> {
|
||||
s.filter(|c| c.len() <= MAX_PREVIEW_BYTES)
|
||||
}
|
||||
|
||||
/// Whether a tool call is a synchronous sub-agent dispatch, i.e. one intercepted
|
||||
/// by `execute_tool_call` and routed to `dispatch_sub_agent` rather than the
|
||||
/// registry. Covers `execute_task` (mode=sync), `execute_subtask`, and the legacy
|
||||
@@ -30,8 +49,12 @@ pub(super) fn is_sync_sub_agent(tool_name: &str, args: &Value) -> bool {
|
||||
|
||||
/// Result of routing a single tool call to its executor.
|
||||
pub(super) enum DispatchResult {
|
||||
/// Normal completion / failure / cancellation — the caller records it.
|
||||
Outcome(ExecutionOutcome),
|
||||
/// Normal completion / failure / cancellation — the caller records it. `preview`
|
||||
/// carries a file-write's before/after snapshot (else `None`) for the diff card.
|
||||
Outcome {
|
||||
outcome: ExecutionOutcome,
|
||||
preview: Option<WritePreview>,
|
||||
},
|
||||
/// The turn must end now and the tool row must stay `pending`: the
|
||||
/// `ask_user_clarification` WS channel closed while awaiting an answer. The
|
||||
/// caller returns `TurnOutcome::Cancelled` **without** recording the tool, so
|
||||
@@ -84,12 +107,38 @@ impl ChatSessionHandler {
|
||||
// Unified cancellable path. The execution owns its in-flight state and
|
||||
// its own stop(); on /stop the work future is dropped (aborting I/O /
|
||||
// killing the child) and the tool is recorded as Cancelled, not Failed.
|
||||
match self.build_execution(tool_name, args.clone(), config) {
|
||||
//
|
||||
// For a file-write tool, bracket the execution with a before/after
|
||||
// snapshot so its diff renders inline and survives a reload (Phase 2).
|
||||
// The reads route memory-vs-disk exactly like the write itself
|
||||
// (`read_current_content`); `new` is captured only on success.
|
||||
let write_path = if is_file_write_tool(tool_name) {
|
||||
args["path"].as_str().map(str::to_string)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let preview_old = match &write_path {
|
||||
Some(p) => cap_preview(self.read_current_content(p).await),
|
||||
None => None,
|
||||
};
|
||||
let outcome = match self.build_execution(tool_name, args.clone(), config) {
|
||||
Some(exec) => drive_execution(exec.as_ref(), token).await,
|
||||
None => ExecutionOutcome::Failed(format!("Unknown tool: {tool_name}")),
|
||||
}
|
||||
};
|
||||
let preview = match &write_path {
|
||||
Some(p) => {
|
||||
let new = if matches!(outcome, ExecutionOutcome::Completed(_)) {
|
||||
cap_preview(self.read_current_content(p).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(WritePreview { old: preview_old, new })
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
return DispatchResult::Outcome { outcome, preview };
|
||||
};
|
||||
DispatchResult::Outcome(outcome)
|
||||
DispatchResult::Outcome { outcome, preview: None }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,17 +70,26 @@ impl<'a> TurnEmitter<'a> {
|
||||
message_id: i64,
|
||||
name: String,
|
||||
arguments: Value,
|
||||
display_name: String,
|
||||
icon: String,
|
||||
label_short: String,
|
||||
label_full: String,
|
||||
path: Option<String>,
|
||||
) {
|
||||
self.emit(ServerEvent::ToolStart {
|
||||
tool_call_id, message_id, name, arguments, label_short, label_full, path,
|
||||
tool_call_id, message_id, name, arguments, display_name, icon, label_short, label_full, path,
|
||||
}).await;
|
||||
}
|
||||
|
||||
pub(super) async fn tool_done(&self, tool_call_id: i64, result: String, result_type: String) {
|
||||
self.emit(ServerEvent::ToolDone { tool_call_id, result, result_type }).await;
|
||||
pub(super) async fn tool_done(
|
||||
&self,
|
||||
tool_call_id: i64,
|
||||
result: String,
|
||||
result_type: String,
|
||||
preview_old: Option<String>,
|
||||
preview_new: Option<String>,
|
||||
) {
|
||||
self.emit(ServerEvent::ToolDone { tool_call_id, result, result_type, preview_old, preview_new }).await;
|
||||
}
|
||||
|
||||
pub(super) async fn tool_error(&self, tool_call_id: i64, error: String) {
|
||||
|
||||
@@ -207,6 +207,21 @@ impl ChatSessionHandler {
|
||||
/// Handles a single tool call within a round: persists the call row, emits
|
||||
/// `ToolStart`, resolves the working directory, runs the approval gate, handles
|
||||
/// `restart`, dispatches, and records the outcome. Returns [`CallFlow::Continue`]
|
||||
/// Card metadata (friendly display name + semantic icon key) for a tool call.
|
||||
/// Delegates to the registry seam [`ToolRegistry::display_meta`], then layers the
|
||||
/// MCP display-name override on for an `mcp__server__tool` name (manifest title >
|
||||
/// live MCP `title` > the prettified name the seam already produced). The single
|
||||
/// place the live loop resolves a card title, mirroring `describe_call`.
|
||||
pub(super) fn tool_ui_meta(&self, name: &str, args: &serde_json::Value) -> (String, String) {
|
||||
let mut meta = self.tools.display_meta(name, args);
|
||||
if let Some((server, tool)) = crate::mcp::parse_mcp_tool_name(name) {
|
||||
if let Some(friendly) = self.mcp.tool_display_name(server, tool) {
|
||||
meta.display_name = friendly;
|
||||
}
|
||||
}
|
||||
(meta.display_name, meta.icon)
|
||||
}
|
||||
|
||||
/// to move on to the next call, or [`CallFlow::End`] to end the whole turn.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_tool_call(
|
||||
@@ -225,10 +240,12 @@ impl ChatSessionHandler {
|
||||
let args_str = serde_json::to_string(&call.arguments)
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
let tool_call_id = chat_llm_tools::append(pool, message_id, &call.name, &args_str).await?;
|
||||
let (display_name, icon) = self.tool_ui_meta(&call.name, &call.arguments);
|
||||
em.tool_start(
|
||||
tool_call_id, message_id,
|
||||
call.name.clone(),
|
||||
call.arguments.clone(),
|
||||
display_name, icon,
|
||||
self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Short),
|
||||
self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Full),
|
||||
self.tools.target_path(&call.name, &call.arguments),
|
||||
@@ -252,7 +269,7 @@ impl ChatSessionHandler {
|
||||
if call.name == tn::RESTART {
|
||||
info!(session_id = self.session_id, tool_call_id, "restart approved — marking done then exiting");
|
||||
chat_llm_tools::complete(pool, tool_call_id, "Riavvio avviato.", "string").await?;
|
||||
em.tool_done(tool_call_id, "Riavvio avviato.".to_string(), "string".to_string()).await;
|
||||
em.tool_done(tool_call_id, "Riavvio avviato.".to_string(), "string".to_string(), None, None).await;
|
||||
// Use _exit() to skip C atexit handlers (e.g. Metal GPU cleanup in
|
||||
// whisper-rs/ggml, which aborts with SIGABRT and yields exit code 134
|
||||
// instead of 255 — breaking the run.sh restart supervisor).
|
||||
@@ -262,15 +279,15 @@ impl ChatSessionHandler {
|
||||
// Route the approved call to its executor. `AbortPending` means the
|
||||
// clarification WS channel closed — end the turn and leave the tool
|
||||
// `pending` for resume to re-ask.
|
||||
let outcome = match self.execute_tool_call(
|
||||
let (outcome, preview) = match self.execute_tool_call(
|
||||
stack_id, config, tool_call_id, &call.name, &call.arguments, token, tx,
|
||||
).await {
|
||||
DispatchResult::Outcome(o) => o,
|
||||
DispatchResult::Outcome { outcome, preview } => (outcome, preview),
|
||||
DispatchResult::AbortPending => return Ok(CallFlow::End(TurnOutcome::Cancelled)),
|
||||
};
|
||||
|
||||
match self.record_tool_outcome(
|
||||
tool_call_id, &call.name, &call.arguments, outcome, em, Some(all_tool_calls),
|
||||
tool_call_id, &call.name, &call.arguments, outcome, preview, em, Some(all_tool_calls),
|
||||
).await? {
|
||||
RecordFlow::Continue => Ok(CallFlow::Continue),
|
||||
RecordFlow::Abort => Ok(CallFlow::End(TurnOutcome::Cancelled)),
|
||||
@@ -312,10 +329,12 @@ impl ChatSessionHandler {
|
||||
let args_str = serde_json::to_string(&call.arguments)
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
let tool_call_id = chat_llm_tools::append(pool, message_id, &call.name, &args_str).await?;
|
||||
let (display_name, icon) = self.tool_ui_meta(&call.name, &call.arguments);
|
||||
em.tool_start(
|
||||
tool_call_id, message_id,
|
||||
call.name.clone(),
|
||||
call.arguments.clone(),
|
||||
display_name, icon,
|
||||
self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Short),
|
||||
self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Full),
|
||||
self.tools.target_path(&call.name, &call.arguments),
|
||||
@@ -346,8 +365,9 @@ impl ChatSessionHandler {
|
||||
Ok(GateOutcome::Proceed) => match self.execute_tool_call(
|
||||
stack_id, config, tool_call_id, &name, &arguments, token, tx,
|
||||
).await {
|
||||
DispatchResult::Outcome(outcome) => Ok(GatedExec::Done { arguments, outcome }),
|
||||
DispatchResult::AbortPending => Ok(GatedExec::AbortTurn),
|
||||
// Sub-agent batches never carry a file-write preview.
|
||||
DispatchResult::Outcome { outcome, .. } => Ok(GatedExec::Done { arguments, outcome }),
|
||||
DispatchResult::AbortPending => Ok(GatedExec::AbortTurn),
|
||||
},
|
||||
Ok(GateOutcome::Rejected) => Ok(GatedExec::Rejected),
|
||||
Ok(GateOutcome::ChannelClosed) => Ok(GatedExec::AbortTurn),
|
||||
@@ -371,7 +391,7 @@ impl ChatSessionHandler {
|
||||
GatedExec::AbortTurn => abort = true,
|
||||
GatedExec::Done { arguments, outcome } => {
|
||||
match self.record_tool_outcome(
|
||||
*tool_call_id, &call.name, &arguments, outcome, em, Some(all_tool_calls),
|
||||
*tool_call_id, &call.name, &arguments, outcome, None, em, Some(all_tool_calls),
|
||||
).await? {
|
||||
RecordFlow::Continue => {}
|
||||
RecordFlow::Abort => abort = true,
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::db::chat_llm_tools;
|
||||
use crate::tools::{is_file_write_tool, ExecutionOutcome};
|
||||
|
||||
use super::ChatSessionHandler;
|
||||
use super::dispatch::WritePreview;
|
||||
use super::emitter::TurnEmitter;
|
||||
|
||||
/// Whether the enclosing loop should keep going after an outcome is recorded.
|
||||
@@ -38,6 +39,7 @@ impl ChatSessionHandler {
|
||||
tool_name: &str,
|
||||
args: &Value,
|
||||
outcome: ExecutionOutcome,
|
||||
preview: Option<WritePreview>,
|
||||
em: &TurnEmitter<'_>,
|
||||
accumulate: Option<&mut Vec<ToolCallEvent>>,
|
||||
) -> anyhow::Result<RecordFlow> {
|
||||
@@ -48,6 +50,15 @@ impl ChatSessionHandler {
|
||||
let kind = result.kind();
|
||||
debug!(session_id = self.session_id, tool = %tool_name, tool_call_id, result_len = wire.len(), "tool done");
|
||||
chat_llm_tools::complete(pool, tool_call_id, &wire, kind).await?;
|
||||
// Persist a file-write's diff snapshot so it re-renders after a reload,
|
||||
// and carry it on the event so an auto-allowed write shows the diff live.
|
||||
let (preview_old, preview_new) = match preview {
|
||||
Some(WritePreview { old, new }) => {
|
||||
chat_llm_tools::set_preview(pool, tool_call_id, old.as_deref(), new.as_deref()).await?;
|
||||
(old, new)
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
if let Some(acc) = accumulate {
|
||||
if is_file_write_tool(tool_name)
|
||||
&& let Some(p) = args["path"].as_str()
|
||||
@@ -61,7 +72,7 @@ impl ChatSessionHandler {
|
||||
status: "done".to_string(),
|
||||
});
|
||||
}
|
||||
em.tool_done(tool_call_id, wire, kind.to_string()).await;
|
||||
em.tool_done(tool_call_id, wire, kind.to_string(), preview_old, preview_new).await;
|
||||
Ok(RecordFlow::Continue)
|
||||
}
|
||||
ExecutionOutcome::Failed(msg) => {
|
||||
|
||||
@@ -164,7 +164,7 @@ impl ChatSessionHandler {
|
||||
if is_error {
|
||||
em.tool_error(parent_tool_call_id, result_str).await;
|
||||
} else {
|
||||
em.tool_done(parent_tool_call_id, result_str, "string".to_string()).await;
|
||||
em.tool_done(parent_tool_call_id, result_str, "string".to_string(), None, None).await;
|
||||
}
|
||||
|
||||
// Now the parent is the deepest active stack.
|
||||
@@ -305,11 +305,13 @@ impl ChatSessionHandler {
|
||||
// Re-dispatch it directly so the question is re-asked to the user.
|
||||
if tc.name == tn::ASK_USER_CLARIFICATION {
|
||||
info!(session_id = self.session_id, tool_call_id = tc.id, "resume: re-asking clarification question");
|
||||
let (display_name, icon) = self.tool_ui_meta(&tc.name, &args);
|
||||
em.tool_start(
|
||||
tc.id,
|
||||
tc.message_id,
|
||||
tc.name.clone(),
|
||||
args.clone(),
|
||||
display_name, icon,
|
||||
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short),
|
||||
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full),
|
||||
self.tools.target_path(&tc.name, &args),
|
||||
@@ -318,7 +320,7 @@ impl ChatSessionHandler {
|
||||
match result {
|
||||
Ok(answer) => {
|
||||
chat_llm_tools::complete(pool, tc.id, &answer, "string").await?;
|
||||
em.tool_done(tc.id, answer, "string".to_string()).await;
|
||||
em.tool_done(tc.id, answer, "string".to_string(), None, None).await;
|
||||
}
|
||||
Err(e) if matches!(e.downcast_ref::<super::AgentFlowSignal>(), Some(super::AgentFlowSignal::QuestionChannelClosed)) => {
|
||||
// WS disconnected again mid-resume. Tool stays 'pending' — next resume re-asks.
|
||||
@@ -335,11 +337,13 @@ impl ChatSessionHandler {
|
||||
}
|
||||
|
||||
// Announce the tool is being re-tried.
|
||||
let (display_name, icon) = self.tool_ui_meta(&tc.name, &args);
|
||||
em.tool_start(
|
||||
tc.id,
|
||||
tc.message_id,
|
||||
tc.name.clone(),
|
||||
args.clone(),
|
||||
display_name, icon,
|
||||
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short),
|
||||
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full),
|
||||
self.tools.target_path(&tc.name, &args),
|
||||
@@ -358,7 +362,7 @@ impl ChatSessionHandler {
|
||||
if tc.name == tn::RESTART {
|
||||
info!(session_id = self.session_id, tool_call_id = tc.id, "restart approved (resume) — marking done then exiting");
|
||||
chat_llm_tools::complete(pool, tc.id, "Riavvio avviato.", "string").await?;
|
||||
em.tool_done(tc.id, "Riavvio avviato.".to_string(), "string".to_string()).await;
|
||||
em.tool_done(tc.id, "Riavvio avviato.".to_string(), "string".to_string(), None, None).await;
|
||||
// Use _exit() to skip C atexit handlers (e.g. Metal GPU cleanup in
|
||||
// whisper-rs/ggml, which aborts with SIGABRT and yields exit code 134
|
||||
// instead of 255 — breaking the run.sh restart supervisor).
|
||||
@@ -371,17 +375,18 @@ impl ChatSessionHandler {
|
||||
// `run_subtask`) through the recursive interception in `dispatch.rs`;
|
||||
// `build_execution` alone does not know them and would fail with
|
||||
// "Unknown tool: execute_task". Args are passed through unchanged.
|
||||
let outcome = match self.execute_tool_call(
|
||||
let (outcome, preview) = match self.execute_tool_call(
|
||||
stack_id, config, tc.id, &tc.name, &args, token, tx,
|
||||
).await {
|
||||
super::dispatch::DispatchResult::Outcome(o) => o,
|
||||
super::dispatch::DispatchResult::Outcome { outcome, preview } => (outcome, preview),
|
||||
// Clarification WS channel closed mid-resume — leave the tool pending
|
||||
// so the next resume re-asks (mirrors the live turn's AbortPending).
|
||||
super::dispatch::DispatchResult::AbortPending => return Ok(true),
|
||||
};
|
||||
// resume passes `None`: it does not accumulate ToolCallEvents nor re-emit
|
||||
// FileChanged (only a live turn does). A /stop mid-resume returns Abort.
|
||||
match self.record_tool_outcome(tc.id, &tc.name, &args, outcome, &em, None).await? {
|
||||
// resume passes `None` for accumulate: it does not accumulate ToolCallEvents
|
||||
// nor re-emit FileChanged (only a live turn does). The write preview IS
|
||||
// persisted so a re-run write's diff survives. A /stop mid-resume returns Abort.
|
||||
match self.record_tool_outcome(tc.id, &tc.name, &args, outcome, preview, &em, None).await? {
|
||||
RecordFlow::Continue => {}
|
||||
RecordFlow::Abort => return Ok(true),
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ pub struct ExecuteCmd;
|
||||
|
||||
impl Tool for ExecuteCmd {
|
||||
fn name(&self) -> &str { crate::tools::tool_names::EXECUTE_CMD }
|
||||
fn display_name(&self) -> &str { "Run Command" }
|
||||
fn icon(&self) -> &str { "shell" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Shell }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
|
||||
@@ -114,6 +114,8 @@ impl EditFile {
|
||||
|
||||
impl Tool for EditFile {
|
||||
fn name(&self) -> &str { "edit_file" }
|
||||
fn display_name(&self) -> &str { "Edit File" }
|
||||
fn icon(&self) -> &str { "edit" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
|
||||
@@ -16,6 +16,8 @@ impl GrepFiles {
|
||||
|
||||
impl Tool for GrepFiles {
|
||||
fn name(&self) -> &str { "grep_files" }
|
||||
fn display_name(&self) -> &str { "Search" }
|
||||
fn icon(&self) -> &str { "search" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
|
||||
@@ -48,6 +48,8 @@ fn apply_insert(text: &str, args: &Value, display: &str) -> Result<(String, Stri
|
||||
|
||||
impl Tool for InsertAtLine {
|
||||
fn name(&self) -> &str { "insert_at_line" }
|
||||
fn display_name(&self) -> &str { "Edit File" }
|
||||
fn icon(&self) -> &str { "edit" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
|
||||
@@ -25,6 +25,8 @@ impl ListFiles {
|
||||
|
||||
impl Tool for ListFiles {
|
||||
fn name(&self) -> &str { "list_files" }
|
||||
fn display_name(&self) -> &str { "List Files" }
|
||||
fn icon(&self) -> &str { "list" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
|
||||
@@ -46,6 +46,8 @@ fn render_hits(store: &str, hits: &[MemoryHit], out: &mut String) {
|
||||
|
||||
impl Tool for MemorySearch {
|
||||
fn name(&self) -> &str { "memory_search" }
|
||||
fn display_name(&self) -> &str { "Search Memory" }
|
||||
fn icon(&self) -> &str { "search" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Introspection }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
|
||||
@@ -49,6 +49,8 @@ fn number_lines(content: &str, start: usize, end_line: Option<usize>, limit: Opt
|
||||
|
||||
impl Tool for ReadFile {
|
||||
fn name(&self) -> &str { "read_file" }
|
||||
fn display_name(&self) -> &str { "Read File" }
|
||||
fn icon(&self) -> &str { "read" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
|
||||
@@ -55,6 +55,8 @@ fn apply_replace(content: &str, args: &Value, display: &str) -> Result<(String,
|
||||
|
||||
impl Tool for ReplaceLines {
|
||||
fn name(&self) -> &str { "replace_lines" }
|
||||
fn display_name(&self) -> &str { "Edit File" }
|
||||
fn icon(&self) -> &str { "edit" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
|
||||
@@ -66,6 +66,8 @@ fn render_search(text: &str, args: &Value, display: &str) -> Result<String> {
|
||||
|
||||
impl Tool for SearchFile {
|
||||
fn name(&self) -> &str { "search_file" }
|
||||
fn display_name(&self) -> &str { "Search" }
|
||||
fn icon(&self) -> &str { "search" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
|
||||
@@ -21,6 +21,8 @@ impl WriteFile {
|
||||
|
||||
impl Tool for WriteFile {
|
||||
fn name(&self) -> &str { "write_file" }
|
||||
fn display_name(&self) -> &str { "Edit File" }
|
||||
fn icon(&self) -> &str { "edit" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
|
||||
@@ -47,6 +47,8 @@ pub struct ImageGenerateTool {
|
||||
|
||||
impl Tool for ImageGenerateTool {
|
||||
fn name(&self) -> &str { "image_generate" }
|
||||
fn display_name(&self) -> &str { "Generate Image" }
|
||||
fn icon(&self) -> &str { "image" }
|
||||
fn category(&self) -> ToolCategory { ToolCategory::Config }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
|
||||
@@ -62,6 +62,33 @@ pub use core_api::tool::{
|
||||
pub const MAX_LABEL_SHORT: usize = 60;
|
||||
pub const MAX_LABEL_FULL: usize = 120;
|
||||
|
||||
/// UI metadata for one tool call — the friendly card title plus a **semantic** icon
|
||||
/// key (never a glyph; the frontend maps the key to an icon + accent color). Computed
|
||||
/// by [`ToolRegistry::display_meta`], the single seam shared by the live WS event and
|
||||
/// the history projection so the two can't drift.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolUiMeta {
|
||||
pub display_name: String,
|
||||
pub icon: String,
|
||||
}
|
||||
|
||||
/// Turns a raw tool id (`snake_case` / `kebab-case`) into a Title-Cased phrase for a
|
||||
/// UI label when no friendly name is declared — `list_recent_files` → "List Recent
|
||||
/// Files". The last-resort fallback for MCP and unknown tools.
|
||||
pub fn prettify_tool_name(name: &str) -> String {
|
||||
name.split(|c| c == '_' || c == '-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|w| {
|
||||
let mut chars = w.chars();
|
||||
match chars.next() {
|
||||
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Registry of all available tools.
|
||||
pub struct ToolRegistry {
|
||||
tools: HashMap<String, Arc<dyn Tool>>,
|
||||
@@ -173,6 +200,44 @@ impl ToolRegistry {
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
/// Friendly card metadata (display name + semantic icon key) for any tool call,
|
||||
/// including non-registry tools. Registry tools delegate to
|
||||
/// [`Tool::display_name`]/[`Tool::icon`]; sub-agent and interface tools are
|
||||
/// handled inline; an `mcp__server__tool` name gets `icon = "mcp"` and a
|
||||
/// prettified tool name — the caller (which holds the [`McpProvider`]) may then
|
||||
/// override `display_name` with the connector's resolved friendly name.
|
||||
///
|
||||
/// [`McpProvider`]: crate::mcp::provider::McpProvider
|
||||
pub fn display_meta(&self, name: &str, args: &Value) -> ToolUiMeta {
|
||||
if let Some(tool) = self.tools.get(name) {
|
||||
return ToolUiMeta {
|
||||
display_name: tool.display_name().to_string(),
|
||||
icon: tool.icon().to_string(),
|
||||
};
|
||||
}
|
||||
// Sub-agent delegation tools (InterfaceTools, not in the registry).
|
||||
if name == tool_names::EXECUTE_TASK
|
||||
|| name == tool_names::EXECUTE_SUBTASK
|
||||
|| name == "run_subtask"
|
||||
{
|
||||
let dn = match args["agent_id"].as_str().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(a) => format!("Sub-agent: {a}"),
|
||||
None => "Sub-agent".to_string(),
|
||||
};
|
||||
return ToolUiMeta { display_name: dn, icon: "subagent".to_string() };
|
||||
}
|
||||
if name == tool_names::SHOW_FILE_TO_USER {
|
||||
return ToolUiMeta { display_name: "Show File".to_string(), icon: "read".to_string() };
|
||||
}
|
||||
// MCP tool `mcp__server__tool`: default to a prettified tool name; the caller
|
||||
// overrides with the connector's manifest/`title` friendly name when known.
|
||||
if let Some(rest) = name.strip_prefix("mcp__") {
|
||||
let tool = rest.split_once("__").map(|(_, t)| t).unwrap_or(rest);
|
||||
return ToolUiMeta { display_name: prettify_tool_name(tool), icon: "mcp".to_string() };
|
||||
}
|
||||
ToolUiMeta { display_name: prettify_tool_name(name), icon: "tool".to_string() }
|
||||
}
|
||||
|
||||
/// Returns the category of a registered tool, or `None` for unknown tools
|
||||
/// (MCP tools, interface tools, call_agent, etc.).
|
||||
pub fn category_of(&self, name: &str) -> Option<ToolCategory> {
|
||||
|
||||
Reference in New Issue
Block a user