feat(ai): add isolated cross-chat agent

This commit is contained in:
digua committed 2026-08-22 17:09:23 +08:00
1 parent 812440c565
commit d1a6c711fe
32 files changed
+2443 -243

No files matched your search

+48 -1
View File
@@ -1,7 +1,8 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import type { AIChatManager, AgentStreamChunk, DatabaseManager, LLMConfigStore } from '@openchatlab/node-runtime'
import { getChartCapabilityAllowedBuiltinTools } from '@openchatlab/node-runtime'
import { getAllowedToolSet, getAvailableToolDefs } from './agent-stream-runner'
import { createCliRunAgentStream, getAllowedToolSet, getAvailableToolDefs } from './agent-stream-runner'
describe('CLI chart capability tool filtering', () => {
it('does not expose uncategorized raw SQL in chart-only turns', () => {
@@ -64,3 +65,49 @@ describe('CLI chart capability tool filtering', () => {
assert.ok(!toolNames.includes('execute_sql'))
})
})
describe('CLI agent conversation target validation', () => {
it('rejects a global conversation before the default session tool path can run', async () => {
let databaseOpenCalls = 0
const dbManager = {
pathProvider: { getAiDataDir: () => '/tmp/chatlab-agent-runner-test' },
open() {
databaseOpenCalls++
throw new Error('session database must not be opened')
},
} as unknown as DatabaseManager
const aiChatManager = {
getAIChat: () => ({
id: 'global-chat-1',
sessionId: '',
kind: 'global',
title: null,
assistantId: 'default',
createdAt: 1,
updatedAt: 1,
}),
} as unknown as AIChatManager
const llmConfigStore = {
getDefaultAssistantConfig: () => null,
} as unknown as LLMConfigStore
const events: AgentStreamChunk[] = []
const runAgentStream = createCliRunAgentStream(dbManager, aiChatManager, { llmConfigStore })
await runAgentStream(
{
userMessage: '分析一下',
aiChatId: 'global-chat-1',
sessionId: 'private-session-1',
},
(event) => events.push(event),
new AbortController().signal
)
assert.equal(databaseOpenCalls, 0)
assert.deepEqual(
events.map((event) => event.type),
['error', 'done']
)
assert.match(String(events[0]?.error && (events[0].error as { message?: string }).message), /does not match/)
})
})
+65
View File
@@ -9,6 +9,8 @@ import type {
AgentStreamChunk,
LLMConfigStore,
SemanticIndexRuntime,
CrossChatAnalysisService,
SessionRuntimeAdapter,
} from '@openchatlab/node-runtime'
import {
CHART_CAPABILITY_CORE_TOOLS,
@@ -23,6 +25,7 @@ import {
getChartCapabilitySkill,
getSkillConfigWithBuiltinChart,
resolveChartRuntimeForRequest,
runCrossChatAgent,
} from '@openchatlab/node-runtime'
import { getChatOverview, normalizeBuiltinToolNames } from '@openchatlab/core'
import type { ChartAutoMode } from '@openchatlab/shared-types'
@@ -33,6 +36,8 @@ import { buildSemanticSearchGuidance } from '@openchatlab/node-runtime'
import { adaptToolsForAgent } from './tool-adapter'
import { loadAssistantConfig } from './assistant-loader'
import { runServerAgent } from './agent'
import { createCliCrossChatTools } from './cross-chat-tool-adapter'
import { getServerAiLogger } from './logger'
function getAiDir(dbManager: DatabaseManager): string {
const pathProvider = (dbManager as any)['pathProvider']
@@ -76,6 +81,8 @@ export function createCliRunAgentStream(
options: {
llmConfigStore?: LLMConfigStore
semanticIndexService?: SemanticIndexRuntime
crossChatAnalysisService?: CrossChatAnalysisService
sessionAdapter?: SessionRuntimeAdapter
} = {}
): (params: AgentStreamRequest, onEvent: (chunk: AgentStreamChunk) => void, abortSignal: AbortSignal) => Promise<void> {
const aiDataDir = getAiDir(dbManager)
@@ -87,7 +94,9 @@ export function createCliRunAgentStream(
userMessage,
aiChatId,
historyLeafMessageId,
chatKind = 'session',
sessionId,
entityRefs,
chatType,
locale,
assistantId,
@@ -99,6 +108,20 @@ export function createCliRunAgentStream(
thinkingLevel,
} = params
const conversation = aiChatManager.getAIChat(aiChatId)
if (
!conversation ||
conversation.kind !== chatKind ||
(chatKind === 'session' && conversation.sessionId !== sessionId)
) {
onEvent({
type: 'error',
error: { name: 'ValidationError', message: 'AI conversation does not match the requested target' },
})
onEvent({ type: 'done', isFinished: true })
return
}
let assistantSystemPrompt: string | undefined
let assistantAllowedTools: string[] | undefined
if (assistantId) {
@@ -114,6 +137,48 @@ export function createCliRunAgentStream(
const contextWindow = llmConfig ? (buildPiModel(llmConfig).contextWindow ?? 128000) : 128000
const maxToolResultTokens = Math.floor(contextWindow * (maxToolResultPercent / 100))
if (chatKind === 'global') {
if (!llmConfig) {
onEvent({ type: 'error', error: { name: 'ConfigError', message: 'LLM service not configured' } })
onEvent({ type: 'done', isFinished: true })
return
}
if (!options.crossChatAnalysisService || !options.sessionAdapter) {
onEvent({ type: 'error', error: { name: 'RuntimeError', message: 'Cross-chat analysis is unavailable' } })
onEvent({ type: 'done', isFinished: true })
return
}
const tools = createCliCrossChatTools({
analysisService: options.crossChatAnalysisService,
sessionAdapter: options.sessionAdapter,
locale,
preprocessConfig: params.preprocessConfig,
maxToolResultTokens,
})
await runCrossChatAgent({
userMessage,
entityRefs,
aiChatId,
historyLeafMessageId,
locale,
piModel: buildPiModel(llmConfig),
apiKey: llmConfig.apiKey,
tools,
aiChatManager,
onEvent,
abortSignal,
thinkingLevel: thinkingLevel as import('@openchatlab/core').ThinkingLevel | undefined,
logger: getServerAiLogger() ?? undefined,
})
return
}
if (!sessionId) {
onEvent({ type: 'error', error: { name: 'ValidationError', message: 'Session ID is required' } })
onEvent({ type: 'done', isFinished: true })
return
}
const db = (dbManager as any).open?.(sessionId)
const resolvedChartAutoMode: ChartAutoMode = chartAutoMode ?? 'suggest'
const chartRuntime = resolveChartRuntimeForRequest({
@@ -0,0 +1,48 @@
import type {
AgentTool,
CrossChatAnalysisService,
PreprocessConfig,
SessionRuntimeAdapter,
} from '@openchatlab/node-runtime'
import { preprocessCrossChatMessages } from '@openchatlab/node-runtime'
import {
CROSS_CHAT_AGENT_TOOL_REGISTRY,
executeToolForAgent,
toAgentToolParameters,
type CrossChatToolExecutionContext,
} from '@openchatlab/tools'
export function createCliCrossChatTools(options: {
analysisService: CrossChatAnalysisService
sessionAdapter: SessionRuntimeAdapter
locale?: string
preprocessConfig?: Record<string, unknown>
maxToolResultTokens: number
}): AgentTool<any, any>[] {
const context: Omit<CrossChatToolExecutionContext, 'abortSignal' | 'reportProgress'> = {
locale: options.locale,
analysisService: options.analysisService,
maxToolResultTokens: options.maxToolResultTokens,
preprocessMessagesBySession: (sessionId, messages) =>
preprocessCrossChatMessages(
options.sessionAdapter,
sessionId,
messages,
options.preprocessConfig as PreprocessConfig | undefined
),
}
return CROSS_CHAT_AGENT_TOOL_REGISTRY.map((tool) => ({
name: tool.name,
label: tool.name,
description: tool.description,
parameters: toAgentToolParameters(tool.inputSchema) as any,
executionMode: tool.executionMode,
execute: async (_toolCallId: string, params: unknown, signal, onUpdate) =>
executeToolForAgent(tool, params, {
...context,
abortSignal: signal,
reportProgress: (progress) => onUpdate?.({ content: [], details: { progress } }),
}),
}))
}
+17
View File
@@ -22,6 +22,9 @@ import {
initAppLogger,
appLogger,
logNativeParserStatus,
createContactsService,
createCrossChatAnalysisService,
createDatabaseManagerAdapter,
} from '@openchatlab/node-runtime'
import type { SemanticIndexRuntime } from '@openchatlab/node-runtime'
import { createServer } from './server'
@@ -136,6 +139,17 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
await migrationRunner.run()
const nativeBinding = resolveNativeBinding()
dbManager = new DatabaseManager(pathProvider, { nativeBinding, runtime })
const sessionAdapter = createDatabaseManagerAdapter(dbManager)
const contactsService = createContactsService({
adapter: sessionAdapter,
pathProvider,
runtimeIdentity: runtime,
nativeBinding,
})
const crossChatAnalysisService = createCrossChatAnalysisService({
adapter: sessionAdapter,
contactsService,
})
const aiDataDir = pathProvider.getAiDataDir()
aiChatManager = new AIChatManager(aiDataDir, { nativeBinding })
@@ -185,6 +199,7 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
pathProvider,
nativeBinding,
runtimeIdentity: runtime,
contactsService,
semanticIndexService,
aiContext: {
aiDataDir,
@@ -197,6 +212,8 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
runAgentStream: createCliRunAgentStream(dbManager, aiChatManager, {
llmConfigStore,
semanticIndexService,
crossChatAnalysisService,
sessionAdapter,
}),
},
})
+3
View File
@@ -29,6 +29,7 @@ import type {
LLMConfigStore,
CustomProviderStore,
CustomModelStore,
ContactsService,
PendingDataDirMigration,
RuntimeIdentity,
} from '@openchatlab/node-runtime'
@@ -71,6 +72,7 @@ export function registerWebRoutes(
pathProvider?: PathProvider
nativeBinding?: string
runtimeIdentity?: RuntimeIdentity
contactsService?: ContactsService
aiContext?: AiContextOptions
/** 由 server 入口注入的共享语义索引运行时;传入时由调用方管理生命周期 */
semanticIndexService?: SemanticIndexRuntime
@@ -132,6 +134,7 @@ export function registerWebRoutes(
getVersion,
nativeBinding: options?.nativeBinding,
semanticIndexService,
contactsService: options?.contactsService,
analyticsService,
getCurrentAiLogPath: () => getServerAiLogger()?.getExistingLogPath() ?? null,
openDirectory: openDirectoryPath,