From 9f280454ba874e3ba4196cb4ba161f9eac1a84db Mon Sep 17 00:00:00 2001 From: Doug von Kohorn Date: Mon, 19 Jan 2026 13:11:25 +0100 Subject: [PATCH] feat: tool-dispatch skill commands --- docs/tools/skills.md | 6 ++ docs/tools/slash-commands.md | 2 + ...skills.buildworkspaceskillcommands.test.ts | 14 ++++ src/agents/skills/types.ts | 13 ++++ src/agents/skills/workspace.ts | 45 +++++++++++++ .../reply/get-reply-inline-actions.ts | 67 +++++++++++++++++++ src/auto-reply/reply/get-reply.ts | 1 + 7 files changed, 148 insertions(+) diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 511702ffb..2a5838af5 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -82,6 +82,12 @@ Notes: - `homepage` — URL surfaced as “Website” in the macOS Skills UI (also supported via `metadata.clawdbot.homepage`). - `user-invocable` — `true|false` (default: `true`). When `true`, the skill is exposed as a user slash command. - `disable-model-invocation` — `true|false` (default: `false`). When `true`, the skill is excluded from the model prompt (still available via user invocation). + - `command-dispatch` — `tool` (optional). When set to `tool`, the slash command bypasses the model and dispatches directly to a tool. + - `command-tool` — tool name to invoke when `command-dispatch: tool` is set. + - `command-arg-mode` — `raw` (default). For tool dispatch, forwards the raw args string to the tool (no core parsing). + + The tool is invoked with params: + `{ command: "", commandName: "", skillName: "" }`. ## Gating (load-time filters) diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 8e64de65c..7ddc2f4e7 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -102,6 +102,8 @@ Notes: - Currently: `/help`, `/commands`, `/status`, `/whoami` (`/id`). - Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text. - **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`). + - By default, skill commands are forwarded to the model as a normal request. + - Skills may optionally declare `command-dispatch: tool` to route the command directly to a tool (deterministic, no model). - **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. ## Usage surfaces (what shows where) diff --git a/src/agents/skills.buildworkspaceskillcommands.test.ts b/src/agents/skills.buildworkspaceskillcommands.test.ts index 369166979..ff40b9443 100644 --- a/src/agents/skills.buildworkspaceskillcommands.test.ts +++ b/src/agents/skills.buildworkspaceskillcommands.test.ts @@ -89,4 +89,18 @@ describe("buildWorkspaceSkillCommandSpecs", () => { expect(longCmd?.description.endsWith("…")).toBe(true); expect(shortCmd?.description).toBe("Short description"); }); + + it("includes tool-dispatch metadata from frontmatter", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-")); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "tool-dispatch"), + name: "tool-dispatch", + description: "Dispatch to a tool", + frontmatterExtra: "command-dispatch: tool\ncommand-tool: sessions_send", + }); + + const commands = buildWorkspaceSkillCommandSpecs(workspaceDir); + const cmd = commands.find((entry) => entry.skillName === "tool-dispatch"); + expect(cmd?.dispatch).toEqual({ kind: "tool", toolName: "sessions_send", argMode: "raw" }); + }); }); diff --git a/src/agents/skills/types.ts b/src/agents/skills/types.ts index f8d64f7f4..1b39b77d6 100644 --- a/src/agents/skills/types.ts +++ b/src/agents/skills/types.ts @@ -31,10 +31,23 @@ export type SkillInvocationPolicy = { disableModelInvocation: boolean; }; +export type SkillCommandDispatchSpec = { + kind: "tool"; + /** Name of the tool to invoke (AnyAgentTool.name). */ + toolName: string; + /** + * How to forward user-provided args to the tool. + * - raw: forward the raw args string (no core parsing). + */ + argMode?: "raw"; +}; + export type SkillCommandSpec = { name: string; skillName: string; description: string; + /** Optional deterministic dispatch behavior for this command. */ + dispatch?: SkillCommandDispatchSpec; }; export type SkillsInstallPreferences = { diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index b231e2d45..93b7a97cc 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -357,10 +357,55 @@ export function buildWorkspaceSkillCommandSpecs( rawDescription.length > SKILL_COMMAND_DESCRIPTION_MAX_LENGTH ? rawDescription.slice(0, SKILL_COMMAND_DESCRIPTION_MAX_LENGTH - 1) + "…" : rawDescription; + const dispatch = (() => { + const kindRaw = ( + entry.frontmatter?.["command-dispatch"] ?? + entry.frontmatter?.["command_dispatch"] ?? + "" + ) + .trim() + .toLowerCase(); + if (!kindRaw) return undefined; + if (kindRaw !== "tool") return undefined; + + const toolName = ( + entry.frontmatter?.["command-tool"] ?? + entry.frontmatter?.["command_tool"] ?? + "" + ).trim(); + if (!toolName) { + debugSkillCommandOnce( + `dispatch:missingTool:${rawName}`, + `Skill command "/${unique}" requested tool dispatch but did not provide command-tool. Ignoring dispatch.`, + { skillName: rawName, command: unique }, + ); + return undefined; + } + + const argModeRaw = ( + entry.frontmatter?.["command-arg-mode"] ?? + entry.frontmatter?.["command_arg_mode"] ?? + "" + ) + .trim() + .toLowerCase(); + const argMode = !argModeRaw || argModeRaw === "raw" ? "raw" : null; + if (!argMode) { + debugSkillCommandOnce( + `dispatch:badArgMode:${rawName}:${argModeRaw}`, + `Skill command "/${unique}" requested tool dispatch but has unknown command-arg-mode. Falling back to raw.`, + { skillName: rawName, command: unique, argMode: argModeRaw }, + ); + } + + return { kind: "tool", toolName, argMode: "raw" } as const; + })(); + specs.push({ name: unique, skillName: rawName, description, + ...(dispatch ? { dispatch } : {}), }); } return specs; diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index 825df3445..14bc024c0 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -14,6 +14,8 @@ import { extractInlineSimpleCommand } from "./reply-inline.js"; import type { TypingController } from "./typing.js"; import { listSkillCommandsForWorkspace, resolveSkillCommandInvocation } from "../skill-commands.js"; import { logVerbose } from "../../globals.js"; +import { createClawdbotTools } from "../../agents/clawdbot-tools.js"; +import { resolveGatewayMessageChannel } from "../../utils/message-channel.js"; export type InlineActionResult = | { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined } @@ -23,11 +25,34 @@ export type InlineActionResult = abortedLastRun: boolean; }; +function extractTextFromToolResult(result: any): string | null { + if (!result || typeof result !== "object") return null; + const content = (result as { content?: unknown }).content; + if (typeof content === "string") { + const trimmed = content.trim(); + return trimmed ? trimmed : null; + } + if (!Array.isArray(content)) return null; + + const parts: string[] = []; + for (const block of content) { + if (!block || typeof block !== "object") continue; + const rec = block as { type?: unknown; text?: unknown }; + if (rec.type === "text" && typeof rec.text === "string") { + parts.push(rec.text); + } + } + const out = parts.join(""); + const trimmed = out.trim(); + return trimmed ? trimmed : null; +} + export async function handleInlineActions(params: { ctx: MsgContext; sessionCtx: TemplateContext; cfg: ClawdbotConfig; agentId: string; + agentDir?: string; sessionEntry?: SessionEntry; previousSessionEntry?: SessionEntry; sessionStore?: Record; @@ -67,6 +92,7 @@ export async function handleInlineActions(params: { sessionCtx, cfg, agentId, + agentDir, sessionEntry, previousSessionEntry, sessionStore, @@ -129,6 +155,47 @@ export async function handleInlineActions(params: { typing.cleanup(); return { kind: "reply", reply: undefined }; } + + const dispatch = skillInvocation.command.dispatch; + if (dispatch?.kind === "tool") { + const rawArgs = (skillInvocation.args ?? "").trim(); + const channel = + resolveGatewayMessageChannel(ctx.Surface) ?? + resolveGatewayMessageChannel(ctx.Provider) ?? + undefined; + + const tools = createClawdbotTools({ + agentSessionKey: sessionKey, + agentChannel: channel, + agentAccountId: (ctx as { AccountId?: string }).AccountId, + agentDir, + workspaceDir, + config: cfg, + }); + + const tool = tools.find((candidate) => candidate.name === dispatch.toolName); + if (!tool) { + typing.cleanup(); + return { kind: "reply", reply: { text: `❌ Tool not available: ${dispatch.toolName}` } }; + } + + const toolCallId = `cmd_${Date.now()}_${Math.random().toString(16).slice(2)}`; + try { + const result = await tool.execute(toolCallId, { + command: rawArgs, + commandName: skillInvocation.command.name, + skillName: skillInvocation.command.skillName, + } as any); + const text = extractTextFromToolResult(result) ?? "✅ Done."; + typing.cleanup(); + return { kind: "reply", reply: { text } }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + typing.cleanup(); + return { kind: "reply", reply: { text: `❌ ${message}` } }; + } + } + const promptParts = [ `Use the "${skillInvocation.command.skillName}" skill for this request.`, skillInvocation.args ? `User input:\n${skillInvocation.args}` : null, diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index bef565a1e..7ab8928d5 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -178,6 +178,7 @@ export async function getReplyFromConfig( sessionCtx, cfg, agentId, + agentDir, sessionEntry, previousSessionEntry, sessionStore,