From 9c7823350bf61ca2998920c72b31be3fd2de92c6 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:09:51 -0500 Subject: [PATCH] feat: add /tools runtime availability view (#54088) * test(memory): lock qmd status counts regression * feat: make /tools show what the agent can use right now * fix: sync web ui slash commands with the shared registry * feat: add profile and unavailable counts to /tools * refine: keep /tools focused on available tools * fix: resolve /tools review regressions * fix: honor model compat in /tools inventory * fix: sync generated protocol models for /tools * fix: restore canonical slash command names * fix: avoid ci lint drift in google helper exports * perf: stop computing unused /tools unavailable counts * docs: clarify /tools runtime behavior --- CHANGELOG.md | 1 + .../OpenClawProtocol/GatewayModels.swift | 104 +++ .../OpenClawProtocol/GatewayModels.swift | 104 +++ docs/.generated/plugin-sdk-api-baseline.json | 8 +- docs/.generated/plugin-sdk-api-baseline.jsonl | 8 +- docs/gateway/protocol.md | 7 + docs/tools/slash-commands.md | 17 + docs/web/webchat.md | 12 +- src/agents/channel-tools.ts | 20 + src/agents/pi-tools.abort.ts | 7 +- src/agents/pi-tools.before-tool-call.ts | 4 + src/agents/pi-tools.schema.ts | 19 +- src/agents/tool-description-summary.ts | 138 +++ ...ls-effective-inventory.integration.test.ts | 116 +++ src/agents/tools-effective-inventory.test.ts | 213 +++++ src/agents/tools-effective-inventory.ts | 231 +++++ src/agents/tools/common.ts | 1 + src/agents/tools/cron-tool.ts | 1 + src/agents/tools/message-tool.ts | 1 + src/agents/tools/tts-tool.ts | 1 + src/auto-reply/commands-registry.data.ts | 802 +---------------- src/auto-reply/commands-registry.shared.ts | 823 ++++++++++++++++++ .../reply/commands-handlers.runtime.ts | 2 + .../reply/commands-info.tools.test.ts | 237 +++++ src/auto-reply/reply/commands-info.ts | 107 +++ src/auto-reply/status.tools.test.ts | 146 ++++ src/auto-reply/status.ts | 91 +- src/gateway/method-scopes.ts | 1 + src/gateway/protocol/index.ts | 9 + .../protocol/schema/agents-models-skills.ts | 40 + .../protocol/schema/protocol-schemas.ts | 8 + src/gateway/protocol/schema/types.ts | 4 + src/gateway/server-methods-list.ts | 1 + src/gateway/server-methods.ts | 2 + src/gateway/server-methods/tools-catalog.ts | 50 +- .../server-methods/tools-effective.test.ts | 234 +++++ src/gateway/server-methods/tools-effective.ts | 159 ++++ src/gateway/server.tools-effective.test.ts | 52 ++ src/memory/search-manager.test.ts | 31 + src/plugin-sdk/google.ts | 6 +- src/plugin-sdk/provider-google.ts | 6 +- src/plugins/tools.ts | 7 + ui/src/ui/app-chat.ts | 10 +- ui/src/ui/app-render.ts | 54 +- ui/src/ui/app-view-state.ts | 5 + ui/src/ui/app.ts | 24 + ui/src/ui/chat/slash-command-executor.ts | 2 +- ui/src/ui/chat/slash-commands.node.test.ts | 49 ++ ui/src/ui/chat/slash-commands.ts | 306 ++++--- ui/src/ui/controllers/agents.test.ts | 57 +- ui/src/ui/controllers/agents.ts | 58 +- ui/src/ui/types.ts | 6 + ...agents-panels-tools-skills.browser.test.ts | 43 + ui/src/ui/views/agents-panels-tools-skills.ts | 86 +- ui/src/ui/views/agents.test.ts | 7 + ui/src/ui/views/agents.ts | 16 + 56 files changed, 3565 insertions(+), 989 deletions(-) create mode 100644 src/agents/tool-description-summary.ts create mode 100644 src/agents/tools-effective-inventory.integration.test.ts create mode 100644 src/agents/tools-effective-inventory.test.ts create mode 100644 src/agents/tools-effective-inventory.ts create mode 100644 src/auto-reply/commands-registry.shared.ts create mode 100644 src/auto-reply/reply/commands-info.tools.test.ts create mode 100644 src/auto-reply/status.tools.test.ts create mode 100644 src/gateway/server-methods/tools-effective.test.ts create mode 100644 src/gateway/server-methods/tools-effective.ts create mode 100644 src/gateway/server.tools-effective.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fdda4eaa4f2..091a5dd2cad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Agents/tools: make `/tools` show the tools the current agent can actually use right now, add a compact default view with an optional detailed mode, and add a live “Available Right Now” section in the Control UI so it is easier to see what will work before you ask. - Control UI/markdown preview: restyle the agent workspace file preview dialog with a frosted backdrop, sized panel, and styled header, and integrate `@create-markdown/preview` v2 system theme for rich markdown rendering (headings, tables, code blocks, callouts, blockquotes) that auto-adapts to the app's light/dark design tokens. (#53411) Thanks @BunsDev. - Skills/install metadata: add one-click install recipes to bundled skills (coding-agent, gh-issues, openai-whisper-api, session-logs, tmux, trello, weather) so the CLI and Control UI can offer dependency installation when requirements are missing. (#53411) Thanks @BunsDev. - CLI/skills: soften missing-requirements label from "missing" to "needs setup" and surface API key setup guidance (where to get a key, CLI save command, storage path) in `openclaw skills info` output. (#53411) Thanks @BunsDev. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index f07039bcda9..924b284babd 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -2764,6 +2764,110 @@ public struct ToolsCatalogResult: Codable, Sendable { } } +public struct ToolsEffectiveParams: Codable, Sendable { + public let agentid: String? + public let sessionkey: String + + public init( + agentid: String?, + sessionkey: String) + { + self.agentid = agentid + self.sessionkey = sessionkey + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case sessionkey = "sessionKey" + } +} + +public struct ToolsEffectiveEntry: Codable, Sendable { + public let id: String + public let label: String + public let description: String + public let rawdescription: String + public let source: AnyCodable + public let pluginid: String? + public let channelid: String? + + public init( + id: String, + label: String, + description: String, + rawdescription: String, + source: AnyCodable, + pluginid: String?, + channelid: String?) + { + self.id = id + self.label = label + self.description = description + self.rawdescription = rawdescription + self.source = source + self.pluginid = pluginid + self.channelid = channelid + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case description + case rawdescription = "rawDescription" + case source + case pluginid = "pluginId" + case channelid = "channelId" + } +} + +public struct ToolsEffectiveGroup: Codable, Sendable { + public let id: AnyCodable + public let label: String + public let source: AnyCodable + public let tools: [ToolsEffectiveEntry] + + public init( + id: AnyCodable, + label: String, + source: AnyCodable, + tools: [ToolsEffectiveEntry]) + { + self.id = id + self.label = label + self.source = source + self.tools = tools + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case source + case tools + } +} + +public struct ToolsEffectiveResult: Codable, Sendable { + public let agentid: String + public let profile: String + public let groups: [ToolsEffectiveGroup] + + public init( + agentid: String, + profile: String, + groups: [ToolsEffectiveGroup]) + { + self.agentid = agentid + self.profile = profile + self.groups = groups + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case profile + case groups + } +} + public struct SkillsBinsParams: Codable, Sendable {} public struct SkillsBinsResult: Codable, Sendable { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index f07039bcda9..924b284babd 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2764,6 +2764,110 @@ public struct ToolsCatalogResult: Codable, Sendable { } } +public struct ToolsEffectiveParams: Codable, Sendable { + public let agentid: String? + public let sessionkey: String + + public init( + agentid: String?, + sessionkey: String) + { + self.agentid = agentid + self.sessionkey = sessionkey + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case sessionkey = "sessionKey" + } +} + +public struct ToolsEffectiveEntry: Codable, Sendable { + public let id: String + public let label: String + public let description: String + public let rawdescription: String + public let source: AnyCodable + public let pluginid: String? + public let channelid: String? + + public init( + id: String, + label: String, + description: String, + rawdescription: String, + source: AnyCodable, + pluginid: String?, + channelid: String?) + { + self.id = id + self.label = label + self.description = description + self.rawdescription = rawdescription + self.source = source + self.pluginid = pluginid + self.channelid = channelid + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case description + case rawdescription = "rawDescription" + case source + case pluginid = "pluginId" + case channelid = "channelId" + } +} + +public struct ToolsEffectiveGroup: Codable, Sendable { + public let id: AnyCodable + public let label: String + public let source: AnyCodable + public let tools: [ToolsEffectiveEntry] + + public init( + id: AnyCodable, + label: String, + source: AnyCodable, + tools: [ToolsEffectiveEntry]) + { + self.id = id + self.label = label + self.source = source + self.tools = tools + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case source + case tools + } +} + +public struct ToolsEffectiveResult: Codable, Sendable { + public let agentid: String + public let profile: String + public let groups: [ToolsEffectiveGroup] + + public init( + agentid: String, + profile: String, + groups: [ToolsEffectiveGroup]) + { + self.agentid = agentid + self.profile = profile + self.groups = groups + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case profile + case groups + } +} + public struct SkillsBinsParams: Codable, Sendable {} public struct SkillsBinsResult: Codable, Sendable { diff --git a/docs/.generated/plugin-sdk-api-baseline.json b/docs/.generated/plugin-sdk-api-baseline.json index e163dad9cb4..18cc09cdfc5 100644 --- a/docs/.generated/plugin-sdk-api-baseline.json +++ b/docs/.generated/plugin-sdk-api-baseline.json @@ -2359,7 +2359,7 @@ "exportName": "buildCommandsMessage", "kind": "function", "source": { - "line": 961, + "line": 1049, "path": "src/auto-reply/status.ts" } }, @@ -2368,7 +2368,7 @@ "exportName": "buildCommandsMessagePaginated", "kind": "function", "source": { - "line": 970, + "line": 1058, "path": "src/auto-reply/status.ts" } }, @@ -2377,7 +2377,7 @@ "exportName": "buildCommandsPaginationKeyboard", "kind": "function", "source": { - "line": 89, + "line": 196, "path": "src/auto-reply/reply/commands-info.ts" } }, @@ -2404,7 +2404,7 @@ "exportName": "buildHelpMessage", "kind": "function", "source": { - "line": 841, + "line": 844, "path": "src/auto-reply/status.ts" } }, diff --git a/docs/.generated/plugin-sdk-api-baseline.jsonl b/docs/.generated/plugin-sdk-api-baseline.jsonl index fffb2c63c22..7a659652dda 100644 --- a/docs/.generated/plugin-sdk-api-baseline.jsonl +++ b/docs/.generated/plugin-sdk-api-baseline.jsonl @@ -258,12 +258,12 @@ {"declaration":"export type ChannelSetupWizard = ChannelSetupWizard;","entrypoint":"channel-setup","exportName":"ChannelSetupWizard","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":247,"sourcePath":"src/channels/plugins/setup-wizard.ts"} {"declaration":"export type OptionalChannelSetupSurface = OptionalChannelSetupSurface;","entrypoint":"channel-setup","exportName":"OptionalChannelSetupSurface","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":29,"sourcePath":"src/plugin-sdk/channel-setup.ts"} {"category":"channel","entrypoint":"command-auth","importSpecifier":"openclaw/plugin-sdk/command-auth","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/command-auth.ts"} -{"declaration":"export function buildCommandsMessage(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandsMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":961,"sourcePath":"src/auto-reply/status.ts"} -{"declaration":"export function buildCommandsMessagePaginated(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): CommandsMessageResult;","entrypoint":"command-auth","exportName":"buildCommandsMessagePaginated","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":970,"sourcePath":"src/auto-reply/status.ts"} -{"declaration":"export function buildCommandsPaginationKeyboard(currentPage: number, totalPages: number, agentId?: string | undefined): { text: string; callback_data: string; }[][];","entrypoint":"command-auth","exportName":"buildCommandsPaginationKeyboard","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":89,"sourcePath":"src/auto-reply/reply/commands-info.ts"} +{"declaration":"export function buildCommandsMessage(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandsMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":1049,"sourcePath":"src/auto-reply/status.ts"} +{"declaration":"export function buildCommandsMessagePaginated(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): CommandsMessageResult;","entrypoint":"command-auth","exportName":"buildCommandsMessagePaginated","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":1058,"sourcePath":"src/auto-reply/status.ts"} +{"declaration":"export function buildCommandsPaginationKeyboard(currentPage: number, totalPages: number, agentId?: string | undefined): { text: string; callback_data: string; }[][];","entrypoint":"command-auth","exportName":"buildCommandsPaginationKeyboard","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":196,"sourcePath":"src/auto-reply/reply/commands-info.ts"} {"declaration":"export function buildCommandText(commandName: string, args?: string | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandText","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":199,"sourcePath":"src/auto-reply/commands-registry.ts"} {"declaration":"export function buildCommandTextFromArgs(command: ChatCommandDefinition, args?: CommandArgs | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandTextFromArgs","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":291,"sourcePath":"src/auto-reply/commands-registry.ts"} -{"declaration":"export function buildHelpMessage(cfg?: OpenClawConfig | undefined): string;","entrypoint":"command-auth","exportName":"buildHelpMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":841,"sourcePath":"src/auto-reply/status.ts"} +{"declaration":"export function buildHelpMessage(cfg?: OpenClawConfig | undefined): string;","entrypoint":"command-auth","exportName":"buildHelpMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":844,"sourcePath":"src/auto-reply/status.ts"} {"declaration":"export function buildModelsProviderData(cfg: OpenClawConfig, agentId?: string | undefined): Promise;","entrypoint":"command-auth","exportName":"buildModelsProviderData","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":37,"sourcePath":"src/auto-reply/reply/commands-models.ts"} {"declaration":"export function createPreCryptoDirectDmAuthorizer(params: { resolveAccess: (senderId: string) => Promise>; issuePairingChallenge?: ((params: { ...; }) => Promise<...>) | undefined; onBlocked?: ((params: { ...; }) => void) | undefined; }): (input: { ...; }) => Promise<...>;","entrypoint":"command-auth","exportName":"createPreCryptoDirectDmAuthorizer","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":105,"sourcePath":"src/plugin-sdk/direct-dm.ts"} {"declaration":"export function findCommandByNativeName(name: string, provider?: string | undefined): ChatCommandDefinition | undefined;","entrypoint":"command-auth","exportName":"findCommandByNativeName","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":187,"sourcePath":"src/auto-reply/commands-registry.ts"} diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 9c886a31716..1f1f434c5b8 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -181,6 +181,13 @@ The Gateway treats these as **claims** and enforces server-side allowlists. - `source`: `core` or `plugin` - `pluginId`: plugin owner when `source="plugin"` - `optional`: whether a plugin tool is optional +- Operators may call `tools.effective` (`operator.read`) to fetch the runtime-effective tool + inventory for a session. + - `sessionKey` is required. + - The gateway derives trusted runtime context from the session server-side instead of accepting + caller-supplied auth or delivery context. + - The response is session-scoped and reflects what the active conversation can use right now, + including core, plugin, and channel tools. ## Exec approvals diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index beddb83a635..2221ddce68d 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -75,6 +75,7 @@ Text + native (when enabled): - `/help` - `/commands` +- `/tools [compact|verbose]` (show what the current agent can use right now; `verbose` adds descriptions) - `/skill [input]` (run a skill by name) - `/status` (show current status; includes provider usage/quota for the current model provider when available) - `/allowlist` (list/add/remove allowlist entries) @@ -157,6 +158,22 @@ Notes: - Example: `/prose` (OpenProse plugin) — see [OpenProse](/prose). - **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg. +## `/tools` + +`/tools` answers a runtime question, not a config question: **what this agent can use right now in +this conversation**. + +- Default `/tools` is compact and optimized for quick scanning. +- `/tools verbose` adds short descriptions. +- Native-command surfaces that support arguments expose the same mode switch as `compact|verbose`. +- Results are session-scoped, so changing agent, channel, thread, sender authorization, or model can + change the output. +- `/tools` includes tools that are actually reachable at runtime, including core tools, connected + plugin tools, and channel-owned tools. + +For profile and override editing, use the Control UI Tools panel or config/catalog surfaces instead +of treating `/tools` as a static catalog. + ## Usage surfaces (what shows where) - **Provider usage/quota** (example: “Claude 80% left”) shows up in `/status` for the current model provider when usage tracking is enabled. diff --git a/docs/web/webchat.md b/docs/web/webchat.md index 307a69a8dcf..fef901f4526 100644 --- a/docs/web/webchat.md +++ b/docs/web/webchat.md @@ -33,10 +33,14 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket. ## 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 +- The Control UI `/agents` Tools panel has two separate views: + - **Available Right Now** uses `tools.effective(sessionKey=...)` and shows what the current + session can actually use at runtime, including core, plugin, and channel-owned tools. + - **Tool Configuration** uses `tools.catalog` and stays focused on profiles, overrides, and + catalog semantics. +- Runtime availability is session-scoped. Switching sessions on the same agent can change the + **Available Right Now** list. +- The config editor does not imply runtime availability; effective access still follows policy precedence (`allow`/`deny`, per-agent and provider/channel overrides). ## Remote use diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index c94204e8802..f9ad4f2f246 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -9,6 +9,23 @@ import type { ChannelAgentTool, ChannelMessageActionName } from "../channels/plu import { normalizeAnyChannelId } from "../channels/registry.js"; import type { OpenClawConfig } from "../config/config.js"; +type ChannelAgentToolMeta = { + channelId: string; +}; + +const channelAgentToolMeta = new WeakMap(); + +export function getChannelAgentToolMeta(tool: ChannelAgentTool): ChannelAgentToolMeta | undefined { + return channelAgentToolMeta.get(tool); +} + +export function copyChannelAgentToolMeta(source: ChannelAgentTool, target: ChannelAgentTool): void { + const meta = channelAgentToolMeta.get(source); + if (meta) { + channelAgentToolMeta.set(target, meta); + } +} + /** * Get the list of supported message actions for a specific channel. * Returns an empty array if channel is not found or has no actions configured. @@ -83,6 +100,9 @@ export function listChannelAgentTools(params: { cfg?: OpenClawConfig }): Channel } const resolved = typeof entry === "function" ? entry(params) : entry; if (Array.isArray(resolved)) { + for (const tool of resolved) { + channelAgentToolMeta.set(tool, { channelId: plugin.id }); + } tools.push(...resolved); } } diff --git a/src/agents/pi-tools.abort.ts b/src/agents/pi-tools.abort.ts index a1ff30ac4d1..887cb64e7d4 100644 --- a/src/agents/pi-tools.abort.ts +++ b/src/agents/pi-tools.abort.ts @@ -1,4 +1,6 @@ +import { copyPluginToolMeta } from "../plugins/tools.js"; import { bindAbortRelay } from "../utils/fetch-timeout.js"; +import { copyChannelAgentToolMeta } from "./channel-tools.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; function throwAbortError(): never { @@ -54,7 +56,7 @@ export function wrapToolWithAbortSignal( if (!execute) { return tool; } - return { + const wrappedTool: AnyAgentTool = { ...tool, execute: async (toolCallId, params, signal, onUpdate) => { const combined = combineAbortSignals(signal, abortSignal); @@ -64,4 +66,7 @@ export function wrapToolWithAbortSignal( return await execute(toolCallId, params, combined, onUpdate); }, }; + copyPluginToolMeta(tool, wrappedTool); + copyChannelAgentToolMeta(tool as never, wrappedTool as never); + return wrappedTool; } diff --git a/src/agents/pi-tools.before-tool-call.ts b/src/agents/pi-tools.before-tool-call.ts index 62bf0e0fb59..5e3184722cb 100644 --- a/src/agents/pi-tools.before-tool-call.ts +++ b/src/agents/pi-tools.before-tool-call.ts @@ -2,8 +2,10 @@ import type { ToolLoopDetectionConfig } from "../config/types.tools.js"; import type { SessionState } from "../logging/diagnostic-session-state.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; +import { copyPluginToolMeta } from "../plugins/tools.js"; import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { isPlainObject } from "../utils.js"; +import { copyChannelAgentToolMeta } from "./channel-tools.js"; import { normalizeToolName } from "./tool-policy.js"; import type { AnyAgentTool } from "./tools/common.js"; @@ -245,6 +247,8 @@ export function wrapToolWithBeforeToolCallHook( } }, }; + copyPluginToolMeta(tool, wrappedTool); + copyChannelAgentToolMeta(tool as never, wrappedTool as never); Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_WRAPPED, { value: true, enumerable: true, diff --git a/src/agents/pi-tools.schema.ts b/src/agents/pi-tools.schema.ts index 01288e75fe8..5e84bb4dfc0 100644 --- a/src/agents/pi-tools.schema.ts +++ b/src/agents/pi-tools.schema.ts @@ -1,4 +1,6 @@ import type { ModelCompatConfig } from "../config/types.models.js"; +import { copyPluginToolMeta } from "../plugins/tools.js"; +import { copyChannelAgentToolMeta } from "./channel-tools.js"; import { usesXaiToolSchemaProfile } from "./model-compat.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js"; @@ -69,6 +71,11 @@ export function normalizeToolParameters( tool: AnyAgentTool, options?: { modelProvider?: string; modelId?: string; modelCompat?: ModelCompatConfig }, ): AnyAgentTool { + function preserveToolMeta(target: AnyAgentTool): AnyAgentTool { + copyPluginToolMeta(tool, target); + copyChannelAgentToolMeta(tool as never, target as never); + return target; + } const schema = tool.parameters && typeof tool.parameters === "object" ? (tool.parameters as Record) @@ -105,10 +112,10 @@ export function normalizeToolParameters( // If schema already has type + properties (no top-level anyOf to merge), // clean it for Gemini/xAI compatibility as appropriate. if ("type" in schema && "properties" in schema && !Array.isArray(schema.anyOf)) { - return { + return preserveToolMeta({ ...tool, parameters: applyProviderCleaning(schema), - }; + }); } // Some tool schemas (esp. unions) may omit `type` at the top-level. If we see @@ -120,10 +127,10 @@ export function normalizeToolParameters( !Array.isArray(schema.oneOf) ) { const schemaWithType = { ...schema, type: "object" }; - return { + return preserveToolMeta({ ...tool, parameters: applyProviderCleaning(schemaWithType), - }; + }); } const variantKey = Array.isArray(schema.anyOf) @@ -189,7 +196,7 @@ export function normalizeToolParameters( additionalProperties: "additionalProperties" in schema ? schema.additionalProperties : true, }; - return { + return preserveToolMeta({ ...tool, // Flatten union schemas into a single object schema: // - Gemini doesn't allow top-level `type` together with `anyOf`. @@ -197,7 +204,7 @@ export function normalizeToolParameters( // - Anthropic accepts proper JSON Schema with constraints. // Merging properties preserves useful enums like `action` while keeping schemas portable. parameters: applyProviderCleaning(flattenedSchema), - }; + }); } /** diff --git a/src/agents/tool-description-summary.ts b/src/agents/tool-description-summary.ts new file mode 100644 index 00000000000..67d38cca095 --- /dev/null +++ b/src/agents/tool-description-summary.ts @@ -0,0 +1,138 @@ +function normalizeSummaryWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function truncateSummary(value: string, maxLen = 120): string { + if (value.length <= maxLen) { + return value; + } + const sliced = value.slice(0, maxLen - 3); + const boundary = sliced.lastIndexOf(" "); + const trimmed = (boundary >= 48 ? sliced.slice(0, boundary) : sliced).trimEnd(); + return `${trimmed}...`; +} + +export function isToolDocBlockStart(line: string): boolean { + const normalized = line.trim().toUpperCase(); + if (!normalized) { + return false; + } + if ( + normalized === "ACTIONS:" || + normalized === "JOB SCHEMA (FOR ADD ACTION):" || + normalized === "JOB SCHEMA:" || + normalized === "SESSION TARGET OPTIONS:" || + normalized === "DEFAULT BEHAVIOR (UNCHANGED FOR BACKWARD COMPATIBILITY):" || + normalized === "SCHEDULE TYPES (SCHEDULE.KIND):" || + normalized === "PAYLOAD TYPES (PAYLOAD.KIND):" || + normalized === "DELIVERY (TOP-LEVEL):" || + normalized === "CRITICAL CONSTRAINTS:" || + normalized === "WAKE MODES (FOR WAKE ACTION):" + ) { + return true; + } + return ( + normalized.endsWith(":") && normalized === normalized.toUpperCase() && normalized.length > 12 + ); +} + +export function summarizeToolDescriptionText(params: { + rawDescription?: string | null; + displaySummary?: string | null; + maxLen?: number; +}): string { + const explicit = typeof params.displaySummary === "string" ? params.displaySummary.trim() : ""; + if (explicit) { + return truncateSummary(normalizeSummaryWhitespace(explicit), params.maxLen); + } + + const raw = typeof params.rawDescription === "string" ? params.rawDescription.trim() : ""; + if (!raw) { + return "Tool"; + } + + const paragraphs = raw + .split(/\n\s*\n/g) + .map((part) => part.trim()) + .filter(Boolean); + for (const paragraph of paragraphs) { + const lines = paragraph + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + if (lines.length === 0) { + continue; + } + const first = lines[0] ?? ""; + if (!first || isToolDocBlockStart(first)) { + continue; + } + if (first.startsWith("{") || first.startsWith("[") || first.startsWith("- ")) { + continue; + } + return truncateSummary(normalizeSummaryWhitespace(first), params.maxLen); + } + + const firstLine = raw + .split("\n") + .map((line) => line.trim()) + .find( + (line) => + line.length > 0 && + !isToolDocBlockStart(line) && + !line.startsWith("{") && + !line.startsWith("[") && + !line.startsWith("- "), + ); + return firstLine ? truncateSummary(normalizeSummaryWhitespace(firstLine), params.maxLen) : "Tool"; +} + +export function describeToolForVerbose(params: { + rawDescription?: string | null; + fallback: string; + maxLen?: number; +}): string { + const raw = typeof params.rawDescription === "string" ? params.rawDescription.trim() : ""; + if (!raw) { + return params.fallback; + } + + const lines = raw.split("\n").map((line) => line.trimEnd()); + const kept: string[] = []; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + if (kept.length > 0 && kept.at(-1) !== "") { + kept.push(""); + } + continue; + } + if ( + isToolDocBlockStart(trimmed) || + trimmed.startsWith("{") || + trimmed.startsWith("[") || + trimmed.startsWith("- ") + ) { + break; + } + kept.push(trimmed); + if (kept.join(" ").length >= (params.maxLen ?? 320)) { + break; + } + } + + const normalized = kept + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + if (!normalized) { + return params.fallback; + } + const maxLen = params.maxLen ?? 320; + if (normalized.length <= maxLen) { + return normalized; + } + const sliced = normalized.slice(0, maxLen - 3); + const boundary = sliced.lastIndexOf(" "); + return `${(boundary >= Math.floor(maxLen / 2) ? sliced.slice(0, boundary) : sliced).trimEnd()}...`; +} diff --git a/src/agents/tools-effective-inventory.integration.test.ts b/src/agents/tools-effective-inventory.integration.test.ts new file mode 100644 index 00000000000..74093f84d0e --- /dev/null +++ b/src/agents/tools-effective-inventory.integration.test.ts @@ -0,0 +1,116 @@ +import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; +import { Type, type TSchema } from "@sinclair/typebox"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +function createWrappedTestTool(params: { + name: string; + label: string; + description: string; +}): AgentTool { + return { + name: params.name, + label: params.label, + description: params.description, + parameters: Type.Object({}, { additionalProperties: false }), + execute: async (): Promise> => ({ + content: [{ type: "text", text: "ok" }], + details: {}, + }), + } as AgentTool; +} + +describe("resolveEffectiveToolInventory integration", () => { + afterEach(() => { + vi.resetModules(); + }); + + it("preserves plugin and channel classification through the real tool wrapper pipeline", async () => { + vi.resetModules(); + vi.doUnmock("./tools-effective-inventory.js"); + vi.doUnmock("./pi-tools.js"); + vi.doUnmock("./agent-scope.js"); + vi.doUnmock("./channel-tools.js"); + vi.doUnmock("../plugins/registry-empty.js"); + vi.doUnmock("../plugins/runtime.js"); + vi.doUnmock("../plugins/tools.js"); + vi.doUnmock("../test-utils/channel-plugins.js"); + + const { createEmptyPluginRegistry } = await import("../plugins/registry-empty.js"); + const { resetPluginRuntimeStateForTest, setActivePluginRegistry } = + await import("../plugins/runtime.js"); + const { createChannelTestPluginBase } = await import("../test-utils/channel-plugins.js"); + const { resolveEffectiveToolInventory } = await import("./tools-effective-inventory.js"); + + const pluginTool = createWrappedTestTool({ + name: "docs_lookup", + label: "Docs Lookup", + description: "Search docs", + }); + const channelTool = createWrappedTestTool({ + name: "channel_action", + label: "Channel Action", + description: "Act in channel", + }); + + const channelPlugin = { + ...createChannelTestPluginBase({ + id: "telegram", + label: "Telegram", + capabilities: { chatTypes: ["direct"] }, + }), + agentTools: [channelTool], + }; + + const registry = createEmptyPluginRegistry(); + registry.tools.push({ + pluginId: "docs", + pluginName: "Docs", + factory: () => pluginTool, + names: ["docs_lookup"], + optional: false, + source: "test", + }); + registry.channels.push({ + pluginId: "telegram", + pluginName: "Telegram", + plugin: channelPlugin, + source: "test", + }); + registry.channelSetups.push({ + pluginId: "telegram", + pluginName: "Telegram", + plugin: channelPlugin, + source: "test", + enabled: true, + }); + setActivePluginRegistry(registry, "tools-effective-integration"); + + const result = resolveEffectiveToolInventory({ cfg: { plugins: { enabled: true } } }); + + const pluginGroup = result.groups.find((group) => group.source === "plugin"); + const channelGroup = result.groups.find((group) => group.source === "channel"); + const coreGroup = result.groups.find((group) => group.source === "core"); + + expect(pluginGroup?.tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "docs_lookup", + source: "plugin", + pluginId: "docs", + }), + ]), + ); + expect(channelGroup?.tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "channel_action", + source: "channel", + channelId: "telegram", + }), + ]), + ); + expect(coreGroup?.tools.some((tool) => tool.id === "docs_lookup")).toBe(false); + expect(coreGroup?.tools.some((tool) => tool.id === "channel_action")).toBe(false); + resetPluginRuntimeStateForTest(); + }); +}); diff --git a/src/agents/tools-effective-inventory.test.ts b/src/agents/tools-effective-inventory.test.ts new file mode 100644 index 00000000000..1c94f23f1fa --- /dev/null +++ b/src/agents/tools-effective-inventory.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it, vi } from "vitest"; + +async function loadHarness(options?: { + tools?: Array<{ name: string; label?: string; description?: string; displaySummary?: string }>; + createToolsMock?: ReturnType; + pluginMeta?: Record; + channelMeta?: Record; + effectivePolicy?: { profile?: string; providerProfile?: string }; + resolvedModelCompat?: Record; +}) { + vi.resetModules(); + vi.doMock("./agent-scope.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveSessionAgentId: () => "main", + resolveAgentWorkspaceDir: () => "/tmp/workspace-main", + resolveAgentDir: () => "/tmp/agents/main/agent", + }; + }); + const createToolsMock = + options?.createToolsMock ?? + vi.fn( + () => + options?.tools ?? [ + { name: "exec", label: "Exec", description: "Run shell commands" }, + { name: "docs_lookup", label: "Docs Lookup", description: "Search docs" }, + ], + ); + vi.doMock("./pi-tools.js", () => ({ + createOpenClawCodingTools: createToolsMock, + })); + vi.doMock("./pi-embedded-runner/model.js", () => ({ + resolveModel: vi.fn(() => ({ + model: options?.resolvedModelCompat ? { compat: options.resolvedModelCompat } : undefined, + authStorage: {} as never, + modelRegistry: {} as never, + })), + })); + vi.doMock("../plugins/tools.js", () => ({ + getPluginToolMeta: (tool: { name: string }) => options?.pluginMeta?.[tool.name], + })); + vi.doMock("./channel-tools.js", () => ({ + getChannelAgentToolMeta: (tool: { name: string }) => options?.channelMeta?.[tool.name], + })); + vi.doMock("./pi-tools.policy.js", () => ({ + resolveEffectiveToolPolicy: () => options?.effectivePolicy ?? {}, + })); + return await import("./tools-effective-inventory.js"); +} + +describe("resolveEffectiveToolInventory", () => { + it("groups core, plugin, and channel tools from the effective runtime set", async () => { + const { resolveEffectiveToolInventory } = await loadHarness({ + tools: [ + { name: "exec", label: "Exec", description: "Run shell commands" }, + { name: "docs_lookup", label: "Docs Lookup", description: "Search docs" }, + { name: "message_actions", label: "Message Actions", description: "Act on messages" }, + ], + pluginMeta: { docs_lookup: { pluginId: "docs" } }, + channelMeta: { message_actions: { channelId: "telegram" } }, + }); + + const result = resolveEffectiveToolInventory({ cfg: {} }); + + expect(result).toEqual({ + agentId: "main", + profile: "full", + groups: [ + { + id: "core", + label: "Built-in tools", + source: "core", + tools: [ + { + id: "exec", + label: "Exec", + description: "Run shell commands", + rawDescription: "Run shell commands", + source: "core", + }, + ], + }, + { + id: "plugin", + label: "Connected tools", + source: "plugin", + tools: [ + { + id: "docs_lookup", + label: "Docs Lookup", + description: "Search docs", + rawDescription: "Search docs", + source: "plugin", + pluginId: "docs", + }, + ], + }, + { + id: "channel", + label: "Channel tools", + source: "channel", + tools: [ + { + id: "message_actions", + label: "Message Actions", + description: "Act on messages", + rawDescription: "Act on messages", + source: "channel", + channelId: "telegram", + }, + ], + }, + ], + }); + }); + + it("disambiguates duplicate labels with source ids", async () => { + const { resolveEffectiveToolInventory } = await loadHarness({ + tools: [ + { name: "docs_lookup", label: "Lookup", description: "Search docs" }, + { name: "jira_lookup", label: "Lookup", description: "Search Jira" }, + ], + pluginMeta: { + docs_lookup: { pluginId: "docs" }, + jira_lookup: { pluginId: "jira" }, + }, + }); + + const result = resolveEffectiveToolInventory({ cfg: {} }); + const labels = result.groups.flatMap((group) => group.tools.map((tool) => tool.label)); + + expect(labels).toEqual(["Lookup (docs)", "Lookup (jira)"]); + }); + + it("prefers displaySummary over raw description", async () => { + const { resolveEffectiveToolInventory } = await loadHarness({ + tools: [ + { + name: "cron", + label: "Cron", + displaySummary: "Schedule and manage cron jobs.", + description: "Long raw description\n\nACTIONS:\n- status", + }, + ], + }); + + const result = resolveEffectiveToolInventory({ cfg: {} }); + + expect(result.groups[0]?.tools[0]).toEqual({ + id: "cron", + label: "Cron", + description: "Schedule and manage cron jobs.", + rawDescription: "Long raw description\n\nACTIONS:\n- status", + source: "core", + }); + }); + + it("falls back to a sanitized summary for multi-line raw descriptions", async () => { + const { resolveEffectiveToolInventory } = await loadHarness({ + tools: [ + { + name: "cron", + label: "Cron", + description: + "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events.\n\nACTIONS:\n- status: Check cron scheduler status\nJOB SCHEMA:\n{ ... }", + }, + ], + }); + + const result = resolveEffectiveToolInventory({ cfg: {} }); + + expect(result.groups[0]?.tools[0]?.description).toBe( + "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events.", + ); + expect(result.groups[0]?.tools[0]?.rawDescription).toContain("ACTIONS:"); + }); + + it("includes the resolved tool profile", async () => { + const { resolveEffectiveToolInventory } = await loadHarness({ + tools: [{ name: "exec", label: "Exec", description: "Run shell commands" }], + effectivePolicy: { profile: "minimal", providerProfile: "coding" }, + }); + + const result = resolveEffectiveToolInventory({ cfg: {} }); + + expect(result.profile).toBe("coding"); + }); + + it("passes resolved model compat into effective tool creation", async () => { + const createToolsMock = vi.fn(() => [ + { name: "exec", label: "Exec", description: "Run shell commands" }, + ]); + const { resolveEffectiveToolInventory } = await loadHarness({ + createToolsMock, + resolvedModelCompat: { supportsTools: true, supportsNativeWebSearch: true }, + }); + + resolveEffectiveToolInventory({ + cfg: {}, + agentDir: "/tmp/agents/main/agent", + modelProvider: "xai", + modelId: "grok-test", + }); + + expect(createToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ + allowGatewaySubagentBinding: true, + modelCompat: { supportsTools: true, supportsNativeWebSearch: true }, + }), + ); + }); +}); diff --git a/src/agents/tools-effective-inventory.ts b/src/agents/tools-effective-inventory.ts new file mode 100644 index 00000000000..72a4b1aeb4c --- /dev/null +++ b/src/agents/tools-effective-inventory.ts @@ -0,0 +1,231 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { getPluginToolMeta } from "../plugins/tools.js"; +import { resolveAgentDir, resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js"; +import { getChannelAgentToolMeta } from "./channel-tools.js"; +import { resolveModel } from "./pi-embedded-runner/model.js"; +import { createOpenClawCodingTools } from "./pi-tools.js"; +import { resolveEffectiveToolPolicy } from "./pi-tools.policy.js"; +import { summarizeToolDescriptionText } from "./tool-description-summary.js"; +import { resolveToolDisplay } from "./tool-display.js"; +import type { AnyAgentTool } from "./tools/common.js"; + +export type EffectiveToolSource = "core" | "plugin" | "channel"; + +export type EffectiveToolInventoryEntry = { + id: string; + label: string; + description: string; + rawDescription: string; + source: EffectiveToolSource; + pluginId?: string; + channelId?: string; +}; + +export type EffectiveToolInventoryGroup = { + id: EffectiveToolSource; + label: string; + source: EffectiveToolSource; + tools: EffectiveToolInventoryEntry[]; +}; + +export type EffectiveToolInventoryResult = { + agentId: string; + profile: string; + groups: EffectiveToolInventoryGroup[]; +}; + +export type ResolveEffectiveToolInventoryParams = { + cfg: OpenClawConfig; + agentId?: string; + sessionKey?: string; + workspaceDir?: string; + agentDir?: string; + messageProvider?: string; + senderIsOwner?: boolean; + senderId?: string | null; + senderName?: string | null; + senderUsername?: string | null; + senderE164?: string | null; + accountId?: string | null; + modelProvider?: string; + modelId?: string; + currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + groupId?: string | null; + groupChannel?: string | null; + groupSpace?: string | null; + replyToMode?: "off" | "first" | "all"; + modelHasVision?: boolean; + requireExplicitMessageTarget?: boolean; + disableMessageTool?: boolean; +}; + +function resolveEffectiveToolLabel(tool: AnyAgentTool): string { + const rawLabel = typeof tool.label === "string" ? tool.label.trim() : ""; + if (rawLabel && rawLabel.toLowerCase() !== tool.name.toLowerCase()) { + return rawLabel; + } + return resolveToolDisplay({ name: tool.name }).title; +} + +function resolveRawToolDescription(tool: AnyAgentTool): string { + return typeof tool.description === "string" ? tool.description.trim() : ""; +} + +function summarizeToolDescription(tool: AnyAgentTool): string { + return summarizeToolDescriptionText({ + rawDescription: resolveRawToolDescription(tool), + displaySummary: tool.displaySummary, + }); +} + +function resolveEffectiveToolSource(tool: AnyAgentTool): { + source: EffectiveToolSource; + pluginId?: string; + channelId?: string; +} { + const pluginMeta = getPluginToolMeta(tool); + if (pluginMeta) { + return { source: "plugin", pluginId: pluginMeta.pluginId }; + } + const channelMeta = getChannelAgentToolMeta(tool as never); + if (channelMeta) { + return { source: "channel", channelId: channelMeta.channelId }; + } + return { source: "core" }; +} + +function groupLabel(source: EffectiveToolSource): string { + switch (source) { + case "plugin": + return "Connected tools"; + case "channel": + return "Channel tools"; + default: + return "Built-in tools"; + } +} + +function disambiguateLabels(entries: EffectiveToolInventoryEntry[]): EffectiveToolInventoryEntry[] { + const counts = new Map(); + for (const entry of entries) { + counts.set(entry.label, (counts.get(entry.label) ?? 0) + 1); + } + return entries.map((entry) => { + if ((counts.get(entry.label) ?? 0) < 2) { + return entry; + } + const suffix = entry.pluginId ?? entry.channelId ?? entry.id; + return { ...entry, label: `${entry.label} (${suffix})` }; + }); +} + +function resolveEffectiveModelCompat(params: { + cfg: OpenClawConfig; + agentDir: string; + modelProvider?: string; + modelId?: string; +}) { + const provider = params.modelProvider?.trim(); + const modelId = params.modelId?.trim(); + if (!provider || !modelId) { + return undefined; + } + try { + return resolveModel(provider, modelId, params.agentDir, params.cfg).model?.compat; + } catch { + return undefined; + } +} + +export function resolveEffectiveToolInventory( + params: ResolveEffectiveToolInventoryParams, +): EffectiveToolInventoryResult { + const agentId = + params.agentId?.trim() || + resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg }); + const workspaceDir = params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, agentId); + const agentDir = params.agentDir ?? resolveAgentDir(params.cfg, agentId); + const modelCompat = resolveEffectiveModelCompat({ + cfg: params.cfg, + agentDir, + modelProvider: params.modelProvider, + modelId: params.modelId, + }); + + const effectiveTools = createOpenClawCodingTools({ + agentId, + sessionKey: params.sessionKey, + workspaceDir, + agentDir, + config: params.cfg, + modelProvider: params.modelProvider, + modelId: params.modelId, + modelCompat, + messageProvider: params.messageProvider, + senderIsOwner: params.senderIsOwner, + senderId: params.senderId, + senderName: params.senderName ?? undefined, + senderUsername: params.senderUsername ?? undefined, + senderE164: params.senderE164 ?? undefined, + agentAccountId: params.accountId ?? undefined, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + groupId: params.groupId ?? undefined, + groupChannel: params.groupChannel ?? undefined, + groupSpace: params.groupSpace ?? undefined, + replyToMode: params.replyToMode, + allowGatewaySubagentBinding: true, + modelHasVision: params.modelHasVision, + requireExplicitMessageTarget: params.requireExplicitMessageTarget, + disableMessageTool: params.disableMessageTool, + }); + const effectivePolicy = resolveEffectiveToolPolicy({ + config: params.cfg, + agentId, + sessionKey: params.sessionKey, + modelProvider: params.modelProvider, + modelId: params.modelId, + }); + const profile = effectivePolicy.providerProfile ?? effectivePolicy.profile ?? "full"; + + const entries = disambiguateLabels( + effectiveTools + .map((tool) => { + const source = resolveEffectiveToolSource(tool); + return { + id: tool.name, + label: resolveEffectiveToolLabel(tool), + description: summarizeToolDescription(tool), + rawDescription: resolveRawToolDescription(tool) || summarizeToolDescription(tool), + ...source, + } satisfies EffectiveToolInventoryEntry; + }) + .toSorted((a, b) => a.label.localeCompare(b.label)), + ); + const groupsBySource = new Map(); + for (const entry of entries) { + const tools = groupsBySource.get(entry.source) ?? []; + tools.push(entry); + groupsBySource.set(entry.source, tools); + } + + const groups = (["core", "plugin", "channel"] as const) + .map((source) => { + const tools = groupsBySource.get(source); + if (!tools || tools.length === 0) { + return null; + } + return { + id: source, + label: groupLabel(source), + source, + tools, + } satisfies EffectiveToolInventoryGroup; + }) + .filter((group): group is EffectiveToolInventoryGroup => group !== null); + + return { agentId, profile, groups }; +} diff --git a/src/agents/tools/common.ts b/src/agents/tools/common.ts index 4ef014a25fb..cff6d62a7a3 100644 --- a/src/agents/tools/common.ts +++ b/src/agents/tools/common.ts @@ -8,6 +8,7 @@ import { sanitizeToolResultImages } from "../tool-images.js"; // oxlint-disable-next-line typescript/no-explicit-any export type AnyAgentTool = AgentTool & { ownerOnly?: boolean; + displaySummary?: string; }; export type StringParamOptions = { diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 2976dee3924..58451934eda 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -213,6 +213,7 @@ export function createCronTool(opts?: CronToolOptions, deps?: CronToolDeps): Any label: "Cron", name: "cron", ownerOnly: true, + displaySummary: "Schedule and manage cron jobs and wake events.", description: `Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. ACTIONS: diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index d5fc6e58833..424f02468d0 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -671,6 +671,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { return { label: "Message", name: "message", + displaySummary: "Send and manage messages across configured channels.", description, parameters: schema, execute: async (_toolCallId, args, signal) => { diff --git a/src/agents/tools/tts-tool.ts b/src/agents/tools/tts-tool.ts index e4ebd302ef4..5696f8c873e 100644 --- a/src/agents/tools/tts-tool.ts +++ b/src/agents/tools/tts-tool.ts @@ -21,6 +21,7 @@ export function createTtsTool(opts?: { return { label: "TTS", name: "tts", + displaySummary: "Convert text to speech and return audio.", description: `Convert text to speech. Audio is delivered automatically from the tool result — reply with ${SILENT_REPLY_TOKEN} after a successful call to avoid duplicate messages.`, parameters: TtsToolSchema, execute: async (_toolCallId, args) => { diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 0e0c44d7515..41c52cfd223 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -1,50 +1,11 @@ import { listChannelPlugins } from "../channels/plugins/index.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; -import { COMMAND_ARG_FORMATTERS } from "./commands-args.js"; -import type { - ChatCommandDefinition, - CommandCategory, - CommandScope, -} from "./commands-registry.types.js"; -import { listThinkingLevels } from "./thinking.js"; - -type DefineChatCommandInput = { - key: string; - nativeName?: string; - description: string; - args?: ChatCommandDefinition["args"]; - argsParsing?: ChatCommandDefinition["argsParsing"]; - formatArgs?: ChatCommandDefinition["formatArgs"]; - argsMenu?: ChatCommandDefinition["argsMenu"]; - acceptsArgs?: boolean; - textAlias?: string; - textAliases?: string[]; - scope?: CommandScope; - category?: CommandCategory; -}; - -function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefinition { - const aliases = (command.textAliases ?? (command.textAlias ? [command.textAlias] : [])) - .map((alias) => alias.trim()) - .filter(Boolean); - const scope = - command.scope ?? (command.nativeName ? (aliases.length ? "both" : "native") : "text"); - const acceptsArgs = command.acceptsArgs ?? Boolean(command.args?.length); - const argsParsing = command.argsParsing ?? (command.args?.length ? "positional" : "none"); - return { - key: command.key, - nativeName: command.nativeName, - description: command.description, - acceptsArgs, - args: command.args, - argsParsing, - formatArgs: command.formatArgs, - argsMenu: command.argsMenu, - textAliases: aliases, - scope, - category: command.category, - }; -} +import { + assertCommandRegistry, + buildBuiltinChatCommands, + defineChatCommand, +} from "./commands-registry.shared.js"; +import type { ChatCommandDefinition } from "./commands-registry.types.js"; type ChannelPlugin = ReturnType[number]; @@ -58,71 +19,6 @@ function defineDockCommand(plugin: ChannelPlugin): ChatCommandDefinition { }); } -function registerAlias(commands: ChatCommandDefinition[], key: string, ...aliases: string[]): void { - const command = commands.find((entry) => entry.key === key); - if (!command) { - throw new Error(`registerAlias: unknown command key: ${key}`); - } - const existing = new Set(command.textAliases.map((alias) => alias.trim().toLowerCase())); - for (const alias of aliases) { - const trimmed = alias.trim(); - if (!trimmed) { - continue; - } - const lowered = trimmed.toLowerCase(); - if (existing.has(lowered)) { - continue; - } - existing.add(lowered); - command.textAliases.push(trimmed); - } -} - -function assertCommandRegistry(commands: ChatCommandDefinition[]): void { - const keys = new Set(); - const nativeNames = new Set(); - const textAliases = new Set(); - for (const command of commands) { - if (keys.has(command.key)) { - throw new Error(`Duplicate command key: ${command.key}`); - } - keys.add(command.key); - - const nativeName = command.nativeName?.trim(); - if (command.scope === "text") { - if (nativeName) { - throw new Error(`Text-only command has native name: ${command.key}`); - } - if (command.textAliases.length === 0) { - throw new Error(`Text-only command missing text alias: ${command.key}`); - } - } else if (!nativeName) { - throw new Error(`Native command missing native name: ${command.key}`); - } else { - const nativeKey = nativeName.toLowerCase(); - if (nativeNames.has(nativeKey)) { - throw new Error(`Duplicate native command: ${nativeName}`); - } - nativeNames.add(nativeKey); - } - - if (command.scope === "native" && command.textAliases.length > 0) { - throw new Error(`Native-only command has text aliases: ${command.key}`); - } - - for (const alias of command.textAliases) { - if (!alias.startsWith("/")) { - throw new Error(`Command alias missing leading '/': ${alias}`); - } - const aliasKey = alias.toLowerCase(); - if (textAliases.has(aliasKey)) { - throw new Error(`Duplicate command alias: ${alias}`); - } - textAliases.add(aliasKey); - } - } -} - let cachedCommands: ChatCommandDefinition[] | null = null; let cachedRegistry: ReturnType | null = null; let cachedNativeCommandSurfaces: Set | null = null; @@ -130,696 +26,12 @@ let cachedNativeRegistry: ReturnType | null = nu function buildChatCommands(): ChatCommandDefinition[] { const commands: ChatCommandDefinition[] = [ - defineChatCommand({ - key: "help", - nativeName: "help", - description: "Show available commands.", - textAlias: "/help", - category: "status", - }), - defineChatCommand({ - key: "commands", - nativeName: "commands", - description: "List all slash commands.", - textAlias: "/commands", - category: "status", - }), - defineChatCommand({ - key: "skill", - nativeName: "skill", - description: "Run a skill by name.", - textAlias: "/skill", - category: "tools", - args: [ - { - name: "name", - description: "Skill name", - type: "string", - required: true, - }, - { - name: "input", - description: "Skill input", - type: "string", - captureRemaining: true, - }, - ], - }), - defineChatCommand({ - key: "status", - nativeName: "status", - description: "Show current status.", - textAlias: "/status", - category: "status", - }), - defineChatCommand({ - key: "allowlist", - description: "List/add/remove allowlist entries.", - textAlias: "/allowlist", - acceptsArgs: true, - scope: "text", - category: "management", - }), - defineChatCommand({ - key: "approve", - nativeName: "approve", - description: "Approve or deny exec requests.", - textAlias: "/approve", - acceptsArgs: true, - category: "management", - }), - defineChatCommand({ - key: "context", - nativeName: "context", - description: "Explain how context is built and used.", - textAlias: "/context", - acceptsArgs: true, - category: "status", - }), - defineChatCommand({ - key: "btw", - nativeName: "btw", - description: "Ask a side question without changing future session context.", - textAlias: "/btw", - acceptsArgs: true, - category: "tools", - }), - defineChatCommand({ - key: "export-session", - nativeName: "export-session", - description: "Export current session to HTML file with full system prompt.", - textAliases: ["/export-session", "/export"], - acceptsArgs: true, - category: "status", - args: [ - { - name: "path", - description: "Output path (default: workspace)", - type: "string", - required: false, - }, - ], - }), - defineChatCommand({ - key: "tts", - nativeName: "tts", - description: "Control text-to-speech (TTS).", - textAlias: "/tts", - category: "media", - args: [ - { - name: "action", - description: "TTS action", - type: "string", - choices: [ - { value: "on", label: "On" }, - { value: "off", label: "Off" }, - { value: "status", label: "Status" }, - { value: "provider", label: "Provider" }, - { value: "limit", label: "Limit" }, - { value: "summary", label: "Summary" }, - { value: "audio", label: "Audio" }, - { value: "help", label: "Help" }, - ], - }, - { - name: "value", - description: "Provider, limit, or text", - type: "string", - captureRemaining: true, - }, - ], - argsMenu: { - arg: "action", - title: - "TTS Actions:\n" + - "• On – Enable TTS for responses\n" + - "• Off – Disable TTS\n" + - "• Status – Show current settings\n" + - "• Provider – Set voice provider (edge, elevenlabs, openai)\n" + - "• Limit – Set max characters for TTS\n" + - "• Summary – Toggle AI summary for long texts\n" + - "• Audio – Generate TTS from custom text\n" + - "• Help – Show usage guide", - }, - }), - defineChatCommand({ - key: "whoami", - nativeName: "whoami", - description: "Show your sender id.", - textAlias: "/whoami", - category: "status", - }), - defineChatCommand({ - key: "session", - nativeName: "session", - description: "Manage session-level settings (for example /session idle).", - textAlias: "/session", - category: "session", - args: [ - { - name: "action", - description: "idle | max-age", - type: "string", - choices: ["idle", "max-age"], - }, - { - name: "value", - description: "Duration (24h, 90m) or off", - type: "string", - captureRemaining: true, - }, - ], - argsMenu: "auto", - }), - defineChatCommand({ - key: "subagents", - nativeName: "subagents", - description: "List, kill, log, spawn, or steer subagent runs for this session.", - textAlias: "/subagents", - category: "management", - args: [ - { - name: "action", - description: "list | kill | log | info | send | steer | spawn", - type: "string", - choices: ["list", "kill", "log", "info", "send", "steer", "spawn"], - }, - { - name: "target", - description: "Run id, index, or session key", - type: "string", - }, - { - name: "value", - description: "Additional input (limit/message)", - type: "string", - captureRemaining: true, - }, - ], - argsMenu: "auto", - }), - defineChatCommand({ - key: "acp", - nativeName: "acp", - description: "Manage ACP sessions and runtime options.", - textAlias: "/acp", - category: "management", - args: [ - { - name: "action", - description: "Action to run", - type: "string", - preferAutocomplete: true, - choices: [ - "spawn", - "cancel", - "steer", - "close", - "sessions", - "status", - "set-mode", - "set", - "cwd", - "permissions", - "timeout", - "model", - "reset-options", - "doctor", - "install", - "help", - ], - }, - { - name: "value", - description: "Action arguments", - type: "string", - captureRemaining: true, - }, - ], - argsMenu: "auto", - }), - defineChatCommand({ - key: "focus", - nativeName: "focus", - description: - "Bind this thread (Discord) or topic/conversation (Telegram) to a session target.", - textAlias: "/focus", - category: "management", - args: [ - { - name: "target", - description: "Subagent label/index or session key/id/label", - type: "string", - captureRemaining: true, - }, - ], - }), - defineChatCommand({ - key: "unfocus", - nativeName: "unfocus", - description: "Remove the current thread (Discord) or topic/conversation (Telegram) binding.", - textAlias: "/unfocus", - category: "management", - }), - defineChatCommand({ - key: "agents", - nativeName: "agents", - description: "List thread-bound agents for this session.", - textAlias: "/agents", - category: "management", - }), - defineChatCommand({ - key: "kill", - nativeName: "kill", - description: "Kill a running subagent (or all).", - textAlias: "/kill", - category: "management", - args: [ - { - name: "target", - description: "Label, run id, index, or all", - type: "string", - }, - ], - argsMenu: "auto", - }), - defineChatCommand({ - key: "steer", - nativeName: "steer", - description: "Send guidance to a running subagent.", - textAlias: "/steer", - category: "management", - args: [ - { - name: "target", - description: "Label, run id, or index", - type: "string", - }, - { - name: "message", - description: "Steering message", - type: "string", - captureRemaining: true, - }, - ], - }), - defineChatCommand({ - key: "config", - nativeName: "config", - description: "Show or set config values.", - textAlias: "/config", - category: "management", - args: [ - { - name: "action", - description: "show | get | set | unset", - type: "string", - choices: ["show", "get", "set", "unset"], - }, - { - name: "path", - description: "Config path", - type: "string", - }, - { - name: "value", - description: "Value for set", - type: "string", - captureRemaining: true, - }, - ], - argsParsing: "none", - formatArgs: COMMAND_ARG_FORMATTERS.config, - }), - defineChatCommand({ - key: "mcp", - nativeName: "mcp", - description: "Show or set OpenClaw MCP servers.", - textAlias: "/mcp", - category: "management", - args: [ - { - name: "action", - description: "show | get | set | unset", - type: "string", - choices: ["show", "get", "set", "unset"], - }, - { - name: "path", - description: "MCP server name", - type: "string", - }, - { - name: "value", - description: "JSON config for set", - type: "string", - captureRemaining: true, - }, - ], - argsParsing: "none", - formatArgs: COMMAND_ARG_FORMATTERS.mcp, - }), - defineChatCommand({ - key: "plugins", - nativeName: "plugins", - description: "List, show, enable, or disable plugins.", - textAliases: ["/plugins", "/plugin"], - category: "management", - args: [ - { - name: "action", - description: "list | show | get | enable | disable", - type: "string", - choices: ["list", "show", "get", "enable", "disable"], - }, - { - name: "path", - description: "Plugin id or name", - type: "string", - }, - ], - argsParsing: "none", - formatArgs: COMMAND_ARG_FORMATTERS.plugins, - }), - defineChatCommand({ - key: "debug", - nativeName: "debug", - description: "Set runtime debug overrides.", - textAlias: "/debug", - category: "management", - args: [ - { - name: "action", - description: "show | reset | set | unset", - type: "string", - choices: ["show", "reset", "set", "unset"], - }, - { - name: "path", - description: "Debug path", - type: "string", - }, - { - name: "value", - description: "Value for set", - type: "string", - captureRemaining: true, - }, - ], - argsParsing: "none", - formatArgs: COMMAND_ARG_FORMATTERS.debug, - }), - defineChatCommand({ - key: "usage", - nativeName: "usage", - description: "Usage footer or cost summary.", - textAlias: "/usage", - category: "options", - args: [ - { - name: "mode", - description: "off, tokens, full, or cost", - type: "string", - choices: ["off", "tokens", "full", "cost"], - }, - ], - argsMenu: "auto", - }), - defineChatCommand({ - key: "stop", - nativeName: "stop", - description: "Stop the current run.", - textAlias: "/stop", - category: "session", - }), - defineChatCommand({ - key: "restart", - nativeName: "restart", - description: "Restart OpenClaw.", - textAlias: "/restart", - category: "tools", - }), - defineChatCommand({ - key: "activation", - nativeName: "activation", - description: "Set group activation mode.", - textAlias: "/activation", - category: "management", - args: [ - { - name: "mode", - description: "mention or always", - type: "string", - choices: ["mention", "always"], - }, - ], - argsMenu: "auto", - }), - defineChatCommand({ - key: "send", - nativeName: "send", - description: "Set send policy.", - textAlias: "/send", - category: "management", - args: [ - { - name: "mode", - description: "on, off, or inherit", - type: "string", - choices: ["on", "off", "inherit"], - }, - ], - argsMenu: "auto", - }), - defineChatCommand({ - key: "reset", - nativeName: "reset", - description: "Reset the current session.", - textAlias: "/reset", - acceptsArgs: true, - category: "session", - }), - defineChatCommand({ - key: "new", - nativeName: "new", - description: "Start a new session.", - textAlias: "/new", - acceptsArgs: true, - category: "session", - }), - defineChatCommand({ - key: "compact", - nativeName: "compact", - description: "Compact the session context.", - textAlias: "/compact", - category: "session", - args: [ - { - name: "instructions", - description: "Extra compaction instructions", - type: "string", - captureRemaining: true, - }, - ], - }), - defineChatCommand({ - key: "think", - nativeName: "think", - description: "Set thinking level.", - textAlias: "/think", - category: "options", - args: [ - { - name: "level", - description: "off, minimal, low, medium, high, xhigh", - type: "string", - choices: ({ provider, model }) => listThinkingLevels(provider, model), - }, - ], - argsMenu: "auto", - }), - defineChatCommand({ - key: "verbose", - nativeName: "verbose", - description: "Toggle verbose mode.", - textAlias: "/verbose", - category: "options", - args: [ - { - name: "mode", - description: "on or off", - type: "string", - choices: ["on", "off"], - }, - ], - argsMenu: "auto", - }), - defineChatCommand({ - key: "fast", - nativeName: "fast", - description: "Toggle fast mode.", - textAlias: "/fast", - category: "options", - args: [ - { - name: "mode", - description: "status, on, or off", - type: "string", - choices: ["status", "on", "off"], - }, - ], - argsMenu: "auto", - }), - defineChatCommand({ - key: "reasoning", - nativeName: "reasoning", - description: "Toggle reasoning visibility.", - textAlias: "/reasoning", - category: "options", - args: [ - { - name: "mode", - description: "on, off, or stream", - type: "string", - choices: ["on", "off", "stream"], - }, - ], - argsMenu: "auto", - }), - defineChatCommand({ - key: "elevated", - nativeName: "elevated", - description: "Toggle elevated mode.", - textAlias: "/elevated", - category: "options", - args: [ - { - name: "mode", - description: "on, off, ask, or full", - type: "string", - choices: ["on", "off", "ask", "full"], - }, - ], - argsMenu: "auto", - }), - defineChatCommand({ - key: "exec", - nativeName: "exec", - description: "Set exec defaults for this session.", - textAlias: "/exec", - category: "options", - args: [ - { - name: "host", - description: "sandbox, gateway, or node", - type: "string", - choices: ["sandbox", "gateway", "node"], - }, - { - name: "security", - description: "deny, allowlist, or full", - type: "string", - choices: ["deny", "allowlist", "full"], - }, - { - name: "ask", - description: "off, on-miss, or always", - type: "string", - choices: ["off", "on-miss", "always"], - }, - { - name: "node", - description: "Node id or name", - type: "string", - }, - ], - argsParsing: "none", - formatArgs: COMMAND_ARG_FORMATTERS.exec, - }), - defineChatCommand({ - key: "model", - nativeName: "model", - description: "Show or set the model.", - textAlias: "/model", - category: "options", - args: [ - { - name: "model", - description: "Model id (provider/model or id)", - type: "string", - }, - ], - }), - defineChatCommand({ - key: "models", - nativeName: "models", - description: "List model providers or provider models.", - textAlias: "/models", - argsParsing: "none", - acceptsArgs: true, - category: "options", - }), - defineChatCommand({ - key: "queue", - nativeName: "queue", - description: "Adjust queue settings.", - textAlias: "/queue", - category: "options", - args: [ - { - name: "mode", - description: "queue mode", - type: "string", - choices: ["steer", "interrupt", "followup", "collect", "steer-backlog"], - }, - { - name: "debounce", - description: "debounce duration (e.g. 500ms, 2s)", - type: "string", - }, - { - name: "cap", - description: "queue cap", - type: "number", - }, - { - name: "drop", - description: "drop policy", - type: "string", - choices: ["old", "new", "summarize"], - }, - ], - argsParsing: "none", - formatArgs: COMMAND_ARG_FORMATTERS.queue, - }), - defineChatCommand({ - key: "bash", - description: "Run host shell commands (host-only).", - textAlias: "/bash", - scope: "text", - category: "tools", - args: [ - { - name: "command", - description: "Shell command", - type: "string", - captureRemaining: true, - }, - ], - }), + ...buildBuiltinChatCommands(), ...listChannelPlugins() .filter((plugin) => plugin.capabilities.nativeCommands) .map((plugin) => defineDockCommand(plugin)), ]; - registerAlias(commands, "whoami", "/id"); - registerAlias(commands, "think", "/thinking", "/t"); - registerAlias(commands, "verbose", "/v"); - registerAlias(commands, "reasoning", "/reason"); - registerAlias(commands, "elevated", "/elev"); - registerAlias(commands, "steer", "/tell"); - assertCommandRegistry(commands); return commands; } diff --git a/src/auto-reply/commands-registry.shared.ts b/src/auto-reply/commands-registry.shared.ts new file mode 100644 index 00000000000..5edf83ebe8a --- /dev/null +++ b/src/auto-reply/commands-registry.shared.ts @@ -0,0 +1,823 @@ +import { COMMAND_ARG_FORMATTERS } from "./commands-args.js"; +import type { + ChatCommandDefinition, + CommandCategory, + CommandScope, +} from "./commands-registry.types.js"; +import { listThinkingLevels } from "./thinking.js"; + +type DefineChatCommandInput = { + key: string; + nativeName?: string; + description: string; + args?: ChatCommandDefinition["args"]; + argsParsing?: ChatCommandDefinition["argsParsing"]; + formatArgs?: ChatCommandDefinition["formatArgs"]; + argsMenu?: ChatCommandDefinition["argsMenu"]; + acceptsArgs?: boolean; + textAlias?: string; + textAliases?: string[]; + scope?: CommandScope; + category?: CommandCategory; +}; + +export function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefinition { + const aliases = (command.textAliases ?? (command.textAlias ? [command.textAlias] : [])) + .map((alias) => alias.trim()) + .filter(Boolean); + const scope = + command.scope ?? (command.nativeName ? (aliases.length ? "both" : "native") : "text"); + const acceptsArgs = command.acceptsArgs ?? Boolean(command.args?.length); + const argsParsing = command.argsParsing ?? (command.args?.length ? "positional" : "none"); + return { + key: command.key, + nativeName: command.nativeName, + description: command.description, + acceptsArgs, + args: command.args, + argsParsing, + formatArgs: command.formatArgs, + argsMenu: command.argsMenu, + textAliases: aliases, + scope, + category: command.category, + }; +} + +export function registerAlias( + commands: ChatCommandDefinition[], + key: string, + ...aliases: string[] +): void { + const command = commands.find((entry) => entry.key === key); + if (!command) { + throw new Error(`registerAlias: unknown command key: ${key}`); + } + const existing = new Set(command.textAliases.map((alias) => alias.trim().toLowerCase())); + for (const alias of aliases) { + const trimmed = alias.trim(); + if (!trimmed) { + continue; + } + const lowered = trimmed.toLowerCase(); + if (existing.has(lowered)) { + continue; + } + existing.add(lowered); + command.textAliases.push(trimmed); + } +} + +export function assertCommandRegistry(commands: ChatCommandDefinition[]): void { + const keys = new Set(); + const nativeNames = new Set(); + const textAliases = new Set(); + for (const command of commands) { + if (keys.has(command.key)) { + throw new Error(`Duplicate command key: ${command.key}`); + } + keys.add(command.key); + + const nativeName = command.nativeName?.trim(); + if (command.scope === "text") { + if (nativeName) { + throw new Error(`Text-only command has native name: ${command.key}`); + } + if (command.textAliases.length === 0) { + throw new Error(`Text-only command missing text alias: ${command.key}`); + } + } else if (!nativeName) { + throw new Error(`Native command missing native name: ${command.key}`); + } else { + const nativeKey = nativeName.toLowerCase(); + if (nativeNames.has(nativeKey)) { + throw new Error(`Duplicate native command: ${nativeName}`); + } + nativeNames.add(nativeKey); + } + + if (command.scope === "native" && command.textAliases.length > 0) { + throw new Error(`Native-only command has text aliases: ${command.key}`); + } + + for (const alias of command.textAliases) { + if (!alias.startsWith("/")) { + throw new Error(`Command alias missing leading '/': ${alias}`); + } + const aliasKey = alias.toLowerCase(); + if (textAliases.has(aliasKey)) { + throw new Error(`Duplicate command alias: ${alias}`); + } + textAliases.add(aliasKey); + } + } +} + +export function buildBuiltinChatCommands(): ChatCommandDefinition[] { + const commands: ChatCommandDefinition[] = [ + defineChatCommand({ + key: "help", + nativeName: "help", + description: "Show available commands.", + textAlias: "/help", + category: "status", + }), + defineChatCommand({ + key: "commands", + nativeName: "commands", + description: "List all slash commands.", + textAlias: "/commands", + category: "status", + }), + defineChatCommand({ + key: "tools", + nativeName: "tools", + description: "List available runtime tools.", + textAlias: "/tools", + category: "status", + args: [ + { + name: "mode", + description: "compact or verbose", + type: "string", + choices: ["compact", "verbose"], + }, + ], + argsMenu: "auto", + }), + defineChatCommand({ + key: "skill", + nativeName: "skill", + description: "Run a skill by name.", + textAlias: "/skill", + category: "tools", + args: [ + { + name: "name", + description: "Skill name", + type: "string", + required: true, + }, + { + name: "input", + description: "Skill input", + type: "string", + captureRemaining: true, + }, + ], + }), + defineChatCommand({ + key: "status", + nativeName: "status", + description: "Show current status.", + textAlias: "/status", + category: "status", + }), + defineChatCommand({ + key: "allowlist", + description: "List/add/remove allowlist entries.", + textAlias: "/allowlist", + acceptsArgs: true, + scope: "text", + category: "management", + }), + defineChatCommand({ + key: "approve", + nativeName: "approve", + description: "Approve or deny exec requests.", + textAlias: "/approve", + acceptsArgs: true, + category: "management", + }), + defineChatCommand({ + key: "context", + nativeName: "context", + description: "Explain how context is built and used.", + textAlias: "/context", + acceptsArgs: true, + category: "status", + }), + defineChatCommand({ + key: "btw", + nativeName: "btw", + description: "Ask a side question without changing future session context.", + textAlias: "/btw", + acceptsArgs: true, + category: "tools", + }), + defineChatCommand({ + key: "export-session", + nativeName: "export-session", + description: "Export current session to HTML file with full system prompt.", + textAliases: ["/export-session", "/export"], + acceptsArgs: true, + category: "status", + args: [ + { + name: "path", + description: "Output path (default: workspace)", + type: "string", + required: false, + }, + ], + }), + defineChatCommand({ + key: "tts", + nativeName: "tts", + description: "Control text-to-speech (TTS).", + textAlias: "/tts", + category: "media", + args: [ + { + name: "action", + description: "TTS action", + type: "string", + choices: [ + { value: "on", label: "On" }, + { value: "off", label: "Off" }, + { value: "status", label: "Status" }, + { value: "provider", label: "Provider" }, + { value: "limit", label: "Limit" }, + { value: "summary", label: "Summary" }, + { value: "audio", label: "Audio" }, + { value: "help", label: "Help" }, + ], + }, + { + name: "value", + description: "Provider, limit, or text", + type: "string", + captureRemaining: true, + }, + ], + argsMenu: { + arg: "action", + title: + "TTS Actions:\n" + + "• On – Enable TTS for responses\n" + + "• Off – Disable TTS\n" + + "• Status – Show current settings\n" + + "• Provider – Set voice provider (edge, elevenlabs, openai)\n" + + "• Limit – Set max characters for TTS\n" + + "• Summary – Toggle AI summary for long texts\n" + + "• Audio – Generate TTS from custom text\n" + + "• Help – Show usage guide", + }, + }), + defineChatCommand({ + key: "whoami", + nativeName: "whoami", + description: "Show your sender id.", + textAlias: "/whoami", + category: "status", + }), + defineChatCommand({ + key: "session", + nativeName: "session", + description: "Manage session-level settings (for example /session idle).", + textAlias: "/session", + category: "session", + args: [ + { + name: "action", + description: "idle | max-age", + type: "string", + choices: ["idle", "max-age"], + }, + { + name: "value", + description: "Duration (24h, 90m) or off", + type: "string", + captureRemaining: true, + }, + ], + argsMenu: "auto", + }), + defineChatCommand({ + key: "subagents", + nativeName: "subagents", + description: "List, kill, log, spawn, or steer subagent runs for this session.", + textAlias: "/subagents", + category: "management", + args: [ + { + name: "action", + description: "list | kill | log | info | send | steer | spawn", + type: "string", + choices: ["list", "kill", "log", "info", "send", "steer", "spawn"], + }, + { + name: "target", + description: "Run id, index, or session key", + type: "string", + }, + { + name: "value", + description: "Additional input (limit/message)", + type: "string", + captureRemaining: true, + }, + ], + argsMenu: "auto", + }), + defineChatCommand({ + key: "acp", + nativeName: "acp", + description: "Manage ACP sessions and runtime options.", + textAlias: "/acp", + category: "management", + args: [ + { + name: "action", + description: "Action to run", + type: "string", + preferAutocomplete: true, + choices: [ + "spawn", + "cancel", + "steer", + "close", + "sessions", + "status", + "set-mode", + "set", + "cwd", + "permissions", + "timeout", + "model", + "reset-options", + "doctor", + "install", + "help", + ], + }, + { + name: "value", + description: "Action arguments", + type: "string", + captureRemaining: true, + }, + ], + argsMenu: "auto", + }), + defineChatCommand({ + key: "focus", + nativeName: "focus", + description: + "Bind this thread (Discord) or topic/conversation (Telegram) to a session target.", + textAlias: "/focus", + category: "management", + args: [ + { + name: "target", + description: "Subagent label/index or session key/id/label", + type: "string", + captureRemaining: true, + }, + ], + }), + defineChatCommand({ + key: "unfocus", + nativeName: "unfocus", + description: "Remove the current thread (Discord) or topic/conversation (Telegram) binding.", + textAlias: "/unfocus", + category: "management", + }), + defineChatCommand({ + key: "agents", + nativeName: "agents", + description: "List thread-bound agents for this session.", + textAlias: "/agents", + category: "management", + }), + defineChatCommand({ + key: "kill", + nativeName: "kill", + description: "Kill a running subagent (or all).", + textAlias: "/kill", + category: "management", + args: [ + { + name: "target", + description: "Label, run id, index, or all", + type: "string", + }, + ], + argsMenu: "auto", + }), + defineChatCommand({ + key: "steer", + nativeName: "steer", + description: "Send guidance to a running subagent.", + textAlias: "/steer", + category: "management", + args: [ + { + name: "target", + description: "Label, run id, or index", + type: "string", + }, + { + name: "message", + description: "Steering message", + type: "string", + captureRemaining: true, + }, + ], + }), + defineChatCommand({ + key: "config", + nativeName: "config", + description: "Show or set config values.", + textAlias: "/config", + category: "management", + args: [ + { + name: "action", + description: "show | get | set | unset", + type: "string", + choices: ["show", "get", "set", "unset"], + }, + { + name: "path", + description: "Config path", + type: "string", + }, + { + name: "value", + description: "Value for set", + type: "string", + captureRemaining: true, + }, + ], + argsParsing: "none", + formatArgs: COMMAND_ARG_FORMATTERS.config, + }), + defineChatCommand({ + key: "mcp", + nativeName: "mcp", + description: "Show or set OpenClaw MCP servers.", + textAlias: "/mcp", + category: "management", + args: [ + { + name: "action", + description: "show | get | set | unset", + type: "string", + choices: ["show", "get", "set", "unset"], + }, + { + name: "path", + description: "MCP server name", + type: "string", + }, + { + name: "value", + description: "JSON config for set", + type: "string", + captureRemaining: true, + }, + ], + argsParsing: "none", + formatArgs: COMMAND_ARG_FORMATTERS.mcp, + }), + defineChatCommand({ + key: "plugins", + nativeName: "plugins", + description: "List, show, enable, or disable plugins.", + textAliases: ["/plugins", "/plugin"], + category: "management", + args: [ + { + name: "action", + description: "list | show | get | enable | disable", + type: "string", + choices: ["list", "show", "get", "enable", "disable"], + }, + { + name: "path", + description: "Plugin id or name", + type: "string", + }, + ], + argsParsing: "none", + formatArgs: COMMAND_ARG_FORMATTERS.plugins, + }), + defineChatCommand({ + key: "debug", + nativeName: "debug", + description: "Set runtime debug overrides.", + textAlias: "/debug", + category: "management", + args: [ + { + name: "action", + description: "show | reset | set | unset", + type: "string", + choices: ["show", "reset", "set", "unset"], + }, + { + name: "path", + description: "Debug path", + type: "string", + }, + { + name: "value", + description: "Value for set", + type: "string", + captureRemaining: true, + }, + ], + argsParsing: "none", + formatArgs: COMMAND_ARG_FORMATTERS.debug, + }), + defineChatCommand({ + key: "usage", + nativeName: "usage", + description: "Usage footer or cost summary.", + textAlias: "/usage", + category: "options", + args: [ + { + name: "mode", + description: "off, tokens, full, or cost", + type: "string", + choices: ["off", "tokens", "full", "cost"], + }, + ], + argsMenu: "auto", + }), + defineChatCommand({ + key: "stop", + nativeName: "stop", + description: "Stop the current run.", + textAlias: "/stop", + category: "session", + }), + defineChatCommand({ + key: "restart", + nativeName: "restart", + description: "Restart OpenClaw.", + textAlias: "/restart", + category: "tools", + }), + defineChatCommand({ + key: "activation", + nativeName: "activation", + description: "Set group activation mode.", + textAlias: "/activation", + category: "management", + args: [ + { + name: "mode", + description: "mention or always", + type: "string", + choices: ["mention", "always"], + }, + ], + argsMenu: "auto", + }), + defineChatCommand({ + key: "send", + nativeName: "send", + description: "Set send policy.", + textAlias: "/send", + category: "management", + args: [ + { + name: "mode", + description: "on, off, or inherit", + type: "string", + choices: ["on", "off", "inherit"], + }, + ], + argsMenu: "auto", + }), + defineChatCommand({ + key: "reset", + nativeName: "reset", + description: "Reset the current session.", + textAlias: "/reset", + acceptsArgs: true, + category: "session", + }), + defineChatCommand({ + key: "new", + nativeName: "new", + description: "Start a new session.", + textAlias: "/new", + acceptsArgs: true, + category: "session", + }), + defineChatCommand({ + key: "compact", + nativeName: "compact", + description: "Compact the session context.", + textAlias: "/compact", + category: "session", + args: [ + { + name: "instructions", + description: "Extra compaction instructions", + type: "string", + captureRemaining: true, + }, + ], + }), + defineChatCommand({ + key: "think", + nativeName: "think", + description: "Set thinking level.", + textAlias: "/think", + category: "options", + args: [ + { + name: "level", + description: "off, minimal, low, medium, high, xhigh", + type: "string", + choices: ({ provider, model }) => listThinkingLevels(provider, model), + }, + ], + argsMenu: "auto", + }), + defineChatCommand({ + key: "verbose", + nativeName: "verbose", + description: "Toggle verbose mode.", + textAlias: "/verbose", + category: "options", + args: [ + { + name: "mode", + description: "on or off", + type: "string", + choices: ["on", "off"], + }, + ], + argsMenu: "auto", + }), + defineChatCommand({ + key: "fast", + nativeName: "fast", + description: "Toggle fast mode.", + textAlias: "/fast", + category: "options", + args: [ + { + name: "mode", + description: "status, on, or off", + type: "string", + choices: ["status", "on", "off"], + }, + ], + argsMenu: "auto", + }), + defineChatCommand({ + key: "reasoning", + nativeName: "reasoning", + description: "Toggle reasoning visibility.", + textAlias: "/reasoning", + category: "options", + args: [ + { + name: "mode", + description: "on, off, or stream", + type: "string", + choices: ["on", "off", "stream"], + }, + ], + argsMenu: "auto", + }), + defineChatCommand({ + key: "elevated", + nativeName: "elevated", + description: "Toggle elevated mode.", + textAlias: "/elevated", + category: "options", + args: [ + { + name: "mode", + description: "on, off, ask, or full", + type: "string", + choices: ["on", "off", "ask", "full"], + }, + ], + argsMenu: "auto", + }), + defineChatCommand({ + key: "exec", + nativeName: "exec", + description: "Set exec defaults for this session.", + textAlias: "/exec", + category: "options", + args: [ + { + name: "host", + description: "sandbox, gateway, or node", + type: "string", + choices: ["sandbox", "gateway", "node"], + }, + { + name: "security", + description: "deny, allowlist, or full", + type: "string", + choices: ["deny", "allowlist", "full"], + }, + { + name: "ask", + description: "off, on-miss, or always", + type: "string", + choices: ["off", "on-miss", "always"], + }, + { + name: "node", + description: "Node id or name", + type: "string", + }, + ], + argsParsing: "none", + formatArgs: COMMAND_ARG_FORMATTERS.exec, + }), + defineChatCommand({ + key: "model", + nativeName: "model", + description: "Show or set the model.", + textAlias: "/model", + category: "options", + args: [ + { + name: "model", + description: "Model id (provider/model or id)", + type: "string", + }, + ], + }), + defineChatCommand({ + key: "models", + nativeName: "models", + description: "List model providers or provider models.", + textAlias: "/models", + argsParsing: "none", + acceptsArgs: true, + category: "options", + }), + defineChatCommand({ + key: "queue", + nativeName: "queue", + description: "Adjust queue settings.", + textAlias: "/queue", + category: "options", + args: [ + { + name: "mode", + description: "queue mode", + type: "string", + choices: ["steer", "interrupt", "followup", "collect", "steer-backlog"], + }, + { + name: "debounce", + description: "debounce duration (e.g. 500ms, 2s)", + type: "string", + }, + { + name: "cap", + description: "queue cap", + type: "number", + }, + { + name: "drop", + description: "drop policy", + type: "string", + choices: ["old", "new", "summarize"], + }, + ], + argsParsing: "none", + formatArgs: COMMAND_ARG_FORMATTERS.queue, + }), + defineChatCommand({ + key: "bash", + description: "Run host shell commands (host-only).", + textAlias: "/bash", + scope: "text", + category: "tools", + args: [ + { + name: "command", + description: "Shell command", + type: "string", + captureRemaining: true, + }, + ], + }), + ]; + + registerAlias(commands, "whoami", "/id"); + registerAlias(commands, "think", "/thinking", "/t"); + registerAlias(commands, "verbose", "/v"); + registerAlias(commands, "reasoning", "/reason"); + registerAlias(commands, "elevated", "/elev"); + registerAlias(commands, "steer", "/tell"); + + assertCommandRegistry(commands); + return commands; +} diff --git a/src/auto-reply/reply/commands-handlers.runtime.ts b/src/auto-reply/reply/commands-handlers.runtime.ts index 5a154adee3e..c9c10b9718f 100644 --- a/src/auto-reply/reply/commands-handlers.runtime.ts +++ b/src/auto-reply/reply/commands-handlers.runtime.ts @@ -11,6 +11,7 @@ import { handleExportSessionCommand, handleHelpCommand, handleStatusCommand, + handleToolsCommand, handleWhoamiCommand, } from "./commands-info.js"; import { handleMcpCommand } from "./commands-mcp.js"; @@ -45,6 +46,7 @@ export function loadCommandHandlers(): CommandHandler[] { handleTtsCommands, handleHelpCommand, handleCommandsListCommand, + handleToolsCommand, handleStatusCommand, handleAllowlistCommand, handleApproveCommand, diff --git a/src/auto-reply/reply/commands-info.tools.test.ts b/src/auto-reply/reply/commands-info.tools.test.ts new file mode 100644 index 00000000000..cd99f52ab45 --- /dev/null +++ b/src/auto-reply/reply/commands-info.tools.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; + +async function loadToolsHarness(options?: { + resolveToolsMock?: ReturnType; + resolveTools?: () => { + agentId: string; + profile: string; + groups: Array<{ + id: "core" | "plugin" | "channel"; + label: string; + source: "core" | "plugin" | "channel"; + pluginId?: string; + channelId?: string; + tools: Array<{ + id: string; + label: string; + description: string; + source: "core" | "plugin" | "channel"; + pluginId?: string; + channelId?: string; + }>; + }>; + }; +}) { + vi.resetModules(); + vi.doMock("../../agents/agent-scope.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveSessionAgentId: () => "main", + }; + }); + const resolveToolsMock = + options?.resolveToolsMock ?? + vi.fn( + options?.resolveTools ?? + (() => ({ + agentId: "main", + profile: "coding", + groups: [ + { + id: "core" as const, + label: "Built-in tools", + source: "core" as const, + tools: [ + { + id: "exec", + label: "Exec", + description: "Run shell commands", + source: "core" as const, + }, + ], + }, + { + id: "plugin" as const, + label: "Connected tools", + source: "plugin" as const, + tools: [ + { + id: "docs_lookup", + label: "Docs Lookup", + description: "Search internal documentation", + source: "plugin" as const, + pluginId: "docs", + }, + ], + }, + ], + })), + ); + vi.doMock("../../agents/tools-effective-inventory.js", () => ({ + resolveEffectiveToolInventory: resolveToolsMock, + })); + vi.doMock("./agent-runner-utils.js", () => ({ + buildThreadingToolContext: () => ({ + currentChannelId: "channel-123", + currentMessageId: "message-456", + }), + })); + vi.doMock("./reply-threading.js", () => ({ + resolveReplyToMode: () => "all", + })); + + const { buildCommandTestParams } = await import("./commands.test-harness.js"); + const { handleToolsCommand } = await import("./commands-info.js"); + return { buildCommandTestParams, handleToolsCommand, resolveToolsMock }; +} + +function buildConfig() { + return { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; +} + +describe("handleToolsCommand", () => { + it("renders a product-facing tool list", async () => { + const { buildCommandTestParams, handleToolsCommand, resolveToolsMock } = + await loadToolsHarness(); + const params = buildCommandTestParams("/tools", buildConfig(), undefined, { + workspaceDir: "/tmp", + }); + params.agentId = "main"; + params.provider = "openai"; + params.model = "gpt-4.1"; + params.ctx = { + ...params.ctx, + From: "telegram:group:abc123", + GroupChannel: "#ops", + GroupSpace: "workspace-1", + SenderName: "User Name", + SenderUsername: "user_name", + SenderE164: "+1000", + MessageThreadId: 99, + AccountId: "acct-1", + Provider: "telegram", + ChatType: "group", + }; + + const result = await handleToolsCommand(params, true); + + expect(result?.reply?.text).toContain("Available tools"); + expect(result?.reply?.text).toContain("Profile: coding"); + expect(result?.reply?.text).toContain("Built-in tools"); + expect(result?.reply?.text).toContain("exec"); + expect(result?.reply?.text).toContain("Connected tools"); + expect(result?.reply?.text).toContain("docs_lookup (docs)"); + expect(result?.reply?.text).not.toContain("unavailable right now"); + expect(resolveToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ + senderIsOwner: false, + senderId: undefined, + senderName: "User Name", + senderUsername: "user_name", + senderE164: "+1000", + accountId: "acct-1", + currentChannelId: "channel-123", + currentThreadTs: "99", + currentMessageId: "message-456", + groupId: "abc123", + groupChannel: "#ops", + groupSpace: "workspace-1", + replyToMode: "all", + }), + ); + }); + + it("returns usage when arguments are provided", async () => { + const { buildCommandTestParams, handleToolsCommand } = await loadToolsHarness(); + const result = await handleToolsCommand( + buildCommandTestParams("/tools extra", buildConfig(), undefined, { workspaceDir: "/tmp" }), + true, + ); + + expect(result).toEqual({ + shouldContinue: false, + reply: { text: "Usage: /tools [compact|verbose]" }, + }); + }); + + it("does not synthesize group ids for direct-chat sender ids", async () => { + const { buildCommandTestParams, handleToolsCommand, resolveToolsMock } = + await loadToolsHarness(); + const params = buildCommandTestParams("/tools", buildConfig(), undefined, { + workspaceDir: "/tmp", + }); + params.ctx = { + ...params.ctx, + From: "telegram:8231046597", + Provider: "telegram", + ChatType: "dm", + }; + + await handleToolsCommand(params, true); + + expect(resolveToolsMock).toHaveBeenCalledWith(expect.objectContaining({ groupId: undefined })); + }); + + it("renders the detailed tool list in verbose mode", async () => { + const { buildCommandTestParams, handleToolsCommand } = await loadToolsHarness(); + const result = await handleToolsCommand( + buildCommandTestParams("/tools verbose", buildConfig(), undefined, { workspaceDir: "/tmp" }), + true, + ); + + expect(result?.reply?.text).toContain("What this agent can use right now:"); + expect(result?.reply?.text).toContain("Profile: coding"); + expect(result?.reply?.text).toContain("Exec - Run shell commands"); + expect(result?.reply?.text).toContain("Docs Lookup - Search internal documentation"); + }); + + it("accepts explicit compact mode", async () => { + const { buildCommandTestParams, handleToolsCommand } = await loadToolsHarness(); + const result = await handleToolsCommand( + buildCommandTestParams("/tools compact", buildConfig(), undefined, { workspaceDir: "/tmp" }), + true, + ); + + expect(result?.reply?.text).toContain("exec"); + expect(result?.reply?.text).toContain("Use /tools verbose for descriptions."); + }); + + it("ignores unauthorized senders", async () => { + const { buildCommandTestParams, handleToolsCommand } = await loadToolsHarness(); + const params = buildCommandTestParams("/tools", buildConfig(), undefined, { + workspaceDir: "/tmp", + }); + params.command = { + ...params.command, + isAuthorizedSender: false, + senderId: "unauthorized", + }; + + const result = await handleToolsCommand(params, true); + + expect(result).toEqual({ shouldContinue: false }); + }); + + it("returns a concise fallback error on effective inventory failures", async () => { + const { buildCommandTestParams, handleToolsCommand } = await loadToolsHarness({ + resolveTools: () => { + throw new Error("boom"); + }, + }); + + const result = await handleToolsCommand( + buildCommandTestParams("/tools", buildConfig(), undefined, { workspaceDir: "/tmp" }), + true, + ); + + expect(result).toEqual({ + shouldContinue: false, + reply: { text: "Couldn't load available tools right now. Try again in a moment." }, + }); + }); +}); diff --git a/src/auto-reply/reply/commands-info.ts b/src/auto-reply/reply/commands-info.ts index 07dc5371830..fba33fbdcde 100644 --- a/src/auto-reply/reply/commands-info.ts +++ b/src/auto-reply/reply/commands-info.ts @@ -1,14 +1,41 @@ +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { resolveEffectiveToolInventory } from "../../agents/tools-effective-inventory.js"; import { logVerbose } from "../../globals.js"; import { listSkillCommandsForAgents } from "../skill-commands.js"; import { buildCommandsMessage, buildCommandsMessagePaginated, buildHelpMessage, + buildToolsMessage, } from "../status.js"; +import { buildThreadingToolContext } from "./agent-runner-utils.js"; import { buildContextReply } from "./commands-context-report.js"; import { buildExportSessionReply } from "./commands-export-session.js"; import { buildStatusReply } from "./commands-status.js"; import type { CommandHandler } from "./commands-types.js"; +import { resolveReplyToMode } from "./reply-threading.js"; + +function extractGroupId(raw: string | undefined | null): string | undefined { + const trimmed = (raw ?? "").trim(); + if (!trimmed) { + return undefined; + } + const parts = trimmed.split(":").filter(Boolean); + if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) { + return parts.slice(2).join(":") || undefined; + } + if ( + parts.length >= 2 && + parts[0]?.toLowerCase() === "whatsapp" && + trimmed.toLowerCase().includes("@g.us") + ) { + return parts.slice(1).join(":") || undefined; + } + if (parts.length >= 2 && (parts[0] === "group" || parts[0] === "channel")) { + return parts.slice(1).join(":") || undefined; + } + return undefined; +} export const handleHelpCommand: CommandHandler = async (params, allowTextCommands) => { if (!allowTextCommands) { @@ -86,6 +113,86 @@ export const handleCommandsListCommand: CommandHandler = async (params, allowTex }; }; +export const handleToolsCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) { + return null; + } + const normalized = params.command.commandBodyNormalized; + let verbose = false; + if (normalized === "/tools" || normalized === "/tools compact") { + verbose = false; + } else if (normalized === "/tools verbose") { + verbose = true; + } else if (normalized.startsWith("/tools ")) { + return { shouldContinue: false, reply: { text: "Usage: /tools [compact|verbose]" } }; + } else { + return null; + } + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /tools from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + + try { + const agentId = + params.agentId ?? + resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg }); + const threadingContext = buildThreadingToolContext({ + sessionCtx: params.ctx, + config: params.cfg, + hasRepliedRef: undefined, + }); + const result = resolveEffectiveToolInventory({ + cfg: params.cfg, + agentId, + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + modelProvider: params.provider, + modelId: params.model, + messageProvider: params.command.channel, + senderIsOwner: params.command.senderIsOwner, + senderId: params.command.senderId, + senderName: params.ctx.SenderName, + senderUsername: params.ctx.SenderUsername, + senderE164: params.ctx.SenderE164, + accountId: params.ctx.AccountId, + currentChannelId: threadingContext.currentChannelId, + currentThreadTs: + typeof params.ctx.MessageThreadId === "string" || + typeof params.ctx.MessageThreadId === "number" + ? String(params.ctx.MessageThreadId) + : undefined, + currentMessageId: threadingContext.currentMessageId, + groupId: params.sessionEntry?.groupId ?? extractGroupId(params.ctx.From), + groupChannel: + params.sessionEntry?.groupChannel ?? params.ctx.GroupChannel ?? params.ctx.GroupSubject, + groupSpace: params.sessionEntry?.space ?? params.ctx.GroupSpace, + replyToMode: resolveReplyToMode( + params.cfg, + params.ctx.OriginatingChannel ?? params.ctx.Provider, + params.ctx.AccountId, + params.ctx.ChatType, + ), + }); + return { + shouldContinue: false, + reply: { text: buildToolsMessage(result, { verbose }) }, + }; + } catch (err) { + const message = String(err); + const text = message.includes("missing scope:") + ? "You do not have permission to view available tools." + : "Couldn't load available tools right now. Try again in a moment."; + return { + shouldContinue: false, + reply: { text }, + }; + } +}; + export function buildCommandsPaginationKeyboard( currentPage: number, totalPages: number, diff --git a/src/auto-reply/status.tools.test.ts b/src/auto-reply/status.tools.test.ts new file mode 100644 index 00000000000..ea022c80e5f --- /dev/null +++ b/src/auto-reply/status.tools.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { buildCommandsMessage, buildHelpMessage, buildToolsMessage } from "./status.js"; + +vi.mock("../plugins/commands.js", () => ({ + listPluginCommands: () => [], +})); + +describe("tools product copy", () => { + it("mentions /tools in command discovery copy", () => { + const cfg = { + commands: { config: false, debug: false }, + } as unknown as OpenClawConfig; + + expect(buildCommandsMessage(cfg)).toContain("/tools - List available runtime tools."); + expect(buildCommandsMessage(cfg)).toContain("More: /tools for available capabilities"); + expect(buildHelpMessage(cfg)).toContain("/tools for available capabilities"); + }); + + it("formats built-in and plugin tools for end users", () => { + const text = buildToolsMessage({ + agentId: "main", + profile: "coding", + groups: [ + { + id: "core", + label: "Built-in tools", + source: "core", + tools: [ + { + id: "exec", + label: "Exec", + description: "Run shell commands", + rawDescription: "Run shell commands", + source: "core", + }, + { + id: "web_search", + label: "Web Search", + description: "Search the web", + rawDescription: "Search the web", + source: "core", + }, + ], + }, + { + id: "plugin", + label: "Connected tools", + source: "plugin", + tools: [ + { + id: "docs_lookup", + label: "Docs Lookup", + description: "Search internal documentation", + rawDescription: "Search internal documentation", + source: "plugin", + pluginId: "docs", + }, + ], + }, + ], + }); + + expect(text).toContain("Available tools"); + expect(text).toContain("Profile: coding"); + expect(text).toContain("Built-in tools"); + expect(text).toContain("exec, web_search"); + expect(text).toContain("Connected tools"); + expect(text).toContain("docs_lookup (docs)"); + expect(text).toContain("Use /tools verbose for descriptions."); + expect(text).not.toContain("unavailable right now"); + }); + + it("keeps detailed descriptions in verbose mode", () => { + const text = buildToolsMessage( + { + agentId: "main", + profile: "minimal", + groups: [ + { + id: "core", + label: "Built-in tools", + source: "core", + tools: [ + { + id: "exec", + label: "Exec", + description: "Run shell commands", + rawDescription: "Run shell commands", + source: "core", + }, + ], + }, + ], + }, + { verbose: true }, + ); + + expect(text).toContain("What this agent can use right now:"); + expect(text).toContain("Profile: minimal"); + expect(text).toContain("Exec - Run shell commands"); + expect(text).toContain("Tool availability depends on this agent's configuration."); + expect(text).not.toContain("unavailable right now"); + }); + + it("trims verbose output before schema-like doc blocks", () => { + const text = buildToolsMessage( + { + agentId: "main", + profile: "coding", + groups: [ + { + id: "core", + label: "Built-in tools", + source: "core", + tools: [ + { + id: "cron", + label: "Cron", + description: "Schedule and manage cron jobs.", + rawDescription: + "Manage Gateway cron jobs and send wake events.\n\nACTIONS:\n- status: Check cron scheduler status\nJOB SCHEMA:\n{ ... }", + source: "core", + }, + ], + }, + ], + }, + { verbose: true }, + ); + + expect(text).toContain("Cron - Manage Gateway cron jobs and send wake events."); + expect(text).not.toContain("ACTIONS:"); + expect(text).not.toContain("JOB SCHEMA:"); + }); + + it("returns the empty state when no tools are available", () => { + expect( + buildToolsMessage({ + agentId: "main", + profile: "full", + groups: [], + }), + ).toBe("No tools are available for this agent right now.\n\nProfile: full"); + }); +}); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 5bf56da4a87..2047da03c8d 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -9,6 +9,9 @@ import { } from "../agents/model-selection.js"; import { resolveSandboxRuntimeStatus } from "../agents/sandbox.js"; import type { SkillCommandSpec } from "../agents/skills.js"; +import { describeToolForVerbose } from "../agents/tool-description-summary.js"; +import { normalizeToolName } from "../agents/tool-policy-shared.js"; +import type { EffectiveToolInventoryResult } from "../agents/tools-effective-inventory.js"; import { derivePromptTokens, normalizeUsage, type UsageLike } from "../agents/usage.js"; import { resolveChannelModelOverride } from "../channels/model-overrides.js"; import { isCommandFlagEnabled } from "../config/commands.js"; @@ -864,7 +867,7 @@ export function buildHelpMessage(cfg?: OpenClawConfig): string { lines.push(" /skill [input]"); lines.push(""); - lines.push("More: /commands for full list"); + lines.push("More: /commands for full list, /tools for available capabilities"); return lines.join("\n"); } @@ -884,6 +887,91 @@ export type CommandsMessageResult = { hasPrev: boolean; }; +type ToolsMessageItem = { + id: string; + name: string; + description: string; + rawDescription: string; + source: EffectiveToolInventoryResult["groups"][number]["source"]; + pluginId?: string; + channelId?: string; +}; + +function sortToolsMessageItems(items: ToolsMessageItem[]): ToolsMessageItem[] { + return items.toSorted((a, b) => a.name.localeCompare(b.name)); +} + +function formatCompactToolEntry(tool: ToolsMessageItem): string { + if (tool.source === "plugin") { + return tool.pluginId ? `${tool.id} (${tool.pluginId})` : tool.id; + } + if (tool.source === "channel") { + return tool.channelId ? `${tool.id} (${tool.channelId})` : tool.id; + } + return tool.id; +} + +function formatVerboseToolDescription(tool: ToolsMessageItem): string { + return describeToolForVerbose({ + rawDescription: tool.rawDescription, + fallback: tool.description, + }); +} + +export function buildToolsMessage( + result: EffectiveToolInventoryResult, + options?: { verbose?: boolean }, +): string { + const groups = result.groups + .map((group) => ({ + label: group.label, + tools: sortToolsMessageItems( + group.tools.map((tool) => ({ + id: normalizeToolName(tool.id), + name: tool.label, + description: tool.description || "Tool", + rawDescription: tool.rawDescription || tool.description || "Tool", + source: tool.source, + pluginId: tool.pluginId, + channelId: tool.channelId, + })), + ), + })) + .filter((group) => group.tools.length > 0); + + if (groups.length === 0) { + const lines = [ + "No tools are available for this agent right now.", + "", + `Profile: ${result.profile}`, + ]; + return lines.join("\n"); + } + + const verbose = options?.verbose === true; + const lines = verbose + ? ["Available tools", "", `Profile: ${result.profile}`, "What this agent can use right now:"] + : ["Available tools", "", `Profile: ${result.profile}`]; + + for (const group of groups) { + lines.push("", group.label); + if (verbose) { + for (const tool of group.tools) { + lines.push(` ${tool.name} - ${formatVerboseToolDescription(tool)}`); + } + continue; + } + lines.push(` ${group.tools.map((tool) => formatCompactToolEntry(tool)).join(", ")}`); + } + + if (verbose) { + lines.push("", "Tool availability depends on this agent's configuration."); + } else { + lines.push("", "Use /tools verbose for descriptions."); + } + return lines.join("\n"); +} + function formatCommandEntry(command: ChatCommandDefinition): string { const primary = command.nativeName ? `/${command.nativeName}` @@ -985,6 +1073,7 @@ export function buildCommandsMessagePaginated( if (!isTelegram) { const lines = ["ℹ️ Slash commands", ""]; lines.push(formatCommandList(items)); + lines.push("", "More: /tools for available capabilities"); return { text: lines.join("\n").trim(), totalPages: 1, diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 47f088646bb..e22023eeaf1 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -61,6 +61,7 @@ const METHOD_SCOPE_GROUPS: Record = { "tts.providers", "models.list", "tools.catalog", + "tools.effective", "agents.list", "agent.identity.get", "skills.status", diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 408074d44e4..c7d230dee0c 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -230,6 +230,9 @@ import { type ToolsCatalogParams, ToolsCatalogParamsSchema, type ToolsCatalogResult, + type ToolsEffectiveParams, + ToolsEffectiveParamsSchema, + type ToolsEffectiveResult, type Snapshot, SnapshotSchema, type StateVersion, @@ -390,6 +393,9 @@ 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 validateToolsEffectiveParams = ajv.compile( + ToolsEffectiveParamsSchema, +); export const validateSkillsBinsParams = ajv.compile(SkillsBinsParamsSchema); export const validateSkillsInstallParams = ajv.compile(SkillsInstallParamsSchema); @@ -572,6 +578,7 @@ export { ModelsListParamsSchema, SkillsStatusParamsSchema, ToolsCatalogParamsSchema, + ToolsEffectiveParamsSchema, SkillsInstallParamsSchema, SkillsUpdateParamsSchema, CronJobSchema, @@ -664,6 +671,8 @@ export type { SkillsStatusParams, ToolsCatalogParams, ToolsCatalogResult, + ToolsEffectiveParams, + ToolsEffectiveResult, SkillsBinsParams, SkillsBinsResult, SkillsInstallParams, diff --git a/src/gateway/protocol/schema/agents-models-skills.ts b/src/gateway/protocol/schema/agents-models-skills.ts index f12ecb409b8..41b332fd48d 100644 --- a/src/gateway/protocol/schema/agents-models-skills.ts +++ b/src/gateway/protocol/schema/agents-models-skills.ts @@ -238,6 +238,14 @@ export const ToolsCatalogParamsSchema = Type.Object( { additionalProperties: false }, ); +export const ToolsEffectiveParamsSchema = Type.Object( + { + agentId: Type.Optional(NonEmptyString), + sessionKey: NonEmptyString, + }, + { additionalProperties: false }, +); + export const ToolCatalogProfileSchema = Type.Object( { id: Type.Union([ @@ -290,3 +298,35 @@ export const ToolsCatalogResultSchema = Type.Object( }, { additionalProperties: false }, ); + +export const ToolsEffectiveEntrySchema = Type.Object( + { + id: NonEmptyString, + label: NonEmptyString, + description: Type.String(), + rawDescription: Type.String(), + source: Type.Union([Type.Literal("core"), Type.Literal("plugin"), Type.Literal("channel")]), + pluginId: Type.Optional(NonEmptyString), + channelId: Type.Optional(NonEmptyString), + }, + { additionalProperties: false }, +); + +export const ToolsEffectiveGroupSchema = Type.Object( + { + id: Type.Union([Type.Literal("core"), Type.Literal("plugin"), Type.Literal("channel")]), + label: NonEmptyString, + source: Type.Union([Type.Literal("core"), Type.Literal("plugin"), Type.Literal("channel")]), + tools: Type.Array(ToolsEffectiveEntrySchema), + }, + { additionalProperties: false }, +); + +export const ToolsEffectiveResultSchema = Type.Object( + { + agentId: NonEmptyString, + profile: NonEmptyString, + groups: Type.Array(ToolsEffectiveGroupSchema), + }, + { additionalProperties: false }, +); diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index cf14fc44610..906f13a71c9 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -39,6 +39,10 @@ import { ToolCatalogProfileSchema, ToolsCatalogParamsSchema, ToolsCatalogResultSchema, + ToolsEffectiveEntrySchema, + ToolsEffectiveGroupSchema, + ToolsEffectiveParamsSchema, + ToolsEffectiveResultSchema, } from "./agents-models-skills.js"; import { ChannelsLogoutParamsSchema, @@ -272,6 +276,10 @@ export const ProtocolSchemas = { ToolCatalogEntry: ToolCatalogEntrySchema, ToolCatalogGroup: ToolCatalogGroupSchema, ToolsCatalogResult: ToolsCatalogResultSchema, + ToolsEffectiveParams: ToolsEffectiveParamsSchema, + ToolsEffectiveEntry: ToolsEffectiveEntrySchema, + ToolsEffectiveGroup: ToolsEffectiveGroupSchema, + ToolsEffectiveResult: ToolsEffectiveResultSchema, SkillsBinsParams: SkillsBinsParamsSchema, SkillsBinsResult: SkillsBinsResultSchema, SkillsInstallParams: SkillsInstallParamsSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index d74c08ad10b..c96d70b2e62 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -102,6 +102,10 @@ export type ToolCatalogProfile = SchemaType<"ToolCatalogProfile">; export type ToolCatalogEntry = SchemaType<"ToolCatalogEntry">; export type ToolCatalogGroup = SchemaType<"ToolCatalogGroup">; export type ToolsCatalogResult = SchemaType<"ToolsCatalogResult">; +export type ToolsEffectiveParams = SchemaType<"ToolsEffectiveParams">; +export type ToolsEffectiveEntry = SchemaType<"ToolsEffectiveEntry">; +export type ToolsEffectiveGroup = SchemaType<"ToolsEffectiveGroup">; +export type ToolsEffectiveResult = SchemaType<"ToolsEffectiveResult">; export type SkillsBinsParams = SchemaType<"SkillsBinsParams">; export type SkillsBinsResult = SchemaType<"SkillsBinsResult">; export type SkillsInstallParams = SchemaType<"SkillsInstallParams">; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index e930f8b0517..d9aa68635d5 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -38,6 +38,7 @@ const BASE_METHODS = [ "talk.mode", "models.list", "tools.catalog", + "tools.effective", "agents.list", "agents.create", "agents.update", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index f6f052f8cc2..6276a5d4b31 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -27,6 +27,7 @@ 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 { toolsEffectiveHandlers } from "./server-methods/tools-effective.js"; import { ttsHandlers } from "./server-methods/tts.js"; import type { GatewayRequestHandlers, GatewayRequestOptions } from "./server-methods/types.js"; import { updateHandlers } from "./server-methods/update.js"; @@ -82,6 +83,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...wizardHandlers, ...talkHandlers, ...toolsCatalogHandlers, + ...toolsEffectiveHandlers, ...ttsHandlers, ...skillsHandlers, ...sessionsHandlers, diff --git a/src/gateway/server-methods/tools-catalog.ts b/src/gateway/server-methods/tools-catalog.ts index 2eec921c4c0..11462bdeaa2 100644 --- a/src/gateway/server-methods/tools-catalog.ts +++ b/src/gateway/server-methods/tools-catalog.ts @@ -15,6 +15,7 @@ import { ErrorCodes, errorShape, formatValidationErrors, + type ToolsCatalogResult, validateToolsCatalogParams, } from "../protocol/index.js"; import type { GatewayRequestHandlers, RespondFn } from "./types.js"; @@ -123,6 +124,33 @@ function buildPluginGroups(params: { .toSorted((a, b) => a.label.localeCompare(b.label)); } +export function buildToolsCatalogResult(params: { + cfg: ReturnType; + agentId?: string; + includePlugins?: boolean; +}): ToolsCatalogResult { + const agentId = params.agentId?.trim() || resolveDefaultAgentId(params.cfg); + 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: params.cfg, + agentId, + existingToolNames, + }), + ); + } + return { + agentId, + profiles: PROFILE_OPTIONS.map((profile) => ({ id: profile.id, label: profile.label })), + groups, + }; +} + export const toolsCatalogHandlers: GatewayRequestHandlers = { "tools.catalog": ({ params, respond }) => { if (!validateToolsCatalogParams(params)) { @@ -140,27 +168,13 @@ export const toolsCatalogHandlers: GatewayRequestHandlers = { 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, - { + buildToolsCatalogResult({ + cfg: resolved.cfg, agentId: resolved.agentId, - profiles: PROFILE_OPTIONS.map((profile) => ({ id: profile.id, label: profile.label })), - groups, - }, + includePlugins: params.includePlugins, + }), undefined, ); }, diff --git a/src/gateway/server-methods/tools-effective.test.ts b/src/gateway/server-methods/tools-effective.test.ts new file mode 100644 index 00000000000..8e614e0dd9d --- /dev/null +++ b/src/gateway/server-methods/tools-effective.test.ts @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveEffectiveToolInventory } from "../../agents/tools-effective-inventory.js"; +import { ErrorCodes } from "../protocol/index.js"; +import { loadSessionEntry } from "../session-utils.js"; +import { toolsEffectiveHandlers } from "./tools-effective.js"; + +vi.mock("../../config/config.js", () => ({ + loadConfig: vi.fn(() => ({})), +})); + +vi.mock("../../agents/agent-scope.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listAgentIds: vi.fn(() => ["main"]), + resolveDefaultAgentId: vi.fn(() => "main"), + resolveSessionAgentId: vi.fn(() => "main"), + }; +}); + +vi.mock("../../agents/tools-effective-inventory.js", () => ({ + resolveEffectiveToolInventory: vi.fn(() => ({ + agentId: "main", + profile: "coding", + groups: [ + { + id: "core", + label: "Built-in tools", + source: "core", + tools: [ + { + id: "exec", + label: "Exec", + description: "Run shell commands", + rawDescription: "Run shell commands", + source: "core", + }, + ], + }, + ], + })), +})); + +vi.mock("../session-utils.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSessionEntry: vi.fn(() => ({ + cfg: {}, + canonicalKey: "main:abc", + entry: { + sessionId: "session-1", + updatedAt: 1, + lastChannel: "telegram", + lastAccountId: "acct-1", + lastThreadId: "thread-2", + lastTo: "channel-1", + groupId: "group-4", + groupChannel: "#ops", + space: "workspace-5", + chatType: "group", + modelProvider: "openai", + model: "gpt-4.1", + }, + })), + resolveSessionModelRef: vi.fn(() => ({ provider: "openai", model: "gpt-4.1" })), + }; +}); + +vi.mock("../../utils/delivery-context.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + deliveryContextFromSession: vi.fn(() => ({ + channel: "telegram", + to: "channel-1", + accountId: "acct-1", + threadId: "thread-2", + })), + }; +}); + +vi.mock("../../auto-reply/reply/reply-threading.js", () => ({ + resolveReplyToMode: vi.fn(() => "first"), +})); + +type RespondCall = [boolean, unknown?, { code: number; message: string }?]; + +function createInvokeParams(params: Record) { + const respond = vi.fn(); + return { + respond, + invoke: async () => + await toolsEffectiveHandlers["tools.effective"]({ + params, + respond: respond as never, + context: {} as never, + client: null, + req: { type: "req", id: "req-1", method: "tools.effective" }, + isWebchatConnect: () => false, + }), + }; +} + +describe("tools.effective handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rejects invalid params", async () => { + const { respond, invoke } = createInvokeParams({ includePlugins: false }); + 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.effective params"); + }); + + it("rejects missing sessionKey", async () => { + const { respond, invoke } = createInvokeParams({}); + 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.effective params"); + }); + + it("rejects caller-supplied auth context params", async () => { + const { respond, invoke } = createInvokeParams({ senderIsOwner: 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.effective params"); + }); + + it("rejects unknown agent ids", async () => { + const { respond, invoke } = createInvokeParams({ + sessionKey: "main:abc", + 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("rejects unknown session keys", async () => { + vi.mocked(loadSessionEntry).mockReturnValueOnce({ + cfg: {}, + canonicalKey: "missing-session", + entry: undefined, + legacyKey: undefined, + storePath: "/tmp/sessions.json", + } as never); + const { respond, invoke } = createInvokeParams({ sessionKey: "missing-session" }); + 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 session key "missing-session"'); + }); + + it("returns the effective runtime inventory", async () => { + const { respond, invoke } = createInvokeParams({ sessionKey: "main:abc" }); + await invoke(); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(true); + expect(call?.[1]).toMatchObject({ + agentId: "main", + profile: "coding", + groups: [ + { + id: "core", + source: "core", + tools: [{ id: "exec", source: "core" }], + }, + ], + }); + expect(vi.mocked(resolveEffectiveToolInventory)).toHaveBeenCalledWith( + expect.objectContaining({ + senderIsOwner: false, + currentChannelId: "channel-1", + currentThreadTs: "thread-2", + accountId: "acct-1", + groupId: "group-4", + groupChannel: "#ops", + groupSpace: "workspace-5", + replyToMode: "first", + messageProvider: "telegram", + modelProvider: "openai", + modelId: "gpt-4.1", + }), + ); + }); + + it("passes senderIsOwner=true for admin-scoped callers", async () => { + const respond = vi.fn(); + await toolsEffectiveHandlers["tools.effective"]({ + params: { sessionKey: "main:abc" }, + respond: respond as never, + context: {} as never, + client: { + connect: { scopes: ["operator.admin"] }, + } as never, + req: { type: "req", id: "req-1", method: "tools.effective" }, + isWebchatConnect: () => false, + }); + expect(vi.mocked(resolveEffectiveToolInventory)).toHaveBeenCalledWith( + expect.objectContaining({ senderIsOwner: true }), + ); + }); + + it("rejects agent ids that do not match the session agent", async () => { + const { respond, invoke } = createInvokeParams({ + sessionKey: "main:abc", + agentId: "other", + }); + vi.mocked(loadSessionEntry).mockReturnValueOnce({ + cfg: {}, + canonicalKey: "main:abc", + entry: { + sessionId: "session-1", + updatedAt: 1, + }, + } as never); + 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 "other"'); + }); +}); diff --git a/src/gateway/server-methods/tools-effective.ts b/src/gateway/server-methods/tools-effective.ts new file mode 100644 index 00000000000..0f661711e01 --- /dev/null +++ b/src/gateway/server-methods/tools-effective.ts @@ -0,0 +1,159 @@ +import { listAgentIds, resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { resolveEffectiveToolInventory } from "../../agents/tools-effective-inventory.js"; +import { resolveReplyToMode } from "../../auto-reply/reply/reply-threading.js"; +import { loadConfig } from "../../config/config.js"; +import { deliveryContextFromSession } from "../../utils/delivery-context.js"; +import { ADMIN_SCOPE } from "../method-scopes.js"; +import { + ErrorCodes, + errorShape, + formatValidationErrors, + validateToolsEffectiveParams, +} from "../protocol/index.js"; +import { loadSessionEntry, resolveSessionModelRef } from "../session-utils.js"; +import type { GatewayRequestHandlers, RespondFn } from "./types.js"; + +function resolveRequestedAgentIdOrRespondError(params: { + rawAgentId: unknown; + cfg: ReturnType; + respond: RespondFn; +}) { + const knownAgents = listAgentIds(params.cfg); + const requestedAgentId = typeof params.rawAgentId === "string" ? params.rawAgentId.trim() : ""; + if (!requestedAgentId) { + return undefined; + } + if (!knownAgents.includes(requestedAgentId)) { + params.respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown agent id "${requestedAgentId}"`), + ); + return null; + } + return requestedAgentId; +} + +function resolveTrustedToolsEffectiveContext(params: { + sessionKey: string; + requestedAgentId?: string; + senderIsOwner: boolean; + respond: RespondFn; +}) { + const loaded = loadSessionEntry(params.sessionKey); + if (!loaded.entry) { + params.respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown session key "${params.sessionKey}"`), + ); + return null; + } + + const sessionAgentId = resolveSessionAgentId({ + sessionKey: loaded.canonicalKey ?? params.sessionKey, + config: loaded.cfg, + }); + if (params.requestedAgentId && params.requestedAgentId !== sessionAgentId) { + params.respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `agent id "${params.requestedAgentId}" does not match session agent "${sessionAgentId}"`, + ), + ); + return null; + } + + const delivery = deliveryContextFromSession(loaded.entry); + const resolvedModel = resolveSessionModelRef(loaded.cfg, loaded.entry, sessionAgentId); + return { + cfg: loaded.cfg, + agentId: sessionAgentId, + senderIsOwner: params.senderIsOwner, + modelProvider: resolvedModel.provider, + modelId: resolvedModel.model, + messageProvider: + delivery?.channel ?? + loaded.entry.lastChannel ?? + loaded.entry.channel ?? + loaded.entry.origin?.provider, + accountId: delivery?.accountId ?? loaded.entry.lastAccountId ?? loaded.entry.origin?.accountId, + currentChannelId: delivery?.to, + currentThreadTs: + delivery?.threadId != null + ? String(delivery.threadId) + : loaded.entry.lastThreadId != null + ? String(loaded.entry.lastThreadId) + : undefined, + groupId: loaded.entry.groupId, + groupChannel: loaded.entry.groupChannel, + groupSpace: loaded.entry.space, + replyToMode: resolveReplyToMode( + loaded.cfg, + delivery?.channel ?? + loaded.entry.lastChannel ?? + loaded.entry.channel ?? + loaded.entry.origin?.provider, + delivery?.accountId ?? loaded.entry.lastAccountId ?? loaded.entry.origin?.accountId, + loaded.entry.chatType ?? loaded.entry.origin?.chatType, + ), + }; +} + +export const toolsEffectiveHandlers: GatewayRequestHandlers = { + "tools.effective": ({ params, respond, client }) => { + if (!validateToolsEffectiveParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid tools.effective params: ${formatValidationErrors(validateToolsEffectiveParams.errors)}`, + ), + ); + return; + } + const cfg = loadConfig(); + const requestedAgentId = resolveRequestedAgentIdOrRespondError({ + rawAgentId: params.agentId, + cfg, + respond, + }); + if (requestedAgentId === null) { + return; + } + const trustedContext = resolveTrustedToolsEffectiveContext({ + sessionKey: params.sessionKey, + requestedAgentId, + senderIsOwner: Array.isArray(client?.connect?.scopes) + ? client.connect.scopes.includes(ADMIN_SCOPE) + : false, + respond, + }); + if (!trustedContext) { + return; + } + respond( + true, + resolveEffectiveToolInventory({ + cfg: trustedContext.cfg, + agentId: trustedContext.agentId, + sessionKey: params.sessionKey, + messageProvider: trustedContext.messageProvider, + modelProvider: trustedContext.modelProvider, + modelId: trustedContext.modelId, + senderIsOwner: trustedContext.senderIsOwner, + currentChannelId: trustedContext.currentChannelId, + currentThreadTs: trustedContext.currentThreadTs, + accountId: trustedContext.accountId, + groupId: trustedContext.groupId, + groupChannel: trustedContext.groupChannel, + groupSpace: trustedContext.groupSpace, + replyToMode: trustedContext.replyToMode, + }), + undefined, + ); + }, +}; diff --git a/src/gateway/server.tools-effective.test.ts b/src/gateway/server.tools-effective.test.ts new file mode 100644 index 00000000000..38df7fe4c3b --- /dev/null +++ b/src/gateway/server.tools-effective.test.ts @@ -0,0 +1,52 @@ +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.effective", () => { + it("returns effective tool inventory data", async () => { + await withServer(async (ws) => { + await connectOk(ws, { token: "secret", scopes: ["operator.read", "operator.write"] }); + const created = await rpcReq<{ key?: string }>(ws, "sessions.create", { + label: "Tools Effective Test", + }); + expect(created.ok).toBe(true); + const sessionKey = created.payload?.key; + expect(sessionKey).toBeTruthy(); + const res = await rpcReq<{ + agentId?: string; + groups?: Array<{ + id?: "core" | "plugin" | "channel"; + source?: "core" | "plugin" | "channel"; + tools?: Array<{ id?: string; source?: "core" | "plugin" | "channel" }>; + }>; + }>(ws, "tools.effective", { sessionKey }); + + expect(res.ok).toBe(true); + expect(res.payload?.agentId).toBeTruthy(); + expect((res.payload?.groups ?? []).length).toBeGreaterThan(0); + expect( + (res.payload?.groups ?? []).some((group) => + (group.tools ?? []).some((tool) => tool.id === "exec"), + ), + ).toBe(true); + }); + }); + + it("rejects unknown agent ids", async () => { + await withServer(async (ws) => { + await connectOk(ws, { token: "secret", scopes: ["operator.read", "operator.write"] }); + const created = await rpcReq<{ key?: string }>(ws, "sessions.create", { + label: "Tools Effective Test", + }); + expect(created.ok).toBe(true); + const unknownAgent = await rpcReq(ws, "tools.effective", { + sessionKey: created.payload?.key, + agentId: "does-not-exist", + }); + expect(unknownAgent.ok).toBe(false); + expect(unknownAgent.error?.message ?? "").toContain("unknown agent id"); + }); + }); +}); diff --git a/src/memory/search-manager.test.ts b/src/memory/search-manager.test.ts index 7e89b4d3f0b..5395f7fa7cc 100644 --- a/src/memory/search-manager.test.ts +++ b/src/memory/search-manager.test.ts @@ -219,6 +219,37 @@ describe("getMemorySearchManager caching", () => { expect(mockPrimary.close).toHaveBeenCalledTimes(2); }); + it("reports real qmd index counts for status-only requests", async () => { + const agentId = "status-counts-agent"; + const cfg = createQmdCfg(agentId); + mockPrimary.status.mockReturnValueOnce({ + ...createManagerStatus({ + backend: "qmd", + provider: "qmd", + model: "qmd", + requestedProvider: "qmd", + withMemorySourceCounts: true, + }), + files: 10, + chunks: 42, + sourceCounts: [{ source: "memory" as const, files: 10, chunks: 42 }], + }); + + const result = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); + const manager = requireManager(result); + + expect(manager.status()).toMatchObject({ + backend: "qmd", + files: 10, + chunks: 42, + sourceCounts: [{ source: "memory", files: 10, chunks: 42 }], + }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(createQmdManagerMock).toHaveBeenCalledWith( + expect.objectContaining({ agentId, mode: "status" }), + ); + }); + it("reuses cached full qmd manager for status-only requests", async () => { const agentId = "status-reuses-full-agent"; const cfg = createQmdCfg(agentId); diff --git a/src/plugin-sdk/google.ts b/src/plugin-sdk/google.ts index 244d3852b43..aac4a88c0c4 100644 --- a/src/plugin-sdk/google.ts +++ b/src/plugin-sdk/google.ts @@ -1,8 +1,6 @@ // Private Google-specific helpers used by bundled Google plugins. export { normalizeGoogleModelId } from "../agents/model-id-normalization.js"; -export { - DEFAULT_GOOGLE_API_BASE_URL, - normalizeGoogleApiBaseUrl, -} from "../infra/google-api-base-url.js"; +export { DEFAULT_GOOGLE_API_BASE_URL } from "../infra/google-api-base-url.js"; +export { normalizeGoogleApiBaseUrl } from "../infra/google-api-base-url.js"; export { parseGeminiAuth } from "../infra/gemini-auth.js"; diff --git a/src/plugin-sdk/provider-google.ts b/src/plugin-sdk/provider-google.ts index 721c94291c8..47b755ffb15 100644 --- a/src/plugin-sdk/provider-google.ts +++ b/src/plugin-sdk/provider-google.ts @@ -1,8 +1,6 @@ // Public Google provider helpers shared by bundled Google extensions. export { normalizeGoogleModelId } from "../agents/model-id-normalization.js"; -export { - DEFAULT_GOOGLE_API_BASE_URL, - normalizeGoogleApiBaseUrl, -} from "../infra/google-api-base-url.js"; +export { DEFAULT_GOOGLE_API_BASE_URL } from "../infra/google-api-base-url.js"; +export { normalizeGoogleApiBaseUrl } from "../infra/google-api-base-url.js"; export { parseGeminiAuth } from "../infra/gemini-auth.js"; diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index e47ffc805dc..06a2b9446e2 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -20,6 +20,13 @@ export function getPluginToolMeta(tool: AnyAgentTool): PluginToolMeta | undefine return pluginToolMeta.get(tool); } +export function copyPluginToolMeta(source: AnyAgentTool, target: AnyAgentTool): void { + const meta = pluginToolMeta.get(source); + if (meta) { + pluginToolMeta.set(target, meta); + } +} + function normalizeAllowlist(list?: string[]) { return new Set((list ?? []).map(normalizeToolName).filter(Boolean)); } diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 092e356a391..8f03bc822e8 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -215,14 +215,14 @@ export async function handleSendChat( // Intercept local slash commands (/status, /model, /compact, etc.) const parsed = parseSlashCommand(message); if (parsed?.command.executeLocal) { - if (isChatBusy(host) && shouldQueueLocalSlashCommand(parsed.command.name)) { + if (isChatBusy(host) && shouldQueueLocalSlashCommand(parsed.command.key)) { if (messageOverride == null) { host.chatMessage = ""; host.chatAttachments = []; } enqueueChatMessage(host, message, undefined, isChatResetCommand(message), { args: parsed.args, - name: parsed.command.name, + name: parsed.command.key, }); return; } @@ -231,7 +231,7 @@ export async function handleSendChat( host.chatMessage = ""; host.chatAttachments = []; } - await dispatchSlashCommand(host, parsed.command.name, parsed.args, { + await dispatchSlashCommand(host, parsed.command.key, parsed.args, { previousDraft: prevDraft, restoreDraft: Boolean(messageOverride && opts?.restoreDraft), }); @@ -260,7 +260,7 @@ export async function handleSendChat( } function shouldQueueLocalSlashCommand(name: string): boolean { - return !["stop", "focus", "export"].includes(name); + return !["stop", "focus", "export-session"].includes(name); } // ── Slash Command Dispatch ── @@ -295,7 +295,7 @@ async function dispatchSlashCommand( case "focus": host.onSlashAction?.("toggle-focus"); return; - case "export": + case "export-session": host.onSlashAction?.("export"); return; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 2baba7bce39..f4383e3e94d 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -2,6 +2,7 @@ import { html, nothing } from "lit"; import { buildAgentMainSessionKey, parseAgentSessionKey, + resolveAgentIdFromSessionKey, } from "../../../src/routing/session-key.js"; import { t } from "../i18n/index.ts"; import { getSafeLocalStorage } from "../local-storage.ts"; @@ -20,7 +21,12 @@ 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, loadToolsCatalog, saveAgentsConfig } from "./controllers/agents.ts"; +import { + loadAgents, + loadToolsCatalog, + loadToolsEffective, + saveAgentsConfig, +} from "./controllers/agents.ts"; import { loadChannels } from "./controllers/channels.ts"; import { loadChatHistory } from "./controllers/chat.ts"; import { @@ -323,6 +329,10 @@ export function renderApp(state: AppViewState) { state.agentsList?.defaultId ?? state.agentsList?.agents?.[0]?.id ?? null; + const activeSessionAgentId = resolveAgentIdFromSessionKey(state.sessionKey); + const toolsPanelUsesActiveSession = Boolean( + resolvedAgentId && activeSessionAgentId && resolvedAgentId === activeSessionAgentId, + ); const getCurrentConfigValue = () => state.configForm ?? (state.configSnapshot?.config as Record | null); const findAgentIndex = (agentId: string) => @@ -966,6 +976,13 @@ export function renderApp(state: AppViewState) { error: state.toolsCatalogError, result: state.toolsCatalogResult, }, + toolsEffective: { + loading: state.toolsEffectiveLoading, + error: state.toolsEffectiveError, + result: state.toolsEffectiveResult, + }, + runtimeSessionKey: state.sessionKey, + runtimeSessionMatchesSelectedAgent: toolsPanelUsesActiveSession, modelCatalog: state.chatModelCatalog ?? [], onRefresh: async () => { await loadAgents(state); @@ -986,6 +1003,12 @@ export function renderApp(state: AppViewState) { } if (state.agentsPanel === "tools" && refreshedAgentId) { void loadToolsCatalog(state, refreshedAgentId); + if (refreshedAgentId === resolveAgentIdFromSessionKey(state.sessionKey)) { + void loadToolsEffective(state, { + agentId: refreshedAgentId, + sessionKey: state.sessionKey, + }); + } } if (state.agentsPanel === "channels") { void loadChannels(state, false); @@ -1011,12 +1034,23 @@ export function renderApp(state: AppViewState) { state.toolsCatalogResult = null; state.toolsCatalogError = null; state.toolsCatalogLoading = false; + state.toolsEffectiveResult = null; + state.toolsEffectiveResultKey = null; + state.toolsEffectiveError = null; + state.toolsEffectiveLoading = false; + state.toolsEffectiveLoadingKey = null; void loadAgentIdentity(state, agentId); if (state.agentsPanel === "files") { void loadAgentFiles(state, agentId); } if (state.agentsPanel === "tools") { void loadToolsCatalog(state, agentId); + if (agentId === resolveAgentIdFromSessionKey(state.sessionKey)) { + void loadToolsEffective(state, { + agentId, + sessionKey: state.sessionKey, + }); + } } if (state.agentsPanel === "skills") { void loadAgentSkills(state, agentId); @@ -1046,6 +1080,24 @@ export function renderApp(state: AppViewState) { ) { void loadToolsCatalog(state, resolvedAgentId); } + if (resolvedAgentId === resolveAgentIdFromSessionKey(state.sessionKey)) { + const toolsRequestKey = `${resolvedAgentId}:${state.sessionKey}`; + if ( + state.toolsEffectiveResultKey !== toolsRequestKey || + state.toolsEffectiveError + ) { + void loadToolsEffective(state, { + agentId: resolvedAgentId, + sessionKey: state.sessionKey, + }); + } + } else { + state.toolsEffectiveResult = null; + state.toolsEffectiveResultKey = null; + state.toolsEffectiveError = null; + state.toolsEffectiveLoading = false; + state.toolsEffectiveLoadingKey = null; + } } if (panel === "channels") { void loadChannels(state, false); diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 04465575a6f..da586aee6ea 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -163,6 +163,11 @@ export type AppViewState = { toolsCatalogLoading: boolean; toolsCatalogError: string | null; toolsCatalogResult: ToolsCatalogResult | null; + toolsEffectiveLoading: boolean; + toolsEffectiveLoadingKey: string | null; + toolsEffectiveResultKey: string | null; + toolsEffectiveError: string | null; + toolsEffectiveResult: import("./types.js").ToolsEffectiveResult | 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 fe67292eab3..3ea81a40097 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -1,5 +1,6 @@ import { LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; +import { resolveAgentIdFromSessionKey } from "../../../src/routing/session-key.js"; import { i18n, I18nController, isSupportedLocale } from "../i18n/index.ts"; import { handleChannelConfigReload as handleChannelConfigReloadInternal, @@ -54,6 +55,7 @@ import { import type { AppViewState } from "./app-view-state.ts"; import { normalizeAssistantIdentity } from "./assistant-identity.ts"; import { exportChatMarkdown } from "./chat/export.ts"; +import { loadToolsEffective as loadToolsEffectiveInternal } from "./controllers/agents.ts"; import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts"; import type { DevicePairingList } from "./controllers/devices.ts"; import type { ExecApprovalRequest } from "./controllers/exec-approval.ts"; @@ -84,6 +86,7 @@ import type { StatusSummary, NostrProfile, ToolsCatalogResult, + ToolsEffectiveResult, } from "./types.ts"; import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types.ts"; import { generateUUID } from "./uuid.ts"; @@ -259,6 +262,11 @@ export class OpenClawApp extends LitElement { @state() toolsCatalogLoading = false; @state() toolsCatalogError: string | null = null; @state() toolsCatalogResult: ToolsCatalogResult | null = null; + @state() toolsEffectiveLoading = false; + @state() toolsEffectiveLoadingKey: string | null = null; + @state() toolsEffectiveResultKey: string | null = null; + @state() toolsEffectiveError: string | null = null; + @state() toolsEffectiveResult: ToolsEffectiveResult | null = null; @state() agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron" = "files"; @state() agentFilesLoading = false; @state() agentFilesError: string | null = null; @@ -496,6 +504,22 @@ export class OpenClawApp extends LitElement { protected updated(changed: Map) { handleUpdated(this as unknown as Parameters[0], changed); + if (!changed.has("sessionKey") || this.agentsPanel !== "tools") { + return; + } + const activeSessionAgentId = resolveAgentIdFromSessionKey(this.sessionKey); + if (this.agentsSelectedId && this.agentsSelectedId === activeSessionAgentId) { + void loadToolsEffectiveInternal(this, { + agentId: this.agentsSelectedId, + sessionKey: this.sessionKey, + }); + return; + } + this.toolsEffectiveResult = null; + this.toolsEffectiveResultKey = null; + this.toolsEffectiveError = null; + this.toolsEffectiveLoading = false; + this.toolsEffectiveLoadingKey = null; } connect() { diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index 11e113193fe..bbbe15f5f4f 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -80,7 +80,7 @@ export async function executeSlashCommand( return await executeFast(client, sessionKey, args); case "verbose": return await executeVerbose(client, sessionKey, args); - case "export": + case "export-session": return { content: "Exporting session...", action: "export" }; case "usage": return await executeUsage(client, sessionKey); diff --git a/ui/src/ui/chat/slash-commands.node.test.ts b/ui/src/ui/chat/slash-commands.node.test.ts index 5b8dc2a8683..0722d32b0e5 100644 --- a/ui/src/ui/chat/slash-commands.node.test.ts +++ b/ui/src/ui/chat/slash-commands.node.test.ts @@ -39,4 +39,53 @@ describe("parseSlashCommand", () => { args: "", }); }); + + it("includes shared /tools with shared arg hints", () => { + const tools = SLASH_COMMANDS.find((entry) => entry.name === "tools"); + expect(tools).toMatchObject({ + key: "tools", + description: "List available runtime tools.", + argOptions: ["compact", "verbose"], + executeLocal: false, + }); + expect(parseSlashCommand("/tools verbose")).toMatchObject({ + command: { name: "tools" }, + args: "verbose", + }); + }); + + it("parses slash aliases through the shared registry", () => { + const exportCommand = SLASH_COMMANDS.find((entry) => entry.key === "export-session"); + expect(exportCommand).toMatchObject({ + name: "export-session", + aliases: ["export"], + executeLocal: true, + }); + expect(parseSlashCommand("/export")).toMatchObject({ + command: { key: "export-session" }, + args: "", + }); + expect(parseSlashCommand("/export-session")).toMatchObject({ + command: { key: "export-session" }, + args: "", + }); + }); + + it("keeps canonical long-form slash names as the primary menu command", () => { + expect(SLASH_COMMANDS.find((entry) => entry.key === "verbose")).toMatchObject({ + name: "verbose", + aliases: ["v"], + }); + expect(SLASH_COMMANDS.find((entry) => entry.key === "think")).toMatchObject({ + name: "think", + aliases: expect.arrayContaining(["thinking", "t"]), + }); + }); + + it("keeps focus as a local slash command", () => { + expect(parseSlashCommand("/focus")).toMatchObject({ + command: { key: "focus", executeLocal: true }, + args: "", + }); + }); }); diff --git a/ui/src/ui/chat/slash-commands.ts b/ui/src/ui/chat/slash-commands.ts index d6b5bc4c337..a31a2deeac3 100644 --- a/ui/src/ui/chat/slash-commands.ts +++ b/ui/src/ui/chat/slash-commands.ts @@ -1,9 +1,16 @@ +import { buildBuiltinChatCommands } from "../../../../src/auto-reply/commands-registry.shared.js"; +import type { + ChatCommandDefinition, + CommandArgChoice, +} from "../../../../src/auto-reply/commands-registry.types.js"; import type { IconName } from "../icons.ts"; export type SlashCommandCategory = "session" | "model" | "agents" | "tools"; export type SlashCommandDef = { + key: string; name: string; + aliases?: string[]; description: string; args?: string; icon?: IconName; @@ -16,147 +23,167 @@ export type SlashCommandDef = { shortcut?: string; }; -export const SLASH_COMMANDS: SlashCommandDef[] = [ - // ── Session ── - { - name: "new", - description: "Start a new session", - icon: "plus", - category: "session", - executeLocal: true, - }, - { - name: "reset", - description: "Reset current session", - icon: "refresh", - category: "session", - executeLocal: true, - }, - { - name: "compact", - description: "Compact session context", - icon: "loader", - category: "session", - executeLocal: true, - }, - { - name: "stop", - description: "Stop current run", - icon: "stop", - category: "session", - executeLocal: true, - }, +const COMMAND_ICON_OVERRIDES: Partial> = { + help: "book", + status: "barChart", + usage: "barChart", + export: "download", + export_session: "download", + tools: "terminal", + skill: "zap", + commands: "book", + new: "plus", + reset: "refresh", + compact: "loader", + stop: "stop", + clear: "trash", + focus: "eye", + unfocus: "eye", + model: "brain", + models: "brain", + think: "brain", + verbose: "terminal", + fast: "zap", + agents: "monitor", + subagents: "folder", + kill: "x", + steer: "send", + tts: "volume2", +}; + +const LOCAL_COMMANDS = new Set([ + "help", + "new", + "reset", + "stop", + "compact", + "focus", + "model", + "think", + "fast", + "verbose", + "export-session", + "usage", + "agents", + "kill", +]); + +const UI_ONLY_COMMANDS: SlashCommandDef[] = [ { + key: "clear", name: "clear", description: "Clear chat history", icon: "trash", category: "session", executeLocal: true, }, - { - name: "focus", - description: "Toggle focus mode", - icon: "eye", - category: "session", - executeLocal: true, - }, +]; - // ── Model ── - { - name: "model", - description: "Show or set model", - args: "", - icon: "brain", - category: "model", - executeLocal: true, - }, - { - name: "think", - description: "Set thinking level", - args: "", - icon: "brain", - category: "model", - executeLocal: true, - argOptions: ["off", "low", "medium", "high"], - }, - { - name: "verbose", - description: "Toggle verbose mode", - args: "", - icon: "terminal", - category: "model", - executeLocal: true, - argOptions: ["on", "off", "full"], - }, - { - name: "fast", - description: "Toggle fast mode", - args: "", - icon: "zap", - category: "model", - executeLocal: true, - argOptions: ["status", "on", "off"], - }, +const CATEGORY_OVERRIDES: Partial> = { + help: "tools", + commands: "tools", + tools: "tools", + skill: "tools", + status: "tools", + export_session: "tools", + usage: "tools", + tts: "tools", + agents: "agents", + subagents: "agents", + kill: "agents", + steer: "agents", + session: "session", + stop: "session", + reset: "session", + new: "session", + compact: "session", + focus: "session", + unfocus: "session", + model: "model", + models: "model", + think: "model", + verbose: "model", + fast: "model", + reasoning: "model", + elevated: "model", + queue: "model", +}; - // ── Tools ── - { - name: "help", - description: "Show available commands", - icon: "book", - category: "tools", - executeLocal: true, - }, - { - name: "status", - description: "Show session status", - icon: "barChart", - category: "tools", - }, - { - name: "export", - description: "Export session to Markdown", - icon: "download", - category: "tools", - executeLocal: true, - }, - { - name: "usage", - description: "Show token usage", - icon: "barChart", - category: "tools", - executeLocal: true, - }, +function normalizeUiKey(command: ChatCommandDefinition): string { + return command.key.replace(/[:.-]/g, "_"); +} - // ── Agents ── - { - name: "agents", - description: "List agents", - icon: "monitor", - category: "agents", - executeLocal: true, - }, - { - name: "kill", - description: "Abort sub-agents", - args: "", - icon: "x", - category: "agents", - executeLocal: true, - }, - { - name: "skill", - description: "Run a skill", - args: "", - icon: "zap", - category: "tools", - }, - { - name: "steer", - description: "Steer a sub-agent", - args: " ", - icon: "send", - category: "agents", - }, +function getSlashAliases(command: ChatCommandDefinition): string[] { + return command.textAliases + .map((alias) => alias.trim()) + .filter((alias) => alias.startsWith("/")) + .map((alias) => alias.slice(1)); +} + +function getPrimarySlashName(command: ChatCommandDefinition): string | null { + const aliases = getSlashAliases(command); + if (aliases.length === 0) { + return null; + } + return aliases[0] ?? null; +} + +function formatArgs(command: ChatCommandDefinition): string | undefined { + if (!command.args?.length) { + return undefined; + } + return command.args + .map((arg) => { + const token = `<${arg.name}>`; + return arg.required ? token : `[${arg.name}]`; + }) + .join(" "); +} + +function choiceToValue(choice: CommandArgChoice): string { + return typeof choice === "string" ? choice : choice.value; +} + +function getArgOptions(command: ChatCommandDefinition): string[] | undefined { + const firstArg = command.args?.[0]; + if (!firstArg || typeof firstArg.choices === "function") { + return undefined; + } + const options = firstArg.choices?.map(choiceToValue).filter(Boolean); + return options?.length ? options : undefined; +} + +function mapCategory(command: ChatCommandDefinition): SlashCommandCategory { + return CATEGORY_OVERRIDES[normalizeUiKey(command)] ?? "tools"; +} + +function mapIcon(command: ChatCommandDefinition): IconName | undefined { + return COMMAND_ICON_OVERRIDES[normalizeUiKey(command)] ?? "terminal"; +} + +function toSlashCommand(command: ChatCommandDefinition): SlashCommandDef | null { + const name = getPrimarySlashName(command); + if (!name) { + return null; + } + return { + key: command.key, + name, + aliases: getSlashAliases(command).filter((alias) => alias !== name), + description: command.description, + args: formatArgs(command), + icon: mapIcon(command), + category: mapCategory(command), + executeLocal: LOCAL_COMMANDS.has(command.key), + argOptions: getArgOptions(command), + }; +} + +export const SLASH_COMMANDS: SlashCommandDef[] = [ + ...buildBuiltinChatCommands() + .map(toSlashCommand) + .filter((command): command is SlashCommandDef => command !== null), + ...UI_ONLY_COMMANDS, ]; const CATEGORY_ORDER: SlashCommandCategory[] = ["session", "model", "tools", "agents"]; @@ -172,7 +199,10 @@ export function getSlashCommandCompletions(filter: string): SlashCommandDef[] { const lower = filter.toLowerCase(); const commands = lower ? SLASH_COMMANDS.filter( - (cmd) => cmd.name.startsWith(lower) || cmd.description.toLowerCase().includes(lower), + (cmd) => + cmd.name.startsWith(lower) || + cmd.aliases?.some((alias) => alias.toLowerCase().startsWith(lower)) || + cmd.description.toLowerCase().includes(lower), ) : SLASH_COMMANDS; return commands.toSorted((a, b) => { @@ -181,7 +211,6 @@ export function getSlashCommandCompletions(filter: string): SlashCommandDef[] { if (ai !== bi) { return ai - bi; } - // Exact prefix matches first if (lower) { const aExact = a.name.startsWith(lower) ? 0 : 1; const bExact = b.name.startsWith(lower) ? 0 : 1; @@ -198,10 +227,6 @@ export type ParsedSlashCommand = { args: string; }; -/** - * Parse a message as a slash command. Returns null if it doesn't match. - * Supports `/command`, `/command args...`, and `/command: args...`. - */ export function parseSlashCommand(text: string): ParsedSlashCommand | null { const trimmed = text.trim(); if (!trimmed.startsWith("/")) { @@ -221,7 +246,12 @@ export function parseSlashCommand(text: string): ParsedSlashCommand | null { return null; } - const command = SLASH_COMMANDS.find((cmd) => cmd.name === name.toLowerCase()); + const normalizedName = name.toLowerCase(); + const command = SLASH_COMMANDS.find( + (cmd) => + cmd.name === normalizedName || + cmd.aliases?.some((alias) => alias.toLowerCase() === normalizedName), + ); if (!command) { return null; } diff --git a/ui/src/ui/controllers/agents.test.ts b/ui/src/ui/controllers/agents.test.ts index a026d447cf9..497609dc43e 100644 --- a/ui/src/ui/controllers/agents.test.ts +++ b/ui/src/ui/controllers/agents.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { loadAgents, loadToolsCatalog, saveAgentsConfig } from "./agents.ts"; +import { loadAgents, loadToolsCatalog, loadToolsEffective, saveAgentsConfig } from "./agents.ts"; import type { AgentsConfigSaveState, AgentsState } from "./agents.ts"; function createState(): { state: AgentsState; request: ReturnType } { @@ -16,6 +16,11 @@ function createState(): { state: AgentsState; request: ReturnType toolsCatalogLoading: false, toolsCatalogError: null, toolsCatalogResult: null, + toolsEffectiveLoading: false, + toolsEffectiveLoadingKey: null, + toolsEffectiveResultKey: null, + toolsEffectiveError: null, + toolsEffectiveResult: null, }; return { state, request }; } @@ -151,6 +156,56 @@ describe("loadToolsCatalog", () => { }); }); +describe("loadToolsEffective", () => { + it("loads effective tools for the active session", async () => { + const { state, request } = createState(); + const payload = { + agentId: "main", + profile: "coding", + groups: [ + { + id: "core", + label: "Built-in tools", + source: "core", + tools: [ + { + id: "read", + label: "Read", + description: "Read files", + rawDescription: "Read files", + source: "core", + }, + ], + }, + ], + }; + request.mockResolvedValue(payload); + + await loadToolsEffective(state, { agentId: "main", sessionKey: "main" }); + + expect(request).toHaveBeenCalledWith("tools.effective", { + agentId: "main", + sessionKey: "main", + }); + expect(state.toolsEffectiveResult).toEqual(payload); + expect(state.toolsEffectiveResultKey).toBe("main:main"); + expect(state.toolsEffectiveError).toBeNull(); + expect(state.toolsEffectiveLoading).toBe(false); + }); + + it("captures effective-tool request errors", async () => { + const { state, request } = createState(); + request.mockRejectedValue(new Error("gateway unavailable")); + + await loadToolsEffective(state, { agentId: "main", sessionKey: "main" }); + + expect(state.toolsEffectiveResult).toBeNull(); + expect(state.toolsEffectiveResultKey).toBeNull(); + expect(state.toolsEffectiveError).toContain("gateway unavailable"); + expect(state.toolsEffectiveLoading).toBe(false); + }); +}); + describe("saveAgentsConfig", () => { it("restores the pre-save agent after reload when it still exists", async () => { const { state, request } = createSaveState(); diff --git a/ui/src/ui/controllers/agents.ts b/ui/src/ui/controllers/agents.ts index ca02843ef57..363b0a5cc20 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, ToolsCatalogResult } from "../types.ts"; +import type { AgentsListResult, ToolsCatalogResult, ToolsEffectiveResult } from "../types.ts"; import { saveConfig } from "./config.ts"; import type { ConfigState } from "./config.ts"; import { @@ -18,6 +18,11 @@ export type AgentsState = { toolsCatalogLoadingAgentId?: string | null; toolsCatalogError: string | null; toolsCatalogResult: ToolsCatalogResult | null; + toolsEffectiveLoading: boolean; + toolsEffectiveLoadingKey?: string | null; + toolsEffectiveResultKey?: string | null; + toolsEffectiveError: string | null; + toolsEffectiveResult: ToolsEffectiveResult | null; }; export type AgentsConfigSaveState = AgentsState & ConfigState; @@ -96,6 +101,57 @@ export async function loadToolsCatalog(state: AgentsState, agentId: string) { } } +export async function loadToolsEffective( + state: AgentsState, + params: { agentId: string; sessionKey: string }, +) { + const resolvedAgentId = params.agentId.trim(); + const resolvedSessionKey = params.sessionKey.trim(); + const requestKey = `${resolvedAgentId}:${resolvedSessionKey}`; + if (!state.client || !state.connected || !resolvedAgentId || !resolvedSessionKey) { + return; + } + if (state.toolsEffectiveLoading && state.toolsEffectiveLoadingKey === requestKey) { + return; + } + state.toolsEffectiveLoading = true; + state.toolsEffectiveLoadingKey = requestKey; + state.toolsEffectiveResultKey = null; + state.toolsEffectiveError = null; + state.toolsEffectiveResult = null; + try { + const res = await state.client.request("tools.effective", { + agentId: resolvedAgentId, + sessionKey: resolvedSessionKey, + }); + if (state.toolsEffectiveLoadingKey !== requestKey) { + return; + } + if (state.agentsSelectedId && state.agentsSelectedId !== resolvedAgentId) { + return; + } + state.toolsEffectiveResultKey = requestKey; + state.toolsEffectiveResult = res; + } catch (err) { + if (state.toolsEffectiveLoadingKey !== requestKey) { + return; + } + if (state.agentsSelectedId && state.agentsSelectedId !== resolvedAgentId) { + return; + } + state.toolsEffectiveResult = null; + state.toolsEffectiveResultKey = null; + state.toolsEffectiveError = isMissingOperatorReadScopeError(err) + ? formatMissingOperatorReadScopeMessage("effective tools") + : String(err); + } finally { + if (state.toolsEffectiveLoadingKey === requestKey) { + state.toolsEffectiveLoadingKey = null; + state.toolsEffectiveLoading = false; + } + } +} + export async function saveAgentsConfig(state: AgentsConfigSaveState) { const selectedBefore = state.agentsSelectedId; await saveConfig(state); diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index cdbc54a9cdd..4b7ce526ab5 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -656,6 +656,12 @@ export type ToolCatalogGroup = import("../../../src/gateway/protocol/schema/types.js").ToolCatalogGroup; export type ToolsCatalogResult = import("../../../src/gateway/protocol/schema/types.js").ToolsCatalogResult; +export type ToolsEffectiveEntry = + import("../../../src/gateway/protocol/schema/types.js").ToolsEffectiveEntry; +export type ToolsEffectiveGroup = + import("../../../src/gateway/protocol/schema/types.js").ToolsEffectiveGroup; +export type ToolsEffectiveResult = + import("../../../src/gateway/protocol/schema/types.js").ToolsEffectiveResult; export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"; 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 index 1917e982e44..a1cb98d96b1 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts @@ -16,6 +16,11 @@ function createBaseParams(overrides: Partial toolsCatalogLoading: false, toolsCatalogError: null, toolsCatalogResult: null, + toolsEffectiveLoading: false, + toolsEffectiveError: null, + toolsEffectiveResult: null, + runtimeSessionKey: "main", + runtimeSessionMatchesSelectedAgent: true, onProfileChange: () => undefined, onOverridesChange: () => undefined, onConfigReload: () => undefined, @@ -99,4 +104,42 @@ describe("agents tools panel (browser)", () => { expect(container.textContent ?? "").toContain("Could not load runtime tool catalog"); }); + + it("renders effective runtime tools separately from the config catalog", async () => { + const container = document.createElement("div"); + render( + renderAgentTools( + createBaseParams({ + toolsEffectiveResult: { + agentId: "main", + profile: "messaging", + groups: [ + { + id: "channel", + label: "Channel tools", + source: "channel", + tools: [ + { + id: "message", + label: "Message Actions", + description: "Send and manage messages in this channel", + rawDescription: "Send and manage messages in this channel", + source: "channel", + channelId: "discord", + }, + ], + }, + ], + }, + }), + ), + container, + ); + await Promise.resolve(); + + const text = container.textContent ?? ""; + expect(text).toContain("Available Right Now"); + expect(text).toContain("Message Actions"); + expect(text).toContain("Channel: discord"); + }); }); diff --git a/ui/src/ui/views/agents-panels-tools-skills.ts b/ui/src/ui/views/agents-panels-tools-skills.ts index 7ddb0c8ed86..f54e3a4b8a0 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.ts @@ -1,6 +1,11 @@ import { html, nothing } from "lit"; import { normalizeToolName } from "../../../../src/agents/tool-policy-shared.js"; -import type { SkillStatusEntry, SkillStatusReport, ToolsCatalogResult } from "../types.ts"; +import type { + SkillStatusEntry, + SkillStatusReport, + ToolsCatalogResult, + ToolsEffectiveResult, +} from "../types.ts"; import { type AgentToolEntry, type AgentToolSection, @@ -41,6 +46,20 @@ function renderToolBadges(section: AgentToolSection, tool: AgentToolEntry) { `; } +function renderEffectiveToolBadge(tool: { + source: "core" | "plugin" | "channel"; + pluginId?: string; + channelId?: string; +}) { + if (tool.source === "plugin") { + return tool.pluginId ? `Connected: ${tool.pluginId}` : "Connected"; + } + if (tool.source === "channel") { + return tool.channelId ? `Channel: ${tool.channelId}` : "Channel"; + } + return "Built-in"; +} + export function renderAgentTools(params: { agentId: string; configForm: Record | null; @@ -50,6 +69,11 @@ export function renderAgentTools(params: { toolsCatalogLoading: boolean; toolsCatalogError: string | null; toolsCatalogResult: ToolsCatalogResult | null; + toolsEffectiveLoading: boolean; + toolsEffectiveError: string | null; + toolsEffectiveResult: ToolsEffectiveResult | null; + runtimeSessionKey: string; + runtimeSessionMatchesSelectedAgent: boolean; onProfileChange: (agentId: string, profile: string | null, clearAllow: boolean) => void; onOverridesChange: (agentId: string, alsoAllow: string[], deny: string[]) => void; onConfigReload: () => void; @@ -237,6 +261,66 @@ export function renderAgentTools(params: { } +
+
Available Right Now
+
+ What this agent can use in the current chat session. + ${params.runtimeSessionKey || "no session"} +
+ ${ + !params.runtimeSessionMatchesSelectedAgent + ? html` +
+ Switch chat to this agent to view its live runtime tools. +
+ ` + : params.toolsEffectiveLoading && + !params.toolsEffectiveResult && + !params.toolsEffectiveError + ? html` +
Loading available tools…
+ ` + : params.toolsEffectiveError + ? html` +
+ Could not load available tools for this session. +
+ ` + : (params.toolsEffectiveResult?.groups?.length ?? 0) === 0 + ? html` +
+ No tools are available for this session right now. +
+ ` + : html` +
+ ${params.toolsEffectiveResult?.groups.map( + (group) => html` +
+
${group.label}
+
+ ${group.tools.map((tool) => { + return html` +
+
+
${tool.label}
+
${tool.description}
+
+ ${renderEffectiveToolBadge(tool)} +
+
+
+ `; + })} +
+
+ `, + )} +
+ ` + } +
+
Quick Presets
diff --git a/ui/src/ui/views/agents.test.ts b/ui/src/ui/views/agents.test.ts index 64388ee9b9d..0735790ee7a 100644 --- a/ui/src/ui/views/agents.test.ts +++ b/ui/src/ui/views/agents.test.ts @@ -86,6 +86,13 @@ function createProps(overrides: Partial = {}): AgentsProps { error: null, result: null, }, + toolsEffective: { + loading: false, + error: null, + result: null, + }, + runtimeSessionKey: "main", + runtimeSessionMatchesSelectedAgent: false, modelCatalog: [], onRefresh: () => undefined, onSelectAgent: () => undefined, diff --git a/ui/src/ui/views/agents.ts b/ui/src/ui/views/agents.ts index b6a8bb4098d..972cf154869 100644 --- a/ui/src/ui/views/agents.ts +++ b/ui/src/ui/views/agents.ts @@ -9,6 +9,7 @@ import type { ModelCatalogEntry, SkillStatusReport, ToolsCatalogResult, + ToolsEffectiveResult, } from "../types.ts"; import { renderAgentOverview } from "./agents-panels-overview.ts"; import { @@ -66,6 +67,12 @@ export type ToolsCatalogState = { result: ToolsCatalogResult | null; }; +export type ToolsEffectiveState = { + loading: boolean; + error: string | null; + result: ToolsEffectiveResult | null; +}; + export type AgentsProps = { basePath: string; loading: boolean; @@ -82,6 +89,9 @@ export type AgentsProps = { agentIdentityById: Record; agentSkills: AgentSkillsState; toolsCatalog: ToolsCatalogState; + toolsEffective: ToolsEffectiveState; + runtimeSessionKey: string; + runtimeSessionMatchesSelectedAgent: boolean; modelCatalog: ModelCatalogEntry[]; onRefresh: () => void; onSelectAgent: (agentId: string) => void; @@ -254,6 +264,12 @@ export function renderAgents(props: AgentsProps) { toolsCatalogLoading: props.toolsCatalog.loading, toolsCatalogError: props.toolsCatalog.error, toolsCatalogResult: props.toolsCatalog.result, + toolsEffectiveLoading: props.toolsEffective.loading, + toolsEffectiveError: props.toolsEffective.error, + toolsEffectiveResult: props.toolsEffective.result, + runtimeSessionKey: props.runtimeSessionKey, + runtimeSessionMatchesSelectedAgent: + props.runtimeSessionMatchesSelectedAgent, onProfileChange: props.onToolsProfileChange, onOverridesChange: props.onToolsOverridesChange, onConfigReload: props.onConfigReload,