feat: integrate tools catalog functionality into agent management

- Added support for loading and displaying a tools catalog in the agent management interface.
- Enhanced the AppViewState to include loading, error, and result states for the tools catalog.
- Implemented loadToolsCatalog function to fetch tools based on the selected agent.
- Updated UI components to reflect tools catalog loading states and errors.
- Refactored agent tools rendering logic to utilize the new tools catalog data structure.
This commit is contained in:
Val Alexander
2026-03-05 17:57:47 -06:00
parent 58c96468cf
commit cfec9a268a
8 changed files with 216 additions and 12 deletions

View File

@@ -16,7 +16,7 @@ import type { AppViewState } from "./app-view-state.ts";
import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts";
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts";
import { loadAgentSkills } from "./controllers/agent-skills.ts";
import { loadAgents } from "./controllers/agents.ts";
import { loadAgents, loadToolsCatalog } from "./controllers/agents.ts";
import { loadChannels } from "./controllers/channels.ts";
import { loadChatHistory } from "./controllers/chat.ts";
import {
@@ -860,12 +860,25 @@ export function renderApp(state: AppViewState) {
agentId: state.agentSkillsAgentId,
filter: state.skillsFilter,
},
toolsCatalog: {
loading: state.toolsCatalogLoading,
error: state.toolsCatalogError,
result: state.toolsCatalogResult,
},
onRefresh: async () => {
await loadAgents(state);
const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? [];
if (agentIds.length > 0) {
void loadAgentIdentities(state, agentIds);
}
const refreshedAgentId =
state.agentsSelectedId ??
state.agentsList?.defaultId ??
state.agentsList?.agents?.[0]?.id ??
null;
if (state.agentsPanel === "tools" && refreshedAgentId) {
void loadToolsCatalog(state, refreshedAgentId);
}
},
onSelectAgent: (agentId) => {
if (state.agentsSelectedId === agentId) {
@@ -881,10 +894,16 @@ export function renderApp(state: AppViewState) {
state.agentSkillsReport = null;
state.agentSkillsError = null;
state.agentSkillsAgentId = null;
state.toolsCatalogResult = null;
state.toolsCatalogError = null;
state.toolsCatalogLoading = false;
void loadAgentIdentity(state, agentId);
if (state.agentsPanel === "files") {
void loadAgentFiles(state, agentId);
}
if (state.agentsPanel === "tools") {
void loadToolsCatalog(state, agentId);
}
if (state.agentsPanel === "skills") {
void loadAgentSkills(state, agentId);
}
@@ -906,6 +925,14 @@ export function renderApp(state: AppViewState) {
void loadAgentSkills(state, resolvedAgentId);
}
}
if (panel === "tools" && resolvedAgentId) {
if (
state.toolsCatalogResult?.agentId !== resolvedAgentId ||
state.toolsCatalogError
) {
void loadToolsCatalog(state, resolvedAgentId);
}
}
if (panel === "channels") {
void loadChannels(state, false);
}

View File

@@ -30,6 +30,7 @@ import type {
SessionsListResult,
SkillStatusReport,
StatusSummary,
ToolsCatalogResult,
} from "./types.ts";
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
@@ -152,6 +153,9 @@ export type AppViewState = {
agentsList: AgentsListResult | null;
agentsError: string | null;
agentsSelectedId: string | null;
toolsCatalogLoading: boolean;
toolsCatalogError: string | null;
toolsCatalogResult: ToolsCatalogResult | null;
agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
agentFilesLoading: boolean;
agentFilesError: string | null;

View File

@@ -81,6 +81,7 @@ import type {
SkillStatusReport,
StatusSummary,
NostrProfile,
ToolsCatalogResult,
} from "./types.ts";
import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types.ts";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
@@ -267,6 +268,9 @@ export class OpenClawApp extends LitElement {
@state() agentsList: AgentsListResult | null = null;
@state() agentsError: string | null = null;
@state() agentsSelectedId: string | null = null;
@state() toolsCatalogLoading = false;
@state() toolsCatalogError: string | null = null;
@state() toolsCatalogResult: ToolsCatalogResult | null = null;
@state() agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron" =
"overview";
@state() agentFilesLoading = false;

View File

@@ -1,5 +1,5 @@
import type { GatewayBrowserClient } from "../gateway.ts";
import type { AgentsListResult } from "../types.ts";
import type { AgentsListResult, ToolsCatalogResult } from "../types.ts";
export type AgentsState = {
client: GatewayBrowserClient | null;
@@ -8,6 +8,9 @@ export type AgentsState = {
agentsError: string | null;
agentsList: AgentsListResult | null;
agentsSelectedId: string | null;
toolsCatalogLoading: boolean;
toolsCatalogError: string | null;
toolsCatalogResult: ToolsCatalogResult | null;
};
export async function loadAgents(state: AgentsState) {
@@ -35,3 +38,36 @@ export async function loadAgents(state: AgentsState) {
state.agentsLoading = false;
}
}
export async function loadToolsCatalog(state: AgentsState, agentId: string) {
const resolvedAgentId = agentId.trim();
if (!state.client || !state.connected || !resolvedAgentId) {
return;
}
if (state.toolsCatalogLoading) {
return;
}
state.toolsCatalogLoading = true;
state.toolsCatalogError = null;
state.toolsCatalogResult = null;
try {
const res = await state.client.request<ToolsCatalogResult>("tools.catalog", {
agentId: resolvedAgentId,
includePlugins: true,
});
if (state.agentsSelectedId && state.agentsSelectedId !== resolvedAgentId) {
return;
}
state.toolsCatalogResult = res;
} catch (err) {
if (state.agentsSelectedId && state.agentsSelectedId !== resolvedAgentId) {
return;
}
state.toolsCatalogResult = null;
state.toolsCatalogError = String(err);
} finally {
if (!state.agentsSelectedId || state.agentsSelectedId === resolvedAgentId) {
state.toolsCatalogLoading = false;
}
}
}

View File

@@ -573,6 +573,15 @@ export type ModelCatalogEntry = {
input?: Array<"text" | "image">;
};
export type ToolCatalogProfile =
import("../../../src/gateway/protocol/schema/types.js").ToolCatalogProfile;
export type ToolCatalogEntry =
import("../../../src/gateway/protocol/schema/types.js").ToolCatalogEntry;
export type ToolCatalogGroup =
import("../../../src/gateway/protocol/schema/types.js").ToolCatalogGroup;
export type ToolsCatalogResult =
import("../../../src/gateway/protocol/schema/types.js").ToolsCatalogResult;
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
export type LogEntry = {

View File

@@ -1,13 +1,15 @@
import { html, nothing } from "lit";
import { normalizeToolName } from "../../../../src/agents/tool-policy-shared.js";
import type { SkillStatusEntry, SkillStatusReport } from "../types.ts";
import type { SkillStatusEntry, SkillStatusReport, ToolsCatalogResult } from "../types.ts";
import {
type AgentToolEntry,
type AgentToolSection,
isAllowedByPolicy,
matchesList,
PROFILE_OPTIONS,
resolveAgentConfig,
resolveToolProfileOptions,
resolveToolProfile,
TOOL_SECTIONS,
resolveToolSections,
} from "./agents-utils.ts";
import type { SkillGroup } from "./skills-grouping.ts";
import { groupSkills } from "./skills-grouping.ts";
@@ -17,12 +19,37 @@ import {
renderSkillStatusChips,
} from "./skills-shared.ts";
function renderToolBadges(section: AgentToolSection, tool: AgentToolEntry) {
const source = tool.source ?? section.source;
const pluginId = tool.pluginId ?? section.pluginId;
const badges: string[] = [];
if (source === "plugin" && pluginId) {
badges.push(`plugin:${pluginId}`);
} else if (source === "core") {
badges.push("core");
}
if (tool.optional) {
badges.push("optional");
}
if (badges.length === 0) {
return nothing;
}
return html`
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px;">
${badges.map((badge) => html`<span class="agent-pill">${badge}</span>`)}
</div>
`;
}
export function renderAgentTools(params: {
agentId: string;
configForm: Record<string, unknown> | null;
configLoading: boolean;
configSaving: boolean;
configDirty: boolean;
toolsCatalogLoading: boolean;
toolsCatalogError: string | null;
toolsCatalogResult: ToolsCatalogResult | null;
onProfileChange: (agentId: string, profile: string | null, clearAllow: boolean) => void;
onOverridesChange: (agentId: string, alsoAllow: string[], deny: string[]) => void;
onConfigReload: () => void;
@@ -32,6 +59,8 @@ export function renderAgentTools(params: {
const agentTools = config.entry?.tools ?? {};
const globalTools = config.globalTools ?? {};
const profile = agentTools.profile ?? globalTools.profile ?? "full";
const profileOptions = resolveToolProfileOptions(params.toolsCatalogResult);
const toolSections = resolveToolSections(params.toolsCatalogResult);
const profileSource = agentTools.profile
? "agent override"
: globalTools.profile
@@ -40,7 +69,11 @@ export function renderAgentTools(params: {
const hasAgentAllow = Array.isArray(agentTools.allow) && agentTools.allow.length > 0;
const hasGlobalAllow = Array.isArray(globalTools.allow) && globalTools.allow.length > 0;
const editable =
Boolean(params.configForm) && !params.configLoading && !params.configSaving && !hasAgentAllow;
Boolean(params.configForm) &&
!params.configLoading &&
!params.configSaving &&
!hasAgentAllow &&
!(params.toolsCatalogLoading && !params.toolsCatalogResult && !params.toolsCatalogError);
const alsoAllow = hasAgentAllow
? []
: Array.isArray(agentTools.alsoAllow)
@@ -50,7 +83,7 @@ export function renderAgentTools(params: {
const basePolicy = hasAgentAllow
? { allow: agentTools.allow ?? [], deny: agentTools.deny ?? [] }
: (resolveToolProfile(profile) ?? undefined);
const toolIds = TOOL_SECTIONS.flatMap((section) => section.tools.map((tool) => tool.id));
const toolIds = toolSections.flatMap((section) => section.tools.map((tool) => tool.id));
const resolveAllowed = (toolId: string) => {
const baseAllowed = isAllowedByPolicy(toolId, basePolicy);
@@ -166,6 +199,22 @@ export function renderAgentTools(params: {
`
: nothing
}
${
params.toolsCatalogLoading && !params.toolsCatalogResult && !params.toolsCatalogError
? html`
<div class="callout info" style="margin-top: 12px">Loading runtime tool catalog…</div>
`
: nothing
}
${
params.toolsCatalogError
? html`
<div class="callout info" style="margin-top: 12px">
Could not load runtime tool catalog. Showing built-in fallback list instead.
</div>
`
: nothing
}
<div class="agent-tools-meta" style="margin-top: 16px;">
<div class="agent-kv">
@@ -191,7 +240,7 @@ export function renderAgentTools(params: {
<div class="agent-tools-presets" style="margin-top: 16px;">
<div class="label">Quick Presets</div>
<div class="agent-tools-buttons">
${PROFILE_OPTIONS.map(
${profileOptions.map(
(option) => html`
<button
class="btn btn--sm ${profile === option.id ? "active" : ""}"
@@ -213,11 +262,18 @@ export function renderAgentTools(params: {
</div>
<div class="agent-tools-grid" style="margin-top: 20px;">
${TOOL_SECTIONS.map(
${toolSections.map(
(section) =>
html`
<div class="agent-tools-section">
<div class="agent-tools-header">${section.label}</div>
<div class="agent-tools-header">
${section.label}
${
section.source === "plugin" && section.pluginId
? html`<span class="agent-pill" style="margin-left: 8px;">plugin:${section.pluginId}</span>`
: nothing
}
</div>
<div class="agent-tools-list">
${section.tools.map((tool) => {
const { allowed } = resolveAllowed(tool.id);
@@ -226,6 +282,7 @@ export function renderAgentTools(params: {
<div>
<div class="agent-tool-title mono">${tool.label}</div>
<div class="agent-tool-sub">${tool.description}</div>
${renderToolBadges(section, tool)}
</div>
<label class="cfg-toggle">
<input

View File

@@ -4,9 +4,33 @@ import {
normalizeToolName,
resolveToolProfilePolicy,
} from "../../../../src/agents/tool-policy-shared.js";
import type { AgentIdentityResult, AgentsFilesListResult, AgentsListResult } from "../types.ts";
import type {
AgentIdentityResult,
AgentsFilesListResult,
AgentsListResult,
ToolCatalogProfile,
ToolsCatalogResult,
} from "../types.ts";
export const TOOL_SECTIONS = [
export type AgentToolEntry = {
id: string;
label: string;
description: string;
source?: "core" | "plugin";
pluginId?: string;
optional?: boolean;
defaultProfiles?: string[];
};
export type AgentToolSection = {
id: string;
label: string;
source?: "core" | "plugin";
pluginId?: string;
tools: AgentToolEntry[];
};
export const FALLBACK_TOOL_SECTIONS: AgentToolSection[] = [
{
id: "fs",
label: "Files",
@@ -97,6 +121,38 @@ export const PROFILE_OPTIONS = [
{ id: "full", label: "Full" },
] as const;
export function resolveToolSections(
toolsCatalogResult: ToolsCatalogResult | null,
): AgentToolSection[] {
if (toolsCatalogResult?.groups?.length) {
return toolsCatalogResult.groups.map((group) => ({
id: group.id,
label: group.label,
source: group.source,
pluginId: group.pluginId,
tools: group.tools.map((tool) => ({
id: tool.id,
label: tool.label,
description: tool.description,
source: tool.source,
pluginId: tool.pluginId,
optional: tool.optional,
defaultProfiles: [...tool.defaultProfiles],
})),
}));
}
return FALLBACK_TOOL_SECTIONS;
}
export function resolveToolProfileOptions(
toolsCatalogResult: ToolsCatalogResult | null,
): readonly ToolCatalogProfile[] | typeof PROFILE_OPTIONS {
if (toolsCatalogResult?.profiles?.length) {
return toolsCatalogResult.profiles;
}
return PROFILE_OPTIONS;
}
type ToolPolicy = {
allow?: string[];
deny?: string[];

View File

@@ -7,6 +7,7 @@ import type {
CronJob,
CronStatus,
SkillStatusReport,
ToolsCatalogResult,
} from "../types.ts";
import { renderAgentOverview } from "./agents-panels-overview.ts";
import {
@@ -58,6 +59,12 @@ export type AgentSkillsState = {
filter: string;
};
export type ToolsCatalogState = {
loading: boolean;
error: string | null;
result: ToolsCatalogResult | null;
};
export type AgentsProps = {
basePath: string;
loading: boolean;
@@ -73,6 +80,7 @@ export type AgentsProps = {
agentIdentityError: string | null;
agentIdentityById: Record<string, AgentIdentityResult>;
agentSkills: AgentSkillsState;
toolsCatalog: ToolsCatalogState;
onRefresh: () => void;
onSelectAgent: (agentId: string) => void;
onSelectPanel: (panel: AgentsPanel) => void;
@@ -257,6 +265,9 @@ export function renderAgents(props: AgentsProps) {
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
toolsCatalogLoading: props.toolsCatalog.loading,
toolsCatalogError: props.toolsCatalog.error,
toolsCatalogResult: props.toolsCatalog.result,
onProfileChange: props.onToolsProfileChange,
onOverridesChange: props.onToolsOverridesChange,
onConfigReload: props.onConfigReload,