Skip to content

Commit b29ee05

Browse files
committed
feat(llm): consolidate tool call streaming events and add activeTools filtering
- Unify tool call event types into single streaming pipeline (tool_call_start, tool_call_delta, tool_call_delta_end, tool_call_available) - Remove separate onLLMToolCall IPC listener in favor of integrated stream events - Add activeTools parameter to StreamingService for dynamic tool availability filtering - Implement experimental_repairToolCall handler to auto-fix malformed tool call JSON (unclosed quotes, braces, brackets) - Replace custom cosineSimilarity implementation with AI SDK built-in function - Simplify LLMStreamChunk interface to flatten tool call data structure with id, name, arguments, and argumentsDelta fields - Remove LLMToolCall interface from preload in favor of inline stream chunk properties - Update EventBus, stream processing, and tool handling to work with consolidated event model - Improves tool call reliability and reduces IPC message overhead by consolidating events
1 parent 7766f0c commit b29ee05

File tree

12 files changed

+237
-86
lines changed

12 files changed

+237
-86
lines changed

src/main/preload.ts

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,13 @@ interface SearchFileResult {
2121
}
2222

2323
interface LLMStreamChunk {
24-
type: 'text' | 'reasoning' | 'error' | 'tool_call_start' | 'tool_call_delta' | 'tool_call_end' | 'tool_call'
24+
type: 'text' | 'reasoning' | 'error' | 'tool_call_start' | 'tool_call_delta' | 'tool_call_delta_end' | 'tool_call_available'
2525
content?: string
2626
error?: string
27-
toolCallDelta?: {
28-
id?: string
29-
name?: string
30-
args?: string
31-
}
32-
toolCall?: LLMToolCall
33-
}
34-
35-
interface LLMToolCall {
36-
id: string
37-
name: string
38-
arguments: Record<string, unknown>
27+
id?: string
28+
name?: string
29+
arguments?: Record<string, unknown>
30+
argumentsDelta?: string
3931
}
4032

4133
interface LLMError {
@@ -47,7 +39,6 @@ interface LLMError {
4739
interface LLMResult {
4840
content: string
4941
reasoning?: string
50-
toolCalls?: LLMToolCall[]
5142
usage?: {
5243
promptTokens: number
5344
completionTokens: number
@@ -193,7 +184,6 @@ export interface ElectronAPI {
193184
embedMany: (params: { texts: string[]; config: any }) => Promise<any>
194185
findSimilar: (params: { query: string; candidates: string[]; config: any; topK?: number }) => Promise<any>
195186
onLLMStream: (callback: (data: LLMStreamChunk) => void) => () => void
196-
onLLMToolCall: (callback: (toolCall: LLMToolCall) => void) => () => void
197187
onLLMError: (callback: (error: LLMError) => void) => () => void
198188
onLLMDone: (callback: (data: LLMResult) => void) => () => void
199189

@@ -438,11 +428,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
438428
ipcRenderer.on('llm:stream', handler)
439429
return () => ipcRenderer.removeListener('llm:stream', handler)
440430
},
441-
onLLMToolCall: (callback: (toolCall: LLMToolCall) => void) => {
442-
const handler = (_: IpcRendererEvent, toolCall: LLMToolCall) => callback(toolCall)
443-
ipcRenderer.on('llm:toolCall', handler)
444-
return () => ipcRenderer.removeListener('llm:toolCall', handler)
445-
},
446431
onLLMError: (callback: (error: LLMError) => void) => {
447432
const handler = (_: IpcRendererEvent, error: LLMError) => callback(error)
448433
ipcRenderer.on('llm:error', handler)

src/main/services/llm/LLMService.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export class LLMService {
3737
messages: LLMMessage[]
3838
tools?: ToolDefinition[]
3939
systemPrompt?: string
40+
activeTools?: string[]
4041
}) {
4142
this.currentAbortController = new AbortController()
4243
try {

src/main/services/llm/services/EmbeddingService.ts

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* 用于代码语义搜索、相似度匹配、RAG
44
*/
55

6-
import { embed, embedMany } from 'ai'
6+
import { embed, embedMany, cosineSimilarity } from 'ai'
77
import { createOpenAI } from '@ai-sdk/openai'
88
import { logger } from '@shared/utils/Logger'
99
import { LLMError } from '../types'
@@ -105,24 +105,10 @@ export class EmbeddingService {
105105
}
106106

107107
/**
108-
* 计算余弦相似度
108+
* 计算余弦相似度(使用 AI SDK 内置实现)
109109
*/
110110
cosineSimilarity(a: number[], b: number[]): number {
111-
if (a.length !== b.length) {
112-
throw new Error('Vectors must have the same length')
113-
}
114-
115-
let dotProduct = 0
116-
let normA = 0
117-
let normB = 0
118-
119-
for (let i = 0; i < a.length; i++) {
120-
dotProduct += a[i] * b[i]
121-
normA += a[i] * a[i]
122-
normB += b[i] * b[i]
123-
}
124-
125-
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB))
111+
return cosineSimilarity(a, b)
126112
}
127113

128114
/**

src/main/services/llm/services/StreamingService.ts

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface StreamingParams {
2222
tools?: ToolDefinition[]
2323
systemPrompt?: string
2424
abortSignal?: AbortSignal
25+
activeTools?: string[] // 限制可用的工具列表
2526
}
2627

2728
export interface StreamingResult {
@@ -46,7 +47,7 @@ export class StreamingService {
4647
* 流式生成文本
4748
*/
4849
async generate(params: StreamingParams): Promise<StreamingResult> {
49-
const { config, messages, tools, systemPrompt, abortSignal } = params
50+
const { config, messages, tools, systemPrompt, abortSignal, activeTools } = params
5051

5152
// 创建 thinking 策略(只为需要特殊处理的模型)
5253
const strategy = ThinkingStrategyFactory.create(config.model)
@@ -79,6 +80,7 @@ export class StreamingService {
7980
model,
8081
messages: coreMessages,
8182
tools: coreTools,
83+
activeTools, // 动态限制可用工具
8284
maxOutputTokens: config.maxTokens,
8385
temperature: config.temperature,
8486
topP: config.topP,
@@ -88,6 +90,46 @@ export class StreamingService {
8890
stopSequences: config.stopSequences,
8991
seed: config.seed,
9092
abortSignal,
93+
// 自动修复工具调用 JSON 格式错误
94+
experimental_repairToolCall: async ({ toolCall, error }) => {
95+
logger.llm.warn('[StreamingService] Tool call parse error, attempting repair:', {
96+
toolName: toolCall.toolName,
97+
error: error.message,
98+
})
99+
100+
try {
101+
const inputText = toolCall.input
102+
103+
// 1. 修复未闭合的引号
104+
let fixed = inputText.replace(/([^\\])"([^"]*?)$/g, '$1"$2"')
105+
106+
// 2. 修复未闭合的大括号
107+
const openBraces = (fixed.match(/\{/g) || []).length
108+
const closeBraces = (fixed.match(/\}/g) || []).length
109+
if (openBraces > closeBraces) {
110+
fixed += '}'.repeat(openBraces - closeBraces)
111+
}
112+
113+
// 3. 修复未闭合的方括号
114+
const openBrackets = (fixed.match(/\[/g) || []).length
115+
const closeBrackets = (fixed.match(/\]/g) || []).length
116+
if (openBrackets > closeBrackets) {
117+
fixed += ']'.repeat(openBrackets - closeBrackets)
118+
}
119+
120+
// 4. 尝试解析修复后的 JSON
121+
JSON.parse(fixed)
122+
123+
logger.llm.info('[StreamingService] Tool call repaired successfully')
124+
return {
125+
...toolCall,
126+
input: fixed,
127+
}
128+
} catch (repairError) {
129+
logger.llm.error('[StreamingService] Tool call repair failed:', repairError)
130+
return null // 返回 null 表示无法修复
131+
}
132+
},
91133
})
92134

93135
// 处理流式响应
@@ -169,10 +211,18 @@ export class StreamingService {
169211
})
170212
break
171213

172-
// 工具调用完成(最终参数)
214+
// 工具调用参数传输完成
215+
case 'tool-input-end':
216+
this.sendEvent({
217+
type: 'tool-call-delta-end',
218+
id: part.id,
219+
})
220+
break
221+
222+
// 工具调用完整信息(包含解析后的参数)
173223
case 'tool-call':
174224
this.sendEvent({
175-
type: 'tool-call',
225+
type: 'tool-call-available',
176226
id: part.toolCallId,
177227
name: part.toolName,
178228
arguments: part.input as Record<string, unknown>,
@@ -268,9 +318,16 @@ export class StreamingService {
268318
})
269319
break
270320

271-
case 'tool-call':
272-
this.window.webContents.send('llm:toolCall', {
273-
type: 'tool_call',
321+
case 'tool-call-delta-end':
322+
this.window.webContents.send('llm:stream', {
323+
type: 'tool_call_delta_end',
324+
id: event.id,
325+
})
326+
break
327+
328+
case 'tool-call-available':
329+
this.window.webContents.send('llm:stream', {
330+
type: 'tool_call_available',
274331
id: event.id,
275332
name: event.name,
276333
arguments: event.arguments,

src/main/services/llm/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ export type StreamEvent =
9090
| { type: 'reasoning'; content: string }
9191
| { type: 'tool-call-start'; id: string; name: string }
9292
| { type: 'tool-call-delta'; id: string; name?: string; argumentsDelta: string }
93-
| { type: 'tool-call'; id: string; name: string; arguments: Record<string, unknown> }
93+
| { type: 'tool-call-delta-end'; id: string }
94+
| { type: 'tool-call-available'; id: string; name: string; arguments: Record<string, unknown> }
9495
| { type: 'error'; error: LLMError }
9596
| { type: 'done'; usage?: TokenUsage; metadata?: ResponseMetadata }
9697

src/renderer/agent/context/CompressionManager.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import { logger } from '@utils/Logger'
1111
import { getAgentConfig } from '../utils/AgentConfig'
12+
import { pruneMessages } from 'ai'
1213
import type { ChatMessage, AssistantMessage, ToolResultMessage, UserMessage, ToolCall, MessageContent } from '../types'
1314

1415
// ===== 类型 =====
@@ -123,6 +124,8 @@ function truncateToolCallArgs(tc: ToolCall, maxChars: number): { tc: ToolCall; t
123124
* - 当前用户消息在数组的最后一条
124125
* - 需要保留最后一条消息的图片(AI 需要分析)
125126
* - 历史消息中的图片替换为占位符(AI 已经分析过,节省 token)
127+
*
128+
* 优化:结合 AI SDK 的 pruneMessages 进行智能修剪
126129
*/
127130
export function prepareMessages(
128131
messages: ChatMessage[],
@@ -137,7 +140,66 @@ export function prepareMessages(
137140
// 过滤 checkpoint 消息
138141
result = result.filter(m => m.role !== 'checkpoint')
139142

140-
// 0. 替换历史消息中的图片为占位符(节省 token)
143+
// 0. 使用 AI SDK 的 pruneMessages 进行智能修剪(L2+)
144+
if (lastLevel >= 2) {
145+
try {
146+
const beforeCount = result.length
147+
148+
// 转换为 AI SDK 格式
149+
const aiMessages = result.map(m => {
150+
if (m.role === 'assistant') {
151+
const am = m as AssistantMessage
152+
return {
153+
role: 'assistant' as const,
154+
content: am.content || '',
155+
tool_calls: am.toolCalls?.map(tc => ({
156+
id: tc.id,
157+
type: 'function' as const,
158+
function: { name: tc.name, arguments: JSON.stringify(tc.arguments) }
159+
}))
160+
}
161+
}
162+
if (m.role === 'tool') {
163+
const tm = m as ToolResultMessage
164+
return {
165+
role: 'tool' as const,
166+
tool_call_id: tm.toolCallId,
167+
name: tm.name,
168+
content: [{ type: 'text' as const, text: tm.content }]
169+
}
170+
}
171+
if (m.role === 'user') {
172+
const um = m as UserMessage
173+
return {
174+
role: 'user' as const,
175+
content: typeof um.content === 'string' ? um.content : JSON.stringify(um.content)
176+
}
177+
}
178+
return {
179+
role: 'system' as const,
180+
content: ''
181+
}
182+
})
183+
184+
// 应用 pruneMessages
185+
const pruned = pruneMessages({
186+
messages: aiMessages as any, // 类型转换,避免复杂的类型匹配
187+
reasoning: lastLevel >= 3 ? 'before-last-message' : 'all',
188+
toolCalls: lastLevel >= 3 ? 'before-last-2-messages' : 'all',
189+
emptyMessages: 'remove'
190+
})
191+
192+
removedMessages = beforeCount - pruned.length
193+
if (removedMessages > 0) {
194+
logger.agent.info(`[Compression] pruneMessages removed ${removedMessages} messages`)
195+
result = result.slice(-pruned.length)
196+
}
197+
} catch (e) {
198+
logger.agent.warn('[Compression] pruneMessages failed:', e)
199+
}
200+
}
201+
202+
// 1. 替换历史消息中的图片为占位符(节省 token)
141203
// 注意:messages 包含刚添加的当前用户消息,它在最后一条
142204
// 需要保留最后一条用户消息的图片,只替换之前的历史消息
143205
const lastIndex = result.length - 1

src/renderer/agent/core/EventBus.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export type AgentEvent =
2020
| { type: 'stream:reasoning'; text: string; phase: 'start' | 'delta' | 'end' }
2121
| { type: 'stream:tool_start'; id: string; name: string }
2222
| { type: 'stream:tool_delta'; id: string; args: string }
23-
| { type: 'stream:tool_end'; id: string; args: Record<string, unknown> }
23+
| { type: 'stream:tool_available'; id: string; name: string; args: Record<string, unknown> }
2424

2525
// LLM 事件
2626
| { type: 'llm:start' }

src/renderer/agent/core/loop.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,41 @@ async function callLLM(
6464
})
6565
const tools = chatMode === 'chat' ? [] : toolManager.getAllToolDefinitions()
6666

67+
// 动态工具控制:根据上下文限制可用工具
68+
let activeTools: string[] | undefined
69+
70+
if (tools.length > 0) {
71+
const allToolNames = tools.map(t => t.name)
72+
const store = useAgentStore.getState()
73+
74+
// 场景1: Chat 模式 - 禁用所有工具(已在上面处理)
75+
// 场景2: Plan 模式 - 启用所有工具(包括 plan 相关工具)
76+
// 场景3: Code 模式 - 根据压缩等级动态调整
77+
78+
// 当上下文压缩等级较高时,限制工具以减少 token 使用
79+
const compressionLevel = store.compressionStats?.level || 0
80+
if (compressionLevel >= 3) {
81+
// L3/L4: 只保留核心工具,移除 AI 辅助工具(节省 token)
82+
const coreTools = allToolNames.filter(name =>
83+
!['analyze_code', 'suggest_refactoring', 'suggest_fixes', 'generate_tests'].includes(name)
84+
)
85+
activeTools = coreTools
86+
logger.agent.info(`[Loop] Compression L${compressionLevel}: ${activeTools.length}/${allToolNames.length} tools active (AI tools disabled)`)
87+
}
88+
89+
// 未来可扩展的场景:
90+
// - 只读模式:activeTools = allToolNames.filter(name => getReadOnlyTools().includes(name))
91+
// - 安全模式:activeTools = allToolNames.filter(name => !getDangerousTools().includes(name))
92+
// - 特定任务:activeTools = getToolsForTask(taskType)
93+
}
94+
6795
// 发送请求
6896
await api.llm.send({
6997
config: config as import('@shared/types/llm').LLMConfig,
7098
messages: messages as LLMMessage[],
7199
tools,
72-
systemPrompt: ''
100+
systemPrompt: '',
101+
activeTools
73102
})
74103

75104
// 等待流式响应完成

0 commit comments

Comments
 (0)