From 9e1a13bf4c6abeb25eeb3b922fe3f062b9a3321b Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:55:59 -0600 Subject: [PATCH] Gateway/UI: data-driven agents tools catalog with provenance (openclaw#24199) thanks @Takhoffman Verified: - pnpm install --frozen-lockfile - pnpm build - gh pr checks 24199 --watch --fail-fast Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + .../OpenClawProtocol/GatewayModels.swift | 224 +++++++++++- .../OpenClawProtocol/GatewayModels.swift | 224 +++++++++++- docs/gateway/protocol.md | 8 + docs/web/webchat.md | 8 + src/agents/tool-catalog.ts | 322 ++++++++++++++++++ src/agents/tool-policy-shared.ts | 90 +---- src/agents/tool-policy.test.ts | 3 +- src/gateway/method-scopes.ts | 1 + src/gateway/protocol/index.ts | 7 + .../protocol/schema/agents-models-skills.ts | 61 ++++ .../protocol/schema/protocol-schemas.ts | 10 + src/gateway/protocol/schema/types.ts | 10 + src/gateway/server-methods-list.ts | 1 + src/gateway/server-methods.ts | 2 + .../server-methods/tools-catalog.test.ts | 120 +++++++ src/gateway/server-methods/tools-catalog.ts | 165 +++++++++ src/gateway/server.tools-catalog.test.ts | 46 +++ ui/src/ui/app-gateway.ts | 6 +- ui/src/ui/app-render.ts | 17 +- ui/src/ui/app-settings.ts | 3 +- ui/src/ui/app-view-state.ts | 4 + ui/src/ui/app.ts | 4 + ui/src/ui/controllers/agents.test.ts | 61 ++++ ui/src/ui/controllers/agents.ts | 29 +- ui/src/ui/types.ts | 29 ++ ...agents-panels-tools-skills.browser.test.ts | 102 ++++++ ui/src/ui/views/agents-panels-tools-skills.ts | 72 +++- ui/src/ui/views/agents-utils.ts | 95 +----- ui/src/ui/views/agents.ts | 7 + vitest.config.ts | 1 + 31 files changed, 1548 insertions(+), 185 deletions(-) create mode 100644 src/agents/tool-catalog.ts create mode 100644 src/gateway/server-methods/tools-catalog.test.ts create mode 100644 src/gateway/server-methods/tools-catalog.ts create mode 100644 src/gateway/server.tools-catalog.test.ts create mode 100644 ui/src/ui/controllers/agents.test.ts create mode 100644 ui/src/ui/views/agents-panels-tools-skills.browser.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1970b546e6a..980a707c964 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Control UI/Agents: make the Tools panel data-driven from runtime `tools.catalog`, add per-tool provenance labels (`core` / `plugin:` + optional marker), and keep a static fallback list when the runtime catalog is unavailable. - Control UI/Cron: add full web cron edit parity (including clone and richer validation/help text), plus all-jobs run history with pagination/search/sort/multi-filter controls and improved cron page layout for cleaner scheduling and failure triage workflows. - Provider/Mistral: add support for the Mistral provider, including memory embeddings and voice support. (#23845) Thanks @vincentkoc. - Update/Core: add an optional built-in auto-updater for package installs (`update.auto.*`), default-off, with stable rollout delay+jitter and beta hourly cadence. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 2909418d0c3..cb5bc976d4c 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -2170,6 +2170,132 @@ public struct SkillsStatusParams: Codable, Sendable { } } +public struct ToolsCatalogParams: Codable, Sendable { + public let agentid: String? + public let includeplugins: Bool? + + public init( + agentid: String?, + includeplugins: Bool?) + { + self.agentid = agentid + self.includeplugins = includeplugins + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case includeplugins = "includePlugins" + } +} + +public struct ToolCatalogProfile: Codable, Sendable { + public let id: AnyCodable + public let label: String + + public init( + id: AnyCodable, + label: String) + { + self.id = id + self.label = label + } + + private enum CodingKeys: String, CodingKey { + case id + case label + } +} + +public struct ToolCatalogEntry: Codable, Sendable { + public let id: String + public let label: String + public let description: String + public let source: AnyCodable + public let pluginid: String? + public let optional: Bool? + public let defaultprofiles: [AnyCodable] + + public init( + id: String, + label: String, + description: String, + source: AnyCodable, + pluginid: String?, + optional: Bool?, + defaultprofiles: [AnyCodable]) + { + self.id = id + self.label = label + self.description = description + self.source = source + self.pluginid = pluginid + self.optional = optional + self.defaultprofiles = defaultprofiles + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case description + case source + case pluginid = "pluginId" + case optional + case defaultprofiles = "defaultProfiles" + } +} + +public struct ToolCatalogGroup: Codable, Sendable { + public let id: String + public let label: String + public let source: AnyCodable + public let pluginid: String? + public let tools: [ToolCatalogEntry] + + public init( + id: String, + label: String, + source: AnyCodable, + pluginid: String?, + tools: [ToolCatalogEntry]) + { + self.id = id + self.label = label + self.source = source + self.pluginid = pluginid + self.tools = tools + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case source + case pluginid = "pluginId" + case tools + } +} + +public struct ToolsCatalogResult: Codable, Sendable { + public let agentid: String + public let profiles: [ToolCatalogProfile] + public let groups: [ToolCatalogGroup] + + public init( + agentid: String, + profiles: [ToolCatalogProfile], + groups: [ToolCatalogGroup]) + { + self.agentid = agentid + self.profiles = profiles + self.groups = groups + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case profiles + case groups + } +} + public struct SkillsBinsParams: Codable, Sendable {} public struct SkillsBinsResult: Codable, Sendable { @@ -2306,15 +2432,39 @@ public struct CronJob: Codable, Sendable { public struct CronListParams: Codable, Sendable { public let includedisabled: Bool? + public let limit: Int? + public let offset: Int? + public let query: String? + public let enabled: AnyCodable? + public let sortby: AnyCodable? + public let sortdir: AnyCodable? public init( - includedisabled: Bool?) + includedisabled: Bool?, + limit: Int?, + offset: Int?, + query: String?, + enabled: AnyCodable?, + sortby: AnyCodable?, + sortdir: AnyCodable?) { self.includedisabled = includedisabled + self.limit = limit + self.offset = offset + self.query = query + self.enabled = enabled + self.sortby = sortby + self.sortdir = sortdir } private enum CodingKeys: String, CodingKey { case includedisabled = "includeDisabled" + case limit + case offset + case query + case enabled + case sortby = "sortBy" + case sortdir = "sortDir" } } @@ -2374,6 +2524,60 @@ public struct CronAddParams: Codable, Sendable { } } +public struct CronRunsParams: Codable, Sendable { + public let scope: AnyCodable? + public let id: String? + public let jobid: String? + public let limit: Int? + public let offset: Int? + public let statuses: [AnyCodable]? + public let status: AnyCodable? + public let deliverystatuses: [AnyCodable]? + public let deliverystatus: AnyCodable? + public let query: String? + public let sortdir: AnyCodable? + + public init( + scope: AnyCodable?, + id: String?, + jobid: String?, + limit: Int?, + offset: Int?, + statuses: [AnyCodable]?, + status: AnyCodable?, + deliverystatuses: [AnyCodable]?, + deliverystatus: AnyCodable?, + query: String?, + sortdir: AnyCodable?) + { + self.scope = scope + self.id = id + self.jobid = jobid + self.limit = limit + self.offset = offset + self.statuses = statuses + self.status = status + self.deliverystatuses = deliverystatuses + self.deliverystatus = deliverystatus + self.query = query + self.sortdir = sortdir + } + + private enum CodingKeys: String, CodingKey { + case scope + case id + case jobid = "jobId" + case limit + case offset + case statuses + case status + case deliverystatuses = "deliveryStatuses" + case deliverystatus = "deliveryStatus" + case query + case sortdir = "sortDir" + } +} + public struct CronRunLogEntry: Codable, Sendable { public let ts: Int public let jobid: String @@ -2389,6 +2593,10 @@ public struct CronRunLogEntry: Codable, Sendable { public let runatms: Int? public let durationms: Int? public let nextrunatms: Int? + public let model: String? + public let provider: String? + public let usage: [String: AnyCodable]? + public let jobname: String? public init( ts: Int, @@ -2404,7 +2612,11 @@ public struct CronRunLogEntry: Codable, Sendable { sessionkey: String?, runatms: Int?, durationms: Int?, - nextrunatms: Int?) + nextrunatms: Int?, + model: String?, + provider: String?, + usage: [String: AnyCodable]?, + jobname: String?) { self.ts = ts self.jobid = jobid @@ -2420,6 +2632,10 @@ public struct CronRunLogEntry: Codable, Sendable { self.runatms = runatms self.durationms = durationms self.nextrunatms = nextrunatms + self.model = model + self.provider = provider + self.usage = usage + self.jobname = jobname } private enum CodingKeys: String, CodingKey { @@ -2437,6 +2653,10 @@ public struct CronRunLogEntry: Codable, Sendable { case runatms = "runAtMs" case durationms = "durationMs" case nextrunatms = "nextRunAtMs" + case model + case provider + case usage + case jobname = "jobName" } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 2909418d0c3..cb5bc976d4c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2170,6 +2170,132 @@ public struct SkillsStatusParams: Codable, Sendable { } } +public struct ToolsCatalogParams: Codable, Sendable { + public let agentid: String? + public let includeplugins: Bool? + + public init( + agentid: String?, + includeplugins: Bool?) + { + self.agentid = agentid + self.includeplugins = includeplugins + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case includeplugins = "includePlugins" + } +} + +public struct ToolCatalogProfile: Codable, Sendable { + public let id: AnyCodable + public let label: String + + public init( + id: AnyCodable, + label: String) + { + self.id = id + self.label = label + } + + private enum CodingKeys: String, CodingKey { + case id + case label + } +} + +public struct ToolCatalogEntry: Codable, Sendable { + public let id: String + public let label: String + public let description: String + public let source: AnyCodable + public let pluginid: String? + public let optional: Bool? + public let defaultprofiles: [AnyCodable] + + public init( + id: String, + label: String, + description: String, + source: AnyCodable, + pluginid: String?, + optional: Bool?, + defaultprofiles: [AnyCodable]) + { + self.id = id + self.label = label + self.description = description + self.source = source + self.pluginid = pluginid + self.optional = optional + self.defaultprofiles = defaultprofiles + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case description + case source + case pluginid = "pluginId" + case optional + case defaultprofiles = "defaultProfiles" + } +} + +public struct ToolCatalogGroup: Codable, Sendable { + public let id: String + public let label: String + public let source: AnyCodable + public let pluginid: String? + public let tools: [ToolCatalogEntry] + + public init( + id: String, + label: String, + source: AnyCodable, + pluginid: String?, + tools: [ToolCatalogEntry]) + { + self.id = id + self.label = label + self.source = source + self.pluginid = pluginid + self.tools = tools + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case source + case pluginid = "pluginId" + case tools + } +} + +public struct ToolsCatalogResult: Codable, Sendable { + public let agentid: String + public let profiles: [ToolCatalogProfile] + public let groups: [ToolCatalogGroup] + + public init( + agentid: String, + profiles: [ToolCatalogProfile], + groups: [ToolCatalogGroup]) + { + self.agentid = agentid + self.profiles = profiles + self.groups = groups + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case profiles + case groups + } +} + public struct SkillsBinsParams: Codable, Sendable {} public struct SkillsBinsResult: Codable, Sendable { @@ -2306,15 +2432,39 @@ public struct CronJob: Codable, Sendable { public struct CronListParams: Codable, Sendable { public let includedisabled: Bool? + public let limit: Int? + public let offset: Int? + public let query: String? + public let enabled: AnyCodable? + public let sortby: AnyCodable? + public let sortdir: AnyCodable? public init( - includedisabled: Bool?) + includedisabled: Bool?, + limit: Int?, + offset: Int?, + query: String?, + enabled: AnyCodable?, + sortby: AnyCodable?, + sortdir: AnyCodable?) { self.includedisabled = includedisabled + self.limit = limit + self.offset = offset + self.query = query + self.enabled = enabled + self.sortby = sortby + self.sortdir = sortdir } private enum CodingKeys: String, CodingKey { case includedisabled = "includeDisabled" + case limit + case offset + case query + case enabled + case sortby = "sortBy" + case sortdir = "sortDir" } } @@ -2374,6 +2524,60 @@ public struct CronAddParams: Codable, Sendable { } } +public struct CronRunsParams: Codable, Sendable { + public let scope: AnyCodable? + public let id: String? + public let jobid: String? + public let limit: Int? + public let offset: Int? + public let statuses: [AnyCodable]? + public let status: AnyCodable? + public let deliverystatuses: [AnyCodable]? + public let deliverystatus: AnyCodable? + public let query: String? + public let sortdir: AnyCodable? + + public init( + scope: AnyCodable?, + id: String?, + jobid: String?, + limit: Int?, + offset: Int?, + statuses: [AnyCodable]?, + status: AnyCodable?, + deliverystatuses: [AnyCodable]?, + deliverystatus: AnyCodable?, + query: String?, + sortdir: AnyCodable?) + { + self.scope = scope + self.id = id + self.jobid = jobid + self.limit = limit + self.offset = offset + self.statuses = statuses + self.status = status + self.deliverystatuses = deliverystatuses + self.deliverystatus = deliverystatus + self.query = query + self.sortdir = sortdir + } + + private enum CodingKeys: String, CodingKey { + case scope + case id + case jobid = "jobId" + case limit + case offset + case statuses + case status + case deliverystatuses = "deliveryStatuses" + case deliverystatus = "deliveryStatus" + case query + case sortdir = "sortDir" + } +} + public struct CronRunLogEntry: Codable, Sendable { public let ts: Int public let jobid: String @@ -2389,6 +2593,10 @@ public struct CronRunLogEntry: Codable, Sendable { public let runatms: Int? public let durationms: Int? public let nextrunatms: Int? + public let model: String? + public let provider: String? + public let usage: [String: AnyCodable]? + public let jobname: String? public init( ts: Int, @@ -2404,7 +2612,11 @@ public struct CronRunLogEntry: Codable, Sendable { sessionkey: String?, runatms: Int?, durationms: Int?, - nextrunatms: Int?) + nextrunatms: Int?, + model: String?, + provider: String?, + usage: [String: AnyCodable]?, + jobname: String?) { self.ts = ts self.jobid = jobid @@ -2420,6 +2632,10 @@ public struct CronRunLogEntry: Codable, Sendable { self.runatms = runatms self.durationms = durationms self.nextrunatms = nextrunatms + self.model = model + self.provider = provider + self.usage = usage + self.jobname = jobname } private enum CodingKeys: String, CodingKey { @@ -2437,6 +2653,10 @@ public struct CronRunLogEntry: Codable, Sendable { case runatms = "runAtMs" case durationms = "durationMs" case nextrunatms = "nextRunAtMs" + case model + case provider + case usage + case jobname = "jobName" } } diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 8bcedbe0631..85a69aca679 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -170,6 +170,14 @@ The Gateway treats these as **claims** and enforces server-side allowlists. - Nodes may call `skills.bins` to fetch the current list of skill executables for auto-allow checks. +### Operator helper methods + +- Operators may call `tools.catalog` (`operator.read`) to fetch the runtime tool catalog for an + agent. The response includes grouped tools and provenance metadata: + - `source`: `core` or `plugin` + - `pluginId`: plugin owner when `source="plugin"` + - `optional`: whether a plugin tool is optional + ## Exec approvals - When an exec request needs approval, the gateway broadcasts `exec.approval.requested`. diff --git a/docs/web/webchat.md b/docs/web/webchat.md index 9853e372159..307a69a8dcf 100644 --- a/docs/web/webchat.md +++ b/docs/web/webchat.md @@ -31,6 +31,14 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket. - History is always fetched from the gateway (no local file watching). - If the gateway is unreachable, WebChat is read-only. +## Control UI agents tools panel + +- The Control UI `/agents` Tools panel fetches a runtime catalog via `tools.catalog` and labels each + tool as `core` or `plugin:` (plus `optional` for optional plugin tools). +- If `tools.catalog` is unavailable, the panel falls back to a built-in static list. +- The panel edits profile and override config, but effective runtime access still follows policy + precedence (`allow`/`deny`, per-agent and provider/channel overrides). + ## Remote use - Remote mode tunnels the gateway WebSocket over SSH/Tailscale. diff --git a/src/agents/tool-catalog.ts b/src/agents/tool-catalog.ts new file mode 100644 index 00000000000..0b0d37ae5ed --- /dev/null +++ b/src/agents/tool-catalog.ts @@ -0,0 +1,322 @@ +export type ToolProfileId = "minimal" | "coding" | "messaging" | "full"; + +type ToolProfilePolicy = { + allow?: string[]; + deny?: string[]; +}; + +export type CoreToolSection = { + id: string; + label: string; + tools: Array<{ + id: string; + label: string; + description: string; + }>; +}; + +type CoreToolDefinition = { + id: string; + label: string; + description: string; + sectionId: string; + profiles: ToolProfileId[]; + includeInOpenClawGroup?: boolean; +}; + +const CORE_TOOL_SECTION_ORDER: Array<{ id: string; label: string }> = [ + { id: "fs", label: "Files" }, + { id: "runtime", label: "Runtime" }, + { id: "web", label: "Web" }, + { id: "memory", label: "Memory" }, + { id: "sessions", label: "Sessions" }, + { id: "ui", label: "UI" }, + { id: "messaging", label: "Messaging" }, + { id: "automation", label: "Automation" }, + { id: "nodes", label: "Nodes" }, + { id: "agents", label: "Agents" }, + { id: "media", label: "Media" }, +]; + +const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [ + { + id: "read", + label: "read", + description: "Read file contents", + sectionId: "fs", + profiles: ["coding"], + }, + { + id: "write", + label: "write", + description: "Create or overwrite files", + sectionId: "fs", + profiles: ["coding"], + }, + { + id: "edit", + label: "edit", + description: "Make precise edits", + sectionId: "fs", + profiles: ["coding"], + }, + { + id: "apply_patch", + label: "apply_patch", + description: "Patch files (OpenAI)", + sectionId: "fs", + profiles: ["coding"], + }, + { + id: "exec", + label: "exec", + description: "Run shell commands", + sectionId: "runtime", + profiles: ["coding"], + }, + { + id: "process", + label: "process", + description: "Manage background processes", + sectionId: "runtime", + profiles: ["coding"], + }, + { + id: "web_search", + label: "web_search", + description: "Search the web", + sectionId: "web", + profiles: [], + includeInOpenClawGroup: true, + }, + { + id: "web_fetch", + label: "web_fetch", + description: "Fetch web content", + sectionId: "web", + profiles: [], + includeInOpenClawGroup: true, + }, + { + id: "memory_search", + label: "memory_search", + description: "Semantic search", + sectionId: "memory", + profiles: ["coding"], + includeInOpenClawGroup: true, + }, + { + id: "memory_get", + label: "memory_get", + description: "Read memory files", + sectionId: "memory", + profiles: ["coding"], + includeInOpenClawGroup: true, + }, + { + id: "sessions_list", + label: "sessions_list", + description: "List sessions", + sectionId: "sessions", + profiles: ["coding", "messaging"], + includeInOpenClawGroup: true, + }, + { + id: "sessions_history", + label: "sessions_history", + description: "Session history", + sectionId: "sessions", + profiles: ["coding", "messaging"], + includeInOpenClawGroup: true, + }, + { + id: "sessions_send", + label: "sessions_send", + description: "Send to session", + sectionId: "sessions", + profiles: ["coding", "messaging"], + includeInOpenClawGroup: true, + }, + { + id: "sessions_spawn", + label: "sessions_spawn", + description: "Spawn sub-agent", + sectionId: "sessions", + profiles: ["coding"], + includeInOpenClawGroup: true, + }, + { + id: "subagents", + label: "subagents", + description: "Manage sub-agents", + sectionId: "sessions", + profiles: ["coding"], + includeInOpenClawGroup: true, + }, + { + id: "session_status", + label: "session_status", + description: "Session status", + sectionId: "sessions", + profiles: ["minimal", "coding", "messaging"], + includeInOpenClawGroup: true, + }, + { + id: "browser", + label: "browser", + description: "Control web browser", + sectionId: "ui", + profiles: [], + includeInOpenClawGroup: true, + }, + { + id: "canvas", + label: "canvas", + description: "Control canvases", + sectionId: "ui", + profiles: [], + includeInOpenClawGroup: true, + }, + { + id: "message", + label: "message", + description: "Send messages", + sectionId: "messaging", + profiles: ["messaging"], + includeInOpenClawGroup: true, + }, + { + id: "cron", + label: "cron", + description: "Schedule tasks", + sectionId: "automation", + profiles: [], + includeInOpenClawGroup: true, + }, + { + id: "gateway", + label: "gateway", + description: "Gateway control", + sectionId: "automation", + profiles: [], + includeInOpenClawGroup: true, + }, + { + id: "nodes", + label: "nodes", + description: "Nodes + devices", + sectionId: "nodes", + profiles: [], + includeInOpenClawGroup: true, + }, + { + id: "agents_list", + label: "agents_list", + description: "List agents", + sectionId: "agents", + profiles: [], + includeInOpenClawGroup: true, + }, + { + id: "image", + label: "image", + description: "Image understanding", + sectionId: "media", + profiles: ["coding"], + includeInOpenClawGroup: true, + }, + { + id: "tts", + label: "tts", + description: "Text-to-speech conversion", + sectionId: "media", + profiles: [], + includeInOpenClawGroup: true, + }, +]; + +const CORE_TOOL_BY_ID = new Map( + CORE_TOOL_DEFINITIONS.map((tool) => [tool.id, tool]), +); + +function listCoreToolIdsForProfile(profile: ToolProfileId): string[] { + return CORE_TOOL_DEFINITIONS.filter((tool) => tool.profiles.includes(profile)).map( + (tool) => tool.id, + ); +} + +const CORE_TOOL_PROFILES: Record = { + minimal: { + allow: listCoreToolIdsForProfile("minimal"), + }, + coding: { + allow: listCoreToolIdsForProfile("coding"), + }, + messaging: { + allow: listCoreToolIdsForProfile("messaging"), + }, + full: {}, +}; + +function buildCoreToolGroupMap() { + const sectionToolMap = new Map(); + for (const tool of CORE_TOOL_DEFINITIONS) { + const groupId = `group:${tool.sectionId}`; + const list = sectionToolMap.get(groupId) ?? []; + list.push(tool.id); + sectionToolMap.set(groupId, list); + } + const openclawTools = CORE_TOOL_DEFINITIONS.filter((tool) => tool.includeInOpenClawGroup).map( + (tool) => tool.id, + ); + return { + "group:openclaw": openclawTools, + ...Object.fromEntries(sectionToolMap.entries()), + }; +} + +export const CORE_TOOL_GROUPS = buildCoreToolGroupMap(); + +export const PROFILE_OPTIONS = [ + { id: "minimal", label: "Minimal" }, + { id: "coding", label: "Coding" }, + { id: "messaging", label: "Messaging" }, + { id: "full", label: "Full" }, +] as const; + +export function resolveCoreToolProfilePolicy(profile?: string): ToolProfilePolicy | undefined { + if (!profile) { + return undefined; + } + const resolved = CORE_TOOL_PROFILES[profile as ToolProfileId]; + if (!resolved) { + return undefined; + } + if (!resolved.allow && !resolved.deny) { + return undefined; + } + return { + allow: resolved.allow ? [...resolved.allow] : undefined, + deny: resolved.deny ? [...resolved.deny] : undefined, + }; +} + +export function listCoreToolSections(): CoreToolSection[] { + return CORE_TOOL_SECTION_ORDER.map((section) => ({ + id: section.id, + label: section.label, + tools: CORE_TOOL_DEFINITIONS.filter((tool) => tool.sectionId === section.id).map((tool) => ({ + id: tool.id, + label: tool.label, + description: tool.description, + })), + })).filter((section) => section.tools.length > 0); +} + +export function resolveCoreToolProfiles(toolId: string): ToolProfileId[] { + const tool = CORE_TOOL_BY_ID.get(toolId); + if (!tool) { + return []; + } + return [...tool.profiles]; +} diff --git a/src/agents/tool-policy-shared.ts b/src/agents/tool-policy-shared.ts index 0bfee5cecaa..e28c623de44 100644 --- a/src/agents/tool-policy-shared.ts +++ b/src/agents/tool-policy-shared.ts @@ -1,4 +1,8 @@ -export type ToolProfileId = "minimal" | "coding" | "messaging" | "full"; +import { + CORE_TOOL_GROUPS, + resolveCoreToolProfilePolicy, + type ToolProfileId, +} from "./tool-catalog.js"; type ToolProfilePolicy = { allow?: string[]; @@ -10,72 +14,7 @@ const TOOL_NAME_ALIASES: Record = { "apply-patch": "apply_patch", }; -export const TOOL_GROUPS: Record = { - // NOTE: Keep canonical (lowercase) tool names here. - "group:memory": ["memory_search", "memory_get"], - "group:web": ["web_search", "web_fetch"], - // Basic workspace/file tools - "group:fs": ["read", "write", "edit", "apply_patch"], - // Host/runtime execution tools - "group:runtime": ["exec", "process"], - // Session management tools - "group:sessions": [ - "sessions_list", - "sessions_history", - "sessions_send", - "sessions_spawn", - "subagents", - "session_status", - ], - // UI helpers - "group:ui": ["browser", "canvas"], - // Automation + infra - "group:automation": ["cron", "gateway"], - // Messaging surface - "group:messaging": ["message"], - // Nodes + device tools - "group:nodes": ["nodes"], - // All OpenClaw native tools (excludes provider plugins). - "group:openclaw": [ - "browser", - "canvas", - "nodes", - "cron", - "message", - "gateway", - "agents_list", - "sessions_list", - "sessions_history", - "sessions_send", - "sessions_spawn", - "subagents", - "session_status", - "memory_search", - "memory_get", - "web_search", - "web_fetch", - "image", - ], -}; - -const TOOL_PROFILES: Record = { - minimal: { - allow: ["session_status"], - }, - coding: { - allow: ["group:fs", "group:runtime", "group:sessions", "group:memory", "image"], - }, - messaging: { - allow: [ - "group:messaging", - "sessions_list", - "sessions_history", - "sessions_send", - "session_status", - ], - }, - full: {}, -}; +export const TOOL_GROUPS: Record = { ...CORE_TOOL_GROUPS }; export function normalizeToolName(name: string) { const normalized = name.trim().toLowerCase(); @@ -104,18 +43,7 @@ export function expandToolGroups(list?: string[]) { } export function resolveToolProfilePolicy(profile?: string): ToolProfilePolicy | undefined { - if (!profile) { - return undefined; - } - const resolved = TOOL_PROFILES[profile as ToolProfileId]; - if (!resolved) { - return undefined; - } - if (!resolved.allow && !resolved.deny) { - return undefined; - } - return { - allow: resolved.allow ? [...resolved.allow] : undefined, - deny: resolved.deny ? [...resolved.deny] : undefined, - }; + return resolveCoreToolProfilePolicy(profile); } + +export type { ToolProfileId }; diff --git a/src/agents/tool-policy.test.ts b/src/agents/tool-policy.test.ts index cf6ab15d341..e2fe0a4d112 100644 --- a/src/agents/tool-policy.test.ts +++ b/src/agents/tool-policy.test.ts @@ -55,7 +55,7 @@ describe("tool-policy", () => { it("resolves known profiles and ignores unknown ones", () => { const coding = resolveToolProfilePolicy("coding"); - expect(coding?.allow).toContain("group:fs"); + expect(coding?.allow).toContain("read"); expect(resolveToolProfilePolicy("nope")).toBeUndefined(); }); @@ -65,6 +65,7 @@ describe("tool-policy", () => { expect(group).toContain("message"); expect(group).toContain("subagents"); expect(group).toContain("session_status"); + expect(group).toContain("tts"); }); it("normalizes tool names and aliases", () => { diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 20629c3d1c0..843f97e1174 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -51,6 +51,7 @@ const METHOD_SCOPE_GROUPS: Record = { "tts.status", "tts.providers", "models.list", + "tools.catalog", "agents.list", "agent.identity.get", "skills.status", diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index e8daa0f4dbc..d595ae55529 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -195,6 +195,9 @@ import { SkillsStatusParamsSchema, type SkillsUpdateParams, SkillsUpdateParamsSchema, + type ToolsCatalogParams, + ToolsCatalogParamsSchema, + type ToolsCatalogResult, type Snapshot, SnapshotSchema, type StateVersion, @@ -319,6 +322,7 @@ export const validateChannelsLogoutParams = ajv.compile( ); export const validateModelsListParams = ajv.compile(ModelsListParamsSchema); export const validateSkillsStatusParams = ajv.compile(SkillsStatusParamsSchema); +export const validateToolsCatalogParams = ajv.compile(ToolsCatalogParamsSchema); export const validateSkillsBinsParams = ajv.compile(SkillsBinsParamsSchema); export const validateSkillsInstallParams = ajv.compile(SkillsInstallParamsSchema); @@ -487,6 +491,7 @@ export { AgentsListResultSchema, ModelsListParamsSchema, SkillsStatusParamsSchema, + ToolsCatalogParamsSchema, SkillsInstallParamsSchema, SkillsUpdateParamsSchema, CronJobSchema, @@ -575,6 +580,8 @@ export type { AgentsListParams, AgentsListResult, SkillsStatusParams, + ToolsCatalogParams, + ToolsCatalogResult, SkillsBinsParams, SkillsBinsResult, SkillsInstallParams, diff --git a/src/gateway/protocol/schema/agents-models-skills.ts b/src/gateway/protocol/schema/agents-models-skills.ts index aaa886dd5e0..20ce701e08c 100644 --- a/src/gateway/protocol/schema/agents-models-skills.ts +++ b/src/gateway/protocol/schema/agents-models-skills.ts @@ -207,3 +207,64 @@ export const SkillsUpdateParamsSchema = Type.Object( }, { additionalProperties: false }, ); + +export const ToolsCatalogParamsSchema = Type.Object( + { + agentId: Type.Optional(NonEmptyString), + includePlugins: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +); + +export const ToolCatalogProfileSchema = Type.Object( + { + id: Type.Union([ + Type.Literal("minimal"), + Type.Literal("coding"), + Type.Literal("messaging"), + Type.Literal("full"), + ]), + label: NonEmptyString, + }, + { additionalProperties: false }, +); + +export const ToolCatalogEntrySchema = Type.Object( + { + id: NonEmptyString, + label: NonEmptyString, + description: Type.String(), + source: Type.Union([Type.Literal("core"), Type.Literal("plugin")]), + pluginId: Type.Optional(NonEmptyString), + optional: Type.Optional(Type.Boolean()), + defaultProfiles: Type.Array( + Type.Union([ + Type.Literal("minimal"), + Type.Literal("coding"), + Type.Literal("messaging"), + Type.Literal("full"), + ]), + ), + }, + { additionalProperties: false }, +); + +export const ToolCatalogGroupSchema = Type.Object( + { + id: NonEmptyString, + label: NonEmptyString, + source: Type.Union([Type.Literal("core"), Type.Literal("plugin")]), + pluginId: Type.Optional(NonEmptyString), + tools: Type.Array(ToolCatalogEntrySchema), + }, + { additionalProperties: false }, +); + +export const ToolsCatalogResultSchema = Type.Object( + { + agentId: NonEmptyString, + profiles: Type.Array(ToolCatalogProfileSchema), + groups: Type.Array(ToolCatalogGroupSchema), + }, + { additionalProperties: false }, +); diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 312b62314b3..fcddef1eec5 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -34,6 +34,11 @@ import { SkillsInstallParamsSchema, SkillsStatusParamsSchema, SkillsUpdateParamsSchema, + ToolCatalogEntrySchema, + ToolCatalogGroupSchema, + ToolCatalogProfileSchema, + ToolsCatalogParamsSchema, + ToolsCatalogResultSchema, } from "./agents-models-skills.js"; import { ChannelsLogoutParamsSchema, @@ -224,6 +229,11 @@ export const ProtocolSchemas: Record = { ModelsListParams: ModelsListParamsSchema, ModelsListResult: ModelsListResultSchema, SkillsStatusParams: SkillsStatusParamsSchema, + ToolsCatalogParams: ToolsCatalogParamsSchema, + ToolCatalogProfile: ToolCatalogProfileSchema, + ToolCatalogEntry: ToolCatalogEntrySchema, + ToolCatalogGroup: ToolCatalogGroupSchema, + ToolsCatalogResult: ToolsCatalogResultSchema, SkillsBinsParams: SkillsBinsParamsSchema, SkillsBinsResult: SkillsBinsResultSchema, SkillsInstallParams: SkillsInstallParamsSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index 311a7b1f0e7..126aadc2921 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -32,6 +32,11 @@ import type { SkillsInstallParamsSchema, SkillsStatusParamsSchema, SkillsUpdateParamsSchema, + ToolCatalogEntrySchema, + ToolCatalogGroupSchema, + ToolCatalogProfileSchema, + ToolsCatalogParamsSchema, + ToolsCatalogResultSchema, } from "./agents-models-skills.js"; import type { ChannelsLogoutParamsSchema, @@ -213,6 +218,11 @@ export type ModelChoice = Static; export type ModelsListParams = Static; export type ModelsListResult = Static; export type SkillsStatusParams = Static; +export type ToolsCatalogParams = Static; +export type ToolCatalogProfile = Static; +export type ToolCatalogEntry = Static; +export type ToolCatalogGroup = Static; +export type ToolsCatalogResult = Static; export type SkillsBinsParams = Static; export type SkillsBinsResult = Static; export type SkillsInstallParams = Static; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 31c9046c3b1..c41707b3966 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -34,6 +34,7 @@ const BASE_METHODS = [ "talk.config", "talk.mode", "models.list", + "tools.catalog", "agents.list", "agents.create", "agents.update", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 60a6662102b..423f87e2ca9 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -23,6 +23,7 @@ import { sessionsHandlers } from "./server-methods/sessions.js"; import { skillsHandlers } from "./server-methods/skills.js"; import { systemHandlers } from "./server-methods/system.js"; import { talkHandlers } from "./server-methods/talk.js"; +import { toolsCatalogHandlers } from "./server-methods/tools-catalog.js"; import { ttsHandlers } from "./server-methods/tts.js"; import type { GatewayRequestHandlers, GatewayRequestOptions } from "./server-methods/types.js"; import { updateHandlers } from "./server-methods/update.js"; @@ -76,6 +77,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...configHandlers, ...wizardHandlers, ...talkHandlers, + ...toolsCatalogHandlers, ...ttsHandlers, ...skillsHandlers, ...sessionsHandlers, diff --git a/src/gateway/server-methods/tools-catalog.test.ts b/src/gateway/server-methods/tools-catalog.test.ts new file mode 100644 index 00000000000..70fcdcdf85e --- /dev/null +++ b/src/gateway/server-methods/tools-catalog.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ErrorCodes } from "../protocol/index.js"; +import { toolsCatalogHandlers } from "./tools-catalog.js"; + +vi.mock("../../config/config.js", () => ({ + loadConfig: vi.fn(() => ({})), +})); + +vi.mock("../../agents/agent-scope.js", () => ({ + listAgentIds: vi.fn(() => ["main"]), + resolveDefaultAgentId: vi.fn(() => "main"), + resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace-main"), + resolveAgentDir: vi.fn(() => "/tmp/agents/main/agent"), +})); + +const pluginToolMetaState = new Map(); + +vi.mock("../../plugins/tools.js", () => ({ + resolvePluginTools: vi.fn(() => [ + { name: "voice_call", label: "voice_call", description: "Plugin calling tool" }, + { name: "matrix_room", label: "matrix_room", description: "Matrix room helper" }, + ]), + getPluginToolMeta: vi.fn((tool: { name: string }) => pluginToolMetaState.get(tool.name)), +})); + +type RespondCall = [boolean, unknown?, { code: number; message: string }?]; + +function createInvokeParams(params: Record) { + const respond = vi.fn(); + return { + respond, + invoke: async () => + await toolsCatalogHandlers["tools.catalog"]({ + params, + respond: respond as never, + context: {} as never, + client: null, + req: { type: "req", id: "req-1", method: "tools.catalog" }, + isWebchatConnect: () => false, + }), + }; +} + +describe("tools.catalog handler", () => { + beforeEach(() => { + pluginToolMetaState.clear(); + pluginToolMetaState.set("voice_call", { pluginId: "voice-call", optional: true }); + pluginToolMetaState.set("matrix_room", { pluginId: "matrix", optional: false }); + }); + + it("rejects invalid params", async () => { + const { respond, invoke } = createInvokeParams({ extra: true }); + await invoke(); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST); + expect(call?.[2]?.message).toContain("invalid tools.catalog params"); + }); + + it("rejects unknown agent ids", async () => { + const { respond, invoke } = createInvokeParams({ agentId: "unknown-agent" }); + await invoke(); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST); + expect(call?.[2]?.message).toContain("unknown agent id"); + }); + + it("returns core groups including tts and excludes plugins when includePlugins=false", async () => { + const { respond, invoke } = createInvokeParams({ includePlugins: false }); + await invoke(); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(true); + const payload = call?.[1] as + | { + agentId: string; + groups: Array<{ + id: string; + source: "core" | "plugin"; + tools: Array<{ id: string; source: "core" | "plugin" }>; + }>; + } + | undefined; + expect(payload?.agentId).toBe("main"); + expect(payload?.groups.some((group) => group.source === "plugin")).toBe(false); + const media = payload?.groups.find((group) => group.id === "media"); + expect(media?.tools.some((tool) => tool.id === "tts" && tool.source === "core")).toBe(true); + }); + + it("includes plugin groups with plugin metadata", async () => { + const { respond, invoke } = createInvokeParams({}); + await invoke(); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(true); + const payload = call?.[1] as + | { + groups: Array<{ + source: "core" | "plugin"; + pluginId?: string; + tools: Array<{ + id: string; + source: "core" | "plugin"; + pluginId?: string; + optional?: boolean; + }>; + }>; + } + | undefined; + const pluginGroups = (payload?.groups ?? []).filter((group) => group.source === "plugin"); + expect(pluginGroups.length).toBeGreaterThan(0); + const voiceCall = pluginGroups + .flatMap((group) => group.tools) + .find((tool) => tool.id === "voice_call"); + expect(voiceCall).toMatchObject({ + source: "plugin", + pluginId: "voice-call", + optional: true, + }); + }); +}); diff --git a/src/gateway/server-methods/tools-catalog.ts b/src/gateway/server-methods/tools-catalog.ts new file mode 100644 index 00000000000..42c539be002 --- /dev/null +++ b/src/gateway/server-methods/tools-catalog.ts @@ -0,0 +1,165 @@ +import { + listAgentIds, + resolveAgentDir, + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../../agents/agent-scope.js"; +import { + listCoreToolSections, + PROFILE_OPTIONS, + resolveCoreToolProfiles, +} from "../../agents/tool-catalog.js"; +import { loadConfig } from "../../config/config.js"; +import { getPluginToolMeta, resolvePluginTools } from "../../plugins/tools.js"; +import { + ErrorCodes, + errorShape, + formatValidationErrors, + validateToolsCatalogParams, +} from "../protocol/index.js"; +import type { GatewayRequestHandlers, RespondFn } from "./types.js"; + +type ToolCatalogEntry = { + id: string; + label: string; + description: string; + source: "core" | "plugin"; + pluginId?: string; + optional?: boolean; + defaultProfiles: Array<"minimal" | "coding" | "messaging" | "full">; +}; + +type ToolCatalogGroup = { + id: string; + label: string; + source: "core" | "plugin"; + pluginId?: string; + tools: ToolCatalogEntry[]; +}; + +function resolveAgentIdOrRespondError(rawAgentId: unknown, respond: RespondFn) { + const cfg = loadConfig(); + const knownAgents = listAgentIds(cfg); + const requestedAgentId = typeof rawAgentId === "string" ? rawAgentId.trim() : ""; + const agentId = requestedAgentId || resolveDefaultAgentId(cfg); + if (requestedAgentId && !knownAgents.includes(agentId)) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown agent id "${requestedAgentId}"`), + ); + return null; + } + return { cfg, agentId }; +} + +function buildCoreGroups(): ToolCatalogGroup[] { + return listCoreToolSections().map((section) => ({ + id: section.id, + label: section.label, + source: "core", + tools: section.tools.map((tool) => ({ + id: tool.id, + label: tool.label, + description: tool.description, + source: "core", + defaultProfiles: resolveCoreToolProfiles(tool.id), + })), + })); +} + +function buildPluginGroups(params: { + cfg: ReturnType; + agentId: string; + existingToolNames: Set; +}): ToolCatalogGroup[] { + const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId); + const agentDir = resolveAgentDir(params.cfg, params.agentId); + const pluginTools = resolvePluginTools({ + context: { + config: params.cfg, + workspaceDir, + agentDir, + agentId: params.agentId, + }, + existingToolNames: params.existingToolNames, + toolAllowlist: ["group:plugins"], + }); + const groups = new Map(); + for (const tool of pluginTools) { + const meta = getPluginToolMeta(tool); + const pluginId = meta?.pluginId ?? "plugin"; + const groupId = `plugin:${pluginId}`; + const existing = + groups.get(groupId) ?? + ({ + id: groupId, + label: pluginId, + source: "plugin", + pluginId, + tools: [], + } as ToolCatalogGroup); + existing.tools.push({ + id: tool.name, + label: typeof tool.label === "string" && tool.label.trim() ? tool.label.trim() : tool.name, + description: + typeof tool.description === "string" && tool.description.trim() + ? tool.description.trim() + : "Plugin tool", + source: "plugin", + pluginId, + optional: meta?.optional, + defaultProfiles: [], + }); + groups.set(groupId, existing); + } + return [...groups.values()] + .map((group) => ({ + ...group, + tools: group.tools.toSorted((a, b) => a.id.localeCompare(b.id)), + })) + .toSorted((a, b) => a.label.localeCompare(b.label)); +} + +export const toolsCatalogHandlers: GatewayRequestHandlers = { + "tools.catalog": ({ params, respond }) => { + if (!validateToolsCatalogParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid tools.catalog params: ${formatValidationErrors(validateToolsCatalogParams.errors)}`, + ), + ); + return; + } + const resolved = resolveAgentIdOrRespondError(params.agentId, respond); + if (!resolved) { + return; + } + const includePlugins = params.includePlugins !== false; + const groups = buildCoreGroups(); + if (includePlugins) { + const existingToolNames = new Set( + groups.flatMap((group) => group.tools.map((tool) => tool.id)), + ); + groups.push( + ...buildPluginGroups({ + cfg: resolved.cfg, + agentId: resolved.agentId, + existingToolNames, + }), + ); + } + respond( + true, + { + agentId: resolved.agentId, + profiles: PROFILE_OPTIONS.map((profile) => ({ id: profile.id, label: profile.label })), + groups, + }, + undefined, + ); + }, +}; diff --git a/src/gateway/server.tools-catalog.test.ts b/src/gateway/server.tools-catalog.test.ts new file mode 100644 index 00000000000..9171dafb53f --- /dev/null +++ b/src/gateway/server.tools-catalog.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { connectOk, installGatewayTestHooks, rpcReq } from "./test-helpers.js"; +import { withServer } from "./test-with-server.js"; + +installGatewayTestHooks({ scope: "suite" }); + +describe("gateway tools.catalog", () => { + it("returns core catalog data and includes tts", async () => { + await withServer(async (ws) => { + await connectOk(ws, { token: "secret", scopes: ["operator.read"] }); + const res = await rpcReq<{ + agentId?: string; + groups?: Array<{ + id?: string; + source?: "core" | "plugin"; + tools?: Array<{ id?: string; source?: "core" | "plugin" }>; + }>; + }>(ws, "tools.catalog", {}); + + expect(res.ok).toBe(true); + expect(res.payload?.agentId).toBeTruthy(); + const mediaGroup = res.payload?.groups?.find((group) => group.id === "media"); + expect(mediaGroup?.tools?.some((tool) => tool.id === "tts" && tool.source === "core")).toBe( + true, + ); + }); + }); + + it("supports includePlugins=false and rejects unknown agent ids", async () => { + await withServer(async (ws) => { + await connectOk(ws, { token: "secret", scopes: ["operator.read"] }); + + const noPlugins = await rpcReq<{ + groups?: Array<{ source?: "core" | "plugin" }>; + }>(ws, "tools.catalog", { includePlugins: false }); + expect(noPlugins.ok).toBe(true); + expect((noPlugins.payload?.groups ?? []).every((group) => group.source !== "plugin")).toBe( + true, + ); + + const unknownAgent = await rpcReq(ws, "tools.catalog", { agentId: "does-not-exist" }); + expect(unknownAgent.ok).toBe(false); + expect(unknownAgent.error?.message ?? "").toContain("unknown agent id"); + }); + }); +}); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 897e7d39c1b..aa324c32b4c 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -13,7 +13,7 @@ import { import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream.ts"; import type { OpenClawApp } from "./app.ts"; import { shouldReloadHistoryForFinalEvent } from "./chat-event-reload.ts"; -import { loadAgents } from "./controllers/agents.ts"; +import { loadAgents, loadToolsCatalog } from "./controllers/agents.ts"; import { loadAssistantIdentity } from "./controllers/assistant-identity.ts"; import { loadChatHistory } from "./controllers/chat.ts"; import { handleChatEvent, type ChatEventPayload } from "./controllers/chat.ts"; @@ -62,6 +62,9 @@ type GatewayHost = { agentsLoading: boolean; agentsList: AgentsListResult | null; agentsError: string | null; + toolsCatalogLoading: boolean; + toolsCatalogError: string | null; + toolsCatalogResult: import("./types.ts").ToolsCatalogResult | null; debugHealth: HealthSnapshot | null; assistantName: string; assistantAvatar: string | null; @@ -166,6 +169,7 @@ export function connectGateway(host: GatewayHost) { resetToolStream(host as unknown as Parameters[0]); void loadAssistantIdentity(host as unknown as OpenClawApp); void loadAgents(host as unknown as OpenClawApp); + void loadToolsCatalog(host as unknown as OpenClawApp); void loadNodes(host as unknown as OpenClawApp, { quiet: true }); void loadDevices(host as unknown as OpenClawApp, { quiet: true }); void refreshActiveTab(host as unknown as Parameters[0]); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 56b266fb17b..d4f8fee89bf 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -8,7 +8,7 @@ import type { AppViewState } from "./app-view-state.ts"; import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts"; import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts"; import { loadAgentSkills } from "./controllers/agent-skills.ts"; -import { loadAgents } from "./controllers/agents.ts"; +import { loadAgents, loadToolsCatalog } from "./controllers/agents.ts"; import { loadChannels } from "./controllers/channels.ts"; import { loadChatHistory } from "./controllers/chat.ts"; import { @@ -528,9 +528,18 @@ export function renderApp(state: AppViewState) { agentSkillsReport: state.agentSkillsReport, agentSkillsError: state.agentSkillsError, agentSkillsAgentId: state.agentSkillsAgentId, + toolsCatalogLoading: state.toolsCatalogLoading, + toolsCatalogError: state.toolsCatalogError, + toolsCatalogResult: state.toolsCatalogResult, skillsFilter: state.skillsFilter, onRefresh: async () => { await loadAgents(state); + const nextSelected = + state.agentsSelectedId ?? + state.agentsList?.defaultId ?? + state.agentsList?.agents?.[0]?.id ?? + null; + await loadToolsCatalog(state, nextSelected); const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? []; if (agentIds.length > 0) { void loadAgentIdentities(state, agentIds); @@ -551,6 +560,9 @@ export function renderApp(state: AppViewState) { state.agentSkillsError = null; state.agentSkillsAgentId = null; void loadAgentIdentity(state, agentId); + if (state.agentsPanel === "tools") { + void loadToolsCatalog(state, agentId); + } if (state.agentsPanel === "files") { void loadAgentFiles(state, agentId); } @@ -570,6 +582,9 @@ export function renderApp(state: AppViewState) { void loadAgentFiles(state, resolvedAgentId); } } + if (panel === "tools") { + void loadToolsCatalog(state, resolvedAgentId); + } if (panel === "skills") { if (resolvedAgentId) { void loadAgentSkills(state, resolvedAgentId); diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 5828a13ea9b..31e8678b038 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -9,7 +9,7 @@ import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll.ts"; import type { OpenClawApp } from "./app.ts"; import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts"; import { loadAgentSkills } from "./controllers/agent-skills.ts"; -import { loadAgents } from "./controllers/agents.ts"; +import { loadAgents, loadToolsCatalog } from "./controllers/agents.ts"; import { loadChannels } from "./controllers/channels.ts"; import { loadConfig, loadConfigSchema } from "./controllers/config.ts"; import { @@ -204,6 +204,7 @@ export async function refreshActiveTab(host: SettingsHost) { } if (host.tab === "agents") { await loadAgents(host as unknown as OpenClawApp); + await loadToolsCatalog(host as unknown as OpenClawApp); await loadConfig(host as unknown as OpenClawApp); const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? []; if (agentIds.length > 0) { diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 1dcc0abeec6..03a47768864 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -37,6 +37,7 @@ import type { SessionUsageTimeSeries, SessionsListResult, SkillStatusReport, + ToolsCatalogResult, StatusSummary, } from "./types.ts"; import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types.ts"; @@ -137,6 +138,9 @@ export type AppViewState = { agentsList: AgentsListResult | null; agentsError: string | null; agentsSelectedId: string | null; + toolsCatalogLoading: boolean; + toolsCatalogError: string | null; + toolsCatalogResult: ToolsCatalogResult | null; agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron"; agentFilesLoading: boolean; agentFilesError: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index ae3e5e507e2..af0f3b6538e 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -78,6 +78,7 @@ import type { ChannelsStatusSnapshot, SessionsListResult, SkillStatusReport, + ToolsCatalogResult, StatusSummary, NostrProfile, } from "./types.ts"; @@ -217,6 +218,9 @@ export class OpenClawApp extends LitElement { @state() agentsList: AgentsListResult | null = null; @state() agentsError: string | null = null; @state() agentsSelectedId: string | null = null; + @state() toolsCatalogLoading = false; + @state() toolsCatalogError: string | null = null; + @state() toolsCatalogResult: ToolsCatalogResult | null = null; @state() agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron" = "overview"; @state() agentFilesLoading = false; diff --git a/ui/src/ui/controllers/agents.test.ts b/ui/src/ui/controllers/agents.test.ts new file mode 100644 index 00000000000..669f62d6362 --- /dev/null +++ b/ui/src/ui/controllers/agents.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadToolsCatalog } from "./agents.ts"; +import type { AgentsState } from "./agents.ts"; + +function createState(): { state: AgentsState; request: ReturnType } { + const request = vi.fn(); + const state: AgentsState = { + client: { + request, + } as unknown as AgentsState["client"], + connected: true, + agentsLoading: false, + agentsError: null, + agentsList: null, + agentsSelectedId: "main", + toolsCatalogLoading: false, + toolsCatalogError: null, + toolsCatalogResult: null, + }; + return { state, request }; +} + +describe("loadToolsCatalog", () => { + it("loads catalog and stores result", async () => { + const { state, request } = createState(); + const payload = { + agentId: "main", + profiles: [{ id: "full", label: "Full" }], + groups: [ + { + id: "media", + label: "Media", + source: "core", + tools: [{ id: "tts", label: "tts", description: "Text-to-speech", source: "core" }], + }, + ], + }; + request.mockResolvedValue(payload); + + await loadToolsCatalog(state, "main"); + + expect(request).toHaveBeenCalledWith("tools.catalog", { + agentId: "main", + includePlugins: true, + }); + expect(state.toolsCatalogResult).toEqual(payload); + expect(state.toolsCatalogError).toBeNull(); + expect(state.toolsCatalogLoading).toBe(false); + }); + + it("captures request errors for fallback UI handling", async () => { + const { state, request } = createState(); + request.mockRejectedValue(new Error("gateway unavailable")); + + await loadToolsCatalog(state, "main"); + + expect(state.toolsCatalogResult).toBeNull(); + expect(state.toolsCatalogError).toContain("gateway unavailable"); + expect(state.toolsCatalogLoading).toBe(false); + }); +}); diff --git a/ui/src/ui/controllers/agents.ts b/ui/src/ui/controllers/agents.ts index e63cd9b60b7..69fd091847f 100644 --- a/ui/src/ui/controllers/agents.ts +++ b/ui/src/ui/controllers/agents.ts @@ -1,5 +1,5 @@ import type { GatewayBrowserClient } from "../gateway.ts"; -import type { AgentsListResult } from "../types.ts"; +import type { AgentsListResult, ToolsCatalogResult } from "../types.ts"; export type AgentsState = { client: GatewayBrowserClient | null; @@ -8,6 +8,9 @@ export type AgentsState = { agentsError: string | null; agentsList: AgentsListResult | null; agentsSelectedId: string | null; + toolsCatalogLoading: boolean; + toolsCatalogError: string | null; + toolsCatalogResult: ToolsCatalogResult | null; }; export async function loadAgents(state: AgentsState) { @@ -35,3 +38,27 @@ export async function loadAgents(state: AgentsState) { state.agentsLoading = false; } } + +export async function loadToolsCatalog(state: AgentsState, agentId?: string | null) { + if (!state.client || !state.connected) { + return; + } + if (state.toolsCatalogLoading) { + return; + } + state.toolsCatalogLoading = true; + state.toolsCatalogError = null; + try { + const res = await state.client.request("tools.catalog", { + agentId: agentId ?? state.agentsSelectedId ?? undefined, + includePlugins: true, + }); + if (res) { + state.toolsCatalogResult = res; + } + } catch (err) { + state.toolsCatalogError = String(err); + } finally { + state.toolsCatalogLoading = false; + } +} diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 012d1cc236d..3c4091479b4 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -345,6 +345,35 @@ export type AgentsListResult = { agents: GatewayAgentRow[]; }; +export type ToolCatalogProfile = { + id: "minimal" | "coding" | "messaging" | "full"; + label: string; +}; + +export type ToolCatalogEntry = { + id: string; + label: string; + description: string; + source: "core" | "plugin"; + pluginId?: string; + optional?: boolean; + defaultProfiles: Array<"minimal" | "coding" | "messaging" | "full">; +}; + +export type ToolCatalogGroup = { + id: string; + label: string; + source: "core" | "plugin"; + pluginId?: string; + tools: ToolCatalogEntry[]; +}; + +export type ToolsCatalogResult = { + agentId: string; + profiles: ToolCatalogProfile[]; + groups: ToolCatalogGroup[]; +}; + export type AgentIdentityResult = { agentId: string; name: string; diff --git a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts new file mode 100644 index 00000000000..1917e982e44 --- /dev/null +++ b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts @@ -0,0 +1,102 @@ +import { render } from "lit"; +import { describe, expect, it } from "vitest"; +import { renderAgentTools } from "./agents-panels-tools-skills.ts"; + +function createBaseParams(overrides: Partial[0]> = {}) { + return { + agentId: "main", + configForm: { + agents: { + list: [{ id: "main", tools: { profile: "full" } }], + }, + } as Record, + configLoading: false, + configSaving: false, + configDirty: false, + toolsCatalogLoading: false, + toolsCatalogError: null, + toolsCatalogResult: null, + onProfileChange: () => undefined, + onOverridesChange: () => undefined, + onConfigReload: () => undefined, + onConfigSave: () => undefined, + ...overrides, + }; +} + +describe("agents tools panel (browser)", () => { + it("renders per-tool provenance badges and optional marker", async () => { + const container = document.createElement("div"); + render( + renderAgentTools( + createBaseParams({ + toolsCatalogResult: { + agentId: "main", + profiles: [ + { id: "minimal", label: "Minimal" }, + { id: "coding", label: "Coding" }, + { id: "messaging", label: "Messaging" }, + { id: "full", label: "Full" }, + ], + groups: [ + { + id: "media", + label: "Media", + source: "core", + tools: [ + { + id: "tts", + label: "tts", + description: "Text-to-speech conversion", + source: "core", + defaultProfiles: [], + }, + ], + }, + { + id: "plugin:voice-call", + label: "voice-call", + source: "plugin", + pluginId: "voice-call", + tools: [ + { + id: "voice_call", + label: "voice_call", + description: "Voice call tool", + source: "plugin", + pluginId: "voice-call", + optional: true, + defaultProfiles: [], + }, + ], + }, + ], + }, + }), + ), + container, + ); + await Promise.resolve(); + + const text = container.textContent ?? ""; + expect(text).toContain("core"); + expect(text).toContain("plugin:voice-call"); + expect(text).toContain("optional"); + }); + + it("shows fallback warning when runtime catalog fails", async () => { + const container = document.createElement("div"); + render( + renderAgentTools( + createBaseParams({ + toolsCatalogError: "unavailable", + toolsCatalogResult: null, + }), + ), + container, + ); + await Promise.resolve(); + + expect(container.textContent ?? "").toContain("Could not load runtime tool catalog"); + }); +}); diff --git a/ui/src/ui/views/agents-panels-tools-skills.ts b/ui/src/ui/views/agents-panels-tools-skills.ts index 687ec749a62..4e25aaefc31 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.ts @@ -1,6 +1,6 @@ import { html, nothing } from "lit"; import { normalizeToolName } from "../../../../src/agents/tool-policy-shared.js"; -import type { SkillStatusEntry, SkillStatusReport } from "../types.ts"; +import type { SkillStatusEntry, SkillStatusReport, ToolsCatalogResult } from "../types.ts"; import { isAllowedByPolicy, matchesList, @@ -23,6 +23,9 @@ export function renderAgentTools(params: { configLoading: boolean; configSaving: boolean; configDirty: boolean; + toolsCatalogLoading: boolean; + toolsCatalogError: string | null; + toolsCatalogResult: ToolsCatalogResult | null; onProfileChange: (agentId: string, profile: string | null, clearAllow: boolean) => void; onOverridesChange: (agentId: string, alsoAllow: string[], deny: string[]) => void; onConfigReload: () => void; @@ -50,7 +53,17 @@ export function renderAgentTools(params: { const basePolicy = hasAgentAllow ? { allow: agentTools.allow ?? [], deny: agentTools.deny ?? [] } : (resolveToolProfile(profile) ?? undefined); - const toolIds = TOOL_SECTIONS.flatMap((section) => section.tools.map((tool) => tool.id)); + const sections = + params.toolsCatalogResult?.groups?.length && + params.toolsCatalogResult.agentId === params.agentId + ? params.toolsCatalogResult.groups + : TOOL_SECTIONS; + const profileOptions = + params.toolsCatalogResult?.profiles?.length && + params.toolsCatalogResult.agentId === params.agentId + ? params.toolsCatalogResult.profiles + : PROFILE_OPTIONS; + const toolIds = sections.flatMap((section) => section.tools.map((tool) => tool.id)); const resolveAllowed = (toolId: string) => { const baseAllowed = isAllowedByPolicy(toolId, basePolicy); @@ -139,6 +152,15 @@ export function renderAgentTools(params: { + ${ + params.toolsCatalogError + ? html` +
+ Could not load runtime tool catalog. Showing fallback list. +
+ ` + : nothing + } ${ !params.configForm ? html` @@ -191,7 +213,7 @@ export function renderAgentTools(params: {
Quick Presets
- ${PROFILE_OPTIONS.map( + ${profileOptions.map( (option) => html`