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:
Tak Hoffman
2026-02-22 23:55:59 -06:00
committed by GitHub
parent 1c753ea786
commit 9e1a13bf4c
31 changed files with 1548 additions and 185 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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