diff --git a/packages/shared/src/__tests__/dynamicTools.test.ts b/packages/shared/src/__tests__/dynamicTools.test.ts index d282be8..13fab9c 100644 --- a/packages/shared/src/__tests__/dynamicTools.test.ts +++ b/packages/shared/src/__tests__/dynamicTools.test.ts @@ -1,20 +1,134 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import { + DynamicToolRegistry, + dynamicToolRegistry, normalizeBuiltinDynamicToolName, buildBuiltinDynamicToolSpecs, filterEnabledBuiltinDynamicToolNames, - BUILTIN_DYNAMIC_TOOL_DEFINITIONS, IMAGE_GENERATION_DYNAMIC_TOOL_NAME, IMAGE_GENERATION_DYNAMIC_TOOL_NAMESPACE, BUILTIN_DYNAMIC_TOOL_NAMES, } from "../dynamicTools"; -describe("dynamicTools", () => { +describe("DynamicToolRegistry", () => { + let registry: DynamicToolRegistry; + + beforeEach(() => { + registry = new DynamicToolRegistry(); + }); + + it("starts empty", () => { + expect(registry.getAllNames()).toEqual([]); + expect(registry.getAllDefinitions()).toEqual([]); + }); + + it("registers and retrieves a tool", () => { + registry.register({ + name: "test_tool", + namespace: "test", + label: "Test Tool", + description: "A test tool", + requiresApproval: false, + inputSchema: { type: "object", properties: {} }, + }); + expect(registry.isKnownToolName("test_tool")).toBe(true); + expect(registry.getDefinition("test_tool")?.label).toBe("Test Tool"); + expect(registry.getAllNames()).toEqual(["test_tool"]); + }); + + it("unregisters a tool", () => { + registry.register({ + name: "temp_tool", + label: "Temp", + description: "Temporary", + requiresApproval: false, + inputSchema: {}, + }); + expect(registry.isKnownToolName("temp_tool")).toBe(true); + registry.unregister("temp_tool"); + expect(registry.isKnownToolName("temp_tool")).toBe(false); + }); + + it("buildEnabledSpecs filters by enabledByName", () => { + registry.register({ + name: "tool_a", + label: "A", + description: "Tool A", + requiresApproval: false, + inputSchema: {}, + }); + registry.register({ + name: "tool_b", + label: "B", + description: "Tool B", + requiresApproval: true, + inputSchema: {}, + }); + + const allSpecs = registry.buildEnabledSpecs(null); + expect(allSpecs).toHaveLength(2); + + const filtered = registry.buildEnabledSpecs({ tool_a: false }); + expect(filtered).toHaveLength(1); + expect(filtered[0].name).toBe("tool_b"); + }); + + it("buildDefaultEnabledByName enables all registered tools", () => { + registry.register({ + name: "tool_x", + label: "X", + description: "X", + requiresApproval: false, + inputSchema: {}, + }); + registry.register({ + name: "tool_y", + label: "Y", + description: "Y", + requiresApproval: false, + inputSchema: {}, + }); + expect(registry.buildDefaultEnabledByName()).toEqual({ + tool_x: true, + tool_y: true, + }); + }); +}); + +describe("dynamicToolRegistry singleton", () => { + it("has image_generate registered by default", () => { + expect(dynamicToolRegistry.isKnownToolName("image_generate")).toBe(true); + const def = dynamicToolRegistry.getDefinition("image_generate"); + expect(def?.namespace).toBe(IMAGE_GENERATION_DYNAMIC_TOOL_NAMESPACE); + expect(def?.name).toBe(IMAGE_GENERATION_DYNAMIC_TOOL_NAME); + expect(def?.requiresApproval).toBe(false); + const schema = def?.inputSchema as Record; + expect(schema.required).toContain("prompt"); + }); + + it("allows registering additional tools at runtime", () => { + dynamicToolRegistry.register({ + name: "custom_search", + namespace: "codenexus", + label: "Custom Search", + description: "Searches local files", + requiresApproval: false, + inputSchema: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + }); + expect(dynamicToolRegistry.isKnownToolName("custom_search")).toBe(true); + // Clean up + dynamicToolRegistry.unregister("custom_search"); + }); +}); + +describe("backward-compatible exports", () => { describe("normalizeBuiltinDynamicToolName", () => { it("returns image_generate for valid name", () => { - expect(normalizeBuiltinDynamicToolName("image_generate")).toBe( - "image_generate", - ); + expect(normalizeBuiltinDynamicToolName("image_generate")).toBe("image_generate"); }); it("returns null for unknown name", () => { @@ -28,9 +142,7 @@ describe("dynamicTools", () => { }); it("trims whitespace", () => { - expect(normalizeBuiltinDynamicToolName(" image_generate ")).toBe( - "image_generate", - ); + expect(normalizeBuiltinDynamicToolName(" image_generate ")).toBe("image_generate"); }); }); @@ -48,7 +160,7 @@ describe("dynamicTools", () => { expect(specs[0].deferLoading).toBe(false); }); - it("uses default BUILTIN_DYNAMIC_TOOL_NAMES when not specified", () => { + it("uses BUILTIN_DYNAMIC_TOOL_NAMES", () => { const specs = buildBuiltinDynamicToolSpecs(BUILTIN_DYNAMIC_TOOL_NAMES); expect(specs.length).toBeGreaterThan(0); }); @@ -56,39 +168,18 @@ describe("dynamicTools", () => { describe("filterEnabledBuiltinDynamicToolNames", () => { it("returns all tools when enabledByName is null/undefined", () => { - expect( - filterEnabledBuiltinDynamicToolNames(["image_generate"], null), - ).toEqual(["image_generate"]); - expect( - filterEnabledBuiltinDynamicToolNames(["image_generate"], undefined), - ).toEqual(["image_generate"]); + expect(filterEnabledBuiltinDynamicToolNames(["image_generate"], null)).toEqual(["image_generate"]); + expect(filterEnabledBuiltinDynamicToolNames(["image_generate"], undefined)).toEqual(["image_generate"]); }); it("filters out explicitly disabled tools", () => { - expect( - filterEnabledBuiltinDynamicToolNames(["image_generate"], { - image_generate: false, - }), - ).toEqual([]); + expect(filterEnabledBuiltinDynamicToolNames(["image_generate"], { image_generate: false })).toEqual([]); }); it("keeps explicitly enabled tools", () => { - expect( - filterEnabledBuiltinDynamicToolNames(["image_generate"], { - image_generate: true, - }), - ).toEqual(["image_generate"]); - }); - }); - - describe("BUILTIN_DYNAMIC_TOOL_DEFINITIONS", () => { - it("has valid input schema for image_generate", () => { - const def = BUILTIN_DYNAMIC_TOOL_DEFINITIONS.image_generate; - expect(def.name).toBe("image_generate"); - expect(def.requiresApproval).toBe(false); - const schema = def.inputSchema as Record; - expect(schema.type).toBe("object"); - expect(schema.required).toContain("prompt"); + expect(filterEnabledBuiltinDynamicToolNames(["image_generate"], { image_generate: true })).toEqual([ + "image_generate", + ]); }); }); }); diff --git a/packages/shared/src/dynamicTools.ts b/packages/shared/src/dynamicTools.ts index 51a6255..1849649 100644 --- a/packages/shared/src/dynamicTools.ts +++ b/packages/shared/src/dynamicTools.ts @@ -1,26 +1,107 @@ /** - * 内置动态工具注册表。 + * 动态工具注册表。 * - * 这里定义的是注入 Codex 会话的工具元数据和 JSON Schema;真正执行由应用侧的动态工具处理器完成。 + * 工具通过 `dynamicToolRegistry.register(definition)` 声明式注册, + * 注册后即可被注入 Codex 会话。真正执行由应用侧的动态工具处理器完成。 + * + * 内置工具(如 image_generate)在模块加载时自动注册,外部工具可在运行时按需追加。 */ -export type BuiltinDynamicToolName = "image_generate"; -export type BuiltinDynamicToolDefinition = { - name: BuiltinDynamicToolName; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type DynamicToolDefinition = { + name: string; namespace?: string; label: string; description: string; requiresApproval: boolean; inputSchema: Record; + developerInstructions?: string; +}; + +export type DynamicToolSpecLike = { + namespace?: string; + name: string; + description: string; + inputSchema: unknown; + deferLoading?: boolean; }; +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +export class DynamicToolRegistry { + private readonly tools = new Map(); + + register(definition: DynamicToolDefinition): void { + this.tools.set(definition.name, definition); + } + + unregister(name: string): void { + this.tools.delete(name); + } + + getDefinition(name: string): DynamicToolDefinition | undefined { + return this.tools.get(name); + } + + getAllNames(): string[] { + return [...this.tools.keys()]; + } + + getAllDefinitions(): DynamicToolDefinition[] { + return [...this.tools.values()]; + } + + isKnownToolName(name: string): boolean { + return this.tools.has(name); + } + + /** + * 根据用户启用/禁用状态,生成需要注入 Codex 会话的动态工具 spec 列表。 + */ + buildEnabledSpecs( + enabledByName: Partial> | null | undefined, + ): DynamicToolSpecLike[] { + return this.getAllDefinitions() + .filter((def) => enabledByName?.[def.name] !== false) + .map((def) => ({ + namespace: def.namespace, + name: def.name, + description: def.description, + inputSchema: def.inputSchema, + deferLoading: false, + })); + } + + /** + * 构建默认的启用/禁用状态(所有已注册工具默认启用)。 + */ + buildDefaultEnabledByName(): Record { + const result: Record = {}; + for (const name of this.tools.keys()) { + result[name] = true; + } + return result; + } +} + +// --------------------------------------------------------------------------- +// Singleton registry instance +// --------------------------------------------------------------------------- + +export const dynamicToolRegistry = new DynamicToolRegistry(); + +// --------------------------------------------------------------------------- +// Built-in: image_generate +// --------------------------------------------------------------------------- + export const IMAGE_GENERATION_DYNAMIC_TOOL_NAMESPACE = "codenexus"; -export const IMAGE_GENERATION_DYNAMIC_TOOL_NAME: BuiltinDynamicToolName = - "image_generate"; +export const IMAGE_GENERATION_DYNAMIC_TOOL_NAME = "image_generate"; -/** - * 强制图片请求走应用内动态工具,避免模型绕到不受应用状态管理的内置图片工具。 - */ export const IMAGE_GENERATION_DYNAMIC_TOOL_DEVELOPER_INSTRUCTIONS = [ "For every user request to create, draw, render, or generate an image, call the codenexus.image_generate dynamic tool.", "If the user's current message includes image attachments, codenexus.image_generate automatically uses those attachments as reference images for image editing.", @@ -28,105 +109,98 @@ export const IMAGE_GENERATION_DYNAMIC_TOOL_DEVELOPER_INSTRUCTIONS = [ "If codenexus.image_generate is unavailable, explain that image generation is unavailable instead of using another image tool.", ].join("\n"); -/** 内置工具定义是注入会话的单一来源,避免 commander/worker 使用不同 schema。 */ -export const BUILTIN_DYNAMIC_TOOL_DEFINITIONS: Record< - BuiltinDynamicToolName, - BuiltinDynamicToolDefinition -> = { - image_generate: { - namespace: IMAGE_GENERATION_DYNAMIC_TOOL_NAMESPACE, - name: IMAGE_GENERATION_DYNAMIC_TOOL_NAME, - label: "Generate image", - description: `${IMAGE_GENERATION_DYNAMIC_TOOL_DEVELOPER_INSTRUCTIONS} Rewrite the user's request into a complete visual prompt before calling. When image attachments are present in the current user message, they are supplied automatically as reference images; do not include image bytes or paths in the tool arguments.`, - requiresApproval: false, - inputSchema: { - type: "object", - additionalProperties: false, - properties: { - prompt: { - type: "string", - minLength: 1, - description: - "A complete image prompt describing subject, composition, style, colors, text, and any constraints requested by the user.", - }, - size: { - type: "string", - description: - "Optional output size, for example 1024x1024, 1024x1536, 1536x1024, or auto.", - }, - quality: { - type: "string", - description: - "Optional quality hint such as auto, low, medium, or high.", - }, - output_format: { - type: "string", - description: "Optional output format such as png, jpeg, or webp.", - }, - n: { - type: "integer", - minimum: 1, - maximum: 4, - description: "Number of images to generate.", - }, +dynamicToolRegistry.register({ + namespace: IMAGE_GENERATION_DYNAMIC_TOOL_NAMESPACE, + name: IMAGE_GENERATION_DYNAMIC_TOOL_NAME, + label: "Generate image", + description: `${IMAGE_GENERATION_DYNAMIC_TOOL_DEVELOPER_INSTRUCTIONS} Rewrite the user's request into a complete visual prompt before calling. When image attachments are present in the current user message, they are supplied automatically as reference images; do not include image bytes or paths in the tool arguments.`, + requiresApproval: false, + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + prompt: { + type: "string", + minLength: 1, + description: + "A complete image prompt describing subject, composition, style, colors, text, and any constraints requested by the user.", + }, + size: { + type: "string", + description: + "Optional output size, for example 1024x1024, 1024x1536, 1536x1024, or auto.", + }, + quality: { + type: "string", + description: + "Optional quality hint such as auto, low, medium, or high.", + }, + output_format: { + type: "string", + description: "Optional output format such as png, jpeg, or webp.", + }, + n: { + type: "integer", + minimum: 1, + maximum: 4, + description: "Number of images to generate.", }, - required: ["prompt"], }, + required: ["prompt"], }, -}; + developerInstructions: IMAGE_GENERATION_DYNAMIC_TOOL_DEVELOPER_INSTRUCTIONS, +}); -export const BUILTIN_COMMANDER_DYNAMIC_TOOL_NAMES: BuiltinDynamicToolName[] = [ - IMAGE_GENERATION_DYNAMIC_TOOL_NAME, -]; +// --------------------------------------------------------------------------- +// Backward-compatible exports (delegate to registry) +// --------------------------------------------------------------------------- -/** worker 侧保持和 commander 一致的内置工具集合,避免同一会话不同角色能力不一致。 */ -export const BUILTIN_WORKER_DYNAMIC_TOOL_NAMES: BuiltinDynamicToolName[] = [ - IMAGE_GENERATION_DYNAMIC_TOOL_NAME, -]; +/** @deprecated Use `dynamicToolRegistry.isKnownToolName(name)` instead. */ +export type BuiltinDynamicToolName = string; -export const BUILTIN_DYNAMIC_TOOL_NAMES: BuiltinDynamicToolName[] = [ - IMAGE_GENERATION_DYNAMIC_TOOL_NAME, -]; +/** @deprecated Use `dynamicToolRegistry.getDefinition(name)` instead. */ +export type BuiltinDynamicToolDefinition = DynamicToolDefinition; -export type DynamicToolSpecLike = { - namespace?: string; - name: string; - description: string; - inputSchema: unknown; - deferLoading?: boolean; +/** @deprecated Use `dynamicToolRegistry.getAllDefinitions()` or `.getDefinition(name)` instead. */ +export const BUILTIN_DYNAMIC_TOOL_DEFINITIONS: Record = { + get image_generate() { + return dynamicToolRegistry.getDefinition(IMAGE_GENERATION_DYNAMIC_TOOL_NAME)!; + }, }; -export function normalizeBuiltinDynamicToolName( - value: unknown, -): BuiltinDynamicToolName | null { - return String(value ?? "").trim() === IMAGE_GENERATION_DYNAMIC_TOOL_NAME - ? IMAGE_GENERATION_DYNAMIC_TOOL_NAME - : null; +/** @deprecated Use `dynamicToolRegistry.getAllNames()` instead. */ +export const BUILTIN_DYNAMIC_TOOL_NAMES: string[] = dynamicToolRegistry.getAllNames(); + +/** @deprecated Use `dynamicToolRegistry.getAllNames()` instead. */ +export const BUILTIN_COMMANDER_DYNAMIC_TOOL_NAMES: string[] = dynamicToolRegistry.getAllNames(); + +/** @deprecated Use `dynamicToolRegistry.getAllNames()` instead. */ +export const BUILTIN_WORKER_DYNAMIC_TOOL_NAMES: string[] = dynamicToolRegistry.getAllNames(); + +/** @deprecated Use `dynamicToolRegistry.isKnownToolName(name)` instead. */ +export function normalizeBuiltinDynamicToolName(value: unknown): string | null { + const name = String(value ?? "").trim(); + return dynamicToolRegistry.isKnownToolName(name) ? name : null; } -/** 生成 Codex app-server 需要的动态工具 spec,调用侧可以按 profile 传入不同工具名集合。 */ -export function buildBuiltinDynamicToolSpecs( - names: BuiltinDynamicToolName[] = [], -): DynamicToolSpecLike[] { +/** @deprecated Use `dynamicToolRegistry.buildEnabledSpecs(enabledByName)` instead. */ +export function buildBuiltinDynamicToolSpecs(names: string[] = []): DynamicToolSpecLike[] { return names - .map((name) => BUILTIN_DYNAMIC_TOOL_DEFINITIONS[name]) - .filter(Boolean) - .map((definition) => ({ - namespace: definition.namespace, - name: definition.name, - description: definition.description, - inputSchema: definition.inputSchema, + .map((name) => dynamicToolRegistry.getDefinition(name)) + .filter((def): def is DynamicToolDefinition => def != null) + .map((def) => ({ + namespace: def.namespace, + name: def.name, + description: def.description, + inputSchema: def.inputSchema, deferLoading: false, })); } -/** 用户设置中显式关闭的内置动态工具会在会话注入前被过滤掉。 */ +/** @deprecated Use `dynamicToolRegistry.buildEnabledSpecs(enabledByName)` instead. */ export function filterEnabledBuiltinDynamicToolNames( - names: BuiltinDynamicToolName[], - enabledByName: - | Partial> - | null - | undefined, -): BuiltinDynamicToolName[] { + names: string[], + enabledByName: Partial> | null | undefined, +): string[] { return names.filter((name) => enabledByName?.[name] !== false); } diff --git a/packages/shared/src/localSettings.ts b/packages/shared/src/localSettings.ts index 8a773cf..d4b27b9 100644 --- a/packages/shared/src/localSettings.ts +++ b/packages/shared/src/localSettings.ts @@ -1,8 +1,5 @@ import { normalizeCustomModelIds } from "./modelCatalog"; -import { - normalizeBuiltinDynamicToolName, - type BuiltinDynamicToolName, -} from "./dynamicTools"; +import { dynamicToolRegistry } from "./dynamicTools"; /** * 用户本地设置的共享 schema。 @@ -34,7 +31,7 @@ export type LocalFlowchartAiSettings = { export type LocalThreadWorkspaceGroupsCollapsedState = Record; export type LocalDynamicToolsSettings = { - enabledByName: Record; + enabledByName: Record; }; export type LocalGoalShutdownSetting = { enabled: boolean; @@ -136,7 +133,7 @@ export type UserLocalSettingsPatch = { timeoutMs: number | null; }>; dynamicTools?: Partial<{ - enabledByName: Partial> | null; + enabledByName: Partial> | null; }>; goalAutomation?: Partial<{ shutdownByThreadId: Record< @@ -184,11 +181,8 @@ const UI_FONT_SIZE_ZOOM_FACTORS: Record = { large: 1.08, }; -function buildDefaultDynamicToolsEnabledByName(): Record< - BuiltinDynamicToolName, - boolean -> { - return { image_generate: true }; +function buildDefaultDynamicToolsEnabledByName(): Record { + return dynamicToolRegistry.buildDefaultEnabledByName(); } function normalizeUiFontFamilyPreset(value: unknown): UiFontFamilyPreset { @@ -466,23 +460,23 @@ function normalizeDynamicToolsSettings( const enabledRecord = toRecord(record?.enabledByName); const enabledByName = buildDefaultDynamicToolsEnabledByName(); for (const [rawName, rawEnabled] of Object.entries(enabledRecord ?? {})) { - const name = normalizeBuiltinDynamicToolName(rawName); - if (!name || typeof rawEnabled !== "boolean") continue; - (enabledByName as Record)[name] = rawEnabled; + const name = String(rawName ?? "").trim(); + if (!name || !dynamicToolRegistry.isKnownToolName(name) || typeof rawEnabled !== "boolean") continue; + enabledByName[name] = rawEnabled; } return { enabledByName }; } function mergeDynamicToolsEnabledByName( - current: Record, + current: Record, patchValue: unknown, -): Record { +): Record { const patchRecord = toRecord(patchValue); const next = { ...current }; for (const [rawName, rawEnabled] of Object.entries(patchRecord ?? {})) { - const name = normalizeBuiltinDynamicToolName(rawName); - if (!name || typeof rawEnabled !== "boolean") continue; - (next as Record)[name] = rawEnabled; + const name = String(rawName ?? "").trim(); + if (!name || !dynamicToolRegistry.isKnownToolName(name) || typeof rawEnabled !== "boolean") continue; + next[name] = rawEnabled; } return next; }