mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 18:50:20 +00:00
Gateway/UI: data-driven agents tools catalog with provenance (openclaw#24199) thanks @Takhoffman
Verified: - pnpm install --frozen-lockfile - pnpm build - gh pr checks 24199 --watch --fail-fast Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
322
src/agents/tool-catalog.ts
Normal file
322
src/agents/tool-catalog.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
|
||||
|
||||
type ToolProfilePolicy = {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
|
||||
export type CoreToolSection = {
|
||||
id: string;
|
||||
label: string;
|
||||
tools: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type CoreToolDefinition = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
sectionId: string;
|
||||
profiles: ToolProfileId[];
|
||||
includeInOpenClawGroup?: boolean;
|
||||
};
|
||||
|
||||
const CORE_TOOL_SECTION_ORDER: Array<{ id: string; label: string }> = [
|
||||
{ id: "fs", label: "Files" },
|
||||
{ id: "runtime", label: "Runtime" },
|
||||
{ id: "web", label: "Web" },
|
||||
{ id: "memory", label: "Memory" },
|
||||
{ id: "sessions", label: "Sessions" },
|
||||
{ id: "ui", label: "UI" },
|
||||
{ id: "messaging", label: "Messaging" },
|
||||
{ id: "automation", label: "Automation" },
|
||||
{ id: "nodes", label: "Nodes" },
|
||||
{ id: "agents", label: "Agents" },
|
||||
{ id: "media", label: "Media" },
|
||||
];
|
||||
|
||||
const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
|
||||
{
|
||||
id: "read",
|
||||
label: "read",
|
||||
description: "Read file contents",
|
||||
sectionId: "fs",
|
||||
profiles: ["coding"],
|
||||
},
|
||||
{
|
||||
id: "write",
|
||||
label: "write",
|
||||
description: "Create or overwrite files",
|
||||
sectionId: "fs",
|
||||
profiles: ["coding"],
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
label: "edit",
|
||||
description: "Make precise edits",
|
||||
sectionId: "fs",
|
||||
profiles: ["coding"],
|
||||
},
|
||||
{
|
||||
id: "apply_patch",
|
||||
label: "apply_patch",
|
||||
description: "Patch files (OpenAI)",
|
||||
sectionId: "fs",
|
||||
profiles: ["coding"],
|
||||
},
|
||||
{
|
||||
id: "exec",
|
||||
label: "exec",
|
||||
description: "Run shell commands",
|
||||
sectionId: "runtime",
|
||||
profiles: ["coding"],
|
||||
},
|
||||
{
|
||||
id: "process",
|
||||
label: "process",
|
||||
description: "Manage background processes",
|
||||
sectionId: "runtime",
|
||||
profiles: ["coding"],
|
||||
},
|
||||
{
|
||||
id: "web_search",
|
||||
label: "web_search",
|
||||
description: "Search the web",
|
||||
sectionId: "web",
|
||||
profiles: [],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "web_fetch",
|
||||
label: "web_fetch",
|
||||
description: "Fetch web content",
|
||||
sectionId: "web",
|
||||
profiles: [],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "memory_search",
|
||||
label: "memory_search",
|
||||
description: "Semantic search",
|
||||
sectionId: "memory",
|
||||
profiles: ["coding"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "memory_get",
|
||||
label: "memory_get",
|
||||
description: "Read memory files",
|
||||
sectionId: "memory",
|
||||
profiles: ["coding"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "sessions_list",
|
||||
label: "sessions_list",
|
||||
description: "List sessions",
|
||||
sectionId: "sessions",
|
||||
profiles: ["coding", "messaging"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "sessions_history",
|
||||
label: "sessions_history",
|
||||
description: "Session history",
|
||||
sectionId: "sessions",
|
||||
profiles: ["coding", "messaging"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "sessions_send",
|
||||
label: "sessions_send",
|
||||
description: "Send to session",
|
||||
sectionId: "sessions",
|
||||
profiles: ["coding", "messaging"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "sessions_spawn",
|
||||
label: "sessions_spawn",
|
||||
description: "Spawn sub-agent",
|
||||
sectionId: "sessions",
|
||||
profiles: ["coding"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "subagents",
|
||||
label: "subagents",
|
||||
description: "Manage sub-agents",
|
||||
sectionId: "sessions",
|
||||
profiles: ["coding"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "session_status",
|
||||
label: "session_status",
|
||||
description: "Session status",
|
||||
sectionId: "sessions",
|
||||
profiles: ["minimal", "coding", "messaging"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "browser",
|
||||
label: "browser",
|
||||
description: "Control web browser",
|
||||
sectionId: "ui",
|
||||
profiles: [],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "canvas",
|
||||
label: "canvas",
|
||||
description: "Control canvases",
|
||||
sectionId: "ui",
|
||||
profiles: [],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "message",
|
||||
label: "message",
|
||||
description: "Send messages",
|
||||
sectionId: "messaging",
|
||||
profiles: ["messaging"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "cron",
|
||||
label: "cron",
|
||||
description: "Schedule tasks",
|
||||
sectionId: "automation",
|
||||
profiles: [],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "gateway",
|
||||
label: "gateway",
|
||||
description: "Gateway control",
|
||||
sectionId: "automation",
|
||||
profiles: [],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "nodes",
|
||||
label: "nodes",
|
||||
description: "Nodes + devices",
|
||||
sectionId: "nodes",
|
||||
profiles: [],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "agents_list",
|
||||
label: "agents_list",
|
||||
description: "List agents",
|
||||
sectionId: "agents",
|
||||
profiles: [],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "image",
|
||||
label: "image",
|
||||
description: "Image understanding",
|
||||
sectionId: "media",
|
||||
profiles: ["coding"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "tts",
|
||||
label: "tts",
|
||||
description: "Text-to-speech conversion",
|
||||
sectionId: "media",
|
||||
profiles: [],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
];
|
||||
|
||||
const CORE_TOOL_BY_ID = new Map<string, CoreToolDefinition>(
|
||||
CORE_TOOL_DEFINITIONS.map((tool) => [tool.id, tool]),
|
||||
);
|
||||
|
||||
function listCoreToolIdsForProfile(profile: ToolProfileId): string[] {
|
||||
return CORE_TOOL_DEFINITIONS.filter((tool) => tool.profiles.includes(profile)).map(
|
||||
(tool) => tool.id,
|
||||
);
|
||||
}
|
||||
|
||||
const CORE_TOOL_PROFILES: Record<ToolProfileId, ToolProfilePolicy> = {
|
||||
minimal: {
|
||||
allow: listCoreToolIdsForProfile("minimal"),
|
||||
},
|
||||
coding: {
|
||||
allow: listCoreToolIdsForProfile("coding"),
|
||||
},
|
||||
messaging: {
|
||||
allow: listCoreToolIdsForProfile("messaging"),
|
||||
},
|
||||
full: {},
|
||||
};
|
||||
|
||||
function buildCoreToolGroupMap() {
|
||||
const sectionToolMap = new Map<string, string[]>();
|
||||
for (const tool of CORE_TOOL_DEFINITIONS) {
|
||||
const groupId = `group:${tool.sectionId}`;
|
||||
const list = sectionToolMap.get(groupId) ?? [];
|
||||
list.push(tool.id);
|
||||
sectionToolMap.set(groupId, list);
|
||||
}
|
||||
const openclawTools = CORE_TOOL_DEFINITIONS.filter((tool) => tool.includeInOpenClawGroup).map(
|
||||
(tool) => tool.id,
|
||||
);
|
||||
return {
|
||||
"group:openclaw": openclawTools,
|
||||
...Object.fromEntries(sectionToolMap.entries()),
|
||||
};
|
||||
}
|
||||
|
||||
export const CORE_TOOL_GROUPS = buildCoreToolGroupMap();
|
||||
|
||||
export const PROFILE_OPTIONS = [
|
||||
{ id: "minimal", label: "Minimal" },
|
||||
{ id: "coding", label: "Coding" },
|
||||
{ id: "messaging", label: "Messaging" },
|
||||
{ id: "full", label: "Full" },
|
||||
] as const;
|
||||
|
||||
export function resolveCoreToolProfilePolicy(profile?: string): ToolProfilePolicy | undefined {
|
||||
if (!profile) {
|
||||
return undefined;
|
||||
}
|
||||
const resolved = CORE_TOOL_PROFILES[profile as ToolProfileId];
|
||||
if (!resolved) {
|
||||
return undefined;
|
||||
}
|
||||
if (!resolved.allow && !resolved.deny) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
allow: resolved.allow ? [...resolved.allow] : undefined,
|
||||
deny: resolved.deny ? [...resolved.deny] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function listCoreToolSections(): CoreToolSection[] {
|
||||
return CORE_TOOL_SECTION_ORDER.map((section) => ({
|
||||
id: section.id,
|
||||
label: section.label,
|
||||
tools: CORE_TOOL_DEFINITIONS.filter((tool) => tool.sectionId === section.id).map((tool) => ({
|
||||
id: tool.id,
|
||||
label: tool.label,
|
||||
description: tool.description,
|
||||
})),
|
||||
})).filter((section) => section.tools.length > 0);
|
||||
}
|
||||
|
||||
export function resolveCoreToolProfiles(toolId: string): ToolProfileId[] {
|
||||
const tool = CORE_TOOL_BY_ID.get(toolId);
|
||||
if (!tool) {
|
||||
return [];
|
||||
}
|
||||
return [...tool.profiles];
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
|
||||
import {
|
||||
CORE_TOOL_GROUPS,
|
||||
resolveCoreToolProfilePolicy,
|
||||
type ToolProfileId,
|
||||
} from "./tool-catalog.js";
|
||||
|
||||
type ToolProfilePolicy = {
|
||||
allow?: string[];
|
||||
@@ -10,72 +14,7 @@ const TOOL_NAME_ALIASES: Record<string, string> = {
|
||||
"apply-patch": "apply_patch",
|
||||
};
|
||||
|
||||
export const TOOL_GROUPS: Record<string, string[]> = {
|
||||
// NOTE: Keep canonical (lowercase) tool names here.
|
||||
"group:memory": ["memory_search", "memory_get"],
|
||||
"group:web": ["web_search", "web_fetch"],
|
||||
// Basic workspace/file tools
|
||||
"group:fs": ["read", "write", "edit", "apply_patch"],
|
||||
// Host/runtime execution tools
|
||||
"group:runtime": ["exec", "process"],
|
||||
// Session management tools
|
||||
"group:sessions": [
|
||||
"sessions_list",
|
||||
"sessions_history",
|
||||
"sessions_send",
|
||||
"sessions_spawn",
|
||||
"subagents",
|
||||
"session_status",
|
||||
],
|
||||
// UI helpers
|
||||
"group:ui": ["browser", "canvas"],
|
||||
// Automation + infra
|
||||
"group:automation": ["cron", "gateway"],
|
||||
// Messaging surface
|
||||
"group:messaging": ["message"],
|
||||
// Nodes + device tools
|
||||
"group:nodes": ["nodes"],
|
||||
// All OpenClaw native tools (excludes provider plugins).
|
||||
"group:openclaw": [
|
||||
"browser",
|
||||
"canvas",
|
||||
"nodes",
|
||||
"cron",
|
||||
"message",
|
||||
"gateway",
|
||||
"agents_list",
|
||||
"sessions_list",
|
||||
"sessions_history",
|
||||
"sessions_send",
|
||||
"sessions_spawn",
|
||||
"subagents",
|
||||
"session_status",
|
||||
"memory_search",
|
||||
"memory_get",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"image",
|
||||
],
|
||||
};
|
||||
|
||||
const TOOL_PROFILES: Record<ToolProfileId, ToolProfilePolicy> = {
|
||||
minimal: {
|
||||
allow: ["session_status"],
|
||||
},
|
||||
coding: {
|
||||
allow: ["group:fs", "group:runtime", "group:sessions", "group:memory", "image"],
|
||||
},
|
||||
messaging: {
|
||||
allow: [
|
||||
"group:messaging",
|
||||
"sessions_list",
|
||||
"sessions_history",
|
||||
"sessions_send",
|
||||
"session_status",
|
||||
],
|
||||
},
|
||||
full: {},
|
||||
};
|
||||
export const TOOL_GROUPS: Record<string, string[]> = { ...CORE_TOOL_GROUPS };
|
||||
|
||||
export function normalizeToolName(name: string) {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
@@ -104,18 +43,7 @@ export function expandToolGroups(list?: string[]) {
|
||||
}
|
||||
|
||||
export function resolveToolProfilePolicy(profile?: string): ToolProfilePolicy | undefined {
|
||||
if (!profile) {
|
||||
return undefined;
|
||||
}
|
||||
const resolved = TOOL_PROFILES[profile as ToolProfileId];
|
||||
if (!resolved) {
|
||||
return undefined;
|
||||
}
|
||||
if (!resolved.allow && !resolved.deny) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
allow: resolved.allow ? [...resolved.allow] : undefined,
|
||||
deny: resolved.deny ? [...resolved.deny] : undefined,
|
||||
};
|
||||
return resolveCoreToolProfilePolicy(profile);
|
||||
}
|
||||
|
||||
export type { ToolProfileId };
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("tool-policy", () => {
|
||||
|
||||
it("resolves known profiles and ignores unknown ones", () => {
|
||||
const coding = resolveToolProfilePolicy("coding");
|
||||
expect(coding?.allow).toContain("group:fs");
|
||||
expect(coding?.allow).toContain("read");
|
||||
expect(resolveToolProfilePolicy("nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -65,6 +65,7 @@ describe("tool-policy", () => {
|
||||
expect(group).toContain("message");
|
||||
expect(group).toContain("subagents");
|
||||
expect(group).toContain("session_status");
|
||||
expect(group).toContain("tts");
|
||||
});
|
||||
|
||||
it("normalizes tool names and aliases", () => {
|
||||
|
||||
@@ -51,6 +51,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
|
||||
"tts.status",
|
||||
"tts.providers",
|
||||
"models.list",
|
||||
"tools.catalog",
|
||||
"agents.list",
|
||||
"agent.identity.get",
|
||||
"skills.status",
|
||||
|
||||
@@ -195,6 +195,9 @@ import {
|
||||
SkillsStatusParamsSchema,
|
||||
type SkillsUpdateParams,
|
||||
SkillsUpdateParamsSchema,
|
||||
type ToolsCatalogParams,
|
||||
ToolsCatalogParamsSchema,
|
||||
type ToolsCatalogResult,
|
||||
type Snapshot,
|
||||
SnapshotSchema,
|
||||
type StateVersion,
|
||||
@@ -319,6 +322,7 @@ export const validateChannelsLogoutParams = ajv.compile<ChannelsLogoutParams>(
|
||||
);
|
||||
export const validateModelsListParams = ajv.compile<ModelsListParams>(ModelsListParamsSchema);
|
||||
export const validateSkillsStatusParams = ajv.compile<SkillsStatusParams>(SkillsStatusParamsSchema);
|
||||
export const validateToolsCatalogParams = ajv.compile<ToolsCatalogParams>(ToolsCatalogParamsSchema);
|
||||
export const validateSkillsBinsParams = ajv.compile<SkillsBinsParams>(SkillsBinsParamsSchema);
|
||||
export const validateSkillsInstallParams =
|
||||
ajv.compile<SkillsInstallParams>(SkillsInstallParamsSchema);
|
||||
@@ -487,6 +491,7 @@ export {
|
||||
AgentsListResultSchema,
|
||||
ModelsListParamsSchema,
|
||||
SkillsStatusParamsSchema,
|
||||
ToolsCatalogParamsSchema,
|
||||
SkillsInstallParamsSchema,
|
||||
SkillsUpdateParamsSchema,
|
||||
CronJobSchema,
|
||||
@@ -575,6 +580,8 @@ export type {
|
||||
AgentsListParams,
|
||||
AgentsListResult,
|
||||
SkillsStatusParams,
|
||||
ToolsCatalogParams,
|
||||
ToolsCatalogResult,
|
||||
SkillsBinsParams,
|
||||
SkillsBinsResult,
|
||||
SkillsInstallParams,
|
||||
|
||||
@@ -207,3 +207,64 @@ export const SkillsUpdateParamsSchema = Type.Object(
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ToolsCatalogParamsSchema = Type.Object(
|
||||
{
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
includePlugins: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ToolCatalogProfileSchema = Type.Object(
|
||||
{
|
||||
id: Type.Union([
|
||||
Type.Literal("minimal"),
|
||||
Type.Literal("coding"),
|
||||
Type.Literal("messaging"),
|
||||
Type.Literal("full"),
|
||||
]),
|
||||
label: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ToolCatalogEntrySchema = Type.Object(
|
||||
{
|
||||
id: NonEmptyString,
|
||||
label: NonEmptyString,
|
||||
description: Type.String(),
|
||||
source: Type.Union([Type.Literal("core"), Type.Literal("plugin")]),
|
||||
pluginId: Type.Optional(NonEmptyString),
|
||||
optional: Type.Optional(Type.Boolean()),
|
||||
defaultProfiles: Type.Array(
|
||||
Type.Union([
|
||||
Type.Literal("minimal"),
|
||||
Type.Literal("coding"),
|
||||
Type.Literal("messaging"),
|
||||
Type.Literal("full"),
|
||||
]),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ToolCatalogGroupSchema = Type.Object(
|
||||
{
|
||||
id: NonEmptyString,
|
||||
label: NonEmptyString,
|
||||
source: Type.Union([Type.Literal("core"), Type.Literal("plugin")]),
|
||||
pluginId: Type.Optional(NonEmptyString),
|
||||
tools: Type.Array(ToolCatalogEntrySchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ToolsCatalogResultSchema = Type.Object(
|
||||
{
|
||||
agentId: NonEmptyString,
|
||||
profiles: Type.Array(ToolCatalogProfileSchema),
|
||||
groups: Type.Array(ToolCatalogGroupSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -34,6 +34,11 @@ import {
|
||||
SkillsInstallParamsSchema,
|
||||
SkillsStatusParamsSchema,
|
||||
SkillsUpdateParamsSchema,
|
||||
ToolCatalogEntrySchema,
|
||||
ToolCatalogGroupSchema,
|
||||
ToolCatalogProfileSchema,
|
||||
ToolsCatalogParamsSchema,
|
||||
ToolsCatalogResultSchema,
|
||||
} from "./agents-models-skills.js";
|
||||
import {
|
||||
ChannelsLogoutParamsSchema,
|
||||
@@ -224,6 +229,11 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
ModelsListParams: ModelsListParamsSchema,
|
||||
ModelsListResult: ModelsListResultSchema,
|
||||
SkillsStatusParams: SkillsStatusParamsSchema,
|
||||
ToolsCatalogParams: ToolsCatalogParamsSchema,
|
||||
ToolCatalogProfile: ToolCatalogProfileSchema,
|
||||
ToolCatalogEntry: ToolCatalogEntrySchema,
|
||||
ToolCatalogGroup: ToolCatalogGroupSchema,
|
||||
ToolsCatalogResult: ToolsCatalogResultSchema,
|
||||
SkillsBinsParams: SkillsBinsParamsSchema,
|
||||
SkillsBinsResult: SkillsBinsResultSchema,
|
||||
SkillsInstallParams: SkillsInstallParamsSchema,
|
||||
|
||||
@@ -32,6 +32,11 @@ import type {
|
||||
SkillsInstallParamsSchema,
|
||||
SkillsStatusParamsSchema,
|
||||
SkillsUpdateParamsSchema,
|
||||
ToolCatalogEntrySchema,
|
||||
ToolCatalogGroupSchema,
|
||||
ToolCatalogProfileSchema,
|
||||
ToolsCatalogParamsSchema,
|
||||
ToolsCatalogResultSchema,
|
||||
} from "./agents-models-skills.js";
|
||||
import type {
|
||||
ChannelsLogoutParamsSchema,
|
||||
@@ -213,6 +218,11 @@ export type ModelChoice = Static<typeof ModelChoiceSchema>;
|
||||
export type ModelsListParams = Static<typeof ModelsListParamsSchema>;
|
||||
export type ModelsListResult = Static<typeof ModelsListResultSchema>;
|
||||
export type SkillsStatusParams = Static<typeof SkillsStatusParamsSchema>;
|
||||
export type ToolsCatalogParams = Static<typeof ToolsCatalogParamsSchema>;
|
||||
export type ToolCatalogProfile = Static<typeof ToolCatalogProfileSchema>;
|
||||
export type ToolCatalogEntry = Static<typeof ToolCatalogEntrySchema>;
|
||||
export type ToolCatalogGroup = Static<typeof ToolCatalogGroupSchema>;
|
||||
export type ToolsCatalogResult = Static<typeof ToolsCatalogResultSchema>;
|
||||
export type SkillsBinsParams = Static<typeof SkillsBinsParamsSchema>;
|
||||
export type SkillsBinsResult = Static<typeof SkillsBinsResultSchema>;
|
||||
export type SkillsInstallParams = Static<typeof SkillsInstallParamsSchema>;
|
||||
|
||||
@@ -34,6 +34,7 @@ const BASE_METHODS = [
|
||||
"talk.config",
|
||||
"talk.mode",
|
||||
"models.list",
|
||||
"tools.catalog",
|
||||
"agents.list",
|
||||
"agents.create",
|
||||
"agents.update",
|
||||
|
||||
@@ -23,6 +23,7 @@ import { sessionsHandlers } from "./server-methods/sessions.js";
|
||||
import { skillsHandlers } from "./server-methods/skills.js";
|
||||
import { systemHandlers } from "./server-methods/system.js";
|
||||
import { talkHandlers } from "./server-methods/talk.js";
|
||||
import { toolsCatalogHandlers } from "./server-methods/tools-catalog.js";
|
||||
import { ttsHandlers } from "./server-methods/tts.js";
|
||||
import type { GatewayRequestHandlers, GatewayRequestOptions } from "./server-methods/types.js";
|
||||
import { updateHandlers } from "./server-methods/update.js";
|
||||
@@ -76,6 +77,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
...configHandlers,
|
||||
...wizardHandlers,
|
||||
...talkHandlers,
|
||||
...toolsCatalogHandlers,
|
||||
...ttsHandlers,
|
||||
...skillsHandlers,
|
||||
...sessionsHandlers,
|
||||
|
||||
120
src/gateway/server-methods/tools-catalog.test.ts
Normal file
120
src/gateway/server-methods/tools-catalog.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ErrorCodes } from "../protocol/index.js";
|
||||
import { toolsCatalogHandlers } from "./tools-catalog.js";
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
listAgentIds: vi.fn(() => ["main"]),
|
||||
resolveDefaultAgentId: vi.fn(() => "main"),
|
||||
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace-main"),
|
||||
resolveAgentDir: vi.fn(() => "/tmp/agents/main/agent"),
|
||||
}));
|
||||
|
||||
const pluginToolMetaState = new Map<string, { pluginId: string; optional: boolean }>();
|
||||
|
||||
vi.mock("../../plugins/tools.js", () => ({
|
||||
resolvePluginTools: vi.fn(() => [
|
||||
{ name: "voice_call", label: "voice_call", description: "Plugin calling tool" },
|
||||
{ name: "matrix_room", label: "matrix_room", description: "Matrix room helper" },
|
||||
]),
|
||||
getPluginToolMeta: vi.fn((tool: { name: string }) => pluginToolMetaState.get(tool.name)),
|
||||
}));
|
||||
|
||||
type RespondCall = [boolean, unknown?, { code: number; message: string }?];
|
||||
|
||||
function createInvokeParams(params: Record<string, unknown>) {
|
||||
const respond = vi.fn();
|
||||
return {
|
||||
respond,
|
||||
invoke: async () =>
|
||||
await toolsCatalogHandlers["tools.catalog"]({
|
||||
params,
|
||||
respond: respond as never,
|
||||
context: {} as never,
|
||||
client: null,
|
||||
req: { type: "req", id: "req-1", method: "tools.catalog" },
|
||||
isWebchatConnect: () => false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("tools.catalog handler", () => {
|
||||
beforeEach(() => {
|
||||
pluginToolMetaState.clear();
|
||||
pluginToolMetaState.set("voice_call", { pluginId: "voice-call", optional: true });
|
||||
pluginToolMetaState.set("matrix_room", { pluginId: "matrix", optional: false });
|
||||
});
|
||||
|
||||
it("rejects invalid params", async () => {
|
||||
const { respond, invoke } = createInvokeParams({ extra: true });
|
||||
await invoke();
|
||||
const call = respond.mock.calls[0] as RespondCall | undefined;
|
||||
expect(call?.[0]).toBe(false);
|
||||
expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST);
|
||||
expect(call?.[2]?.message).toContain("invalid tools.catalog params");
|
||||
});
|
||||
|
||||
it("rejects unknown agent ids", async () => {
|
||||
const { respond, invoke } = createInvokeParams({ agentId: "unknown-agent" });
|
||||
await invoke();
|
||||
const call = respond.mock.calls[0] as RespondCall | undefined;
|
||||
expect(call?.[0]).toBe(false);
|
||||
expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST);
|
||||
expect(call?.[2]?.message).toContain("unknown agent id");
|
||||
});
|
||||
|
||||
it("returns core groups including tts and excludes plugins when includePlugins=false", async () => {
|
||||
const { respond, invoke } = createInvokeParams({ includePlugins: false });
|
||||
await invoke();
|
||||
const call = respond.mock.calls[0] as RespondCall | undefined;
|
||||
expect(call?.[0]).toBe(true);
|
||||
const payload = call?.[1] as
|
||||
| {
|
||||
agentId: string;
|
||||
groups: Array<{
|
||||
id: string;
|
||||
source: "core" | "plugin";
|
||||
tools: Array<{ id: string; source: "core" | "plugin" }>;
|
||||
}>;
|
||||
}
|
||||
| undefined;
|
||||
expect(payload?.agentId).toBe("main");
|
||||
expect(payload?.groups.some((group) => group.source === "plugin")).toBe(false);
|
||||
const media = payload?.groups.find((group) => group.id === "media");
|
||||
expect(media?.tools.some((tool) => tool.id === "tts" && tool.source === "core")).toBe(true);
|
||||
});
|
||||
|
||||
it("includes plugin groups with plugin metadata", async () => {
|
||||
const { respond, invoke } = createInvokeParams({});
|
||||
await invoke();
|
||||
const call = respond.mock.calls[0] as RespondCall | undefined;
|
||||
expect(call?.[0]).toBe(true);
|
||||
const payload = call?.[1] as
|
||||
| {
|
||||
groups: Array<{
|
||||
source: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
tools: Array<{
|
||||
id: string;
|
||||
source: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
optional?: boolean;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
| undefined;
|
||||
const pluginGroups = (payload?.groups ?? []).filter((group) => group.source === "plugin");
|
||||
expect(pluginGroups.length).toBeGreaterThan(0);
|
||||
const voiceCall = pluginGroups
|
||||
.flatMap((group) => group.tools)
|
||||
.find((tool) => tool.id === "voice_call");
|
||||
expect(voiceCall).toMatchObject({
|
||||
source: "plugin",
|
||||
pluginId: "voice-call",
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
165
src/gateway/server-methods/tools-catalog.ts
Normal file
165
src/gateway/server-methods/tools-catalog.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
listAgentIds,
|
||||
resolveAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../../agents/agent-scope.js";
|
||||
import {
|
||||
listCoreToolSections,
|
||||
PROFILE_OPTIONS,
|
||||
resolveCoreToolProfiles,
|
||||
} from "../../agents/tool-catalog.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { getPluginToolMeta, resolvePluginTools } from "../../plugins/tools.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateToolsCatalogParams,
|
||||
} from "../protocol/index.js";
|
||||
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
|
||||
type ToolCatalogEntry = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
source: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
optional?: boolean;
|
||||
defaultProfiles: Array<"minimal" | "coding" | "messaging" | "full">;
|
||||
};
|
||||
|
||||
type ToolCatalogGroup = {
|
||||
id: string;
|
||||
label: string;
|
||||
source: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
tools: ToolCatalogEntry[];
|
||||
};
|
||||
|
||||
function resolveAgentIdOrRespondError(rawAgentId: unknown, respond: RespondFn) {
|
||||
const cfg = loadConfig();
|
||||
const knownAgents = listAgentIds(cfg);
|
||||
const requestedAgentId = typeof rawAgentId === "string" ? rawAgentId.trim() : "";
|
||||
const agentId = requestedAgentId || resolveDefaultAgentId(cfg);
|
||||
if (requestedAgentId && !knownAgents.includes(agentId)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `unknown agent id "${requestedAgentId}"`),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return { cfg, agentId };
|
||||
}
|
||||
|
||||
function buildCoreGroups(): ToolCatalogGroup[] {
|
||||
return listCoreToolSections().map((section) => ({
|
||||
id: section.id,
|
||||
label: section.label,
|
||||
source: "core",
|
||||
tools: section.tools.map((tool) => ({
|
||||
id: tool.id,
|
||||
label: tool.label,
|
||||
description: tool.description,
|
||||
source: "core",
|
||||
defaultProfiles: resolveCoreToolProfiles(tool.id),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildPluginGroups(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
agentId: string;
|
||||
existingToolNames: Set<string>;
|
||||
}): ToolCatalogGroup[] {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId);
|
||||
const agentDir = resolveAgentDir(params.cfg, params.agentId);
|
||||
const pluginTools = resolvePluginTools({
|
||||
context: {
|
||||
config: params.cfg,
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
agentId: params.agentId,
|
||||
},
|
||||
existingToolNames: params.existingToolNames,
|
||||
toolAllowlist: ["group:plugins"],
|
||||
});
|
||||
const groups = new Map<string, ToolCatalogGroup>();
|
||||
for (const tool of pluginTools) {
|
||||
const meta = getPluginToolMeta(tool);
|
||||
const pluginId = meta?.pluginId ?? "plugin";
|
||||
const groupId = `plugin:${pluginId}`;
|
||||
const existing =
|
||||
groups.get(groupId) ??
|
||||
({
|
||||
id: groupId,
|
||||
label: pluginId,
|
||||
source: "plugin",
|
||||
pluginId,
|
||||
tools: [],
|
||||
} as ToolCatalogGroup);
|
||||
existing.tools.push({
|
||||
id: tool.name,
|
||||
label: typeof tool.label === "string" && tool.label.trim() ? tool.label.trim() : tool.name,
|
||||
description:
|
||||
typeof tool.description === "string" && tool.description.trim()
|
||||
? tool.description.trim()
|
||||
: "Plugin tool",
|
||||
source: "plugin",
|
||||
pluginId,
|
||||
optional: meta?.optional,
|
||||
defaultProfiles: [],
|
||||
});
|
||||
groups.set(groupId, existing);
|
||||
}
|
||||
return [...groups.values()]
|
||||
.map((group) => ({
|
||||
...group,
|
||||
tools: group.tools.toSorted((a, b) => a.id.localeCompare(b.id)),
|
||||
}))
|
||||
.toSorted((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
export const toolsCatalogHandlers: GatewayRequestHandlers = {
|
||||
"tools.catalog": ({ params, respond }) => {
|
||||
if (!validateToolsCatalogParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid tools.catalog params: ${formatValidationErrors(validateToolsCatalogParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const resolved = resolveAgentIdOrRespondError(params.agentId, respond);
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const includePlugins = params.includePlugins !== false;
|
||||
const groups = buildCoreGroups();
|
||||
if (includePlugins) {
|
||||
const existingToolNames = new Set(
|
||||
groups.flatMap((group) => group.tools.map((tool) => tool.id)),
|
||||
);
|
||||
groups.push(
|
||||
...buildPluginGroups({
|
||||
cfg: resolved.cfg,
|
||||
agentId: resolved.agentId,
|
||||
existingToolNames,
|
||||
}),
|
||||
);
|
||||
}
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
agentId: resolved.agentId,
|
||||
profiles: PROFILE_OPTIONS.map((profile) => ({ id: profile.id, label: profile.label })),
|
||||
groups,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
};
|
||||
46
src/gateway/server.tools-catalog.test.ts
Normal file
46
src/gateway/server.tools-catalog.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { connectOk, installGatewayTestHooks, rpcReq } from "./test-helpers.js";
|
||||
import { withServer } from "./test-with-server.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
describe("gateway tools.catalog", () => {
|
||||
it("returns core catalog data and includes tts", async () => {
|
||||
await withServer(async (ws) => {
|
||||
await connectOk(ws, { token: "secret", scopes: ["operator.read"] });
|
||||
const res = await rpcReq<{
|
||||
agentId?: string;
|
||||
groups?: Array<{
|
||||
id?: string;
|
||||
source?: "core" | "plugin";
|
||||
tools?: Array<{ id?: string; source?: "core" | "plugin" }>;
|
||||
}>;
|
||||
}>(ws, "tools.catalog", {});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.payload?.agentId).toBeTruthy();
|
||||
const mediaGroup = res.payload?.groups?.find((group) => group.id === "media");
|
||||
expect(mediaGroup?.tools?.some((tool) => tool.id === "tts" && tool.source === "core")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("supports includePlugins=false and rejects unknown agent ids", async () => {
|
||||
await withServer(async (ws) => {
|
||||
await connectOk(ws, { token: "secret", scopes: ["operator.read"] });
|
||||
|
||||
const noPlugins = await rpcReq<{
|
||||
groups?: Array<{ source?: "core" | "plugin" }>;
|
||||
}>(ws, "tools.catalog", { includePlugins: false });
|
||||
expect(noPlugins.ok).toBe(true);
|
||||
expect((noPlugins.payload?.groups ?? []).every((group) => group.source !== "plugin")).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const unknownAgent = await rpcReq(ws, "tools.catalog", { agentId: "does-not-exist" });
|
||||
expect(unknownAgent.ok).toBe(false);
|
||||
expect(unknownAgent.error?.message ?? "").toContain("unknown agent id");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user