mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
refactor(ai): unify desktop tool adapters
This commit is contained in:
1 parent
9123bd8476
commit
16ee4d8348
16 files changed
+189
-311
No files matched your search
@@ -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,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,
|
||||
]
|
||||
@@ -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))
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
+4
-16
@@ -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 = {
|
||||
+13
-25
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
@@ -37,5 +37,5 @@ export const timeStatsTool: ToolDefinition = {
|
||||
description: '获取聊天活跃时段分布(按小时/星期/每日趋势)',
|
||||
inputSchema,
|
||||
handler,
|
||||
category: 'core',
|
||||
category: 'analysis',
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user