From 16ee4d83485d21eea5919e3d8098724d5761b7d9 Mon Sep 17 00:00:00 2001 From: digua Date: Mon, 13 Jul 2026 18:53:34 +0800 Subject: [PATCH] refactor(ai): unify desktop tool adapters --- apps/cli/src/ai/tool-adapter.ts | 85 +++++-------------- apps/desktop/main/ai/tools/debug-executor.ts | 3 +- .../main/ai/tools/definitions/index.test.ts | 35 -------- .../main/ai/tools/definitions/index.ts | 67 --------------- apps/desktop/main/ai/tools/index.ts | 19 +++-- .../main/ai/tools/shared-tool-adapter.ts | 72 ++++------------ .../http-routes/src/ai/tool-executor.test.ts | 20 +---- .../http-routes/src/ai/tool-executor.ts | 38 +++------ packages/http-routes/src/index.ts | 2 + .../src/routes/web/ai-tools.test.ts | 26 +++++- .../http-routes/src/routes/web/ai-tools.ts | 48 +++-------- packages/tools/src/agent-adapter.ts | 65 ++++++++++++++ .../tools/src/definitions/member-stats.ts | 2 +- packages/tools/src/definitions/time-stats.ts | 2 +- packages/tools/src/index.ts | 3 + packages/tools/src/registry.test.ts | 13 +++ 16 files changed, 189 insertions(+), 311 deletions(-) delete mode 100644 apps/desktop/main/ai/tools/definitions/index.test.ts delete mode 100644 apps/desktop/main/ai/tools/definitions/index.ts rename apps/desktop/main/ai/tools/tool-executor-core.test.ts => packages/http-routes/src/ai/tool-executor.test.ts (67%) rename apps/desktop/main/ai/tools/tool-executor-core.ts => packages/http-routes/src/ai/tool-executor.ts (75%) create mode 100644 packages/tools/src/agent-adapter.ts diff --git a/apps/cli/src/ai/tool-adapter.ts b/apps/cli/src/ai/tool-adapter.ts index c4655b06..189fec1b 100644 --- a/apps/cli/src/ai/tool-adapter.ts +++ b/apps/cli/src/ai/tool-adapter.ts @@ -12,14 +12,13 @@ import type { RawMessage, ToolTimeRange, } from '@openchatlab/tools' -import { CoreDataProvider } from '@openchatlab/tools' +import { CoreDataProvider, executeToolForAgent, toAgentToolParameters } from '@openchatlab/tools' import type { DatabaseAdapter } from '@openchatlab/core' import { applyPreprocessingPipeline, batchSegmentWithFrequency, preprocessMessages, type AgentTool, - type AgentToolResult, type PreprocessableMessage, type PreprocessConfig, type TruncationStrategy, @@ -55,18 +54,6 @@ export interface ServerToolContext { maxMessagesLimit?: number } -function convertJsonSchemaToParameters(schema: ToolDefinition['inputSchema']) { - const properties: Record = {} - for (const [key, prop] of Object.entries(schema.properties)) { - properties[key] = { ...prop } - } - return { - type: 'object' as const, - properties, - required: schema.required || [], - } -} - export interface AdaptToolsOptions { maxToolResultTokens?: number } @@ -85,9 +72,8 @@ export function adaptToolsForAgent( name: tool.name, label: tool.name, description: tool.description, - parameters: convertJsonSchemaToParameters(tool.inputSchema) as any, - async execute(_toolCallId: string, params: unknown): Promise> { - const toolParams = (params && typeof params === 'object' ? params : {}) as Record + parameters: toAgentToolParameters(tool.inputSchema) as any, + async execute(_toolCallId: string, params: unknown) { const ctx = getContext() const execCtx: ToolExecutionContext = { db: ctx.db, @@ -107,52 +93,27 @@ export function adaptToolsForAgent( ctx.preprocessConfig as PreprocessConfig | undefined ) as RawMessage[], } - try { - const result = await tool.handler(toolParams, execCtx) - const chartDetails = - result.chart || result.charts - ? { - ...(result.chart ? { chart: result.chart } : {}), - ...(result.charts ? { charts: result.charts } : {}), - } - : {} + const result = await executeToolForAgent(tool, params, execCtx) + const details = result.details as Record | null + if (!Array.isArray(details?.rawMessages)) return result - if (result.rawMessages && result.rawMessages.length > 0) { - // tools may mirror rawMessages inside data; keep it out of extraDetails - // so the pipeline only renders scalar metadata (same as the desktop adapter) - const { rawMessages: _rawInData, ...extraDetails } = (result.data ?? {}) as Record - const preprocessCfg = ctx.preprocessConfig as PreprocessConfig | undefined - const pipelineResult = applyPreprocessingPipeline({ - rawMessages: result.rawMessages as PreprocessableMessage[], - preprocessConfig: preprocessCfg, - anonymizeNames: preprocessCfg?.anonymizeNames ?? false, - ownerPlatformId: ctx.ownerPlatformId, - locale: ctx.locale, - maxToolResultTokens: tokenBudget, - truncationStrategy: TOOL_TRUNCATION_STRATEGY[tool.name] ?? 'keep_last', - extraDetails, - logger: getServerAiLogger() ?? undefined, - }) - return { - content: [{ type: 'text', text: pipelineResult.text }], - details: Object.keys(chartDetails).length > 0 ? chartDetails : null, - } - } - - const baseDetails = - typeof result.data === 'object' && result.data !== null - ? (result.data as Record) - : result.data === undefined - ? null - : { value: result.data } - - return { - content: [{ type: 'text', text: result.content }], - details: Object.keys(chartDetails).length > 0 ? { ...(baseDetails ?? {}), ...chartDetails } : baseDetails, - } - } catch (error) { - const msg = error instanceof Error ? error.message : String(error) - return { content: [{ type: 'text', text: `Error: ${msg}` }], details: null } + const { rawMessages, chart, charts, ...extraDetails } = details + const preprocessCfg = ctx.preprocessConfig as PreprocessConfig | undefined + const pipelineResult = applyPreprocessingPipeline({ + rawMessages: rawMessages as PreprocessableMessage[], + preprocessConfig: preprocessCfg, + anonymizeNames: preprocessCfg?.anonymizeNames ?? false, + ownerPlatformId: ctx.ownerPlatformId, + locale: ctx.locale, + maxToolResultTokens: tokenBudget, + truncationStrategy: TOOL_TRUNCATION_STRATEGY[tool.name] ?? 'keep_last', + extraDetails, + logger: getServerAiLogger() ?? undefined, + }) + const chartDetails = { ...(chart ? { chart } : {}), ...(charts ? { charts } : {}) } + return { + content: [{ type: 'text', text: pipelineResult.text }], + details: Object.keys(chartDetails).length > 0 ? chartDetails : null, } }, }, diff --git a/apps/desktop/main/ai/tools/debug-executor.ts b/apps/desktop/main/ai/tools/debug-executor.ts index bd03ecff..6a825798 100644 --- a/apps/desktop/main/ai/tools/debug-executor.ts +++ b/apps/desktop/main/ai/tools/debug-executor.ts @@ -1,10 +1,9 @@ import type { BatchSegmentOptions, SupportedLocale } from '@openchatlab/core' -import type { AiToolExecuteRequest, AiToolExecuteResult } from '@openchatlab/http-routes' +import { executeRegistryTool, type AiToolExecuteRequest, type AiToolExecuteResult } from '@openchatlab/http-routes' import { batchSegmentWithFrequency } from '@openchatlab/node-runtime' import type { SemanticSearchToolService } from '@openchatlab/tools' import { t as i18nT } from '../../i18n' import { WorkerDataProvider } from './worker-data-provider' -import { executeRegistryTool } from './tool-executor-core' /** * 构造 Electron 手动调试用的 AI 工具执行器。 diff --git a/apps/desktop/main/ai/tools/definitions/index.test.ts b/apps/desktop/main/ai/tools/definitions/index.test.ts deleted file mode 100644 index ae898446..00000000 --- a/apps/desktop/main/ai/tools/definitions/index.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { mock, describe, it } from 'node:test' -import assert from 'node:assert/strict' -import { BUILTIN_TOOL_CATALOG } from '@openchatlab/core' - -type MinEntry = { name: string; category: string } - -describe('Electron TOOL_REGISTRY', () => { - it('covers all BUILTIN_TOOL_CATALOG entries with correct name and category', async () => { - const recorded: MinEntry[] = [] - - // Mock the Electron-specific adapter module before loading the registry. - // This prevents @openchatlab/node-runtime (pi-ai) and electron from being - // resolved in the test environment, which lacks those native bindings. - await mock.module('../shared-tool-adapter', { - namedExports: { - adaptSharedTool(tool: { name: string }, opts: { category: string }) { - const entry = { name: tool.name, category: opts.category, factory: () => ({}) } - recorded.push(entry) - return entry - }, - }, - }) - - // Dynamic import keeps the mock active before the module graph resolves. - const { TOOL_REGISTRY } = (await import('./index.js')) as { TOOL_REGISTRY: MinEntry[] } - - // Drift guard: every tool in BUILTIN_TOOL_CATALOG must appear in TOOL_REGISTRY - // with the correct name and category. - const registeredMap = new Map(TOOL_REGISTRY.map((e) => [e.name, e.category])) - const mismatches = BUILTIN_TOOL_CATALOG.filter((e) => registeredMap.get(e.name) !== e.category).map( - (e) => `${e.name} (expected category=${e.category}, got ${registeredMap.get(e.name) ?? 'missing'})` - ) - assert.deepEqual(mismatches, [], `catalog/registry drift in Electron TOOL_REGISTRY: ${mismatches.join(', ')}`) - }) -}) diff --git a/apps/desktop/main/ai/tools/definitions/index.ts b/apps/desktop/main/ai/tools/definitions/index.ts deleted file mode 100644 index 470dd782..00000000 --- a/apps/desktop/main/ai/tools/definitions/index.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * 工具定义聚合 + 统一注册表 - * - * TOOL_REGISTRY 是全局唯一的工具清单,驱动后端加载和前端目录展示。 - * 所有工具定义来自 @openchatlab/tools 共享包,通过 adaptSharedTool 适配为 Electron AgentTool。 - */ - -import type { ToolRegistryEntry } from '../types' - -import { - chatOverviewTool, - searchMessagesTool, - deepSearchMessagesTool, - recentMessagesTool, - getMessageContextTool, - getSegmentMessagesTool, - getMembersTool, - schemaTool, - sqlQueryTool, - memberStatsTool, - timeStatsTool, - getMemberNameHistoryTool, - getConversationBetweenTool, - getSegmentSummariesTool, - responseTimeAnalysisTool, - keywordFrequencyTool, - renderChartTool, - SQL_TOOL_DEFS, - createSqlToolDefinition, -} from '@openchatlab/tools' - -import { adaptSharedTool } from '../shared-tool-adapter' - -// SQL 工具转换为 ToolDefinition 再适配 -const sqlToolDefinitions = SQL_TOOL_DEFS.map(createSqlToolDefinition) - -export const sqlToolEntries: ToolRegistryEntry[] = sqlToolDefinitions.map((t) => - adaptSharedTool(t, { category: 'analysis' }) -) - -export const SQL_TOOL_NAMES = SQL_TOOL_DEFS.map((d) => d.name) - -export const TOOL_REGISTRY: ToolRegistryEntry[] = [ - // ==================== Core 工具(始终加载) ==================== - adaptSharedTool(chatOverviewTool, { category: 'core' }), - adaptSharedTool(searchMessagesTool, { category: 'core', truncationStrategy: 'keep_first' }), - adaptSharedTool(deepSearchMessagesTool, { category: 'core', truncationStrategy: 'keep_first' }), - adaptSharedTool(recentMessagesTool, { category: 'core', truncationStrategy: 'keep_last' }), - adaptSharedTool(getMessageContextTool, { category: 'core', truncationStrategy: 'keep_last' }), - adaptSharedTool(getSegmentMessagesTool, { category: 'core', truncationStrategy: 'keep_last' }), - adaptSharedTool(getMembersTool, { category: 'core' }), - adaptSharedTool(schemaTool, { category: 'core' }), - - // ==================== Analysis 工具(按需加载) ==================== - adaptSharedTool(memberStatsTool, { category: 'analysis' }), - adaptSharedTool(timeStatsTool, { category: 'analysis' }), - adaptSharedTool(getMemberNameHistoryTool, { category: 'analysis' }), - adaptSharedTool(getConversationBetweenTool, { category: 'analysis', truncationStrategy: 'keep_last' }), - adaptSharedTool(getSegmentSummariesTool, { category: 'analysis' }), - adaptSharedTool(responseTimeAnalysisTool, { category: 'analysis' }), - adaptSharedTool(keywordFrequencyTool, { category: 'analysis' }), - adaptSharedTool(renderChartTool, { category: 'analysis' }), - adaptSharedTool(sqlQueryTool, { category: 'analysis' }), - - // ==================== SQL 分析工具 ==================== - ...sqlToolEntries, -] diff --git a/apps/desktop/main/ai/tools/index.ts b/apps/desktop/main/ai/tools/index.ts index 5e3b72a1..3083cc3b 100644 --- a/apps/desktop/main/ai/tools/index.ts +++ b/apps/desktop/main/ai/tools/index.ts @@ -7,7 +7,6 @@ import type { AgentTool } from '@openchatlab/node-runtime' import type { ToolContext, TruncationStrategy } from './types' -import { TOOL_REGISTRY } from './definitions' import { isAnalysisToolAllowed } from './tool-filter' import { t as i18nT } from '../../i18n' import { applyPreprocessingPipeline, type PreprocessableMessage } from '@openchatlab/node-runtime' @@ -19,18 +18,24 @@ import { getSkillConfigWithBuiltinChart, wrapWithChartSchemaGate, } from '@openchatlab/node-runtime' -import { semanticSearchCurrentChatTool, retrieveChatEvidenceTool } from '@openchatlab/tools' +import { + AGENT_TOOL_REGISTRY, + RETRIEVE_CHAT_EVIDENCE_TOOL_NAME, + SEMANTIC_SEARCH_TOOL_NAME, + semanticSearchCurrentChatTool, + retrieveChatEvidenceTool, +} from '@openchatlab/tools' import { adaptSharedTool } from './shared-tool-adapter' +const runtimeManagedTools = new Set([SEMANTIC_SEARCH_TOOL_NAME, RETRIEVE_CHAT_EVIDENCE_TOOL_NAME]) +const TOOL_REGISTRY = AGENT_TOOL_REGISTRY.filter((tool) => !runtimeManagedTools.has(tool.name)).map(adaptSharedTool) + // 语义检索工具按需暴露:仅当前会话可检索时追加,不进 TOOL_REGISTRY(避免被当作始终加载的 core 工具) -const semanticSearchEntry = adaptSharedTool(semanticSearchCurrentChatTool, { category: 'core' }) +const semanticSearchEntry = adaptSharedTool(semanticSearchCurrentChatTool) // 证据检索工具始终暴露(语义不可用时可降级到关键词路径)。 // 不进 TOOL_REGISTRY / 工具目录:它是始终加载、用户不可切换的特殊 core 工具,行为同语义工具一样特殊处理。 -const retrieveChatEvidenceEntry = adaptSharedTool(retrieveChatEvidenceTool, { - category: 'core', - truncationStrategy: 'keep_first', -}) +const retrieveChatEvidenceEntry = adaptSharedTool(retrieveChatEvidenceTool) const CORE_TOOL_NAMES = new Set(TOOL_REGISTRY.filter((e) => e.category === 'core').map((e) => e.name)) diff --git a/apps/desktop/main/ai/tools/shared-tool-adapter.ts b/apps/desktop/main/ai/tools/shared-tool-adapter.ts index e45b834b..256e0eb7 100644 --- a/apps/desktop/main/ai/tools/shared-tool-adapter.ts +++ b/apps/desktop/main/ai/tools/shared-tool-adapter.ts @@ -5,18 +5,19 @@ * Electron 端使用 WorkerDataProvider 替代 Server 端的 CoreDataProvider。 */ -import type { ToolDefinition, ToolExecutionContext, RawMessage } from '@openchatlab/tools' -import type { AgentTool, AgentToolResult, PreprocessableMessage, PreprocessConfig } from '@openchatlab/node-runtime' +import { + executeToolForAgent, + toAgentToolParameters, + type ToolDefinition, + type ToolExecutionContext, + type RawMessage, +} from '@openchatlab/tools' +import type { AgentTool, PreprocessableMessage, PreprocessConfig } from '@openchatlab/node-runtime' import { batchSegmentWithFrequency, preprocessMessages } from '@openchatlab/node-runtime' -import type { ToolContext, ToolRegistryEntry, ToolCategory } from './types' +import type { ToolContext, ToolRegistryEntry } from './types' import { WorkerDataProvider } from './worker-data-provider' import { t as i18nT } from '../../i18n' -interface AdaptOptions { - category: ToolCategory - truncationStrategy?: 'keep_first' | 'keep_last' -} - function buildExecutionContext(ctx: ToolContext): ToolExecutionContext { return { dataProvider: new WorkerDataProvider(ctx.sessionId, ctx.abortSignal), @@ -44,64 +45,21 @@ function buildExecutionContext(ctx: ToolContext): ToolExecutionContext { } } -export function adaptSharedTool(tool: ToolDefinition, options: AdaptOptions): ToolRegistryEntry { +export function adaptSharedTool(tool: ToolDefinition): ToolRegistryEntry { return { name: tool.name, - category: options.category, - truncationStrategy: options.truncationStrategy ?? tool.truncationStrategy, + category: tool.category ?? 'core', + truncationStrategy: tool.truncationStrategy, factory(context: ToolContext): AgentTool { - const schema = { - type: 'object' as const, - properties: { ...tool.inputSchema.properties }, - required: tool.inputSchema.required ?? [], - } - return { name: tool.name, label: tool.name, // 保留英文原始描述作为 fallback:translateTool 会在 i18n key 命中时覆盖为译文, // 缺 key 时回退到此英文描述,避免把裸 i18n key 当作工具描述传给 LLM。 description: tool.description, - parameters: schema as any, - async execute(_toolCallId: string, params: unknown): Promise> { - const toolParams = (params && typeof params === 'object' ? params : {}) as Record - const execCtx = buildExecutionContext(context) - try { - const result = await tool.handler(toolParams, execCtx) - const chartDetails = - result.chart || result.charts - ? { - ...(result.chart ? { chart: result.chart } : {}), - ...(result.charts ? { charts: result.charts } : {}), - } - : {} - - if (result.rawMessages && result.rawMessages.length > 0) { - const baseData = (typeof result.data === 'object' && result.data !== null ? result.data : {}) as Record< - string, - unknown - > - return { - content: [{ type: 'text', text: result.content }], - details: { ...baseData, ...chartDetails, rawMessages: result.rawMessages }, - } - } - - const baseDetails = - typeof result.data === 'object' && result.data !== null - ? (result.data as Record) - : result.data === undefined - ? null - : { value: result.data } - - return { - content: [{ type: 'text', text: result.content }], - details: Object.keys(chartDetails).length > 0 ? { ...(baseDetails ?? {}), ...chartDetails } : baseDetails, - } - } catch (error) { - const msg = error instanceof Error ? error.message : String(error) - return { content: [{ type: 'text', text: `Error: ${msg}` }], details: null } - } + parameters: toAgentToolParameters(tool.inputSchema) as any, + async execute(_toolCallId: string, params: unknown) { + return executeToolForAgent(tool, params, buildExecutionContext(context)) }, } }, diff --git a/apps/desktop/main/ai/tools/tool-executor-core.test.ts b/packages/http-routes/src/ai/tool-executor.test.ts similarity index 67% rename from apps/desktop/main/ai/tools/tool-executor-core.test.ts rename to packages/http-routes/src/ai/tool-executor.test.ts index 453f2950..da3da494 100644 --- a/apps/desktop/main/ai/tools/tool-executor-core.test.ts +++ b/packages/http-routes/src/ai/tool-executor.test.ts @@ -1,9 +1,9 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import type { SemanticSearchToolResult, SemanticSearchToolService } from '@openchatlab/tools' -import type { AiToolExecuteRequest } from '@openchatlab/http-routes' -import { buildToolExecutionContext, executeRegistryTool } from './tool-executor-core' +import type { SemanticSearchToolService } from '@openchatlab/tools' +import type { AiToolExecuteRequest } from '../context' +import { executeRegistryTool } from './tool-executor' function makeRequest(overrides: Partial = {}): AiToolExecuteRequest { return { @@ -16,19 +16,7 @@ function makeRequest(overrides: Partial = {}): AiToolExecu } } -describe('tool-executor-core context injection', () => { - it('buildToolExecutionContext forwards semanticIndexService and request identity', () => { - const service = { - canSearch: () => true, - searchForTool: async () => ({}) as unknown as SemanticSearchToolResult, - } satisfies SemanticSearchToolService - const params = makeRequest() - const ctx = buildToolExecutionContext(params, { semanticIndexService: service }) - assert.equal(ctx.sessionId, 'sess-1') - assert.equal(ctx.abortSignal, params.abortSignal) - assert.equal(ctx.semanticIndexService, service) - }) - +describe('shared tool executor context injection', () => { it('manual execution reaches the injected semanticIndexService', async () => { const calls: Array<{ sessionId: string; query: string }> = [] const service: SemanticSearchToolService = { diff --git a/apps/desktop/main/ai/tools/tool-executor-core.ts b/packages/http-routes/src/ai/tool-executor.ts similarity index 75% rename from apps/desktop/main/ai/tools/tool-executor-core.ts rename to packages/http-routes/src/ai/tool-executor.ts index 207b4f9c..f0c51368 100644 --- a/apps/desktop/main/ai/tools/tool-executor-core.ts +++ b/packages/http-routes/src/ai/tool-executor.ts @@ -8,39 +8,19 @@ import { stripAvatarFields } from '@openchatlab/core' import { AGENT_TOOL_REGISTRY } from '@openchatlab/tools' import type { ToolDataProvider, ToolExecutionContext, SemanticSearchToolService } from '@openchatlab/tools' -import type { AiToolExecuteRequest, AiToolExecuteResult } from '@openchatlab/http-routes' +import type { AiToolExecuteRequest, AiToolExecuteResult } from '../context' const MAX_RESULT_CHARS = 500_000 /** 工具执行依赖:由平台注入。注入 semanticIndexService 后,手动执行也能命中语义检索工具。 */ export interface AiToolExecutionDeps { + db?: ToolExecutionContext['db'] dataProvider?: ToolDataProvider semanticIndexService?: SemanticSearchToolService segmentText?: ToolExecutionContext['segmentText'] translateTemplate?: ToolExecutionContext['translateTemplate'] } -function assertNotAborted(signal: AbortSignal): void { - if (signal.aborted) { - throw new Error('cancelled') - } -} - -/** 组装工具执行上下文:sessionId/abortSignal 来自请求,其余能力由平台注入。 */ -export function buildToolExecutionContext( - params: AiToolExecuteRequest, - deps: AiToolExecutionDeps -): ToolExecutionContext { - return { - sessionId: params.sessionId, - abortSignal: params.abortSignal, - dataProvider: deps.dataProvider, - semanticIndexService: deps.semanticIndexService, - segmentText: deps.segmentText, - translateTemplate: deps.translateTemplate, - } -} - /** 在注入的上下文上执行注册表工具,处理取消、超大结果截断与错误映射。 */ export async function executeRegistryTool( params: AiToolExecuteRequest, @@ -53,13 +33,21 @@ export async function executeRegistryTool( } try { - assertNotAborted(abortSignal) - const execCtx = buildToolExecutionContext(params, deps) + if (abortSignal.aborted) return { success: false, error: 'cancelled' } + const execCtx: ToolExecutionContext = { + sessionId: params.sessionId, + abortSignal, + db: deps.db, + dataProvider: deps.dataProvider, + semanticIndexService: deps.semanticIndexService, + segmentText: deps.segmentText, + translateTemplate: deps.translateTemplate, + } const startTime = Date.now() const result = await entry.handler(toolParams, execCtx) const elapsed = Date.now() - startTime - assertNotAborted(abortSignal) + if (abortSignal.aborted) return { success: false, error: 'cancelled' } let details = (result.data as Record | undefined) ?? undefined let truncated = false diff --git a/packages/http-routes/src/index.ts b/packages/http-routes/src/index.ts index 06c44613..35d0aab9 100644 --- a/packages/http-routes/src/index.ts +++ b/packages/http-routes/src/index.ts @@ -26,6 +26,8 @@ export { } from './errors' export { parseTimeFilter } from './helpers' export { buildAnalyticsCacheKey, withAnalyticsCache } from './analytics-cache' +export { executeRegistryTool } from './ai/tool-executor' +export type { AiToolExecutionDeps } from './ai/tool-executor' // Individual route registration for granular testing or selective registration export { registerSystemRoutes } from './routes/system' diff --git a/packages/http-routes/src/routes/web/ai-tools.test.ts b/packages/http-routes/src/routes/web/ai-tools.test.ts index 937e78d6..f4ecf489 100644 --- a/packages/http-routes/src/routes/web/ai-tools.test.ts +++ b/packages/http-routes/src/routes/web/ai-tools.test.ts @@ -110,9 +110,16 @@ describe('ai tool debug routes', () => { assert.deepEqual(executeResp.json(), { success: false, error: 'cancelled' }) }) - it('keeps the CoreDataProvider fallback when no platform hook is provided', async () => { + it('keeps the shared executor fallback and missing-session response when no platform hook is provided', async () => { app = Fastify() - registerAiToolRoutes(app, createContext()) + registerAiToolRoutes( + app, + createContext({ + dbManager: { + open: (sessionId: string) => (sessionId === 'existing-session' ? ({} as never) : null), + } as HttpRouteContext['dbManager'], + }) + ) await app.ready() const resp = await app.inject({ @@ -128,5 +135,20 @@ describe('ai tool debug routes', () => { assert.equal(resp.statusCode, 404) assert.deepEqual(resp.json(), { success: false, error: 'Session not found: missing-session' }) + + const fallbackResp = await app.inject({ + method: 'POST', + url: '/_web/ai/tools/execute', + payload: { + testId: 'test-fallback', + toolName: 'semantic_search_current_chat', + sessionId: 'existing-session', + params: { query: 'release plan' }, + }, + }) + + assert.equal(fallbackResp.statusCode, 200) + assert.equal(fallbackResp.json().success, true) + assert.match(fallbackResp.json().content[0].text, /not available/i) }) }) diff --git a/packages/http-routes/src/routes/web/ai-tools.ts b/packages/http-routes/src/routes/web/ai-tools.ts index 156a676f..33f0cd19 100644 --- a/packages/http-routes/src/routes/web/ai-tools.ts +++ b/packages/http-routes/src/routes/web/ai-tools.ts @@ -1,10 +1,9 @@ import type { FastifyInstance } from 'fastify' import type { HttpRouteContext } from '../../context' +import { appLogger } from '@openchatlab/node-runtime' import { AGENT_TOOL_REGISTRY, CoreDataProvider } from '@openchatlab/tools' -import type { ToolExecutionContext } from '@openchatlab/tools' -import { stripAvatarFields } from '@openchatlab/core' +import { executeRegistryTool } from '../../ai/tool-executor' -const MAX_RESULT_CHARS = 500_000 const activeToolTests = new Map() export function registerAiToolRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { @@ -45,45 +44,22 @@ export function registerAiToolRoutes(server: FastifyInstance, ctx: HttpRouteCont return reply.code(404).send({ success: false, error: `Session not found: ${sessionId}` }) } - const execCtx: ToolExecutionContext = { - sessionId, - db, - dataProvider: new CoreDataProvider(db), - abortSignal: abortController.signal, - } - - const startTime = Date.now() - const result = await entry.handler(params, execCtx) - const elapsed = Date.now() - startTime - - if (abortController.signal.aborted) { - return { success: false, error: 'cancelled' } - } - - let details = (result.data as Record | undefined) ?? undefined - let truncated = false - - if (details) { - stripAvatarFields(details) - const raw = JSON.stringify(details) - if (raw.length > MAX_RESULT_CHARS) { - truncated = true - details = { _truncated: true, _originalSize: raw.length, _preview: raw.slice(0, MAX_RESULT_CHARS) } + const result = await executeRegistryTool( + { testId, toolName, params, sessionId, abortSignal: abortController.signal }, + { + db, + dataProvider: new CoreDataProvider(db), } + ) + if (!result.success && result.error !== 'cancelled') { + appLogger.error('ai-tools', `Failed to execute tool ${toolName}`, { error: result.error }) } - - return { - success: true, - elapsed, - content: [{ type: 'text', text: result.content }], - details, - truncated, - } + return result } catch (error) { if (abortController.signal.aborted) { return { success: false, error: 'cancelled' } } - console.error(`Failed to execute tool ${toolName}:`, error) + appLogger.error('ai-tools', `Failed to execute tool ${toolName}`, error) return { success: false, error: String(error) } } finally { activeToolTests.delete(testId) diff --git a/packages/tools/src/agent-adapter.ts b/packages/tools/src/agent-adapter.ts new file mode 100644 index 00000000..88f0809e --- /dev/null +++ b/packages/tools/src/agent-adapter.ts @@ -0,0 +1,65 @@ +import type { JsonSchema, ToolDefinition, ToolExecutionContext, ToolResult } from './types' + +interface AgentToolParameters { + type: 'object' + properties: Record + required: string[] +} + +interface AgentToolExecutionResult { + content: Array<{ type: 'text'; text: string }> + details: unknown +} + +/** 将平台无关的工具输入 schema 转成 Agent SDK 接受的参数结构,并避免共享原对象引用。 */ +export function toAgentToolParameters(schema: JsonSchema): AgentToolParameters { + return { + type: 'object', + properties: Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, { ...value }])), + required: schema.required ?? [], + } +} + +/** 统一把工具返回的数据、图表和原始消息整理成 CLI/Desktop 共用的 Agent 结果结构。 */ +function toAgentToolResult(result: ToolResult): AgentToolExecutionResult { + const chartDetails = { + ...(result.chart ? { chart: result.chart } : {}), + ...(result.charts ? { charts: result.charts } : {}), + } + + if (result.rawMessages?.length) { + const data = typeof result.data === 'object' && result.data !== null ? result.data : {} + const { rawMessages: _ignored, ...details } = data as Record + return { + content: [{ type: 'text', text: result.content }], + details: { ...details, ...chartDetails, rawMessages: result.rawMessages }, + } + } + + const details = + typeof result.data === 'object' && result.data !== null + ? (result.data as Record) + : result.data === undefined + ? null + : { value: result.data } + + return { + content: [{ type: 'text', text: result.content }], + details: Object.keys(chartDetails).length > 0 ? { ...(details ?? {}), ...chartDetails } : details, + } +} + +/** 执行共享工具并把异常收敛成 Agent 可直接返回给模型的文本结果。 */ +export async function executeToolForAgent( + tool: ToolDefinition, + params: unknown, + context: ToolExecutionContext +): Promise { + const toolParams = (params && typeof params === 'object' ? params : {}) as Record + try { + return toAgentToolResult(await tool.handler(toolParams, context)) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { content: [{ type: 'text', text: `Error: ${message}` }], details: null } + } +} diff --git a/packages/tools/src/definitions/member-stats.ts b/packages/tools/src/definitions/member-stats.ts index 59eef723..f5659d20 100644 --- a/packages/tools/src/definitions/member-stats.ts +++ b/packages/tools/src/definitions/member-stats.ts @@ -41,5 +41,5 @@ export const memberStatsTool: ToolDefinition = { description: '获取成员活跃度排行,包括消息数量和占比', inputSchema, handler, - category: 'core', + category: 'analysis', } diff --git a/packages/tools/src/definitions/time-stats.ts b/packages/tools/src/definitions/time-stats.ts index 63024de9..6166ad6e 100644 --- a/packages/tools/src/definitions/time-stats.ts +++ b/packages/tools/src/definitions/time-stats.ts @@ -37,5 +37,5 @@ export const timeStatsTool: ToolDefinition = { description: '获取聊天活跃时段分布(按小时/星期/每日趋势)', inputSchema, handler, - category: 'core', + category: 'analysis', } diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 4a22b591..c5fd12a3 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -17,6 +17,9 @@ export { // === Providers === export { CoreDataProvider } from './providers/core-data-provider' +// === Agent Adapter === +export { executeToolForAgent, toAgentToolParameters } from './agent-adapter' + // === Tool Definitions === export { memberStatsTool } from './definitions/member-stats' export { timeStatsTool } from './definitions/time-stats' diff --git a/packages/tools/src/registry.test.ts b/packages/tools/src/registry.test.ts index de3dcefe..ed720335 100644 --- a/packages/tools/src/registry.test.ts +++ b/packages/tools/src/registry.test.ts @@ -1,5 +1,6 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' +import { BUILTIN_TOOL_CATALOG } from '@openchatlab/core' import { AGENT_TOOL_REGISTRY, MCP_TOOL_REGISTRY, @@ -8,6 +9,18 @@ import { getToolByName, } from './registry' +describe('agent tool registry metadata', () => { + it('keeps agent registry categories aligned with the builtin catalog allowlist metadata', () => { + const registeredCategories = new Map(AGENT_TOOL_REGISTRY.map((tool) => [tool.name, tool.category ?? 'core'])) + const mismatches = BUILTIN_TOOL_CATALOG.filter((tool) => registeredCategories.get(tool.name) !== tool.category).map( + (tool) => + `${tool.name} (expected category=${tool.category}, got ${registeredCategories.get(tool.name) ?? 'missing'})` + ) + + assert.deepEqual(mismatches, []) + }) +}) + describe('semantic_search_current_chat registry placement', () => { it('is registered in AGENT registry only, not MCP (privacy: no external semantic access in Phase 1)', () => { const inAgent = AGENT_TOOL_REGISTRY.some((t) => t.name === SEMANTIC_SEARCH_TOOL_NAME)