@myassis/gateway 1.0.77 → 1.0.81
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/dist/config/index.js +1 -1
- package/dist/services/agent/AgentManager.js +2 -1
- package/dist/services/llm/LLMClient.js +35 -4
- package/dist/services/memory/ContextBuilder.js +223 -33
- package/dist/services/memory/MemoryManager.js +18 -6
- package/dist/services/memory/ToolLedger.js +181 -0
- package/dist/services/session/Session.js +78 -0
- package/dist/services/systemPrompt.js +62 -53
- package/dist/services/tools/index.js +2 -1
- package/dist/services/tools/plan.js +111 -0
- package/package.json +1 -1
package/dist/config/index.js
CHANGED
|
@@ -90,7 +90,7 @@ exports.appConfig = {
|
|
|
90
90
|
/** 超出 toolPairKeep 的旧工具调用内容截断长度 */
|
|
91
91
|
oldToolContentLimit: parseInt(process.env.OLD_TOOL_CONTENT_LIMIT || '500', 10),
|
|
92
92
|
/** 单次请求内最大工具调用轮数 */
|
|
93
|
-
maxToolRounds: parseInt(process.env.MAX_TOOL_ROUNDS || '
|
|
93
|
+
maxToolRounds: parseInt(process.env.MAX_TOOL_ROUNDS || '200', 10),
|
|
94
94
|
/** 触发上下文压缩的字符阈值 */
|
|
95
95
|
summaryTriggerChars: parseInt(process.env.SUMMARY_TRIGGER_CHARS || '60000', 10),
|
|
96
96
|
appName: '我的助手'
|
|
@@ -254,7 +254,8 @@ class AgentManager {
|
|
|
254
254
|
createdAt: s.createdAt,
|
|
255
255
|
updatedAt: s.updatedAt,
|
|
256
256
|
messageQueue: s.messageQueue,
|
|
257
|
-
messageQueueAutoExecute: s.messageQueueAutoExecute
|
|
257
|
+
messageQueueAutoExecute: s.messageQueueAutoExecute,
|
|
258
|
+
plan: s.plan
|
|
258
259
|
}));
|
|
259
260
|
}
|
|
260
261
|
/**
|
|
@@ -12,7 +12,36 @@ const logger = (0, shared_1.getLogger)('LLMClient');
|
|
|
12
12
|
/** 历史工具输出在上下文中的最大保留长度 */
|
|
13
13
|
const HISTORY_TOOL_OUTPUT_LIMIT = index_js_2.appConfig.oldToolContentLimit;
|
|
14
14
|
/** 请求体字符硬上限,超过则视为上下文超限 */
|
|
15
|
-
const HARD_MAX_CHARS = 200000;
|
|
15
|
+
const HARD_MAX_CHARS = 200000;
|
|
16
|
+
/**
|
|
17
|
+
* 规范化工具调用参数。
|
|
18
|
+
*
|
|
19
|
+
* OpenAI 兼容接口要求 arguments 是「合法 JSON 字符串」;传入 null / undefined /
|
|
20
|
+
* 被截断的半截 JSON 都会导致 400 Invalid request body。
|
|
21
|
+
*/
|
|
22
|
+
function normalizeArguments(input) {
|
|
23
|
+
if (input == null)
|
|
24
|
+
return '{}';
|
|
25
|
+
if (typeof input === 'object') {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.stringify(input);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return '{}';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const text = String(input);
|
|
34
|
+
if (!text.trim())
|
|
35
|
+
return '{}';
|
|
36
|
+
try {
|
|
37
|
+
JSON.parse(text);
|
|
38
|
+
return text;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// 非法 JSON(例如历史裁剪产生的半截字符串)降级为带原文的合法对象
|
|
42
|
+
return JSON.stringify({ _raw: text });
|
|
43
|
+
}
|
|
44
|
+
} /**
|
|
16
45
|
* LLM 调用结果类型
|
|
17
46
|
*/
|
|
18
47
|
function convertBase64ToImage(base64Img) {
|
|
@@ -200,7 +229,7 @@ class LLMClient {
|
|
|
200
229
|
return [{
|
|
201
230
|
role: 'tool',
|
|
202
231
|
tool_call_id: m.tool_call_id,
|
|
203
|
-
content: m.content,
|
|
232
|
+
content: m.content || '(无输出)',
|
|
204
233
|
}];
|
|
205
234
|
}
|
|
206
235
|
}
|
|
@@ -213,7 +242,7 @@ class LLMClient {
|
|
|
213
242
|
id: tc.id,
|
|
214
243
|
function: {
|
|
215
244
|
name: tc.toolName,
|
|
216
|
-
arguments: tc.input
|
|
245
|
+
arguments: normalizeArguments(tc.input)
|
|
217
246
|
},
|
|
218
247
|
type: 'function'
|
|
219
248
|
})),
|
|
@@ -233,11 +262,13 @@ class LLMClient {
|
|
|
233
262
|
const toolCallItem = toolCall.toolCalls[i];
|
|
234
263
|
if (!toolCallItem?.id)
|
|
235
264
|
continue;
|
|
265
|
+
if (!toolCallItem.toolName)
|
|
266
|
+
continue;
|
|
236
267
|
item.tool_calls.push({
|
|
237
268
|
id: toolCallItem.id,
|
|
238
269
|
function: {
|
|
239
270
|
name: toolCallItem.toolName,
|
|
240
|
-
arguments: toolCallItem.input
|
|
271
|
+
arguments: normalizeArguments(toolCallItem.input)
|
|
241
272
|
},
|
|
242
273
|
type: 'function'
|
|
243
274
|
});
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.nextTrimLevel = exports.buildContext = exports.estimateTokens = exports.estimateChars = exports.TrimLevel = void 0;
|
|
4
4
|
const shared_1 = require("@myassis/shared");
|
|
5
5
|
const index_js_1 = require("../../config/index.js");
|
|
6
|
+
const ToolLedger_js_1 = require("./ToolLedger.js");
|
|
6
7
|
const logger = (0, shared_1.getLogger)('ContextBuilder');
|
|
7
8
|
/**
|
|
8
9
|
* 上下文构造与分级裁剪
|
|
@@ -14,17 +15,35 @@ const logger = (0, shared_1.getLogger)('ContextBuilder');
|
|
|
14
15
|
*/
|
|
15
16
|
/** 中英混合场景下的粗略 token 估算系数 */
|
|
16
17
|
const CHARS_PER_TOKEN = 1.6;
|
|
17
|
-
/**
|
|
18
|
+
/** 骨架化后单条工具结果保留的长度 */
|
|
19
|
+
const SKELETON_OUTPUT_LIMIT = 120;
|
|
20
|
+
/**
|
|
21
|
+
* 裁剪级别,逐级加重。
|
|
22
|
+
*
|
|
23
|
+
* 设计原则:**任何级别都不会删除工具调用记录**。
|
|
24
|
+
* 一旦删除,模型就看不到自己已经做过什么,会重复调用同一个工具
|
|
25
|
+
* (重复读文件、重复写入、重复执行命令),比截断内容的代价大得多。
|
|
26
|
+
* 因此加重裁剪只压缩「负载」(输出正文、参数值、附件),
|
|
27
|
+
* 始终保留「调用事实」(工具名 + 关键参数 + 成功/失败)。
|
|
28
|
+
*/
|
|
18
29
|
var TrimLevel;
|
|
19
30
|
(function (TrimLevel) {
|
|
20
31
|
/** 不裁剪 */
|
|
21
32
|
TrimLevel[TrimLevel["None"] = 0] = "None";
|
|
22
|
-
/** 截断超出 toolPairKeep
|
|
33
|
+
/** 截断超出 toolPairKeep 轮的旧工具输出 */
|
|
23
34
|
TrimLevel[TrimLevel["OldToolPayload"] = 1] = "OldToolPayload";
|
|
24
|
-
/**
|
|
35
|
+
/** 收紧保留轮数、截断参数值、降级旧附件为占位符 */
|
|
25
36
|
TrimLevel[TrimLevel["Aggressive"] = 2] = "Aggressive";
|
|
26
|
-
/**
|
|
27
|
-
TrimLevel[TrimLevel["
|
|
37
|
+
/** 旧工具调用退化为骨架:仅保留工具名、关键参数与结果摘要 */
|
|
38
|
+
TrimLevel[TrimLevel["SkeletonOldCalls"] = 3] = "SkeletonOldCalls";
|
|
39
|
+
/**
|
|
40
|
+
* 最后手段:把旧工具调用折叠进一份「已完成操作」账本。
|
|
41
|
+
*
|
|
42
|
+
* 消息条目本身被移除,但调用事实以清单形式并入首条 system 消息,
|
|
43
|
+
* 模型依然知道哪些操作已经做过 —— 这是「不丢事实」与「控制体积」的折中,
|
|
44
|
+
* 仅在骨架化后仍超预算(数百轮调用)时启用。
|
|
45
|
+
*/
|
|
46
|
+
TrimLevel[TrimLevel["FoldToLedger"] = 4] = "FoldToLedger";
|
|
28
47
|
})(TrimLevel || (exports.TrimLevel = TrimLevel = {}));
|
|
29
48
|
/** 估算单条消息的字符数(含附件与工具参数) */
|
|
30
49
|
function messageChars(message) {
|
|
@@ -66,11 +85,53 @@ function clip(text, limit) {
|
|
|
66
85
|
return text;
|
|
67
86
|
return text.slice(0, limit) + '...(内容过长已截断)';
|
|
68
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* 截断工具调用参数,同时保证结果仍是合法 JSON。
|
|
90
|
+
*
|
|
91
|
+
* 不能直接对 arguments 字符串做 substring:截断后会变成
|
|
92
|
+
* `{"path":"a.ts","content":"xxx` 这类非法 JSON,严格校验的厂商
|
|
93
|
+
* (如豆包/Volcengine)会直接返回 400 Invalid request body。
|
|
94
|
+
* 因此这里解析后逐个截断字符串字段,再重新序列化。
|
|
95
|
+
*/
|
|
96
|
+
function clipJsonArguments(args, limit) {
|
|
97
|
+
if (typeof args !== 'string')
|
|
98
|
+
return args;
|
|
99
|
+
if (args.length <= limit)
|
|
100
|
+
return args;
|
|
101
|
+
try {
|
|
102
|
+
const parsed = JSON.parse(args);
|
|
103
|
+
const shrink = (value) => {
|
|
104
|
+
if (typeof value === 'string') {
|
|
105
|
+
return value.length > limit ? value.slice(0, limit) + '...(已截断)' : value;
|
|
106
|
+
}
|
|
107
|
+
if (Array.isArray(value))
|
|
108
|
+
return value.map(shrink);
|
|
109
|
+
if (value && typeof value === 'object') {
|
|
110
|
+
const out = {};
|
|
111
|
+
for (const key of Object.keys(value))
|
|
112
|
+
out[key] = shrink(value[key]);
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
116
|
+
};
|
|
117
|
+
return JSON.stringify(shrink(parsed));
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// 无法解析(非 JSON 参数)时保持原样,宁可占用上下文也不发出非法请求
|
|
121
|
+
return args;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
69
124
|
/** 浅拷贝一条消息,保证裁剪不影响原对象 */
|
|
70
125
|
function cloneMessage(message) {
|
|
71
126
|
const copy = { ...message };
|
|
72
127
|
if (Array.isArray(message.tool_calls)) {
|
|
73
|
-
copy.tool_calls = message.tool_calls.map((tc) => ({ ...tc }));
|
|
128
|
+
copy.tool_calls = message.tool_calls.map((tc) => (tc?.function ? { ...tc, function: { ...tc.function } } : { ...tc }));
|
|
129
|
+
}
|
|
130
|
+
if (Array.isArray(message.toolCalls)) {
|
|
131
|
+
copy.toolCalls = message.toolCalls.map((tc) => ({
|
|
132
|
+
...tc,
|
|
133
|
+
toolCalls: Array.isArray(tc?.toolCalls) ? tc.toolCalls.map((x) => ({ ...x })) : tc?.toolCalls,
|
|
134
|
+
}));
|
|
74
135
|
}
|
|
75
136
|
if (Array.isArray(message.attachments)) {
|
|
76
137
|
copy.attachments = message.attachments.map((att) => ({ ...att }));
|
|
@@ -112,10 +173,9 @@ function clipPair(messages, pair, limit) {
|
|
|
112
173
|
// 工具参数往往是上下文膨胀的主因(写文件 / patch 类调用)
|
|
113
174
|
if (Array.isArray(head.tool_calls)) {
|
|
114
175
|
for (const tc of head.tool_calls) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
tc.function.arguments = clip(tc.function.arguments, limit);
|
|
176
|
+
tc.input = clipJsonArguments(tc.input, limit);
|
|
177
|
+
if (tc.function) {
|
|
178
|
+
tc.function.arguments = clipJsonArguments(tc.function.arguments, limit);
|
|
119
179
|
}
|
|
120
180
|
}
|
|
121
181
|
}
|
|
@@ -123,6 +183,138 @@ function clipPair(messages, pair, limit) {
|
|
|
123
183
|
messages[i].content = clip(messages[i].content, limit);
|
|
124
184
|
}
|
|
125
185
|
}
|
|
186
|
+
/** 从工具参数中提取用于标识「这次调用做了什么」的关键字段 */
|
|
187
|
+
const IDENTITY_KEYS = [
|
|
188
|
+
'path', 'file', 'filePath', 'filename', 'dir', 'directory',
|
|
189
|
+
'command', 'cmd', 'query', 'pattern', 'url', 'name', 'key', 'id',
|
|
190
|
+
'action', 'method', 'sessionId', 'target',
|
|
191
|
+
];
|
|
192
|
+
/** 单个关键参数值的最大长度 */
|
|
193
|
+
const IDENTITY_VALUE_LIMIT = 200;
|
|
194
|
+
/**
|
|
195
|
+
* 将工具参数压成「身份摘要」:只留能标识调用对象的关键字段。
|
|
196
|
+
*
|
|
197
|
+
* 例如 write_file 的 4000 字 content 被丢掉,但 path 保留,
|
|
198
|
+
* 模型据此知道「src/a.ts 已经写过了」,不会重复写入。
|
|
199
|
+
*/
|
|
200
|
+
function skeletonArguments(args) {
|
|
201
|
+
let parsed;
|
|
202
|
+
if (typeof args === 'string') {
|
|
203
|
+
try {
|
|
204
|
+
parsed = JSON.parse(args);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return JSON.stringify({ _omitted: true });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
else if (args && typeof args === 'object') {
|
|
211
|
+
parsed = args;
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
return '{}';
|
|
215
|
+
}
|
|
216
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
217
|
+
return JSON.stringify({ _omitted: true });
|
|
218
|
+
}
|
|
219
|
+
const out = {};
|
|
220
|
+
for (const key of Object.keys(parsed)) {
|
|
221
|
+
if (!IDENTITY_KEYS.includes(key))
|
|
222
|
+
continue;
|
|
223
|
+
const value = parsed[key];
|
|
224
|
+
if (value == null)
|
|
225
|
+
continue;
|
|
226
|
+
if (typeof value === 'object')
|
|
227
|
+
continue;
|
|
228
|
+
const text = String(value);
|
|
229
|
+
out[key] = text.length > IDENTITY_VALUE_LIMIT
|
|
230
|
+
? text.slice(0, IDENTITY_VALUE_LIMIT) + '...'
|
|
231
|
+
: text;
|
|
232
|
+
}
|
|
233
|
+
// 标记参数已省略,避免模型误以为这是完整参数而照抄
|
|
234
|
+
out._omitted = true;
|
|
235
|
+
return JSON.stringify(out);
|
|
236
|
+
}
|
|
237
|
+
/** 从工具结果中提炼一行摘要,保留成功/失败信号 */
|
|
238
|
+
function skeletonOutput(content) {
|
|
239
|
+
const text = typeof content === 'string' ? content : JSON.stringify(content ?? '');
|
|
240
|
+
if (!text)
|
|
241
|
+
return '(已执行,结果略)';
|
|
242
|
+
let success = null;
|
|
243
|
+
let body = text;
|
|
244
|
+
try {
|
|
245
|
+
const parsed = JSON.parse(text);
|
|
246
|
+
if (parsed && typeof parsed === 'object') {
|
|
247
|
+
if (typeof parsed.success === 'boolean')
|
|
248
|
+
success = parsed.success;
|
|
249
|
+
if (parsed.output != null) {
|
|
250
|
+
body = typeof parsed.output === 'string' ? parsed.output : JSON.stringify(parsed.output);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
// 非 JSON 结果,直接用原文
|
|
256
|
+
}
|
|
257
|
+
const head = body.replace(/\s+/g, ' ').slice(0, SKELETON_OUTPUT_LIMIT);
|
|
258
|
+
const flag = success === null ? '' : success ? '[成功] ' : '[失败] ';
|
|
259
|
+
return `${flag}${head}${body.length > SKELETON_OUTPUT_LIMIT ? '…' : ''}(完整结果已省略,勿重复调用)`;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* 将一个工具调用对退化为骨架。
|
|
263
|
+
*
|
|
264
|
+
* 保留 assistant.tool_calls 与配对的 tool 消息(协议要求且模型需要看到调用事实),
|
|
265
|
+
* 只把参数压成身份摘要、把输出压成一行结论。
|
|
266
|
+
*/
|
|
267
|
+
function skeletonizePair(messages, pair) {
|
|
268
|
+
const head = messages[pair.start];
|
|
269
|
+
head.content = clip(head.content, SKELETON_OUTPUT_LIMIT);
|
|
270
|
+
// 旧轮次的思维链对当前决策价值最低,直接丢弃
|
|
271
|
+
delete head.reasoning_content;
|
|
272
|
+
if (Array.isArray(head.tool_calls)) {
|
|
273
|
+
for (const tc of head.tool_calls) {
|
|
274
|
+
tc.input = skeletonArguments(tc.input);
|
|
275
|
+
if (tc.function) {
|
|
276
|
+
tc.function.arguments = skeletonArguments(tc.function.arguments);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
for (let i = pair.start + 1; i < pair.end; i++) {
|
|
281
|
+
messages[i].content = skeletonOutput(messages[i].content);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* 把最早的工具调用对折叠进账本。
|
|
286
|
+
*
|
|
287
|
+
* 与已废弃的「直接丢弃」不同:调用事实不会消失,而是转成
|
|
288
|
+
* 首条 system 消息中的「已完成操作」清单。
|
|
289
|
+
*/
|
|
290
|
+
function foldPairsToLedger(messages, pairs, foldCount, protectFrom) {
|
|
291
|
+
if (foldCount <= 0)
|
|
292
|
+
return messages;
|
|
293
|
+
const foldIndexes = new Set();
|
|
294
|
+
const folded = [];
|
|
295
|
+
for (let i = 0; i < foldCount; i++) {
|
|
296
|
+
for (let j = pairs[i].start; j < pairs[i].end; j++) {
|
|
297
|
+
foldIndexes.add(j);
|
|
298
|
+
folded.push(messages[j]);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const ledger = (0, ToolLedger_js_1.renderRequestLedger)(folded);
|
|
302
|
+
const kept = messages.filter((_, index) => !foldIndexes.has(index));
|
|
303
|
+
if (!ledger)
|
|
304
|
+
return kept;
|
|
305
|
+
// 并入首条 system 消息:部分厂商只允许 system 位于首位
|
|
306
|
+
if (kept[0]?.role === 'system' && typeof kept[0].content === 'string') {
|
|
307
|
+
kept[0] = { ...kept[0], content: `${kept[0].content}\n\n${ledger}` };
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
kept.splice(Math.min(protectFrom, kept.length), 0, {
|
|
311
|
+
role: 'assistant',
|
|
312
|
+
content: ledger,
|
|
313
|
+
attachments: [],
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return kept;
|
|
317
|
+
}
|
|
126
318
|
/** 将较早的图片 / 音频附件降级为文本占位符,避免 base64 永久占用上下文 */
|
|
127
319
|
function degradeAttachments(messages, keepLastN) {
|
|
128
320
|
const withAttachments = [];
|
|
@@ -161,35 +353,31 @@ function buildContext(messages, protectFrom, maxChars = index_js_1.appConfig.con
|
|
|
161
353
|
const keep = target >= TrimLevel.Aggressive
|
|
162
354
|
? index_js_1.appConfig.minToolPairKeep
|
|
163
355
|
: index_js_1.appConfig.toolPairKeep;
|
|
164
|
-
// 1)
|
|
356
|
+
// 1) 最重级别:把最早的工具调用退化为骨架(保留调用事实,丢弃负载)
|
|
357
|
+
if (target >= TrimLevel.SkeletonOldCalls) {
|
|
358
|
+
const skeletonUntil = Math.max(0, pairs.length - index_js_1.appConfig.minToolPairKeep);
|
|
359
|
+
for (let i = 0; i < skeletonUntil; i++) {
|
|
360
|
+
skeletonizePair(working, pairs[i]);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// 2) 截断超出 keep 轮的旧工具负载(骨架化过的 pair 已经很小,重复处理无害)
|
|
165
364
|
const clipUntil = Math.max(0, pairs.length - keep);
|
|
365
|
+
const argLimit = target >= TrimLevel.Aggressive
|
|
366
|
+
? Math.max(200, Math.floor(index_js_1.appConfig.oldToolContentLimit / 2))
|
|
367
|
+
: index_js_1.appConfig.oldToolContentLimit;
|
|
166
368
|
for (let i = 0; i < clipUntil; i++) {
|
|
167
|
-
clipPair(working, pairs[i],
|
|
369
|
+
clipPair(working, pairs[i], argLimit);
|
|
168
370
|
}
|
|
169
|
-
//
|
|
371
|
+
// 3) 降级旧附件
|
|
170
372
|
if (target >= TrimLevel.Aggressive) {
|
|
171
373
|
degradeAttachments(working, 1);
|
|
172
374
|
}
|
|
173
|
-
//
|
|
174
|
-
if (target >= TrimLevel.
|
|
175
|
-
|
|
176
|
-
const dropIndexes = new Set();
|
|
177
|
-
for (let i = 0; i < dropCount; i++) {
|
|
178
|
-
for (let j = pairs[i].start; j < pairs[i].end; j++) {
|
|
179
|
-
dropIndexes.add(j);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
const dropped = working.filter((_, index) => !dropIndexes.has(index));
|
|
183
|
-
// 用一条系统提示替代被丢弃的轮次,避免模型认为自己没做过这些事
|
|
184
|
-
dropped.splice(protectFrom, 0, {
|
|
185
|
-
role: 'system',
|
|
186
|
-
content: `(已省略 ${dropCount} 轮较早的工具调用记录以控制上下文长度)`,
|
|
187
|
-
attachments: [],
|
|
188
|
-
});
|
|
189
|
-
working = dropped;
|
|
375
|
+
// 4) 最后手段:折叠为账本(调用事实保留在 system 清单中)
|
|
376
|
+
if (target >= TrimLevel.FoldToLedger && pairs.length > index_js_1.appConfig.minToolPairKeep) {
|
|
377
|
+
working = foldPairsToLedger(working, pairs, pairs.length - index_js_1.appConfig.minToolPairKeep, protectFrom);
|
|
190
378
|
}
|
|
191
379
|
};
|
|
192
|
-
for (const target of [minLevel, TrimLevel.Aggressive, TrimLevel.
|
|
380
|
+
for (const target of [minLevel, TrimLevel.Aggressive, TrimLevel.SkeletonOldCalls, TrimLevel.FoldToLedger]) {
|
|
193
381
|
if (target < level)
|
|
194
382
|
continue;
|
|
195
383
|
applyLevel(target);
|
|
@@ -207,8 +395,10 @@ exports.buildContext = buildContext;
|
|
|
207
395
|
function nextTrimLevel(level) {
|
|
208
396
|
if (level < TrimLevel.Aggressive)
|
|
209
397
|
return TrimLevel.Aggressive;
|
|
210
|
-
if (level < TrimLevel.
|
|
211
|
-
return TrimLevel.
|
|
398
|
+
if (level < TrimLevel.SkeletonOldCalls)
|
|
399
|
+
return TrimLevel.SkeletonOldCalls;
|
|
400
|
+
if (level < TrimLevel.FoldToLedger)
|
|
401
|
+
return TrimLevel.FoldToLedger;
|
|
212
402
|
return null;
|
|
213
403
|
}
|
|
214
404
|
exports.nextTrimLevel = nextTrimLevel;
|
|
@@ -32,6 +32,7 @@ const index_js_1 = require("../../stores/index.js");
|
|
|
32
32
|
const LLMClient_js_1 = require("../llm/LLMClient.js");
|
|
33
33
|
const index_js_2 = require("../../config/index.js");
|
|
34
34
|
const ContextBuilder_js_1 = require("./ContextBuilder.js");
|
|
35
|
+
const ToolLedger_js_1 = require("./ToolLedger.js");
|
|
35
36
|
const DEFAULT_CONFIG = {
|
|
36
37
|
summaryThreshold: 10,
|
|
37
38
|
summaryTriggerChars: index_js_2.appConfig.summaryTriggerChars,
|
|
@@ -61,10 +62,19 @@ class MemoryManager {
|
|
|
61
62
|
this.childAgent = childAgent;
|
|
62
63
|
this.res = res;
|
|
63
64
|
}
|
|
64
|
-
|
|
65
|
+
/**
|
|
66
|
+
* 构造注入上下文的摘要消息。
|
|
67
|
+
*
|
|
68
|
+
* @param omitted 被摘要(即不再出现在消息数组中)的原始消息。
|
|
69
|
+
* 这些消息的工具调用会被提取为「已完成操作」账本附在摘要之后,
|
|
70
|
+
* 防止模型因为看不到调用记录而重复执行同一个工具。
|
|
71
|
+
*/
|
|
72
|
+
toSummaryMessage(summary, createdAt, modelName, omitted = []) {
|
|
73
|
+
const ledger = (0, ToolLedger_js_1.renderLedger)(omitted);
|
|
74
|
+
const content = `对话内容摘要:\n${summary}` + (ledger ? `\n\n${ledger}` : '');
|
|
65
75
|
return {
|
|
66
76
|
sessionId: this.session.id,
|
|
67
|
-
content
|
|
77
|
+
content,
|
|
68
78
|
role: 'system',
|
|
69
79
|
id: `summary_${createdAt}`,
|
|
70
80
|
createdAt,
|
|
@@ -121,16 +131,18 @@ class MemoryManager {
|
|
|
121
131
|
this.session.lastMessageSummaryAt = boundary;
|
|
122
132
|
this.session.save();
|
|
123
133
|
return [
|
|
124
|
-
this.toSummaryMessage(summary, boundary, ''),
|
|
134
|
+
this.toSummaryMessage(summary, boundary, '', toSummarize),
|
|
125
135
|
...messages.slice(-keep),
|
|
126
136
|
];
|
|
127
137
|
}
|
|
128
138
|
// 增量摘要:边界之后的消息才是“新消息”
|
|
129
139
|
const boundaryAt = this.session.lastMessageSummaryAt;
|
|
130
140
|
const newMessages = messages.filter((m) => m.createdAt > boundaryAt);
|
|
141
|
+
// 边界之前的消息已被摘要取代,其工具调用需通过账本保留
|
|
142
|
+
const omitted = messages.filter((m) => m.createdAt <= boundaryAt);
|
|
131
143
|
if (!this.shouldSummarize(newMessages) || newMessages.length <= keep) {
|
|
132
144
|
return [
|
|
133
|
-
this.toSummaryMessage(this.session.lastMessageSummary, boundaryAt, ''),
|
|
145
|
+
this.toSummaryMessage(this.session.lastMessageSummary, boundaryAt, '', omitted),
|
|
134
146
|
...newMessages,
|
|
135
147
|
];
|
|
136
148
|
}
|
|
@@ -139,7 +151,7 @@ class MemoryManager {
|
|
|
139
151
|
const summary = await this.generateSummaryAsync(toSummarize, this.session.lastMessageSummary);
|
|
140
152
|
if (!summary) {
|
|
141
153
|
return [
|
|
142
|
-
this.toSummaryMessage(this.session.lastMessageSummary, boundaryAt, ''),
|
|
154
|
+
this.toSummaryMessage(this.session.lastMessageSummary, boundaryAt, '', omitted),
|
|
143
155
|
...newMessages,
|
|
144
156
|
];
|
|
145
157
|
}
|
|
@@ -148,7 +160,7 @@ class MemoryManager {
|
|
|
148
160
|
this.session.lastMessageSummaryAt = nextBoundary;
|
|
149
161
|
this.session.save();
|
|
150
162
|
return [
|
|
151
|
-
this.toSummaryMessage(summary, nextBoundary, ''),
|
|
163
|
+
this.toSummaryMessage(summary, nextBoundary, '', [...omitted, ...toSummarize]),
|
|
152
164
|
...newMessages.slice(-keep),
|
|
153
165
|
];
|
|
154
166
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderRequestLedger = exports.renderLedger = exports.renderOperations = exports.extractOperations = exports.extractOperationsFromRequest = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* 工具调用账本
|
|
6
|
+
*
|
|
7
|
+
* 当很早的工具调用连「骨架」都无法保留在消息数组中时(例如数百轮调用),
|
|
8
|
+
* 本模块把这些调用压成一份确定性的「已完成操作清单」,随摘要一起注入 system 消息。
|
|
9
|
+
*
|
|
10
|
+
* 为什么不交给 LLM 生成:
|
|
11
|
+
* 摘要由模型改写,操作清单一旦被模型漏写或改写,模型就会重复调用同一个工具。
|
|
12
|
+
* 因此账本从消息历史中**确定性提取**,每次实时重算,不依赖模型输出、也无需落库。
|
|
13
|
+
*/
|
|
14
|
+
/** 从参数中提取调用目标的关键字段,顺序即优先级 */
|
|
15
|
+
const TARGET_KEYS = [
|
|
16
|
+
'path', 'filePath', 'file', 'filename', 'dir', 'directory',
|
|
17
|
+
'command', 'cmd', 'query', 'pattern', 'url', 'name', 'target', 'key',
|
|
18
|
+
];
|
|
19
|
+
/** 账本最多渲染的条目数,超出只保留计数 */
|
|
20
|
+
const MAX_LEDGER_ENTRIES = 60;
|
|
21
|
+
/** 单个目标字符串的最大长度 */
|
|
22
|
+
const MAX_TARGET_LEN = 120;
|
|
23
|
+
/** 从工具参数中提取一个可读的调用目标 */
|
|
24
|
+
function extractTarget(input) {
|
|
25
|
+
if (input == null)
|
|
26
|
+
return '';
|
|
27
|
+
let parsed = input;
|
|
28
|
+
if (typeof input === 'string') {
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(input);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return input.length > MAX_TARGET_LEN ? input.slice(0, MAX_TARGET_LEN) + '…' : input;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (!parsed || typeof parsed !== 'object')
|
|
37
|
+
return '';
|
|
38
|
+
for (const key of TARGET_KEYS) {
|
|
39
|
+
const value = parsed[key];
|
|
40
|
+
if (value == null || typeof value === 'object')
|
|
41
|
+
continue;
|
|
42
|
+
const text = String(value).trim();
|
|
43
|
+
if (!text)
|
|
44
|
+
continue;
|
|
45
|
+
return text.length > MAX_TARGET_LEN ? text.slice(0, MAX_TARGET_LEN) + '…' : text;
|
|
46
|
+
}
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 从「请求态」消息数组中提取操作清单。
|
|
51
|
+
*
|
|
52
|
+
* 与 extractOperations 的区别在于数据形状:请求态消息用的是
|
|
53
|
+
* OpenAI 协议格式(assistant.tool_calls + 配对的 tool 消息),
|
|
54
|
+
* 而非会话持久化的 toolCalls 嵌套结构。
|
|
55
|
+
*/
|
|
56
|
+
function extractOperationsFromRequest(messages) {
|
|
57
|
+
const map = new Map();
|
|
58
|
+
// 先建立 tool_call_id -> 是否成功 的索引
|
|
59
|
+
const outcome = new Map();
|
|
60
|
+
for (const message of messages || []) {
|
|
61
|
+
if (message?.role !== 'tool' || !message.tool_call_id)
|
|
62
|
+
continue;
|
|
63
|
+
const text = typeof message.content === 'string' ? message.content : '';
|
|
64
|
+
let success = null;
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(text);
|
|
67
|
+
if (parsed && typeof parsed.success === 'boolean')
|
|
68
|
+
success = parsed.success;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
if (text.includes('[失败]'))
|
|
72
|
+
success = false;
|
|
73
|
+
else if (text.includes('[成功]'))
|
|
74
|
+
success = true;
|
|
75
|
+
}
|
|
76
|
+
outcome.set(message.tool_call_id, success);
|
|
77
|
+
}
|
|
78
|
+
for (const message of messages || []) {
|
|
79
|
+
if (message?.role !== 'assistant' || !Array.isArray(message.tool_calls))
|
|
80
|
+
continue;
|
|
81
|
+
for (const tc of message.tool_calls) {
|
|
82
|
+
const name = tc?.toolName || tc?.function?.name;
|
|
83
|
+
if (!name)
|
|
84
|
+
continue;
|
|
85
|
+
const target = extractTarget(tc.input ?? tc.function?.arguments);
|
|
86
|
+
const key = `${name}\u0000${target}`;
|
|
87
|
+
const success = tc.id ? (outcome.get(tc.id) ?? null) : null;
|
|
88
|
+
const existing = map.get(key);
|
|
89
|
+
if (existing) {
|
|
90
|
+
existing.count++;
|
|
91
|
+
if (success !== null)
|
|
92
|
+
existing.lastSuccess = success;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
map.set(key, { tool: name, target, count: 1, lastSuccess: success });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return [...map.values()];
|
|
100
|
+
}
|
|
101
|
+
exports.extractOperationsFromRequest = extractOperationsFromRequest;
|
|
102
|
+
/** 判断一次工具调用是否成功 */
|
|
103
|
+
function isSuccess(item) {
|
|
104
|
+
if (item?.status === 'error')
|
|
105
|
+
return false;
|
|
106
|
+
if (item?.status === 'success')
|
|
107
|
+
return true;
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 从消息中提取去重后的操作清单。
|
|
112
|
+
*
|
|
113
|
+
* 同一 工具+目标 的多次调用合并为一条并累加次数,
|
|
114
|
+
* 这样「读了 30 次同一个文件」不会占据 30 行。
|
|
115
|
+
*/
|
|
116
|
+
function extractOperations(messages) {
|
|
117
|
+
const map = new Map();
|
|
118
|
+
for (const message of messages || []) {
|
|
119
|
+
for (const group of (message.toolCalls || [])) {
|
|
120
|
+
for (const item of (group?.toolCalls || [])) {
|
|
121
|
+
if (!item?.toolName)
|
|
122
|
+
continue;
|
|
123
|
+
const tool = item.actionName ? `${item.toolName}.${item.actionName}` : item.toolName;
|
|
124
|
+
const target = extractTarget(item.input);
|
|
125
|
+
const key = `${tool}\u0000${target}`;
|
|
126
|
+
const existing = map.get(key);
|
|
127
|
+
if (existing) {
|
|
128
|
+
existing.count++;
|
|
129
|
+
const success = isSuccess(item);
|
|
130
|
+
if (success !== null)
|
|
131
|
+
existing.lastSuccess = success;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
map.set(key, { tool, target, count: 1, lastSuccess: isSuccess(item) });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return [...map.values()];
|
|
140
|
+
}
|
|
141
|
+
exports.extractOperations = extractOperations;
|
|
142
|
+
/**
|
|
143
|
+
* 将操作清单渲染为文本。
|
|
144
|
+
*
|
|
145
|
+
* 返回空串表示没有需要记录的操作。
|
|
146
|
+
*/
|
|
147
|
+
function renderOperations(operations) {
|
|
148
|
+
if (operations.length === 0)
|
|
149
|
+
return '';
|
|
150
|
+
// 失败的操作最有价值(避免模型以为没做过而重试),优先展示;其次按调用次数
|
|
151
|
+
const sorted = operations.sort((a, b) => {
|
|
152
|
+
const aFailed = a.lastSuccess === false ? 1 : 0;
|
|
153
|
+
const bFailed = b.lastSuccess === false ? 1 : 0;
|
|
154
|
+
if (aFailed !== bFailed)
|
|
155
|
+
return bFailed - aFailed;
|
|
156
|
+
return b.count - a.count;
|
|
157
|
+
});
|
|
158
|
+
const shown = sorted.slice(0, MAX_LEDGER_ENTRIES);
|
|
159
|
+
const lines = shown.map((op) => {
|
|
160
|
+
const times = op.count > 1 ? ` ×${op.count}` : '';
|
|
161
|
+
const flag = op.lastSuccess === false ? ' [失败]' : '';
|
|
162
|
+
const target = op.target ? ` ${op.target}` : '';
|
|
163
|
+
return `- ${op.tool}${target}${times}${flag}`;
|
|
164
|
+
});
|
|
165
|
+
const rest = sorted.length - shown.length;
|
|
166
|
+
if (rest > 0) {
|
|
167
|
+
lines.push(`- …另有 ${rest} 项操作未列出`);
|
|
168
|
+
}
|
|
169
|
+
return `【已完成操作】以下工具调用**已经执行过**,除非需要确认最新状态,不要重复调用:\n${lines.join('\n')}`;
|
|
170
|
+
}
|
|
171
|
+
exports.renderOperations = renderOperations;
|
|
172
|
+
/** 从会话消息渲染账本 */
|
|
173
|
+
function renderLedger(messages) {
|
|
174
|
+
return renderOperations(extractOperations(messages));
|
|
175
|
+
}
|
|
176
|
+
exports.renderLedger = renderLedger;
|
|
177
|
+
/** 从请求态消息渲染账本 */
|
|
178
|
+
function renderRequestLedger(messages) {
|
|
179
|
+
return renderOperations(extractOperationsFromRequest(messages));
|
|
180
|
+
}
|
|
181
|
+
exports.renderRequestLedger = renderRequestLedger;
|
|
@@ -106,6 +106,9 @@ class Session {
|
|
|
106
106
|
lastMessageSummary = null;
|
|
107
107
|
lastMessageSummaryAt = null;
|
|
108
108
|
isCurrent;
|
|
109
|
+
// 计划模式:当前需求拆分出的执行计划
|
|
110
|
+
plan = [];
|
|
111
|
+
planUpdatedAt = null;
|
|
109
112
|
// 用于停止生成
|
|
110
113
|
isGenerating = false;
|
|
111
114
|
unreadCount = 0;
|
|
@@ -437,6 +440,59 @@ class Session {
|
|
|
437
440
|
this.unreadCount++;
|
|
438
441
|
this.save();
|
|
439
442
|
}
|
|
443
|
+
// ========== 计划模式 ==========
|
|
444
|
+
/**
|
|
445
|
+
* 更新当前会话的执行计划(全量替换)
|
|
446
|
+
*/
|
|
447
|
+
updatePlan(steps) {
|
|
448
|
+
this.plan = steps.map(item => ({ step: item.step, status: item.status }));
|
|
449
|
+
this.planUpdatedAt = Date.now();
|
|
450
|
+
return this.plan;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* 清除当前会话的执行计划
|
|
454
|
+
*/
|
|
455
|
+
clearPlan() {
|
|
456
|
+
this.plan = [];
|
|
457
|
+
this.planUpdatedAt = null;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* 计划是否已全部完成
|
|
461
|
+
*/
|
|
462
|
+
isPlanCompleted() {
|
|
463
|
+
return this.plan.length > 0 && this.plan.every(item => item.status === 'completed');
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* 构造计划进度提醒。
|
|
467
|
+
*
|
|
468
|
+
* 模型倾向于只在任务开头和结尾调用 updatePlan,导致界面进度长期停在第一项。
|
|
469
|
+
* 因此当会话已有计划、且连续 roundsSincePlanUpdate 轮工具调用都没有同步进度时,
|
|
470
|
+
* 返回一条提醒文案注入上下文;无需提醒时返回 null。
|
|
471
|
+
*/
|
|
472
|
+
buildPlanReminder(roundsSincePlanUpdate) {
|
|
473
|
+
// 没有计划或计划已完成,无需提醒
|
|
474
|
+
if (this.plan.length === 0 || this.isPlanCompleted())
|
|
475
|
+
return null;
|
|
476
|
+
// 允许模型连续做 1 轮工具调用后再要求同步,避免过于频繁地打断
|
|
477
|
+
if (roundsSincePlanUpdate < 2)
|
|
478
|
+
return null;
|
|
479
|
+
const current = this.plan.find(item => item.status === 'in_progress');
|
|
480
|
+
const nextPending = this.plan.find(item => item.status === 'pending');
|
|
481
|
+
const completed = this.plan.filter(item => item.status === 'completed').length;
|
|
482
|
+
const lines = [
|
|
483
|
+
`【计划进度提醒】当前计划共 ${this.plan.length} 步,已完成 ${completed} 步,`
|
|
484
|
+
+ `已连续 ${roundsSincePlanUpdate} 轮工具调用未同步计划状态。`,
|
|
485
|
+
];
|
|
486
|
+
if (current) {
|
|
487
|
+
lines.push(`进行中的步骤是「${current.step}」:如果它已经做完,请立即调用 updatePlan 把它置为 completed`
|
|
488
|
+
+ `${nextPending ? `,并把「${nextPending.step}」置为 in_progress` : ''}。`);
|
|
489
|
+
}
|
|
490
|
+
else if (nextPending) {
|
|
491
|
+
lines.push(`当前没有进行中的步骤,请立即调用 updatePlan 把「${nextPending.step}」置为 in_progress。`);
|
|
492
|
+
}
|
|
493
|
+
lines.push('调用时必须传入完整步骤列表,且同一时刻最多一个步骤为 in_progress。');
|
|
494
|
+
return lines.join('\n');
|
|
495
|
+
}
|
|
440
496
|
// 清除未读消息数并持久化
|
|
441
497
|
clearUnreadCount() {
|
|
442
498
|
this.unreadCount = 0;
|
|
@@ -489,6 +545,10 @@ class Session {
|
|
|
489
545
|
this.stopGenerating();
|
|
490
546
|
}
|
|
491
547
|
}
|
|
548
|
+
// 上一轮计划已全部完成,新一轮需求开始前清空,避免残留在界面上
|
|
549
|
+
if (this.isPlanCompleted()) {
|
|
550
|
+
this.clearPlan();
|
|
551
|
+
}
|
|
492
552
|
// Add user message
|
|
493
553
|
this.getAbortController();
|
|
494
554
|
this.isGenerating = true;
|
|
@@ -579,6 +639,8 @@ class Session {
|
|
|
579
639
|
: (await dataService_js_1.modelsService.list(token)).data.map(x => (0, models_js_1.toModel)(x));
|
|
580
640
|
// 工具调用轮次计数,防止无限循环
|
|
581
641
|
let toolRound = 0;
|
|
642
|
+
// 计划模式:距上次调用 updatePlan 已经过的工具轮次,用于在中间过程强制提醒模型同步进度
|
|
643
|
+
let roundsSincePlanUpdate = 0;
|
|
582
644
|
// 当前裁剪级别,遇到上下文超限时逐级加重
|
|
583
645
|
let trimLevel = ContextBuilder_js_1.TrimLevel.OldToolPayload;
|
|
584
646
|
/** 判断错误是否为上下文超限 */
|
|
@@ -901,6 +963,9 @@ class Session {
|
|
|
901
963
|
};
|
|
902
964
|
await Promise.all(llmResult.toolCalls.map(x => startToolCall(x)));
|
|
903
965
|
toolCalls.push(toolCall);
|
|
966
|
+
// 计划模式:本轮是否调用了 updatePlan
|
|
967
|
+
const calledUpdatePlan = llmResult.toolCalls.some((tc) => tc.toolName === 'updatePlan');
|
|
968
|
+
roundsSincePlanUpdate = calledUpdatePlan ? 0 : roundsSincePlanUpdate + 1;
|
|
904
969
|
// 将工具结果添加到消息历史(格式化为 tool 角色的消息)
|
|
905
970
|
for (const result of toolResults) {
|
|
906
971
|
const toolResultMessage = {
|
|
@@ -915,6 +980,17 @@ class Session {
|
|
|
915
980
|
};
|
|
916
981
|
messages.push(toolResultMessage);
|
|
917
982
|
}
|
|
983
|
+
// 计划模式:模型常常只在开头和结尾调用 updatePlan,中间过程不同步进度。
|
|
984
|
+
// 这里在已有计划且连续多轮未同步时,主动注入一条提醒,迫使模型推进计划状态。
|
|
985
|
+
const reminder = this.buildPlanReminder(roundsSincePlanUpdate);
|
|
986
|
+
if (reminder) {
|
|
987
|
+
messages.push({
|
|
988
|
+
role: 'assistant',
|
|
989
|
+
content: reminder,
|
|
990
|
+
attachments: []
|
|
991
|
+
});
|
|
992
|
+
roundsSincePlanUpdate = 0;
|
|
993
|
+
}
|
|
918
994
|
// 继续下一轮(带上工具结果继续调用模型)
|
|
919
995
|
return await processModelResponse();
|
|
920
996
|
}
|
|
@@ -990,6 +1066,8 @@ class Session {
|
|
|
990
1066
|
*/
|
|
991
1067
|
stopGenerating() {
|
|
992
1068
|
this.isGenerating = false;
|
|
1069
|
+
// 停止生成后清除计划,避免 Desktop 的进度圈停留在未完成状态
|
|
1070
|
+
this.clearPlan();
|
|
993
1071
|
if (this.abortController) {
|
|
994
1072
|
this.abortController.abort();
|
|
995
1073
|
this.abortController = null;
|
|
@@ -79,26 +79,26 @@ function getChatStyle(settings) {
|
|
|
79
79
|
function getIndentityInfoAsync(settings, agent) {
|
|
80
80
|
const style = getChatStyle(settings);
|
|
81
81
|
const agentName = agent?.name || settings.agentName || settings.assistantName;
|
|
82
|
-
return `【身份】
|
|
83
|
-
- 你是「${agentName}」,${settings.userName}的智能助手。${style}
|
|
82
|
+
return `【身份】
|
|
83
|
+
- 你是「${agentName}」,${settings.userName}的智能助手。${style}
|
|
84
84
|
- 自称"我",称用户为"您"或「${settings.userName}」`;
|
|
85
85
|
}
|
|
86
86
|
function getInstalledSkillsPrompt(skills) {
|
|
87
87
|
// 替换技能列表(模板中应该有占位符,如 {{skills}})
|
|
88
88
|
const skillsList = skills.map((skill, index) => `skillId: "${skill.id}". **${skill.name}** - ${skill.description || '无描述'}`).join('\n');
|
|
89
89
|
const skillSystemPrompt = `【技能系统】当你出现无法解决用户需求时,需要通过技能系统来帮助完成。 技能有两种,一种是通过skill工具可以获取到的技能,一种是通过clawhub安装的技能。`;
|
|
90
|
-
const skillToolPrompt = `【skill工具技能】
|
|
91
|
-
【已安装skill技能】
|
|
92
|
-
${skillsList}
|
|
93
|
-
【强制调用流程】
|
|
94
|
-
1.必须先调用技能管理工具获取详情
|
|
95
|
-
2.如果技能的scriptsUrl不为空,并且当前目录中的skills目录下不存在对应的技能,需要先下载,然后解压后才可以使用,下载目录统一下载到当前目录的skills目录下的该技能目录。
|
|
90
|
+
const skillToolPrompt = `【skill工具技能】
|
|
91
|
+
【已安装skill技能】
|
|
92
|
+
${skillsList}
|
|
93
|
+
【强制调用流程】
|
|
94
|
+
1.必须先调用技能管理工具获取详情
|
|
95
|
+
2.如果技能的scriptsUrl不为空,并且当前目录中的skills目录下不存在对应的技能,需要先下载,然后解压后才可以使用,下载目录统一下载到当前目录的skills目录下的该技能目录。
|
|
96
96
|
3.下载直接使用命令行工具,不要使用fetch工具。运行完脚本记得把脚本及其解压内容删除。`;
|
|
97
|
-
const clawhubSkillPrompt = `
|
|
98
|
-
【clawhub技能】
|
|
99
|
-
1.通过clawhub安装的技能,首先通过命令行工具查看clawhub命令是否安装
|
|
100
|
-
2.如果没有安装需要先安装clawhub
|
|
101
|
-
3.如果已经安装可以使用clawhub search命令搜索相关工具
|
|
97
|
+
const clawhubSkillPrompt = `
|
|
98
|
+
【clawhub技能】
|
|
99
|
+
1.通过clawhub安装的技能,首先通过命令行工具查看clawhub命令是否安装
|
|
100
|
+
2.如果没有安装需要先安装clawhub
|
|
101
|
+
3.如果已经安装可以使用clawhub search命令搜索相关工具
|
|
102
102
|
4.安装后根据技能中的SKILL.md文档描述进行使用`;
|
|
103
103
|
return `\n${skillSystemPrompt}\n${skillToolPrompt}\n${clawhubSkillPrompt}\n`;
|
|
104
104
|
}
|
|
@@ -116,46 +116,55 @@ async function getSystemPromptAsync(agent, customPrompt) {
|
|
|
116
116
|
const skillPrompts = getInstalledSkillsPrompt(skills);
|
|
117
117
|
const chineseLocalName = exports.LANGUAGES.filter(x => x.code === settings.language)[0]?.chineseName ?? '简体中文';
|
|
118
118
|
const date = new Date();
|
|
119
|
-
let prompt = `${getIndentityInfoAsync(settings, agent)}。
|
|
120
|
-
【当前运行环境】:${osInfo}。
|
|
121
|
-
【当前时间】:${date.toLocaleString(settings.language)},weekday:${date.getDay()}。
|
|
122
|
-
【语言】你的回复语言严格遵循用户设备系统语言:${chineseLocalName},无论用户使用任何语言提问,你都只使用【${chineseLocalName}】进行完整回复,不要混用其他语言。
|
|
123
|
-
【尽力原则】充分分析用户需求,尽力满足,不要偷懒,不要得过且过,要诚实,不能胡编乱造。
|
|
124
|
-
【工具调用】
|
|
125
|
-
1.逐步思考,分步骤调用工具,工具调用前尽量返回content,明确当前工具调用的目的。
|
|
126
|
-
2.工具调用的参数全部采用JSON语法,调用前先验证JSON语法正确后,再回复,切记未验证JSON语法就直接回复。
|
|
127
|
-
【命令执行】执行命令时一定要根据当前系统来执行命令
|
|
128
|
-
【文件操作】
|
|
129
|
-
1.文件编辑后一定要验证编辑是否成功,文件内容与预期是否一致。
|
|
130
|
-
2.文件工具目前未提供批量替换操作,如果需要批量修改,请自行创建脚本来完成。
|
|
131
|
-
3.大文件写入如果无法识别,请先写入主体内容,再使用edit工具进行修改来操作
|
|
132
|
-
【特殊语法】mermaid折线图需要使用xychart-beta,完整示例如下:
|
|
133
|
-
xychart-beta
|
|
134
|
-
title "2025年上半年产品销量对比"
|
|
135
|
-
x-axis ["1月", "2月", "3月", "4月", "5月", "6月"]
|
|
136
|
-
y-axis "销量 (件)" 0 --> 80
|
|
137
|
-
line "智能手机" [45, 52, 38, 65, 58, 72]
|
|
138
|
-
line "笔记本电脑" [30, 28, 35, 42, 38, 45]
|
|
139
|
-
line "平板电脑" [20, 25, 30, 28, 35, 40]
|
|
140
|
-
【解决问题思路】
|
|
141
|
-
1.首先分析用户需求,根据需求罗列出实施计划。如果对需求不够明确,需要把计划罗列完后,让用户确认后再执行。
|
|
142
|
-
2.按照计划分步执行
|
|
143
|
-
3.所有步骤完成后,验证结果,比如编写程序,则需要执行类型检查
|
|
144
|
-
4.验证完全通过,总结完成情况,告诉用户
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
119
|
+
let prompt = `${getIndentityInfoAsync(settings, agent)}。
|
|
120
|
+
【当前运行环境】:${osInfo}。
|
|
121
|
+
【当前时间】:${date.toLocaleString(settings.language)},weekday:${date.getDay()}。
|
|
122
|
+
【语言】你的回复语言严格遵循用户设备系统语言:${chineseLocalName},无论用户使用任何语言提问,你都只使用【${chineseLocalName}】进行完整回复,不要混用其他语言。
|
|
123
|
+
【尽力原则】充分分析用户需求,尽力满足,不要偷懒,不要得过且过,要诚实,不能胡编乱造。
|
|
124
|
+
【工具调用】
|
|
125
|
+
1.逐步思考,分步骤调用工具,工具调用前尽量返回content,明确当前工具调用的目的。
|
|
126
|
+
2.工具调用的参数全部采用JSON语法,调用前先验证JSON语法正确后,再回复,切记未验证JSON语法就直接回复。
|
|
127
|
+
【命令执行】执行命令时一定要根据当前系统来执行命令
|
|
128
|
+
【文件操作】
|
|
129
|
+
1.文件编辑后一定要验证编辑是否成功,文件内容与预期是否一致。
|
|
130
|
+
2.文件工具目前未提供批量替换操作,如果需要批量修改,请自行创建脚本来完成。
|
|
131
|
+
3.大文件写入如果无法识别,请先写入主体内容,再使用edit工具进行修改来操作
|
|
132
|
+
【特殊语法】mermaid折线图需要使用xychart-beta,完整示例如下:
|
|
133
|
+
xychart-beta
|
|
134
|
+
title "2025年上半年产品销量对比"
|
|
135
|
+
x-axis ["1月", "2月", "3月", "4月", "5月", "6月"]
|
|
136
|
+
y-axis "销量 (件)" 0 --> 80
|
|
137
|
+
line "智能手机" [45, 52, 38, 65, 58, 72]
|
|
138
|
+
line "笔记本电脑" [30, 28, 35, 42, 38, 45]
|
|
139
|
+
line "平板电脑" [20, 25, 30, 28, 35, 40]
|
|
140
|
+
【解决问题思路】
|
|
141
|
+
1.首先分析用户需求,根据需求罗列出实施计划。如果对需求不够明确,需要把计划罗列完后,让用户确认后再执行。
|
|
142
|
+
2.按照计划分步执行
|
|
143
|
+
3.所有步骤完成后,验证结果,比如编写程序,则需要执行类型检查
|
|
144
|
+
4.验证完全通过,总结完成情况,告诉用户
|
|
145
|
+
【计划模式】
|
|
146
|
+
1.当用户需求需要拆分成两步以上才能完成时,必须先调用 updatePlan 工具给出完整的计划步骤(每步 status 初始为 pending,第一步直接置为 in_progress)。
|
|
147
|
+
2.**每完成一个步骤就必须立刻调用一次 updatePlan**:把刚完成的步骤置为 completed,把下一个要做的步骤置为 in_progress。禁止只在开头和结尾各调用一次、中间全程不同步——这会让用户界面的进度一直停在第一项。
|
|
148
|
+
3.判断标准:只要你准备开始做「下一个步骤」的事情,就说明上一个步骤已完成,此时必须先调用 updatePlan 再继续。
|
|
149
|
+
4.每次调用 updatePlan 都要传入完整步骤列表,不要只传变化的部分;同一时刻最多一个步骤为 in_progress。
|
|
150
|
+
5.计划步骤描述要简洁(建议不超过 30 字),可执行、可验证,不要写"分析需求""总结"之类的空步骤。
|
|
151
|
+
6.如果收到【计划进度提醒】,说明你已多轮未同步进度,必须立即调用 updatePlan 修正状态。
|
|
152
|
+
7.任务结束前必须把所有步骤都置为 completed,否则用户界面的计划进度圈不会消失。
|
|
153
|
+
8.一句话就能答完的简单需求不要调用 updatePlan。
|
|
154
|
+
【代码规范】
|
|
155
|
+
## 1 面向对象代码设计
|
|
156
|
+
1)根据功能先添加接口
|
|
157
|
+
2)对应接口添加一个基类
|
|
158
|
+
3)实现类默认继承这个基类
|
|
159
|
+
## 2 添加新功能
|
|
160
|
+
1)服务端根据DDD架构,进行分层设计:控制器、服务、领域服务
|
|
161
|
+
2)客户端将一个页面分为3部分,页面组件、页面样式、页面执行的脚本,页面执行的脚本中,数据服务与业务逻辑分开处理,数据服务又分为本地数据和远程数据
|
|
162
|
+
## 3 修改已经存在的代码文件
|
|
163
|
+
1)修改Bug,找到Bug后精确替换,不要调整架构
|
|
164
|
+
2)修改业务功能,删除旧的业务功能,重新添加新的,不要在旧的上面做修改,直接删除旧功能,然后添加新功能
|
|
165
|
+
【代码提交】务必类型检查通过
|
|
166
|
+
【任务完成过程中】不能独白
|
|
167
|
+
【会话标题】任务开始前,先修改会话标题,标题必须简洁明了,能概括任务内容
|
|
159
168
|
${skillPrompts}`;
|
|
160
169
|
// 如果有自定义提示词,追加
|
|
161
170
|
if (customPrompt) {
|
|
@@ -37,10 +37,11 @@ const edit_js_1 = require("./edit.js");
|
|
|
37
37
|
const webFetch_js_1 = require("./webFetch.js");
|
|
38
38
|
const sessionsSpawn_js_1 = require("./sessionsSpawn.js");
|
|
39
39
|
const setSessionTitle_js_1 = require("./setSessionTitle.js");
|
|
40
|
+
const plan_js_1 = require("./plan.js");
|
|
40
41
|
exports.tools = [
|
|
41
42
|
search_js_1.searchTool, calculator_js_1.calculatorTool, screenshot_js_1.screenshotTool, keyboard_js_1.keyboardTool, mouse_js_1.mouseTool,
|
|
42
43
|
skill_js_1.skillTool, exec_js_1.execTool, task_js_1.taskTool, model_js_1.modelTool, fetch_js_1.fetchTool,
|
|
43
|
-
edit_js_1.editTool, webFetch_js_1.webFetchTool, file_js_1.fileTool, sessionsSpawn_js_1.sessionsSpawnTool, setSessionTitle_js_1.setSessionTitleTool
|
|
44
|
+
edit_js_1.editTool, webFetch_js_1.webFetchTool, file_js_1.fileTool, sessionsSpawn_js_1.sessionsSpawnTool, setSessionTitle_js_1.setSessionTitleTool, plan_js_1.updatePlanTool
|
|
44
45
|
];
|
|
45
46
|
function getToolByName(name) {
|
|
46
47
|
return exports.tools.find(tool => tool.name === name);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.updatePlanTool = void 0;
|
|
4
|
+
const SessionManager_js_1 = require("../session/SessionManager.js");
|
|
5
|
+
const WebSocketService_js_1 = require("../WebSocketService.js");
|
|
6
|
+
const shared_1 = require("@myassis/shared");
|
|
7
|
+
const logger = (0, shared_1.getLogger)('PlanTool');
|
|
8
|
+
const VALID_STATUS = ['pending', 'in_progress', 'completed'];
|
|
9
|
+
/**
|
|
10
|
+
* 规范化计划步骤,过滤非法数据
|
|
11
|
+
*/
|
|
12
|
+
function normalizeSteps(steps) {
|
|
13
|
+
if (!Array.isArray(steps) || steps.length === 0)
|
|
14
|
+
return null;
|
|
15
|
+
const normalized = [];
|
|
16
|
+
for (const item of steps) {
|
|
17
|
+
if (!item)
|
|
18
|
+
continue;
|
|
19
|
+
const step = typeof item === 'string' ? item : item.step;
|
|
20
|
+
if (typeof step !== 'string' || step.trim().length === 0)
|
|
21
|
+
continue;
|
|
22
|
+
const status = VALID_STATUS.includes(item?.status) ? item.status : 'pending';
|
|
23
|
+
normalized.push({ step: step.trim(), status });
|
|
24
|
+
}
|
|
25
|
+
return normalized.length > 0 ? normalized : null;
|
|
26
|
+
}
|
|
27
|
+
exports.updatePlanTool = {
|
|
28
|
+
name: 'updatePlan',
|
|
29
|
+
description: `维护当前需求的多步执行计划(计划模式)。
|
|
30
|
+
当用户需求需要拆分成两步以上才能完成时,必须先调用本工具给出完整的计划步骤。
|
|
31
|
+
调用后 Desktop 会话底部会显示计划进度圈,鼠标悬浮可查看每个步骤的状态。
|
|
32
|
+
规则:
|
|
33
|
+
1. 每完成一个步骤都必须立刻再调用一次本工具:把已完成步骤置为 completed,把下一个步骤置为 in_progress。
|
|
34
|
+
禁止只在任务开头和结尾各调用一次,中间不同步会导致用户看到的进度一直停在第一项。
|
|
35
|
+
2. 每次调用都要传入完整的步骤列表(含已完成步骤),不要只传增量。
|
|
36
|
+
3. 同一时刻最多只有一个步骤处于 in_progress。
|
|
37
|
+
4. 全部步骤都变为 completed 后,进度圈才会消失,所以任务结束时务必把最后一个步骤标记为 completed。`,
|
|
38
|
+
parameters: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
steps: {
|
|
42
|
+
type: 'array',
|
|
43
|
+
description: '完整的计划步骤列表,按执行顺序排列',
|
|
44
|
+
items: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
description: '单个计划步骤',
|
|
47
|
+
properties: {
|
|
48
|
+
step: { type: 'string', description: '步骤描述,简洁明确,建议不超过 30 字' },
|
|
49
|
+
status: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
description: '步骤状态:pending 待完成、in_progress 进行中、completed 已完成',
|
|
52
|
+
enum: ['pending', 'in_progress', 'completed'],
|
|
53
|
+
default: 'pending',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
required: ['step', 'status'],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
explanation: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
description: '可选,本次计划变更的简要说明',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
required: ['steps'],
|
|
65
|
+
},
|
|
66
|
+
handler: async (args, sessionId, _messageId, userId) => {
|
|
67
|
+
try {
|
|
68
|
+
const steps = normalizeSteps(args?.steps);
|
|
69
|
+
if (!steps) {
|
|
70
|
+
return { success: false, errorMessage: '计划步骤不能为空,steps 需要是 [{ step, status }] 数组' };
|
|
71
|
+
}
|
|
72
|
+
if (steps.filter(s => s.status === 'in_progress').length > 1) {
|
|
73
|
+
return { success: false, errorMessage: '同一时刻最多只能有一个步骤处于 in_progress' };
|
|
74
|
+
}
|
|
75
|
+
if (!sessionId) {
|
|
76
|
+
return { success: false, errorMessage: '无当前会话,无法更新计划' };
|
|
77
|
+
}
|
|
78
|
+
if (!userId) {
|
|
79
|
+
return { success: false, errorMessage: '未登录' };
|
|
80
|
+
}
|
|
81
|
+
const session = (0, SessionManager_js_1.getSessionManager)(String(userId)).getSession(sessionId);
|
|
82
|
+
if (!session) {
|
|
83
|
+
return { success: false, errorMessage: '会话不存在' };
|
|
84
|
+
}
|
|
85
|
+
session.updatePlan(steps);
|
|
86
|
+
// 推送计划进度到 Desktop,驱动底部进度圈刷新
|
|
87
|
+
WebSocketService_js_1.webSocketService.sendToUser(String(userId), {
|
|
88
|
+
type: 'plan_updated',
|
|
89
|
+
payload: {
|
|
90
|
+
sessionId,
|
|
91
|
+
agentId: session.agentId,
|
|
92
|
+
steps: session.plan,
|
|
93
|
+
updatedAt: session.planUpdatedAt,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
const completed = steps.filter(s => s.status === 'completed').length;
|
|
97
|
+
return {
|
|
98
|
+
success: true,
|
|
99
|
+
output: JSON.stringify({
|
|
100
|
+
total: steps.length,
|
|
101
|
+
completed,
|
|
102
|
+
steps,
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
logger.error('更新计划失败:', error);
|
|
108
|
+
return { success: false, errorMessage: `更新计划失败: ${error?.message}` };
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
};
|