/** * manage_tools — introspect, enable, disable, and hot-create tools at runtime. * * Inspired by devduck's manage_tools + leverages the careless custom-tools store. * * Actions: * - list → all registered tools (grouped) + enabled/disabled state * - groups → list tool groups only * - active → list currently-active tool names (after settings filter) * - disable → add tool(s) to settings.disabledTools (by name) * - enable → remove tool(s) from settings.disabledTools * - disable_group→ disable every tool in a group * - enable_group → enable every tool in a group * - custom_list → list user-created custom tools * - custom_delete→ delete a custom tool * - reload → fire a rebuild event (agent will pick up on next turn) * * All actions that mutate settings update the `careless-settings` localStorage * entry and emit a `storage` event so useSettings refreshes cross-component. * * Concurrency: mutation ops are serialized via an async chain (writeLock) so * parallel calls queue instead of racing. See manage-tools.log.md bug #1. */ import { tool } from '@strands-agents/sdk' import { z } from 'zod' import { TOOL_GROUPS, getAllToolNames, buildTools } from './index' import { loadCustomTools } from './self-modify' import { get as idbGet, set as idbSet, del as idbDel } from 'idb-keyval' const SETTINGS_KEY = 'careless-settings' const CUSTOM_TOOLS_KEY = 'careless-custom-tools' /** Never let the agent disable these — disabling them would brick self-recovery. */ const PROTECTED_TOOLS = new Set([ 'manage_tools', 'manage_messages', 'update_self_prompt', 'create_tool', ]) interface StoredSettings { disabledTools?: string[] enableTools?: boolean enableVision?: boolean enableMesh?: boolean enableMemory?: boolean [k: string]: unknown } function loadSettings(): StoredSettings { try { const raw = localStorage.getItem(SETTINGS_KEY) return raw ? JSON.parse(raw) : {} } catch { return {} } } function saveSettings(s: StoredSettings) { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)) window.dispatchEvent(new StorageEvent('storage', { key: SETTINGS_KEY, newValue: JSON.stringify(s) })) window.dispatchEvent(new CustomEvent('careless:settings-changed', { detail: s })) window.dispatchEvent(new CustomEvent('careless:tools-changed')) } /** Serialize mutations — fixes the concurrent-write race documented in manage-tools.log.md */ let writeLock: Promise = Promise.resolve() function serialize(fn: () => Promise): Promise { const next = writeLock.then(fn, fn) writeLock = next.then(() => undefined, () => undefined) return next } function groupToolNames(groupId: string): string[] { const g = TOOL_GROUPS.find(x => x.id === groupId) return g ? g.tools.map(t => t.name) : [] } function flattenBuiltins() { const out: { name: string; group: string; group_label: string; description?: string }[] = [] for (const g of TOOL_GROUPS) { for (const t of g.tools) { out.push({ name: t.name, group: g.id, group_label: g.label, description: (t as { description?: string }).description }) } } return out } export const manageToolsTool = tool({ name: 'manage_tools', description: 'Introspect & modify the tool registry. Actions: list | groups | active | disable | enable | ' + 'disable_group | enable_group | custom_list | custom_delete | reload. ' + 'Mutations persist to settings (localStorage) and emit a reload event. ' + 'Mutations are serialized (parallel-safe). Protected tools (manage_*, update_self_prompt, create_tool) cannot be disabled.', inputSchema: z.object({ action: z.enum([ 'list', 'groups', 'active', 'disable', 'enable', 'disable_group', 'enable_group', 'custom_list', 'custom_delete', 'reload', ]), name: z.string().optional().describe('Tool name (or comma-separated list) for enable/disable/custom_delete'), group: z.string().optional().describe('Group id for disable_group/enable_group (see groups action)'), filter: z.string().optional().describe('Substring filter for list action — applies to both builtin and custom tools'), }), callback: async (input) => { try { if (input.action === 'list') { const settings = loadSettings() const disabledSet = new Set(settings.disabledTools || []) const builtins = flattenBuiltins() const custom = (await loadCustomTools()).map(t => ({ name: t.name, group: 'custom', group_label: 'User-created', description: t.description, })) // Apply filter to BOTH builtin and custom (fixes bug #2) const filterFn = (t: { name: string; group: string; description?: string }) => !input.filter || t.name.toLowerCase().includes(input.filter.toLowerCase()) || t.group.toLowerCase().includes(input.filter.toLowerCase()) || (t.description || '').toLowerCase().includes(input.filter.toLowerCase()) const builtinsFiltered = builtins.filter(filterFn).map(t => ({ ...t, enabled: !disabledSet.has(t.name), protected: PROTECTED_TOOLS.has(t.name) })) const customFiltered = custom.filter(filterFn).map(t => ({ ...t, enabled: !disabledSet.has(t.name), protected: false })) return JSON.stringify({ status: 'success', filter: input.filter || null, total: builtinsFiltered.length + customFiltered.length, builtin_count: builtinsFiltered.length, custom_count: customFiltered.length, disabled_count: disabledSet.size, tools: builtinsFiltered, custom_tools: customFiltered, }) } if (input.action === 'groups') { const settings = loadSettings() const disabledSet = new Set(settings.disabledTools || []) const groups = TOOL_GROUPS.map(g => ({ id: g.id, label: g.label, count: g.tools.length, tool_names: g.tools.map(t => t.name), disabled_in_group: g.tools.filter(t => disabledSet.has(t.name)).length, })) return JSON.stringify({ status: 'success', count: groups.length, groups }) } if (input.action === 'active') { const settings = loadSettings() const live = buildTools(settings as never) const names = live.map((t: { name: string }) => t.name).sort() return JSON.stringify({ status: 'success', count: names.length, active_tools: names }) } if (input.action === 'disable' || input.action === 'enable') { const targets = (input.name || '').split(',').map(s => s.trim()).filter(Boolean) if (!targets.length) return JSON.stringify({ status: 'error', error: 'name parameter required' }) return serialize(async () => { const settings = loadSettings() const disabledSet = new Set(settings.disabledTools || []) const allNames = new Set([...getAllToolNames(), ...(await loadCustomTools()).map(t => t.name)]) const unknown = targets.filter(t => !allNames.has(t)) const knownAll = targets.filter(t => allNames.has(t)) // Footgun guard: refuse to disable protected tools let rejected: string[] = [] let known = knownAll if (input.action === 'disable') { rejected = knownAll.filter(t => PROTECTED_TOOLS.has(t)) known = knownAll.filter(t => !PROTECTED_TOOLS.has(t)) } // Track no-ops for idempotency signal const before = new Set(disabledSet) if (input.action === 'disable') known.forEach(n => disabledSet.add(n)) else known.forEach(n => disabledSet.delete(n)) const changed = known.filter(n => (input.action === 'disable' ? !before.has(n) : before.has(n))) const noop = known.filter(n => !changed.includes(n)) settings.disabledTools = Array.from(disabledSet) saveSettings(settings) return JSON.stringify({ status: 'success', action: input.action, applied: changed, // actually flipped state no_op: noop, // already in desired state rejected_protected: rejected, // protected tools refused unknown, disabled_now: settings.disabledTools.length, disabled_list: settings.disabledTools, }) }) } if (input.action === 'disable_group' || input.action === 'enable_group') { if (!input.group) return JSON.stringify({ status: 'error', error: 'group parameter required' }) const names = groupToolNames(input.group) if (!names.length) return JSON.stringify({ status: 'error', error: `group '${input.group}' not found` }) return serialize(async () => { const settings = loadSettings() const disabledSet = new Set(settings.disabledTools || []) const before = new Set(disabledSet) let rejected: string[] = [] let affected = names if (input.action === 'disable_group') { rejected = names.filter(n => PROTECTED_TOOLS.has(n)) affected = names.filter(n => !PROTECTED_TOOLS.has(n)) } if (input.action === 'disable_group') affected.forEach(n => disabledSet.add(n)) else affected.forEach(n => disabledSet.delete(n)) const changed = affected.filter(n => (input.action === 'disable_group' ? !before.has(n) : before.has(n))) const noop = affected.filter(n => !changed.includes(n)) settings.disabledTools = Array.from(disabledSet) saveSettings(settings) return JSON.stringify({ status: 'success', action: input.action, group: input.group, changed, // actually flipped no_op: noop, // already in desired state rejected_protected: rejected, disabled_now: settings.disabledTools.length, }) }) } if (input.action === 'custom_list') { const tools = await loadCustomTools() return JSON.stringify({ status: 'success', count: tools.length, tools: tools.map(t => ({ name: t.name, description: t.description, created_at: t.createdAt, params: Object.keys(t.params || {}), })), }) } if (input.action === 'custom_delete') { if (!input.name) return JSON.stringify({ status: 'error', error: 'name parameter required' }) const targets = input.name.split(',').map(s => s.trim()).filter(Boolean) return serialize(async () => { const all = (await idbGet>(CUSTOM_TOOLS_KEY)) || [] const keep = all.filter(t => !targets.includes(t.name)) if (keep.length === all.length) { return JSON.stringify({ status: 'error', error: `No custom tool matched: ${targets.join(', ')}` }) } if (keep.length === 0) await idbDel(CUSTOM_TOOLS_KEY) else await idbSet(CUSTOM_TOOLS_KEY, keep) window.dispatchEvent(new CustomEvent('careless:custom-tools-changed')) window.dispatchEvent(new CustomEvent('careless:tools-changed')) return JSON.stringify({ status: 'success', deleted: all.length - keep.length, deleted_names: all.filter(t => targets.includes(t.name)).map(t => t.name), remaining: keep.length, }) }) } if (input.action === 'reload') { window.dispatchEvent(new CustomEvent('careless:tools-changed')) window.dispatchEvent(new CustomEvent('careless:custom-tools-changed')) return JSON.stringify({ status: 'success', note: 'Reload events fired. Agent picks up fresh tools on next turn.' }) } return JSON.stringify({ status: 'error', error: 'unknown action' }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) export const MANAGE_TOOLS_TOOLS = [manageToolsTool]