@cicctencent/agent-core 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +952 -0
- package/dist/agent/engine.d.ts +113 -0
- package/dist/agent/engine.d.ts.map +1 -0
- package/dist/agent/history-manager.d.ts +78 -0
- package/dist/agent/history-manager.d.ts.map +1 -0
- package/dist/agent/llm-caller.d.ts +58 -0
- package/dist/agent/llm-caller.d.ts.map +1 -0
- package/dist/agent/tool-executor.d.ts +95 -0
- package/dist/agent/tool-executor.d.ts.map +1 -0
- package/dist/agent/types.d.ts +128 -0
- package/dist/agent/types.d.ts.map +1 -0
- package/dist/agent/workflow.d.ts +74 -0
- package/dist/agent/workflow.d.ts.map +1 -0
- package/dist/context/collector.d.ts +12 -0
- package/dist/context/collector.d.ts.map +1 -0
- package/dist/context/manager.d.ts +16 -0
- package/dist/context/manager.d.ts.map +1 -0
- package/dist/context/types.d.ts +39 -0
- package/dist/context/types.d.ts.map +1 -0
- package/dist/hooks/types.d.ts +47 -0
- package/dist/hooks/types.d.ts.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/llm/anthropic-provider.d.ts +28 -0
- package/dist/llm/anthropic-provider.d.ts.map +1 -0
- package/dist/llm/factory.d.ts +19 -0
- package/dist/llm/factory.d.ts.map +1 -0
- package/dist/llm/ollama-provider.d.ts +13 -0
- package/dist/llm/ollama-provider.d.ts.map +1 -0
- package/dist/llm/openai-provider.d.ts +18 -0
- package/dist/llm/openai-provider.d.ts.map +1 -0
- package/dist/llm/provider.d.ts +7 -0
- package/dist/llm/provider.d.ts.map +1 -0
- package/dist/llm/types.d.ts +78 -0
- package/dist/llm/types.d.ts.map +1 -0
- package/dist/mcp/base-client.d.ts +50 -0
- package/dist/mcp/base-client.d.ts.map +1 -0
- package/dist/mcp/client.d.ts +27 -0
- package/dist/mcp/client.d.ts.map +1 -0
- package/dist/mcp/factory.d.ts +16 -0
- package/dist/mcp/factory.d.ts.map +1 -0
- package/dist/mcp/http-client.d.ts +24 -0
- package/dist/mcp/http-client.d.ts.map +1 -0
- package/dist/mcp/proxy-agent.d.ts +49 -0
- package/dist/mcp/proxy-agent.d.ts.map +1 -0
- package/dist/mcp/stdio-client.d.ts +16 -0
- package/dist/mcp/stdio-client.d.ts.map +1 -0
- package/dist/mcp/types.d.ts +60 -0
- package/dist/mcp/types.d.ts.map +1 -0
- package/dist/memory/checkpoint.d.ts +62 -0
- package/dist/memory/checkpoint.d.ts.map +1 -0
- package/dist/memory/extractor.d.ts +59 -0
- package/dist/memory/extractor.d.ts.map +1 -0
- package/dist/memory/persona.d.ts +38 -0
- package/dist/memory/persona.d.ts.map +1 -0
- package/dist/memory/pipeline.d.ts +50 -0
- package/dist/memory/pipeline.d.ts.map +1 -0
- package/dist/memory/prompts.d.ts +9 -0
- package/dist/memory/prompts.d.ts.map +1 -0
- package/dist/memory/recall.d.ts +66 -0
- package/dist/memory/recall.d.ts.map +1 -0
- package/dist/memory/scene.d.ts +49 -0
- package/dist/memory/scene.d.ts.map +1 -0
- package/dist/memory/types.d.ts +43 -0
- package/dist/memory/types.d.ts.map +1 -0
- package/dist/middleware/types.d.ts +52 -0
- package/dist/middleware/types.d.ts.map +1 -0
- package/dist/sandbox/types.d.ts +59 -0
- package/dist/sandbox/types.d.ts.map +1 -0
- package/dist/security/guard.d.ts +41 -0
- package/dist/security/guard.d.ts.map +1 -0
- package/dist/security/types.d.ts +36 -0
- package/dist/security/types.d.ts.map +1 -0
- package/dist/skill/manager.d.ts +95 -0
- package/dist/skill/manager.d.ts.map +1 -0
- package/dist/skill/router.d.ts +26 -0
- package/dist/skill/router.d.ts.map +1 -0
- package/dist/skill/types.d.ts +55 -0
- package/dist/skill/types.d.ts.map +1 -0
- package/dist/tool/delegate.d.ts +34 -0
- package/dist/tool/delegate.d.ts.map +1 -0
- package/dist/tool/registry.d.ts +24 -0
- package/dist/tool/registry.d.ts.map +1 -0
- package/dist/tool/types.d.ts +101 -0
- package/dist/tool/types.d.ts.map +1 -0
- package/dist/types.d.ts +150 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils/token-counter.d.ts +22 -0
- package/dist/utils/token-counter.d.ts.map +1 -0
- package/package.json +42 -0
- package/src/agent/engine.ts +875 -0
- package/src/agent/history-manager.ts +323 -0
- package/src/agent/llm-caller.ts +383 -0
- package/src/agent/tool-executor.ts +713 -0
- package/src/agent/types.ts +136 -0
- package/src/agent/workflow.ts +339 -0
- package/src/context/collector.ts +59 -0
- package/src/context/manager.ts +40 -0
- package/src/context/types.ts +51 -0
- package/src/hooks/types.ts +77 -0
- package/src/index.ts +178 -0
- package/src/llm/anthropic-provider.ts +412 -0
- package/src/llm/factory.ts +34 -0
- package/src/llm/ollama-provider.ts +39 -0
- package/src/llm/openai-provider.ts +200 -0
- package/src/llm/provider.ts +15 -0
- package/src/llm/types.ts +91 -0
- package/src/mcp/base-client.ts +159 -0
- package/src/mcp/client.ts +81 -0
- package/src/mcp/factory.ts +45 -0
- package/src/mcp/http-client.ts +149 -0
- package/src/mcp/proxy-agent.ts +227 -0
- package/src/mcp/stdio-client.ts +104 -0
- package/src/mcp/types.ts +65 -0
- package/src/memory/checkpoint.ts +146 -0
- package/src/memory/extractor.ts +164 -0
- package/src/memory/persona.ts +106 -0
- package/src/memory/pipeline.ts +192 -0
- package/src/memory/prompts.ts +73 -0
- package/src/memory/recall.ts +217 -0
- package/src/memory/scene.ts +137 -0
- package/src/memory/types.ts +54 -0
- package/src/middleware/types.ts +62 -0
- package/src/sandbox/types.ts +185 -0
- package/src/security/guard.ts +249 -0
- package/src/security/types.ts +46 -0
- package/src/skill/manager.ts +420 -0
- package/src/skill/router.ts +158 -0
- package/src/skill/types.ts +67 -0
- package/src/tool/delegate.ts +105 -0
- package/src/tool/registry.ts +64 -0
- package/src/tool/types.ts +124 -0
- package/src/types.ts +188 -0
- package/src/utils/token-counter.ts +108 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// HistoryManager — 会话历史管理(滑动窗口 + 智能压缩)
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import type { Message, ToolCall } from '../types.ts';
|
|
6
|
+
import type { ToolResultStore, OffloadEntry } from './types.ts';
|
|
7
|
+
import { countMessageTokens } from '../utils/token-counter.ts';
|
|
8
|
+
|
|
9
|
+
/** 历史管理配置 */
|
|
10
|
+
export interface HistoryManagerConfig {
|
|
11
|
+
/** 消息历史上限(滑动窗口),默认 50 */
|
|
12
|
+
maxMessages: number;
|
|
13
|
+
/** 上下文压缩配置 */
|
|
14
|
+
contextBudget?: {
|
|
15
|
+
maxToolOutput?: number;
|
|
16
|
+
compactThreshold?: number;
|
|
17
|
+
maxContextTokens?: number;
|
|
18
|
+
};
|
|
19
|
+
/** 工具结果存储(可选) */
|
|
20
|
+
toolResultStore?: ToolResultStore;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 会话历史管理器
|
|
25
|
+
*
|
|
26
|
+
* 职责:
|
|
27
|
+
* - 维护 per-session 的消息历史(滑动窗口)
|
|
28
|
+
* - 多级智能压缩(token 感知)
|
|
29
|
+
* - Offload 大工具结果到外部存储
|
|
30
|
+
*/
|
|
31
|
+
export class HistoryManager {
|
|
32
|
+
private config: HistoryManagerConfig;
|
|
33
|
+
/** sessionId → Message[](会话历史,滑动窗口) */
|
|
34
|
+
private historyMap = new Map<string, Message[]>();
|
|
35
|
+
/** sessionId → 当前 token 总数(增量维护,避免全量重算) */
|
|
36
|
+
private tokenCountMap = new Map<string, number>();
|
|
37
|
+
|
|
38
|
+
constructor(config: HistoryManagerConfig) {
|
|
39
|
+
this.config = config;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---- 公开接口 ----
|
|
43
|
+
|
|
44
|
+
/** 注入会话历史(从外部恢复,如持久化存储) */
|
|
45
|
+
setHistory(sessionId: string, messages: Message[]): void {
|
|
46
|
+
this.historyMap.set(sessionId, [...messages]);
|
|
47
|
+
// 重建 token 计数
|
|
48
|
+
this.tokenCountMap.set(sessionId, countMessageTokens(messages));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 获取会话历史副本(用于持久化保存) */
|
|
52
|
+
getHistorySnapshot(sessionId: string): Message[] {
|
|
53
|
+
return [...(this.historyMap.get(sessionId) || [])];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 获取会话历史副本(用于构建 LLM 请求) */
|
|
57
|
+
getHistory(sessionId: string): Message[] {
|
|
58
|
+
return [...(this.historyMap.get(sessionId) || [])];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 追加消息到会话历史(含自动压缩) */
|
|
62
|
+
addHistory(sessionId: string, msg: Message): void {
|
|
63
|
+
if (!this.historyMap.has(sessionId)) {
|
|
64
|
+
this.historyMap.set(sessionId, []);
|
|
65
|
+
}
|
|
66
|
+
const history = this.historyMap.get(sessionId)!;
|
|
67
|
+
history.push(msg);
|
|
68
|
+
|
|
69
|
+
// 增量 token 计数
|
|
70
|
+
const addedTokens = countMessageTokens([msg]);
|
|
71
|
+
const currentTokens = (this.tokenCountMap.get(sessionId) || 0) + addedTokens;
|
|
72
|
+
this.tokenCountMap.set(sessionId, currentTokens);
|
|
73
|
+
|
|
74
|
+
// Level 1: token 预算压缩
|
|
75
|
+
const maxTokens = this.config.contextBudget?.maxContextTokens ?? 100000;
|
|
76
|
+
if (currentTokens > maxTokens) {
|
|
77
|
+
this.compactHistorySmart(history, maxTokens, sessionId);
|
|
78
|
+
// 压缩后重算 token 数
|
|
79
|
+
this.tokenCountMap.set(sessionId, countMessageTokens(history));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Level 2: 成对裁剪(消息数超过阈值时触发)
|
|
83
|
+
const threshold = this.config.contextBudget?.compactThreshold
|
|
84
|
+
?? Math.floor(this.config.maxMessages * 0.8);
|
|
85
|
+
if (history.length > threshold) {
|
|
86
|
+
this.compactHistoryPairs(history);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 安全兜底:极端情况下仍使用滑动窗口
|
|
90
|
+
while (history.length > this.config.maxMessages) {
|
|
91
|
+
history.shift();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 清理指定会话的历史 */
|
|
96
|
+
clearHistory(sessionId: string): void {
|
|
97
|
+
this.historyMap.delete(sessionId);
|
|
98
|
+
this.tokenCountMap.delete(sessionId);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 清理所有会话历史 */
|
|
102
|
+
clearAll(): void {
|
|
103
|
+
this.historyMap.clear();
|
|
104
|
+
this.tokenCountMap.clear();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---- 工具输出处理(智能截断 + 文件化)----
|
|
108
|
+
|
|
109
|
+
/** 不应被 offload 的读取类工具(避免 offload → read → offload 无限链) */
|
|
110
|
+
private static READ_THROUGH_TOOLS = new Set(['read_offload', 'read_tool_result']);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 处理工具输出:
|
|
114
|
+
* - 小结果直接返回
|
|
115
|
+
* - 大结果写入 OffloadStore,返回摘要标签(保留 100% 可溯源)
|
|
116
|
+
* - 无 OffloadStore 时回退到截断
|
|
117
|
+
*/
|
|
118
|
+
processToolOutput(output: string, callId: string, toolName?: string, sessionId?: string): string {
|
|
119
|
+
const max = this.config.contextBudget?.maxToolOutput ?? 8000;
|
|
120
|
+
|
|
121
|
+
// 读取类工具:直接返回完整内容,不做 offload(避免无限链)
|
|
122
|
+
if (toolName && HistoryManager.READ_THROUGH_TOOLS.has(toolName)) {
|
|
123
|
+
return output.length <= max ? output : this.truncateOutput(output);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 普通工具:在 maxToolOutput 预算内直接返回,超出则 offload
|
|
127
|
+
const offloadThreshold = max;
|
|
128
|
+
if (output.length <= offloadThreshold) return output;
|
|
129
|
+
|
|
130
|
+
// 优先使用 OffloadStore(非破坏性卸载)
|
|
131
|
+
const store = this.config.toolResultStore;
|
|
132
|
+
if (store && sessionId) {
|
|
133
|
+
const nodeId = `${toolName || 'tool'}_${Date.now()}_${Math.abs(HistoryManager.simpleHash(output)).toString(36)}`;
|
|
134
|
+
const preview = output.slice(0, 300).replace(/\n/g, ' ').trim();
|
|
135
|
+
store.writeOffload(
|
|
136
|
+
{ nodeId, toolName: toolName || 'unknown', summary: preview, createdAt: Date.now(), charCount: output.length, sessionId },
|
|
137
|
+
output
|
|
138
|
+
);
|
|
139
|
+
// 同时写入 toolResultStore(按 callId),让 read_tool_result 也能找到
|
|
140
|
+
store.write(callId, output);
|
|
141
|
+
return (
|
|
142
|
+
`[offload:${nodeId}] ${toolName || 'tool'}: ${preview.slice(0, 150)}... ` +
|
|
143
|
+
`(${output.length} chars offloaded, use read_offload(node_id="${nodeId}") or read_tool_result(call_id="${callId}") to retrieve) [/offload]`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 回退:超大结果写入文件
|
|
148
|
+
if (output.length > max && store) {
|
|
149
|
+
const filePath = store.write(callId, output);
|
|
150
|
+
const previewLen = Math.min(200, Math.floor(max * 0.1));
|
|
151
|
+
const preview = output.slice(0, previewLen);
|
|
152
|
+
return (
|
|
153
|
+
`[Result saved to file: ${output.length} chars → ${filePath}]\n` +
|
|
154
|
+
`Preview:\n${preview}...\n\n` +
|
|
155
|
+
`Use read_tool_result(call_id="${callId}") to read full content.`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 无 store 时回退到截断
|
|
160
|
+
return this.truncateOutput(output);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 压缩消息列表(公开接口,供 LLMCaller 在上下文过长时调用)
|
|
165
|
+
*
|
|
166
|
+
* 使用与 addHistory 相同的多级压缩策略,但直接作用于传入的 messages 数组。
|
|
167
|
+
* 返回压缩后的 token 估算值。
|
|
168
|
+
*/
|
|
169
|
+
compressMessages(messages: Message[], targetTokens?: number): number {
|
|
170
|
+
const maxTokens = targetTokens ?? this.config.contextBudget?.maxContextTokens ?? 100000;
|
|
171
|
+
// 更激进的目标:压缩到 70% 的预算,给 system prompt + tool defs 留空间
|
|
172
|
+
const aggressiveTarget = Math.floor(maxTokens * 0.7);
|
|
173
|
+
this.compactHistorySmart(messages, aggressiveTarget, '__compress__');
|
|
174
|
+
return countMessageTokens(messages);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---- 内部方法:智能压缩 ----
|
|
178
|
+
|
|
179
|
+
/** Level 1: 智能压缩(token 感知多级压缩管道) */
|
|
180
|
+
private compactHistorySmart(history: Message[], targetTokens: number, sessionId: string): void {
|
|
181
|
+
// 阶段 1: 卸载超长工具参数
|
|
182
|
+
this.offloadToolArguments(history);
|
|
183
|
+
if (countMessageTokens(history) <= targetTokens) return;
|
|
184
|
+
|
|
185
|
+
// 阶段 2: 卸载 base64 数据块
|
|
186
|
+
this.offloadBase64Content(history);
|
|
187
|
+
if (countMessageTokens(history) <= targetTokens) return;
|
|
188
|
+
|
|
189
|
+
// 阶段 3: 将大工具结果卸载到 OffloadStore(非破坏性,保留溯源)
|
|
190
|
+
this.offloadLargeToolResults(history, sessionId);
|
|
191
|
+
if (countMessageTokens(history) <= targetTokens) return;
|
|
192
|
+
|
|
193
|
+
// 阶段 4: 截断大工具结果(无 OffloadStore 时回退)
|
|
194
|
+
this.truncateLargeToolResults(history);
|
|
195
|
+
if (countMessageTokens(history) <= targetTokens) return;
|
|
196
|
+
|
|
197
|
+
// 阶段 5: 消息对裁剪(兜底)
|
|
198
|
+
this.compactHistoryPairs(history);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 压缩阶段 1: 卸载超长工具参数 */
|
|
202
|
+
private offloadToolArguments(history: Message[]): void {
|
|
203
|
+
const maxArgsLen = 500;
|
|
204
|
+
for (const msg of history) {
|
|
205
|
+
if (msg.role === 'assistant' && msg.tool_calls) {
|
|
206
|
+
for (const tc of msg.tool_calls) {
|
|
207
|
+
if (tc.function.arguments && tc.function.arguments.length > maxArgsLen) {
|
|
208
|
+
try {
|
|
209
|
+
const parsed = JSON.parse(tc.function.arguments);
|
|
210
|
+
const summary: Record<string, string> = {};
|
|
211
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
212
|
+
const val = typeof value === 'string' ? value : JSON.stringify(value);
|
|
213
|
+
summary[key] = val.length > 50 ? val.slice(0, 50) + '...' : val;
|
|
214
|
+
}
|
|
215
|
+
tc.function.arguments = JSON.stringify(summary);
|
|
216
|
+
} catch {
|
|
217
|
+
tc.function.arguments = `[args: ${tc.function.arguments.length} chars]`;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 压缩阶段 2: 卸载 base64 数据块 */
|
|
226
|
+
private offloadBase64Content(history: Message[]): void {
|
|
227
|
+
const base64Pattern = /[A-Za-z0-9+/]{200,}={0,2}/g;
|
|
228
|
+
for (const msg of history) {
|
|
229
|
+
if (msg.role === 'tool' && typeof msg.content === 'string' && msg.content.length > 500) {
|
|
230
|
+
msg.content = msg.content.replace(base64Pattern, (match) => {
|
|
231
|
+
return `[base64: ${match.length} chars]`;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 压缩阶段 3: 非破坏性卸载大工具结果 */
|
|
238
|
+
private offloadLargeToolResults(history: Message[], sessionId: string): void {
|
|
239
|
+
const store = this.config.toolResultStore;
|
|
240
|
+
if (!store) return;
|
|
241
|
+
|
|
242
|
+
const offloadThreshold = 2000;
|
|
243
|
+
for (const msg of history) {
|
|
244
|
+
if (msg.role !== 'tool' || typeof msg.content !== 'string') continue;
|
|
245
|
+
if (msg.content.length <= offloadThreshold) continue;
|
|
246
|
+
if (/^\[offload:/.test(msg.content)) continue;
|
|
247
|
+
|
|
248
|
+
const toolName = HistoryManager.findToolNameForCallId(msg.tool_call_id || '', history);
|
|
249
|
+
const nodeId = `${toolName}_${Date.now()}_${Math.abs(HistoryManager.simpleHash(msg.content)).toString(36)}`;
|
|
250
|
+
const preview = msg.content.slice(0, 300).replace(/\n/g, ' ').trim();
|
|
251
|
+
|
|
252
|
+
store.writeOffload(
|
|
253
|
+
{ nodeId, toolName, summary: preview, createdAt: Date.now(), charCount: msg.content.length, sessionId },
|
|
254
|
+
msg.content
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
msg.content = (
|
|
258
|
+
`[offload:${nodeId}] ${toolName}: ${preview.slice(0, 150)}... ` +
|
|
259
|
+
`(${msg.content.length} chars offloaded, use read_offload(node_id="${nodeId}") to retrieve) [/offload]`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** 压缩阶段 4: 截断大工具结果 */
|
|
265
|
+
private truncateLargeToolResults(history: Message[]): void {
|
|
266
|
+
const maxResultLen = this.config.contextBudget?.maxToolOutput ?? 8000;
|
|
267
|
+
for (const msg of history) {
|
|
268
|
+
if (msg.role === 'tool' && typeof msg.content === 'string' && msg.content.length > maxResultLen) {
|
|
269
|
+
const headLen = Math.floor(maxResultLen * 0.4);
|
|
270
|
+
const tailLen = Math.floor(maxResultLen * 0.4);
|
|
271
|
+
msg.content = msg.content.slice(0, headLen)
|
|
272
|
+
+ `\n\n... [${msg.content.length - headLen - tailLen} characters truncated] ...\n\n`
|
|
273
|
+
+ msg.content.slice(-tailLen);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Level 2: 成对裁剪历史(移除最早的 assistant + 关联 tool 消息) */
|
|
279
|
+
private compactHistoryPairs(history: Message[]): void {
|
|
280
|
+
const keepCount = Math.floor(this.config.maxMessages * 0.5);
|
|
281
|
+
while (history.length > keepCount) {
|
|
282
|
+
const idx = history.findIndex(m => m.role === 'assistant');
|
|
283
|
+
if (idx === -1) break;
|
|
284
|
+
history.splice(idx, 1);
|
|
285
|
+
while (idx < history.length && history[idx]?.role === 'tool') {
|
|
286
|
+
history.splice(idx, 1);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 截断工具输出(保留首尾,中间省略) */
|
|
292
|
+
private truncateOutput(output: string): string {
|
|
293
|
+
const max = this.config.contextBudget?.maxToolOutput ?? 8000;
|
|
294
|
+
if (output.length <= max) return output;
|
|
295
|
+
const headLen = Math.floor(max * 0.4);
|
|
296
|
+
const tailLen = Math.floor(max * 0.4);
|
|
297
|
+
return output.slice(0, headLen)
|
|
298
|
+
+ `\n\n... [${output.length - headLen - tailLen} characters omitted] ...\n\n`
|
|
299
|
+
+ output.slice(-tailLen);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---- 静态辅助 ----
|
|
303
|
+
|
|
304
|
+
/** 根据 tool_call_id 从历史中查找工具名 */
|
|
305
|
+
static findToolNameForCallId(callId: string, history: Message[]): string {
|
|
306
|
+
for (const msg of history) {
|
|
307
|
+
if (msg.role === 'assistant' && msg.tool_calls) {
|
|
308
|
+
const match = msg.tool_calls.find(tc => tc.id === callId);
|
|
309
|
+
if (match) return match.function.name;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return 'unknown_tool';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** DJB2 哈希(用于生成短 nodeId) */
|
|
316
|
+
static simpleHash(str: string): number {
|
|
317
|
+
let hash = 5381;
|
|
318
|
+
for (let i = 0; i < str.length; i++) {
|
|
319
|
+
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
|
|
320
|
+
}
|
|
321
|
+
return hash;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// LLMCaller — LLM 调用 + 重试 + 降级
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import type { AgentEvent, Message, ToolCall, TokenUsage } from '../types.ts';
|
|
6
|
+
import type { LLMProvider } from '../llm/types.ts';
|
|
7
|
+
import type { ToolDefinition } from '../tool/types.ts';
|
|
8
|
+
import type { RetryPolicy } from './types.ts';
|
|
9
|
+
import type { HookEvent, HookContext, HookResult } from '../hooks/types.ts';
|
|
10
|
+
import type { HistoryManager } from './history-manager.ts';
|
|
11
|
+
|
|
12
|
+
/** LLM 调用配置 */
|
|
13
|
+
export interface LLMCallerConfig {
|
|
14
|
+
/** 主 LLM Provider */
|
|
15
|
+
llmProvider: LLMProvider;
|
|
16
|
+
/** 降级 LLM Provider(可选) */
|
|
17
|
+
fallbackProvider?: LLMProvider;
|
|
18
|
+
/** 重试策略 */
|
|
19
|
+
retryPolicy?: RetryPolicy;
|
|
20
|
+
/** LLM 推理超时(毫秒) */
|
|
21
|
+
llmTimeout: number;
|
|
22
|
+
/** Hook 执行器(由 Engine 注入) */
|
|
23
|
+
runHooks: (event: HookEvent, ctx: HookContext) => Promise<HookResult[]>;
|
|
24
|
+
/** 事件创建辅助(由 Engine 注入) */
|
|
25
|
+
createEvent: (sessionId: string, event: Record<string, unknown>) => AgentEvent;
|
|
26
|
+
/** 历史管理器(可选,用于上下文过长时压缩) */
|
|
27
|
+
historyManager?: HistoryManager;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 单次 LLM 调用结果 */
|
|
31
|
+
export interface LLMCallResult {
|
|
32
|
+
/** 完整的 assistant message */
|
|
33
|
+
assistant: Message;
|
|
34
|
+
/** 本次调用消耗的 token */
|
|
35
|
+
usage?: TokenUsage;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* LLM 调用管理器
|
|
40
|
+
*
|
|
41
|
+
* 职责:
|
|
42
|
+
* - 流式调用 LLM(支持 tool_calls 累积)
|
|
43
|
+
* - 重试 + 指数退避
|
|
44
|
+
* - 主 Provider → Fallback Provider 降级
|
|
45
|
+
*/
|
|
46
|
+
export class LLMCaller {
|
|
47
|
+
private config: LLMCallerConfig;
|
|
48
|
+
|
|
49
|
+
constructor(config: LLMCallerConfig) {
|
|
50
|
+
this.config = config;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 更新主 Provider(热更新场景) */
|
|
54
|
+
updateProvider(provider: LLMProvider): void {
|
|
55
|
+
this.config.llmProvider = provider;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 带重试和降级的 LLM 调用
|
|
60
|
+
* 返回 assistant message 或 null(所有尝试失败)
|
|
61
|
+
*
|
|
62
|
+
* 特殊处理:当 API 返回“输入过长”错误时,自动压缩历史并重试(最多一次)
|
|
63
|
+
*/
|
|
64
|
+
async *callWithRetry(
|
|
65
|
+
sessionId: string,
|
|
66
|
+
messages: Message[],
|
|
67
|
+
toolDefs: ToolDefinition[],
|
|
68
|
+
abortSignal: AbortSignal,
|
|
69
|
+
): AsyncGenerator<AgentEvent, Message | null> {
|
|
70
|
+
const policy = this.config.retryPolicy;
|
|
71
|
+
const maxRetries = policy?.maxRetries ?? 0;
|
|
72
|
+
const initialDelay = policy?.initialDelay ?? 1000;
|
|
73
|
+
const maxDelay = policy?.maxDelay ?? 30000;
|
|
74
|
+
const backoffFactor = policy?.backoffFactor ?? 2;
|
|
75
|
+
|
|
76
|
+
// 尝试主 Provider
|
|
77
|
+
const { result: firstResult, contextTooLong } = yield* this.callOnceWithDiag(sessionId, messages, toolDefs, abortSignal, this.config.llmProvider, false);
|
|
78
|
+
if (firstResult !== null) return firstResult;
|
|
79
|
+
|
|
80
|
+
// 上下文过长:压缩后重试(仅一次)
|
|
81
|
+
if (contextTooLong && this.config.historyManager) {
|
|
82
|
+
const tokensAfter = this.config.historyManager.compressMessages(messages);
|
|
83
|
+
console.log(`[LLMCaller] Context too long, compressed to ~${tokensAfter} tokens, retrying...`);
|
|
84
|
+
yield this.config.createEvent(sessionId, {
|
|
85
|
+
type: 'thinking',
|
|
86
|
+
content: `🔄 Context too long, compressed history and retrying...`,
|
|
87
|
+
});
|
|
88
|
+
const { result } = yield* this.callOnceWithDiag(sessionId, messages, toolDefs, abortSignal, this.config.llmProvider, false);
|
|
89
|
+
if (result !== null) return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 重试主 Provider
|
|
93
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
94
|
+
if (abortSignal.aborted) return null;
|
|
95
|
+
|
|
96
|
+
const delay = Math.min(initialDelay * Math.pow(backoffFactor, attempt - 1), maxDelay);
|
|
97
|
+
const providerName = this.config.llmProvider.getModel().provider || 'primary';
|
|
98
|
+
|
|
99
|
+
yield this.config.createEvent(sessionId, {
|
|
100
|
+
type: 'retry',
|
|
101
|
+
attempt,
|
|
102
|
+
maxRetries,
|
|
103
|
+
delay,
|
|
104
|
+
error: `LLM call failed, retrying...`,
|
|
105
|
+
provider: providerName,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await sleep(delay);
|
|
109
|
+
if (abortSignal.aborted) return null;
|
|
110
|
+
|
|
111
|
+
const { result } = yield* this.callOnceWithDiag(sessionId, messages, toolDefs, abortSignal, this.config.llmProvider, false);
|
|
112
|
+
if (result !== null) return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 主 Provider 重试耗尽,尝试 fallback
|
|
116
|
+
if (this.config.fallbackProvider) {
|
|
117
|
+
const fallbackName = this.config.fallbackProvider.getModel().provider || 'fallback';
|
|
118
|
+
console.log(`[LLMCaller] Primary provider exhausted retries, switching to fallback: ${fallbackName}`);
|
|
119
|
+
|
|
120
|
+
yield this.config.createEvent(sessionId, {
|
|
121
|
+
type: 'retry',
|
|
122
|
+
attempt: maxRetries + 1,
|
|
123
|
+
maxRetries,
|
|
124
|
+
delay: 0,
|
|
125
|
+
error: `Switching to fallback provider: ${fallbackName}`,
|
|
126
|
+
provider: fallbackName,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const { result } = yield* this.callOnceWithDiag(sessionId, messages, toolDefs, abortSignal, this.config.fallbackProvider, true);
|
|
130
|
+
if (result !== null) return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 所有尝试失败
|
|
134
|
+
const errorMsg = 'All LLM providers failed after retries';
|
|
135
|
+
await this.config.runHooks('on_error', { sessionId, iteration: 0, error: errorMsg, recoverable: false });
|
|
136
|
+
yield this.config.createEvent(sessionId, { type: 'error', error: errorMsg, recoverable: false });
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 是否为“上下文过长”错误 */
|
|
141
|
+
private isContextTooLongError(err: any): boolean {
|
|
142
|
+
const msg = (err?.message || '').toLowerCase();
|
|
143
|
+
return msg.includes('too long') || msg.includes('input length') ||
|
|
144
|
+
msg.includes('context length') || msg.includes('too many tokens') ||
|
|
145
|
+
msg.includes('maximum context') || msg.includes('max_tokens');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 单次 LLM 调用(流式),带诊断信息 */
|
|
149
|
+
private async *callOnceWithDiag(
|
|
150
|
+
sessionId: string,
|
|
151
|
+
messages: Message[],
|
|
152
|
+
toolDefs: ToolDefinition[],
|
|
153
|
+
abortSignal: AbortSignal,
|
|
154
|
+
provider: LLMProvider,
|
|
155
|
+
isFallback: boolean,
|
|
156
|
+
): AsyncGenerator<AgentEvent, { result: Message | null; contextTooLong: boolean }> {
|
|
157
|
+
try {
|
|
158
|
+
// Hook: before_ll_call(仅主 Provider 首次调用时触发)
|
|
159
|
+
if (!isFallback) {
|
|
160
|
+
await this.config.runHooks('before_ll_call', {
|
|
161
|
+
sessionId,
|
|
162
|
+
iteration: 0,
|
|
163
|
+
messages,
|
|
164
|
+
tools: toolDefs,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 使用流式 API
|
|
169
|
+
const stream = provider.chatStream({
|
|
170
|
+
messages,
|
|
171
|
+
tools: toolDefs.length > 0 ? toolDefs : undefined,
|
|
172
|
+
toolChoice: toolDefs.length > 0 ? 'auto' : undefined,
|
|
173
|
+
abortSignal,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// 累积流式数据
|
|
177
|
+
let contentParts: string[] = [];
|
|
178
|
+
let reasoningParts: string[] = [];
|
|
179
|
+
const toolCallsMap = new Map<number, { id: string; name: string; args: string }>();
|
|
180
|
+
let role: 'system' | 'user' | 'assistant' | 'tool' = 'assistant';
|
|
181
|
+
let chunkUsage: TokenUsage | undefined;
|
|
182
|
+
let toolStreamingLastEmit = 0;
|
|
183
|
+
|
|
184
|
+
for await (const chunk of stream) {
|
|
185
|
+
if (abortSignal.aborted) break;
|
|
186
|
+
|
|
187
|
+
if (chunk.usage) {
|
|
188
|
+
chunkUsage = chunk.usage;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const delta = chunk.delta;
|
|
192
|
+
|
|
193
|
+
if (delta.reasoning_content) {
|
|
194
|
+
reasoningParts.push(delta.reasoning_content);
|
|
195
|
+
yield this.config.createEvent(sessionId, { type: 'thinking', content: delta.reasoning_content });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (delta.content) {
|
|
199
|
+
contentParts.push(delta.content);
|
|
200
|
+
yield this.config.createEvent(sessionId, { type: 'message', content: delta.content, delta: true });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (delta.tool_calls) {
|
|
204
|
+
for (const tc of delta.tool_calls as any[]) {
|
|
205
|
+
const idx = tc.index ?? 0;
|
|
206
|
+
if (!toolCallsMap.has(idx)) {
|
|
207
|
+
toolCallsMap.set(idx, { id: '', name: '', args: '' });
|
|
208
|
+
}
|
|
209
|
+
const entry = toolCallsMap.get(idx)!;
|
|
210
|
+
if (tc.id) entry.id = tc.id;
|
|
211
|
+
if (tc.function?.name) entry.name = tc.function.name;
|
|
212
|
+
if (tc.function?.arguments) entry.args += tc.function.arguments;
|
|
213
|
+
}
|
|
214
|
+
const totalLen = Array.from(toolCallsMap.values()).reduce((s, e) => s + e.args.length, 0);
|
|
215
|
+
const threshold = Math.max(toolStreamingLastEmit * 2, 500);
|
|
216
|
+
if (totalLen - toolStreamingLastEmit >= threshold) {
|
|
217
|
+
toolStreamingLastEmit = totalLen;
|
|
218
|
+
const names = Array.from(toolCallsMap.values()).map(e => e.name || 'tool').join(', ');
|
|
219
|
+
yield this.config.createEvent(sessionId, {
|
|
220
|
+
type: 'thinking',
|
|
221
|
+
content: `⏳ Generating ${names} parameters (${totalLen} chars)...`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (delta.role) role = delta.role;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const fullContent = contentParts.join('') || null;
|
|
230
|
+
const fullReasoning = reasoningParts.join('') || null;
|
|
231
|
+
const toolCalls: ToolCall[] = [];
|
|
232
|
+
|
|
233
|
+
const sortedEntries = Array.from(toolCallsMap.entries()).sort((a, b) => a[0] - b[0]);
|
|
234
|
+
for (const [, entry] of sortedEntries) {
|
|
235
|
+
if (entry.id && entry.name) {
|
|
236
|
+
toolCalls.push({
|
|
237
|
+
id: entry.id,
|
|
238
|
+
type: 'function',
|
|
239
|
+
function: { name: entry.name, arguments: entry.args },
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const assistant: Message = {
|
|
245
|
+
role,
|
|
246
|
+
content: fullContent,
|
|
247
|
+
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
|
248
|
+
...(fullReasoning ? { reasoning_content: fullReasoning } : {}),
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
if (!fullContent && toolCalls.length === 0) {
|
|
252
|
+
return { result: null, contextTooLong: false };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { result: assistant, contextTooLong: false };
|
|
256
|
+
} catch (err: any) {
|
|
257
|
+
const contextTooLong = this.isContextTooLongError(err);
|
|
258
|
+
console.warn(`[LLMCaller] LLM call failed (${isFallback ? 'fallback' : 'primary'})${contextTooLong ? ' [CONTEXT_TOO_LONG]' : ''}:`, err?.message || err);
|
|
259
|
+
return { result: null, contextTooLong };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 单次 LLM 调用(流式),返回 assistant message 或 null(保留向后兼容) */
|
|
264
|
+
private async *callOnce(
|
|
265
|
+
sessionId: string,
|
|
266
|
+
messages: Message[],
|
|
267
|
+
toolDefs: ToolDefinition[],
|
|
268
|
+
abortSignal: AbortSignal,
|
|
269
|
+
provider: LLMProvider,
|
|
270
|
+
isFallback: boolean,
|
|
271
|
+
): AsyncGenerator<AgentEvent, Message | null> {
|
|
272
|
+
try {
|
|
273
|
+
// Hook: before_ll_call(仅主 Provider 首次调用时触发)
|
|
274
|
+
if (!isFallback) {
|
|
275
|
+
await this.config.runHooks('before_ll_call', {
|
|
276
|
+
sessionId,
|
|
277
|
+
iteration: 0,
|
|
278
|
+
messages,
|
|
279
|
+
tools: toolDefs,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 使用流式 API
|
|
284
|
+
const stream = provider.chatStream({
|
|
285
|
+
messages,
|
|
286
|
+
tools: toolDefs.length > 0 ? toolDefs : undefined,
|
|
287
|
+
toolChoice: toolDefs.length > 0 ? 'auto' : undefined,
|
|
288
|
+
abortSignal,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// 累积流式数据
|
|
292
|
+
let contentParts: string[] = [];
|
|
293
|
+
let reasoningParts: string[] = [];
|
|
294
|
+
const toolCallsMap = new Map<number, { id: string; name: string; args: string }>();
|
|
295
|
+
let role: 'system' | 'user' | 'assistant' | 'tool' = 'assistant';
|
|
296
|
+
let chunkUsage: TokenUsage | undefined;
|
|
297
|
+
let toolStreamingLastEmit = 0; // 工具参数流式生成进度追踪
|
|
298
|
+
|
|
299
|
+
for await (const chunk of stream) {
|
|
300
|
+
if (abortSignal.aborted) break;
|
|
301
|
+
|
|
302
|
+
if (chunk.usage) {
|
|
303
|
+
chunkUsage = chunk.usage;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const delta = chunk.delta;
|
|
307
|
+
|
|
308
|
+
if (delta.reasoning_content) {
|
|
309
|
+
reasoningParts.push(delta.reasoning_content);
|
|
310
|
+
yield this.config.createEvent(sessionId, { type: 'thinking', content: delta.reasoning_content });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (delta.content) {
|
|
314
|
+
contentParts.push(delta.content);
|
|
315
|
+
yield this.config.createEvent(sessionId, { type: 'message', content: delta.content, delta: true });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (delta.tool_calls) {
|
|
319
|
+
for (const tc of delta.tool_calls as any[]) {
|
|
320
|
+
const idx = tc.index ?? 0;
|
|
321
|
+
if (!toolCallsMap.has(idx)) {
|
|
322
|
+
toolCallsMap.set(idx, { id: '', name: '', args: '' });
|
|
323
|
+
}
|
|
324
|
+
const entry = toolCallsMap.get(idx)!;
|
|
325
|
+
if (tc.id) entry.id = tc.id;
|
|
326
|
+
if (tc.function?.name) entry.name = tc.function.name;
|
|
327
|
+
if (tc.function?.arguments) entry.args += tc.function.arguments;
|
|
328
|
+
}
|
|
329
|
+
// 工具参数流式生成时,定期发出 thinking 事件让 UI 有可见进度
|
|
330
|
+
const totalLen = Array.from(toolCallsMap.values()).reduce((s, e) => s + e.args.length, 0);
|
|
331
|
+
const threshold = Math.max(toolStreamingLastEmit * 2, 500);
|
|
332
|
+
if (totalLen - toolStreamingLastEmit >= threshold) {
|
|
333
|
+
toolStreamingLastEmit = totalLen;
|
|
334
|
+
const names = Array.from(toolCallsMap.values()).map(e => e.name || 'tool').join(', ');
|
|
335
|
+
yield this.config.createEvent(sessionId, {
|
|
336
|
+
type: 'thinking',
|
|
337
|
+
content: `⏳ Generating ${names} parameters (${totalLen} chars)...`,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (delta.role) role = delta.role;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 组装完整 assistant message
|
|
346
|
+
const fullContent = contentParts.join('') || null;
|
|
347
|
+
const fullReasoning = reasoningParts.join('') || null;
|
|
348
|
+
const toolCalls: ToolCall[] = [];
|
|
349
|
+
|
|
350
|
+
const sortedEntries = Array.from(toolCallsMap.entries()).sort((a, b) => a[0] - b[0]);
|
|
351
|
+
for (const [, entry] of sortedEntries) {
|
|
352
|
+
if (entry.id && entry.name) {
|
|
353
|
+
toolCalls.push({
|
|
354
|
+
id: entry.id,
|
|
355
|
+
type: 'function',
|
|
356
|
+
function: { name: entry.name, arguments: entry.args },
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const assistant: Message = {
|
|
362
|
+
role,
|
|
363
|
+
content: fullContent,
|
|
364
|
+
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
|
365
|
+
...(fullReasoning ? { reasoning_content: fullReasoning } : {}),
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
if (!fullContent && toolCalls.length === 0) {
|
|
369
|
+
return null; // 空响应,由重试逻辑处理
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return assistant;
|
|
373
|
+
} catch (err: any) {
|
|
374
|
+
console.warn(`[LLMCaller] LLM call failed (${isFallback ? 'fallback' : 'primary'}):`, err?.message || err);
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** 辅助:异步延迟 */
|
|
381
|
+
function sleep(ms: number): Promise<void> {
|
|
382
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
383
|
+
}
|