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
This commit is contained in:
Tak Hoffman
2026-03-24 21:09:51 -05:00
committed by GitHub
parent fb04801ed7
commit 9c7823350b
56 changed files with 3565 additions and 989 deletions

View File

@@ -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<ChannelAgentTool, ChannelAgentToolMeta>();
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);
}
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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<string, unknown>)
@@ -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),
};
});
}
/**

View File

@@ -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()}...`;
}

View File

@@ -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<TSchema, unknown> {
return {
name: params.name,
label: params.label,
description: params.description,
parameters: Type.Object({}, { additionalProperties: false }),
execute: async (): Promise<AgentToolResult<unknown>> => ({
content: [{ type: "text", text: "ok" }],
details: {},
}),
} as AgentTool<TSchema, unknown>;
}
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();
});
});

View File

@@ -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<typeof vi.fn>;
pluginMeta?: Record<string, { pluginId: string } | undefined>;
channelMeta?: Record<string, { channelId: string } | undefined>;
effectivePolicy?: { profile?: string; providerProfile?: string };
resolvedModelCompat?: Record<string, unknown>;
}) {
vi.resetModules();
vi.doMock("./agent-scope.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./agent-scope.js")>();
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 },
}),
);
});
});

View File

@@ -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<string, number>();
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<EffectiveToolSource, EffectiveToolInventoryEntry[]>();
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 };
}

View File

@@ -8,6 +8,7 @@ import { sanitizeToolResultImages } from "../tool-images.js";
// oxlint-disable-next-line typescript/no-explicit-any
export type AnyAgentTool = AgentTool<any, unknown> & {
ownerOnly?: boolean;
displaySummary?: string;
};
export type StringParamOptions = {

View File

@@ -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:

View File

@@ -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) => {

View File

@@ -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) => {

View File

@@ -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<typeof listChannelPlugins>[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<string>();
const nativeNames = new Set<string>();
const textAliases = new Set<string>();
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<typeof getActivePluginRegistry> | null = null;
let cachedNativeCommandSurfaces: Set<string> | null = null;
@@ -130,696 +26,12 @@ let cachedNativeRegistry: ReturnType<typeof getActivePluginRegistry> | 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;
}

View File

@@ -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<string>();
const nativeNames = new Set<string>();
const textAliases = new Set<string>();
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;
}

View File

@@ -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,

View File

@@ -0,0 +1,237 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
async function loadToolsHarness(options?: {
resolveToolsMock?: ReturnType<typeof vi.fn>;
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<typeof import("../../agents/agent-scope.js")>();
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." },
});
});
});

View File

@@ -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 || "<unknown>"}`,
);
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,

View File

@@ -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");
});
});

View File

@@ -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 <name> [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,

View File

@@ -61,6 +61,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"tts.providers",
"models.list",
"tools.catalog",
"tools.effective",
"agents.list",
"agent.identity.get",
"skills.status",

View File

@@ -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<ChannelsLogoutParams>(
export const validateModelsListParams = ajv.compile<ModelsListParams>(ModelsListParamsSchema);
export const validateSkillsStatusParams = ajv.compile<SkillsStatusParams>(SkillsStatusParamsSchema);
export const validateToolsCatalogParams = ajv.compile<ToolsCatalogParams>(ToolsCatalogParamsSchema);
export const validateToolsEffectiveParams = ajv.compile<ToolsEffectiveParams>(
ToolsEffectiveParamsSchema,
);
export const validateSkillsBinsParams = ajv.compile<SkillsBinsParams>(SkillsBinsParamsSchema);
export const validateSkillsInstallParams =
ajv.compile<SkillsInstallParams>(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,

View File

@@ -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 },
);

View File

@@ -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,

View File

@@ -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">;

View File

@@ -38,6 +38,7 @@ const BASE_METHODS = [
"talk.mode",
"models.list",
"tools.catalog",
"tools.effective",
"agents.list",
"agents.create",
"agents.update",

View File

@@ -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,

View File

@@ -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<typeof loadConfig>;
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,
);
},

View File

@@ -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<typeof import("../../agents/agent-scope.js")>();
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<typeof import("../session-utils.js")>();
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<typeof import("../../utils/delivery-context.js")>();
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<string, unknown>) {
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"');
});
});

View File

@@ -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<typeof loadConfig>;
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,
);
},
};

View File

@@ -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");
});
});
});

View File

@@ -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);

View File

@@ -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";

View File

@@ -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";

View File

@@ -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));
}