refactor(ai): unify desktop tool adapters

This commit is contained in:
digua committed 2026-07-13 20:42:27 +08:00
1 parent 9123bd8476
commit 16ee4d8348
16 files changed
+189 -311

No files matched your search

+23 -62
View File
@@ -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<string, unknown> = {}
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<AgentToolResult<unknown>> {
const toolParams = (params && typeof params === 'object' ? params : {}) as Record<string, unknown>
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<string, unknown> | 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<string, unknown>
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<string, unknown>)
: 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,
}
},
},
+1 -2
View File
@@ -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 工具执行器。
@@ -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(', ')}`)
})
})
@@ -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,
]
+12 -7
View File
@@ -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))
@@ -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<any> {
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<AgentToolResult<unknown>> {
const toolParams = (params && typeof params === 'object' ? params : {}) as Record<string, unknown>
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<string, unknown>)
: 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))
},
}
},
@@ -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> = {}): AiToolExecuteRequest {
return {
@@ -16,19 +16,7 @@ function makeRequest(overrides: Partial<AiToolExecuteRequest> = {}): 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 = {
@@ -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<string, unknown> | undefined) ?? undefined
let truncated = false
+2
View File
@@ -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'
@@ -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)
})
})
+12 -36
View File
@@ -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<string, AbortController>()
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<string, unknown> | 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)
+65
View File
@@ -0,0 +1,65 @@
import type { JsonSchema, ToolDefinition, ToolExecutionContext, ToolResult } from './types'
interface AgentToolParameters {
type: 'object'
properties: Record<string, unknown>
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<string, unknown>
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<string, unknown>)
: 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<AgentToolExecutionResult> {
const toolParams = (params && typeof params === 'object' ? params : {}) as Record<string, unknown>
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 }
}
}
@@ -41,5 +41,5 @@ export const memberStatsTool: ToolDefinition = {
description: '获取成员活跃度排行,包括消息数量和占比',
inputSchema,
handler,
category: 'core',
category: 'analysis',
}
+1 -1
View File
@@ -37,5 +37,5 @@ export const timeStatsTool: ToolDefinition = {
description: '获取聊天活跃时段分布(按小时/星期/每日趋势)',
inputSchema,
handler,
category: 'core',
category: 'analysis',
}
+3
View File
@@ -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'
+13
View File
@@ -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)