diff --git a/src/tui/commands/slash-command-handler.ts b/src/tui/commands/slash-command-handler.ts index b2ce8ed..0c61dea 100644 --- a/src/tui/commands/slash-command-handler.ts +++ b/src/tui/commands/slash-command-handler.ts @@ -3,6 +3,7 @@ import { normalizeLocalLlmBaseUrl } from "../persist-user-local-models-config.js import { isThemeName, THEME_NAMES } from "../theme/theme.js"; import { parseSlashCommand } from "./slash-command-parser.js"; import { resolveSlashCommand, SLASH_COMMANDS } from "./slash-commands.js"; +import { renderToolsOverview, renderToolsSearch } from "./tools-listing.js"; export interface SlashDispatchCallbacks { onAbort(): void; @@ -196,6 +197,8 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult { return pureActions([], { triggerSessionPicker: true }); case "new": return pureActions([], { triggerSessionNew: true }); + case "tools": + return dispatchToolsSub(parsed.args); case "skills": return dispatchSkillsSub(parsed.args); case "skill": @@ -486,6 +489,21 @@ function dispatchLlmSub(rawArgs: string): SlashDispatchResult { }); } +/** + * `/tools` answers "what can this agent actually do" without touching a + * panel: the listing is pure text, filtered through the same config + * gates the runtime applies so disabled families never show up. + * Users used to search /skills for "filesystem", find nothing, and + * conclude the agent could not touch files (#71). + */ +function dispatchToolsSub(rawArgs: string): SlashDispatchResult { + const query = rawArgs.trim(); + return pureActions([], { + systemMessage: + query.length === 0 ? renderToolsOverview() : renderToolsSearch(query), + }); +} + function dispatchSkillsSub(rawArgs: string): SlashDispatchResult { const argPart = rawArgs.trim(); if (argPart.length === 0) { diff --git a/src/tui/commands/slash-commands.ts b/src/tui/commands/slash-commands.ts index d19193a..c481a64 100644 --- a/src/tui/commands/slash-commands.ts +++ b/src/tui/commands/slash-commands.ts @@ -22,6 +22,11 @@ export const SLASH_COMMANDS: readonly SlashCommandDef[] = [ "write debug zip (TUI snapshot + recent trace NDJSON) to ~/Documents/atomic-agent-debug", }, { name: "help", description: "list available slash commands" }, + { + name: "tools", + description: + "list built-in tools (fs, shell, browser, memory, vision): `/tools` | `/tools `", + }, { name: "theme", description: diff --git a/src/tui/commands/tools-listing.test.ts b/src/tui/commands/tools-listing.test.ts new file mode 100644 index 0000000..83e44df --- /dev/null +++ b/src/tui/commands/tools-listing.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_TOOL_DESCRIPTORS } from "../../prompt/tool-descriptors.js"; +import { + effectiveToolDescriptors, + listToolFamilies, + renderToolsOverview, + renderToolsSearch, + searchTools, + type ToolGateSourceConfig, +} from "./tools-listing.js"; + +/** + * Every gate open. Tests pass explicit descriptors / configs so results + * never depend on the config file of the machine running the suite. + */ +const ALL_ENABLED: ToolGateSourceConfig = { + browser: { enabled: true }, + web: { search: { enabled: true } }, + vision: { enabled: true }, + memory: { + profile: { enabled: true }, + notes: { enabled: true }, + lessons: { enabled: true }, + procedures: { enabled: true }, + }, + tasks: { enabled: true, agentToolsEnabled: true }, + mcp: { servers: [{}] }, +}; + +describe("listToolFamilies", () => { + it("groups tools by namespace and sorts both levels", () => { + const families = listToolFamilies(DEFAULT_TOOL_DESCRIPTORS); + const names = families.map((f) => f.family); + expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b))); + const fs = families.find((f) => f.family === "os.fs"); + expect(fs).toBeDefined(); + expect(fs!.tools).toContain("os.fs.read"); + expect(fs!.tools).toContain("os.fs.write"); + expect(fs!.tools).toEqual([...fs!.tools].sort((a, b) => a.localeCompare(b))); + }); + + it("covers the families users ask about", () => { + const names = listToolFamilies(DEFAULT_TOOL_DESCRIPTORS).map((f) => f.family); + for (const family of ["os.fs", "os.shell", "os.web", "browser"]) { + expect(names).toContain(family); + } + }); +}); + +describe("effectiveToolDescriptors", () => { + it("keeps the full catalog when every gate is open", () => { + const names = effectiveToolDescriptors(ALL_ENABLED).map((d) => d.name); + expect(names).toEqual(DEFAULT_TOOL_DESCRIPTORS.map((d) => d.name)); + }); + + it("drops browser.* when the browser is disabled in config", () => { + const names = effectiveToolDescriptors({ + ...ALL_ENABLED, + browser: { enabled: false }, + }).map((d) => d.name); + expect(names.some((n) => n.startsWith("browser."))).toBe(false); + expect(names).toContain("os.fs.read"); + }); + + it("drops mcp.* when no MCP servers are configured", () => { + const names = effectiveToolDescriptors({ + ...ALL_ENABLED, + mcp: { servers: [] }, + }).map((d) => d.name); + expect(names.some((n) => n.startsWith("mcp."))).toBe(false); + }); + + it("drops tasks.* when agent task tools are off", () => { + const names = effectiveToolDescriptors({ + ...ALL_ENABLED, + tasks: { enabled: true, agentToolsEnabled: false }, + }).map((d) => d.name); + expect(names.some((n) => n.startsWith("tasks."))).toBe(false); + }); +}); + +describe("searchTools", () => { + it("resolves the alias that started this issue", () => { + // A user searched /skills for "filesystem", found nothing, and + // concluded the agent could not touch files (#71). No tool is + // literally named "filesystem", so the alias has to carry it. + const hits = searchTools("filesystem", DEFAULT_TOOL_DESCRIPTORS); + expect(hits.length).toBeGreaterThan(0); + expect(hits).toContain("os.fs.write"); + }); + + it("routes git to the os.git family, not the shell", () => { + const hits = searchTools("git", DEFAULT_TOOL_DESCRIPTORS); + expect(hits).toContain("os.git.status"); + expect(hits).toContain("os.git.log"); + expect(hits).not.toContain("os.shell.run"); + }); + + it("matches a family prefix directly", () => { + expect(searchTools("browser", DEFAULT_TOOL_DESCRIPTORS)).toContain( + "browser.navigate", + ); + }); + + it("matches a substring of a tool name", () => { + expect(searchTools("grep", DEFAULT_TOOL_DESCRIPTORS)).toContain("os.fs.grep"); + }); + + it("is case-insensitive", () => { + expect(searchTools("BROWSER", DEFAULT_TOOL_DESCRIPTORS)).toContain( + "browser.navigate", + ); + }); + + it("returns nothing for an unrelated query", () => { + expect(searchTools("kubernetes", DEFAULT_TOOL_DESCRIPTORS)).toEqual([]); + }); + + it("returns nothing for an empty query", () => { + expect(searchTools(" ", DEFAULT_TOOL_DESCRIPTORS)).toEqual([]); + }); +}); + +describe("renderToolsOverview", () => { + it("names the config-dependence and points at /skills", () => { + const out = renderToolsOverview(DEFAULT_TOOL_DESCRIPTORS); + expect(out).toContain("enabled under the current config"); + expect(out).toContain("/skills"); + expect(out).toContain("os.fs"); + }); + + it("does not repeat single-segment tool names", () => { + const out = renderToolsOverview(DEFAULT_TOOL_DESCRIPTORS); + expect(out).toContain(" reply"); + expect(out).not.toContain("reply reply"); + expect(out).not.toContain("finish finish"); + }); + + it("omits families disabled by config", () => { + const descriptors = effectiveToolDescriptors({ + ...ALL_ENABLED, + browser: { enabled: false }, + }); + const out = renderToolsOverview(descriptors); + // The footer hint still says `/tools browser`; only the family + // lines (two-space indented) must lose the browser entry. + expect(out).not.toMatch(/^ {2}browser/m); + }); +}); + +describe("renderToolsSearch", () => { + it("lists matches with their full names", () => { + const out = renderToolsSearch("filesystem", DEFAULT_TOOL_DESCRIPTORS); + expect(out).toContain("os.fs.read"); + }); + + it("says so plainly on a miss and offers the next step", () => { + const out = renderToolsSearch("kubernetes", DEFAULT_TOOL_DESCRIPTORS); + expect(out).toContain("no built-in tool matches"); + expect(out).toContain("/tools"); + expect(out).toContain("/skills"); + }); + + it("tells apart missing tools from config-disabled tools", () => { + const descriptors = effectiveToolDescriptors({ + ...ALL_ENABLED, + browser: { enabled: false }, + }); + const out = renderToolsSearch("browser", descriptors); + expect(out).toContain("turned off in your config"); + expect(out).toContain("browser.navigate"); + }); +}); diff --git a/src/tui/commands/tools-listing.ts b/src/tui/commands/tools-listing.ts new file mode 100644 index 0000000..e507141 --- /dev/null +++ b/src/tui/commands/tools-listing.ts @@ -0,0 +1,206 @@ +import { getConfig } from "../../config/index.js"; +import type { ToolDescriptor } from "../../prompt/stable-prefix.js"; +import { DEFAULT_TOOL_DESCRIPTORS } from "../../prompt/tool-descriptors.js"; +import { filterToolDescriptorsByConfig } from "../../runtime/filter-disabled-tools.js"; + +/** + * Human-readable grouping for `/tools`. Built-in tools are namespaced + * (`os.fs.read`, `browser.navigate`, …), so the family is everything up + * to the last dot; single-segment names fall back to themselves. + */ +function familyOf(name: string): string { + const lastDot = name.lastIndexOf("."); + return lastDot === -1 ? name : name.slice(0, lastDot); +} + +/** + * Names users search for, mapped to the family they actually live under. + * `/skills` returning nothing for "filesystem" is what made a user + * conclude the agent could not touch files at all (#71); the same + * aliases let `/tools filesystem` answer instead of drawing a blank. + */ +const FAMILY_ALIASES: ReadonlyMap = new Map([ + ["filesystem", "os.fs"], + ["file", "os.fs"], + ["files", "os.fs"], + ["fs", "os.fs"], + ["disk", "os.fs"], + ["shell", "os.shell"], + ["terminal", "os.shell"], + ["bash", "os.shell"], + ["web", "os.web"], + ["http", "os.http"], + ["net", "os.http"], + ["network", "os.http"], + ["browser", "browser"], + ["chrome", "browser"], + ["memory", "memory"], + ["notes", "memory.notes"], + ["vision", "vision"], + ["image", "vision"], + ["images", "vision"], + ["git", "os.git"], +]); + +/** + * The slice of the app config that decides which built-in tools are + * actually registered at runtime. Structurally satisfied by + * `AtomicAgentConfig`, narrow enough for tests to construct by hand. + */ +export interface ToolGateSourceConfig { + readonly browser: { readonly enabled: boolean }; + readonly web: { readonly search: { readonly enabled: boolean } }; + readonly vision: { readonly enabled: boolean }; + readonly memory: { + readonly profile: { readonly enabled: boolean }; + readonly notes: { readonly enabled: boolean }; + readonly lessons: { readonly enabled: boolean }; + readonly procedures: { readonly enabled: boolean }; + }; + readonly tasks: { + readonly enabled: boolean; + readonly agentToolsEnabled: boolean; + }; + readonly mcp: { readonly servers: readonly unknown[] }; +} + +/** + * The catalog in `DEFAULT_TOOL_DESCRIPTORS` is static; the runtime + * drops config-gated families before the model ever sees them (see + * `bootstrap.ts` → `filterToolDescriptorsByConfig`). `/tools` must + * apply the same gates or it advertises tools the agent cannot call + * (e.g. `browser.*` under `browser.enabled=false`). + * + * Two gates are approximated because their runtime inputs are probed + * at bootstrap, not read from config: vision uses `vision.enabled` + * alone (the mmproj capability probe is not visible here), and the + * MCP gate uses the configured server list instead of live + * connections. Both approximations only ever err on the side of the + * user's stated config. + */ +export function effectiveToolDescriptors( + config: ToolGateSourceConfig = getConfig(), +): readonly ToolDescriptor[] { + return filterToolDescriptorsByConfig(DEFAULT_TOOL_DESCRIPTORS, { + browser: { enabled: config.browser.enabled }, + web: { search: { enabled: config.web.search.enabled } }, + vision: { + enabled: config.vision.enabled, + providerAvailable: config.vision.enabled, + }, + memory: { + profile: { enabled: config.memory.profile.enabled }, + notes: { enabled: config.memory.notes.enabled }, + lessons: { enabled: config.memory.lessons.enabled }, + procedures: { enabled: config.memory.procedures.enabled }, + }, + tasks: { + agentToolsEnabled: config.tasks.enabled && config.tasks.agentToolsEnabled, + }, + mcp: { enabled: config.mcp.servers.length > 0 }, + }); +} + +export interface ToolFamilyListing { + readonly family: string; + readonly tools: readonly string[]; +} + +/** Enabled built-in tools grouped by family, families and tools sorted. */ +export function listToolFamilies( + descriptors: readonly ToolDescriptor[] = effectiveToolDescriptors(), +): readonly ToolFamilyListing[] { + const byFamily = new Map(); + for (const descriptor of descriptors) { + const family = familyOf(descriptor.name); + const bucket = byFamily.get(family); + if (bucket) bucket.push(descriptor.name); + else byFamily.set(family, [descriptor.name]); + } + return [...byFamily.entries()] + .map(([family, tools]) => ({ + family, + tools: [...tools].sort((a, b) => a.localeCompare(b)), + })) + .sort((a, b) => a.family.localeCompare(b.family)); +} + +/** + * Resolve a user query to matching tools. Matches an alias + * ("filesystem"), a family prefix ("os.fs"), or any substring of a tool + * name. Returns an empty array when nothing matches. + */ +export function searchTools( + query: string, + descriptors: readonly ToolDescriptor[] = effectiveToolDescriptors(), +): readonly string[] { + const q = query.trim().toLowerCase(); + if (q.length === 0) return []; + const needle = FAMILY_ALIASES.get(q) ?? q; + return descriptors + .map((d) => d.name) + .filter((name) => name.toLowerCase().includes(needle)) + .sort((a, b) => a.localeCompare(b)); +} + +/** `/tools` with no argument: every enabled family, one line each. */ +export function renderToolsOverview( + descriptors: readonly ToolDescriptor[] = effectiveToolDescriptors(), +): string { + const families = listToolFamilies(descriptors); + const total = families.reduce((sum, f) => sum + f.tools.length, 0); + const lines = [ + `built-in tools (${total}) enabled under the current config:`, + "", + ...families.map(renderFamilyLine), + "", + "these are separate from /skills, which lists optional playbooks.", + "`/tools ` filters, e.g. `/tools filesystem` or `/tools browser`.", + ]; + return lines.join("\n"); +} + +/** `/tools `: matching tools, or a clear miss message. */ +export function renderToolsSearch( + query: string, + descriptors: readonly ToolDescriptor[] = effectiveToolDescriptors(), +): string { + const matches = searchTools(query, descriptors); + if (matches.length === 0) { + const gatedMatches = searchTools(query, DEFAULT_TOOL_DESCRIPTORS); + if (gatedMatches.length > 0) { + return [ + `no enabled tool matches "${query.trim()}". these exist but are turned off in your config:`, + "", + ...gatedMatches.map((name) => ` ${name}`), + ].join("\n"); + } + return ( + `no built-in tool matches "${query.trim()}".\n` + + "run `/tools` for the full list, or `/skills` for optional skill packs." + ); + } + return [ + `built-in tools matching "${query.trim()}" (${matches.length}):`, + "", + ...matches.map((name) => ` ${name}`), + ].join("\n"); +} + +/** + * One overview line per family. Namespaced families show short member + * names after the prefix; a single-segment tool (`reply`, `finish`) + * IS its own family, so repeating the name would render "reply reply". + */ +function renderFamilyLine(f: ToolFamilyListing): string { + if (f.tools.length === 1 && f.tools[0] === f.family) { + return ` ${f.family}`; + } + return ` ${f.family} ${f.tools.map(shortName).join(" ")}`; +} + +/** Drop the family prefix so the overview stays one line per family. */ +function shortName(name: string): string { + const lastDot = name.lastIndexOf("."); + return lastDot === -1 ? name : name.slice(lastDot + 1); +} diff --git a/src/tui/components/skills-panel.tsx b/src/tui/components/skills-panel.tsx index f8757c6..f79e965 100644 --- a/src/tui/components/skills-panel.tsx +++ b/src/tui/components/skills-panel.tsx @@ -128,6 +128,14 @@ function FilterBar({ {autoRefresh ? " · auto" : ""} {loading ? " · …" : ""} + {/* Skills are optional playbooks; the built-in tools (fs, shell, + browser, memory, vision) are always loaded and never appear + here. Users searched this list for "filesystem", found + nothing, and concluded the agent could not touch files (#71). */} + + {" "} + built-in tools: /tools + ); }