mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 03:40:21 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
138
src/agents/tool-description-summary.ts
Normal file
138
src/agents/tool-description-summary.ts
Normal 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()}...`;
|
||||
}
|
||||
116
src/agents/tools-effective-inventory.integration.test.ts
Normal file
116
src/agents/tools-effective-inventory.integration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
213
src/agents/tools-effective-inventory.test.ts
Normal file
213
src/agents/tools-effective-inventory.test.ts
Normal 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 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
231
src/agents/tools-effective-inventory.ts
Normal file
231
src/agents/tools-effective-inventory.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
823
src/auto-reply/commands-registry.shared.ts
Normal file
823
src/auto-reply/commands-registry.shared.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
237
src/auto-reply/reply/commands-info.tools.test.ts
Normal file
237
src/auto-reply/reply/commands-info.tools.test.ts
Normal 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." },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
146
src/auto-reply/status.tools.test.ts
Normal file
146
src/auto-reply/status.tools.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -38,6 +38,7 @@ const BASE_METHODS = [
|
||||
"talk.mode",
|
||||
"models.list",
|
||||
"tools.catalog",
|
||||
"tools.effective",
|
||||
"agents.list",
|
||||
"agents.create",
|
||||
"agents.update",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
|
||||
234
src/gateway/server-methods/tools-effective.test.ts
Normal file
234
src/gateway/server-methods/tools-effective.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
159
src/gateway/server-methods/tools-effective.ts
Normal file
159
src/gateway/server-methods/tools-effective.ts
Normal 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,
|
||||
);
|
||||
},
|
||||
};
|
||||
52
src/gateway/server.tools-effective.test.ts
Normal file
52
src/gateway/server.tools-effective.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user