@myassis/gateway 1.0.88 → 1.0.90
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 +14 -0
- package/dist/services/memory/ContextBuilder.js +25 -1
- package/dist/services/memory/MemoryManager.js +78 -0
- package/dist/services/session/Session.js +105 -11
- package/dist/services/tools/edit.js +1 -1
- package/dist/services/tools/file.js +1 -1
- package/package.json +1 -1
package/dist/config/index.js
CHANGED
|
@@ -103,6 +103,20 @@ exports.appConfig = {
|
|
|
103
103
|
oldToolContentLimit: parseInt(process.env.OLD_TOOL_CONTENT_LIMIT || '500', 10),
|
|
104
104
|
/** 单次请求内最大工具调用轮数 */
|
|
105
105
|
maxToolRounds: parseInt(process.env.MAX_TOOL_ROUNDS || '200', 10),
|
|
106
|
+
/**
|
|
107
|
+
* 轮内压缩触发阈值(字符)。
|
|
108
|
+
*
|
|
109
|
+
* 会话级压缩只在轮次开始时做一次,而一轮内的工具循环可能上百次,
|
|
110
|
+
* 体积完全可能在轮内涨破预算。超过此阈值就在轮内先做摘要,
|
|
111
|
+
* 而不是等到裁剪到最重级别仍超限、把整轮请求打断。
|
|
112
|
+
*/
|
|
113
|
+
turnCompactTriggerChars: parseInt(process.env.TURN_COMPACT_TRIGGER_CHARS || '144000', 10),
|
|
114
|
+
/** 轮内压缩时保留原文的最近工具调用轮数 */
|
|
115
|
+
turnCompactKeepPairs: parseInt(process.env.TURN_COMPACT_KEEP_PAIRS || '3', 10),
|
|
116
|
+
/** 单轮请求内最多做几次轮内压缩,防止摘要模型被反复调用 */
|
|
117
|
+
maxInTurnCompactions: parseInt(process.env.MAX_IN_TURN_COMPACTIONS || '4', 10),
|
|
118
|
+
/** 模型瞬时故障(空回复 / 超时 / 网络抖动)的自动重试次数 */
|
|
119
|
+
transientModelRetries: parseInt(process.env.TRANSIENT_MODEL_RETRIES || '2', 10),
|
|
106
120
|
/** 是否启用后台预压缩:回复结束后预先生成摘要,降低下一轮首字延迟 */
|
|
107
121
|
enablePrecompression: process.env.ENABLE_PRECOMPRESSION !== 'false',
|
|
108
122
|
/** 回复结束后延迟多久启动预压缩(毫秒),留出时间给用户连续追问 */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.nextTrimLevel = exports.buildContext = exports.estimateTokens = exports.estimateChars = exports.TrimLevel = void 0;
|
|
3
|
+
exports.nextTrimLevel = exports.planTurnCompaction = 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
6
|
const ToolLedger_js_1 = require("./ToolLedger.js");
|
|
@@ -391,6 +391,30 @@ function buildContext(messages, protectFrom, maxChars = index_js_1.appConfig.con
|
|
|
391
391
|
return { messages: working, chars, level, trimmed: level > TrimLevel.None };
|
|
392
392
|
}
|
|
393
393
|
exports.buildContext = buildContext;
|
|
394
|
+
/**
|
|
395
|
+
* 规划「轮内压缩」:在一轮请求内部把最早的工具调用折叠成可摘要的区间。
|
|
396
|
+
*
|
|
397
|
+
* 为什么需要它:buildContext 的裁剪是**无损事实、有损内容**的(截断、骨架化、
|
|
398
|
+
* 账本化),一旦裁到最高级别仍超预算就只能抛 exceed max message tokens,
|
|
399
|
+
* 本轮直接失败 —— 表现为用户看到的「会话中断」。轮内压缩把已经完成的工具
|
|
400
|
+
* 调用交给摘要模型转成一段文字,从根上降低体积,让长任务能一直跑下去。
|
|
401
|
+
*
|
|
402
|
+
* @param messages 请求态消息数组
|
|
403
|
+
* @param protectFrom 该下标之前是历史/摘要区,不参与折叠
|
|
404
|
+
* @param keepPairs 末尾保留原文的工具调用轮数
|
|
405
|
+
* @returns null 表示可折叠的量太少,不值得调用摘要模型
|
|
406
|
+
*/
|
|
407
|
+
function planTurnCompaction(messages, protectFrom, keepPairs) {
|
|
408
|
+
const pairs = collectToolPairs(messages, protectFrom);
|
|
409
|
+
const foldCount = pairs.length - Math.max(1, keepPairs);
|
|
410
|
+
// 至少要折叠 2 轮才有意义:折 1 轮省下的体积通常抵不上一次摘要调用
|
|
411
|
+
if (foldCount < 2)
|
|
412
|
+
return null;
|
|
413
|
+
const start = pairs[0].start;
|
|
414
|
+
const end = pairs[foldCount - 1].end;
|
|
415
|
+
return { start, end, foldable: messages.slice(start, end) };
|
|
416
|
+
}
|
|
417
|
+
exports.planTurnCompaction = planTurnCompaction;
|
|
394
418
|
/** 依据当前级别推导下一个更重的裁剪级别,用于超限重试 */
|
|
395
419
|
function nextTrimLevel(level) {
|
|
396
420
|
if (level < TrimLevel.Aggressive)
|
|
@@ -44,6 +44,26 @@ const DEFAULT_CONFIG = {
|
|
|
44
44
|
* 前台请求与后台预压缩共用这张表,确保同一会话同一时刻只有一次摘要生成:
|
|
45
45
|
* 否则两边会各自调用一次摘要模型(浪费配额),并竞争写入 lastMessageSummary。
|
|
46
46
|
*/
|
|
47
|
+
/**
|
|
48
|
+
* 轮内压缩提示词头部。
|
|
49
|
+
*
|
|
50
|
+
* 与会话摘要提示词刻意不同:这里压缩的是**未完成任务的中间过程**,
|
|
51
|
+
* 目标是「让同一个模型能接着干」,所以要求输出交接说明而非概述 ——
|
|
52
|
+
* 概述会丢掉文件路径、失败尝试这些防止重复劳动的关键事实。
|
|
53
|
+
*/
|
|
54
|
+
const TURN_COMPACT_PROMPT_HEAD = `你正在压缩一个**正在进行中**的任务的中间过程,供同一个模型继续接着往下做。
|
|
55
|
+
输出会替换掉这些原始记录,因此必须是「可据此继续工作」的交接说明,而不是概述。
|
|
56
|
+
|
|
57
|
+
必须保留:
|
|
58
|
+
1. 已确认的事实与结论(文件路径、函数名、配置项、报错原文关键片段、数据结论)
|
|
59
|
+
2. 已经做过的修改(改了哪个文件的哪一处,改成了什么)
|
|
60
|
+
3. 已经排除的可能性与失败的尝试(标注 ⚠️,避免重复走弯路)
|
|
61
|
+
4. 尚未完成的下一步
|
|
62
|
+
|
|
63
|
+
不要写客套话,不要建议用户做什么,直接输出要点。
|
|
64
|
+
|
|
65
|
+
=== 中间过程 ===
|
|
66
|
+
`;
|
|
47
67
|
const compressionTasks = new Map();
|
|
48
68
|
/** 后台预压缩的中断控制器,用于会话删除时取消 */
|
|
49
69
|
const backgroundAborts = new Map();
|
|
@@ -255,6 +275,64 @@ class MemoryManager {
|
|
|
255
275
|
...plan.retained,
|
|
256
276
|
];
|
|
257
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* 轮内压缩:把一轮请求内已完成的工具调用摘要成一条 assistant 消息。
|
|
280
|
+
*
|
|
281
|
+
* 与会话级压缩的区别:
|
|
282
|
+
* - 会话级压缩只在轮次开始时执行,输入是持久化的会话消息;
|
|
283
|
+
* - 轮内压缩发生在工具循环**进行中**,输入是尚未落库的请求态消息
|
|
284
|
+
* (assistant(tool_calls) + tool 结果),因此不能改写会话摘要边界,
|
|
285
|
+
* 只返回文本由调用方就地替换消息区间。
|
|
286
|
+
*
|
|
287
|
+
* 摘要失败返回 null,调用方回退到分级裁剪,绝不因此中断本轮。
|
|
288
|
+
*/
|
|
289
|
+
async compactTurnSegmentAsync(segment) {
|
|
290
|
+
if (!segment || segment.length === 0)
|
|
291
|
+
return null;
|
|
292
|
+
if (!this.childAgent) {
|
|
293
|
+
this.emitSSE({ type: 'context_compressing' });
|
|
294
|
+
}
|
|
295
|
+
const ledger = (0, ToolLedger_js_1.renderRequestLedger)(segment);
|
|
296
|
+
const transcript = segment.map((m) => {
|
|
297
|
+
if (m.role === 'tool') {
|
|
298
|
+
const body = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
|
|
299
|
+
const name = m.tool_call_name || m.tool_call_id || '?';
|
|
300
|
+
return '工具结果(' + name + '): ' + (body ? body.slice(0, 4000) : '');
|
|
301
|
+
}
|
|
302
|
+
const calls = Array.isArray(m.tool_calls)
|
|
303
|
+
? m.tool_calls
|
|
304
|
+
.map((tc) => {
|
|
305
|
+
const name = tc?.function?.name || tc?.toolName || '?';
|
|
306
|
+
const args = String(tc?.function?.arguments || tc?.input || '').slice(0, 800);
|
|
307
|
+
return name + '(' + args + ')';
|
|
308
|
+
})
|
|
309
|
+
.join('; ')
|
|
310
|
+
: '';
|
|
311
|
+
const text = typeof m.content === 'string' ? m.content : '';
|
|
312
|
+
return '助手: ' + text + (calls ? '\n[调用: ' + calls + ']' : '');
|
|
313
|
+
}).join('\n\n').slice(0, 60000);
|
|
314
|
+
const prompt = TURN_COMPACT_PROMPT_HEAD + transcript;
|
|
315
|
+
try {
|
|
316
|
+
const models = await this.getSummaryModels();
|
|
317
|
+
const llmClient = new LLMClient_js_1.LLMClient(models, [{ role: 'user', content: prompt }], this.signal, [], 600000);
|
|
318
|
+
if (this.session.useSystemMode) {
|
|
319
|
+
const { getRequestToken, getServerBaseUrl } = await Promise.resolve().then(() => __importStar(require('../../api/index.js')));
|
|
320
|
+
llmClient.setSystemMode(true, this.session.selectModelId, getServerBaseUrl(), getRequestToken());
|
|
321
|
+
}
|
|
322
|
+
const response = await llmClient.Chat();
|
|
323
|
+
const content = (response.content || '').trim();
|
|
324
|
+
if (!content) {
|
|
325
|
+
logger.warn('轮内压缩摘要为空,回退到分级裁剪');
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
return '【中间过程已压缩】以下是本轮此前工作的交接说明:\n' + content
|
|
329
|
+
+ (ledger ? '\n\n' + ledger : '');
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
logger.warn('轮内压缩失败,回退到分级裁剪: ' + (error?.message || error));
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
258
336
|
/**
|
|
259
337
|
* 是否值得后台预压缩:仅当确实需要生成新摘要时才启动。
|
|
260
338
|
*/
|
|
@@ -840,6 +840,8 @@ class Session {
|
|
|
840
840
|
: (await dataService_js_1.modelsService.list(token)).data.map(x => (0, models_js_1.toModel)(x));
|
|
841
841
|
// 工具调用轮次计数,防止无限循环
|
|
842
842
|
let toolRound = 0;
|
|
843
|
+
// 达到工具轮上限后置位:强制模型停止调用工具、直接产出最终回复
|
|
844
|
+
let forceFinalize = false;
|
|
843
845
|
// 计划模式:距上次调用 updatePlan 已经过的工具轮次,用于在中间过程强制提醒模型同步进度
|
|
844
846
|
let roundsSincePlanUpdate = 0;
|
|
845
847
|
// 计划模式:收尾时提醒模型补完计划的次数,避免反复提醒导致死循环
|
|
@@ -852,19 +854,82 @@ class Session {
|
|
|
852
854
|
return text.includes('exceed max message tokens')
|
|
853
855
|
|| text.includes('context_length_exceeded');
|
|
854
856
|
};
|
|
857
|
+
/**
|
|
858
|
+
* 判断错误是否为「瞬时故障」:重试同一请求就可能成功。
|
|
859
|
+
*
|
|
860
|
+
* 模型返回空内容、网关超时、连接被重置都属于这类。它们过去会直接
|
|
861
|
+
* 冒泡成 SSE error,让界面留下一个空气泡等用户手动点「继续」;
|
|
862
|
+
* 而这类错误恰恰是最适合自动重试的。
|
|
863
|
+
*/
|
|
864
|
+
const isTransientFailure = (error) => {
|
|
865
|
+
const text = String(error?.message || error);
|
|
866
|
+
if (text === 'aborted')
|
|
867
|
+
return false;
|
|
868
|
+
return text.includes('model return empty content')
|
|
869
|
+
|| text.includes('Request timed out')
|
|
870
|
+
|| text.includes('fetch failed')
|
|
871
|
+
|| text.includes('ECONNRESET')
|
|
872
|
+
|| text.includes('socket hang up')
|
|
873
|
+
|| text.includes('All models are unavailable');
|
|
874
|
+
};
|
|
875
|
+
/** 本轮已执行的轮内压缩次数,受 maxInTurnCompactions 限制 */
|
|
876
|
+
let inTurnCompactions = 0;
|
|
877
|
+
/**
|
|
878
|
+
* 轮内压缩:把本轮已完成的旧工具调用换成一段摘要文本。
|
|
879
|
+
*
|
|
880
|
+
* 直接原地改写 messages(请求态数组),使后续所有轮次都受益。
|
|
881
|
+
* 不触碰 this.messages / 会话摘要边界:那是会话级压缩的职责,
|
|
882
|
+
* 这里只影响「发给模型的内容」,界面上的工具调用记录完整保留。
|
|
883
|
+
*
|
|
884
|
+
* @returns 是否真的压缩成功
|
|
885
|
+
*/
|
|
886
|
+
const compactInTurn = async () => {
|
|
887
|
+
if (inTurnCompactions >= index_js_2.appConfig.maxInTurnCompactions)
|
|
888
|
+
return false;
|
|
889
|
+
const plan = (0, ContextBuilder_js_1.planTurnCompaction)(messages, messagesLength, index_js_2.appConfig.turnCompactKeepPairs);
|
|
890
|
+
if (!plan)
|
|
891
|
+
return false;
|
|
892
|
+
const summary = await memoryManager.compactTurnSegmentAsync(plan.foldable);
|
|
893
|
+
if (!summary)
|
|
894
|
+
return false;
|
|
895
|
+
inTurnCompactions++;
|
|
896
|
+
// 用一条 assistant 消息替换整个区间:不能用 tool 角色(缺少配对的
|
|
897
|
+
// tool_calls 会被严格校验的厂商判为非法请求),也不能只删不补
|
|
898
|
+
// (模型会忘记自己做过什么而重复调用工具)。
|
|
899
|
+
messages.splice(plan.start, plan.end - plan.start, {
|
|
900
|
+
role: 'assistant',
|
|
901
|
+
content: summary,
|
|
902
|
+
attachments: [],
|
|
903
|
+
});
|
|
904
|
+
logger.info(`轮内压缩完成: 折叠 ${plan.end - plan.start} 条消息,第 ${inTurnCompactions} 次`);
|
|
905
|
+
// 压缩后体积已降下来,裁剪级别回落到最轻,让模型重新看到完整的近期输出
|
|
906
|
+
trimLevel = ContextBuilder_js_1.TrimLevel.OldToolPayload;
|
|
907
|
+
return true;
|
|
908
|
+
};
|
|
855
909
|
/**
|
|
856
910
|
* 调用模型:先按预算裁剪上下文,超限时逐级加重裁剪后重试。
|
|
857
911
|
*
|
|
912
|
+
* 三层自愈,逐级升级,任一层成功就继续本轮,不把问题抛给用户:
|
|
913
|
+
* 1. 预防:估算体积超过 turnCompactTriggerChars 就先做轮内压缩;
|
|
914
|
+
* 2. 超限:先尝试轮内压缩(有损内容、保留事实),再退化为分级裁剪;
|
|
915
|
+
* 3. 瞬时故障:空回复 / 超时 / 网络抖动按 transientModelRetries 自动重试。
|
|
916
|
+
*
|
|
858
917
|
* 裁剪只作用于投影副本,不会修改 messages / this.messages 中的原始数据。
|
|
859
918
|
*/
|
|
860
919
|
const callModel = async () => {
|
|
861
|
-
let
|
|
920
|
+
let transientRetries = 0;
|
|
862
921
|
while (true) {
|
|
922
|
+
// 预防式压缩:等到厂商报超限才处理已经太晚(那意味着一次废掉的请求,
|
|
923
|
+
// 且部分厂商在超限时返回的是难以识别的 400),这里提前把体积降下来。
|
|
924
|
+
if ((0, ContextBuilder_js_1.estimateChars)(messages) > index_js_2.appConfig.turnCompactTriggerChars) {
|
|
925
|
+
await compactInTurn();
|
|
926
|
+
}
|
|
863
927
|
const built = (0, ContextBuilder_js_1.buildContext)(messages, messagesLength, index_js_2.appConfig.contextMaxChars, trimLevel);
|
|
864
928
|
if (built.trimmed) {
|
|
865
929
|
logger.debug(`上下文裁剪: level=${built.level} chars=${built.chars}`);
|
|
866
930
|
}
|
|
867
|
-
|
|
931
|
+
// 收尾阶段不再下发工具定义:只要工具还在,模型大概率继续调用而不收尾
|
|
932
|
+
const llmClient = new LLMClient_js_1.LLMClient(models, built.messages, this.abortController.signal, forceFinalize ? [] : tools);
|
|
868
933
|
llmClient.setPreferredModel(this.selectModelId);
|
|
869
934
|
// 系统模式:转发到 Server LLM 代理
|
|
870
935
|
if (this.useSystemMode) {
|
|
@@ -875,14 +940,27 @@ class Session {
|
|
|
875
940
|
return await llmClient.Chat();
|
|
876
941
|
}
|
|
877
942
|
catch (error) {
|
|
878
|
-
|
|
879
|
-
if (!isContextOverflow(error))
|
|
943
|
+
if (this.abortController?.signal?.aborted)
|
|
880
944
|
throw error;
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
945
|
+
if (isContextOverflow(error)) {
|
|
946
|
+
// 先试无损度更高的轮内压缩,它能真正减少体积而不是继续截断
|
|
947
|
+
if (await compactInTurn())
|
|
948
|
+
continue;
|
|
949
|
+
const next = (0, ContextBuilder_js_1.nextTrimLevel)(trimLevel);
|
|
950
|
+
if (next === null)
|
|
951
|
+
throw error;
|
|
952
|
+
logger.warn(`上下文超限,加重裁剪重试: ${trimLevel} -> ${next}`);
|
|
953
|
+
trimLevel = next;
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
if (isTransientFailure(error) && transientRetries < index_js_2.appConfig.transientModelRetries) {
|
|
957
|
+
transientRetries++;
|
|
958
|
+
logger.warn(`模型瞬时故障,自动重试 ${transientRetries}/${index_js_2.appConfig.transientModelRetries}: ${error?.message}`);
|
|
959
|
+
// 退避一下再重试:紧接着重发大概率撞上同一个瞬时故障
|
|
960
|
+
await sleep(800 * transientRetries);
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
throw error;
|
|
886
964
|
}
|
|
887
965
|
}
|
|
888
966
|
};
|
|
@@ -902,8 +980,14 @@ class Session {
|
|
|
902
980
|
if (llmResult.toolCalls && llmResult.toolCalls.length > 0) {
|
|
903
981
|
toolRound++;
|
|
904
982
|
if (toolRound > index_js_2.appConfig.maxToolRounds) {
|
|
905
|
-
|
|
906
|
-
|
|
983
|
+
// 不能直接抛错:抛错会走 SSE error 分支,本轮已完成的工作只剩一个
|
|
984
|
+
// 报错气泡,用户只能手动点「继续」重来。改为置位收尾标记,让模型
|
|
985
|
+
// 基于已有结果向用户交付进展,本轮正常 complete。
|
|
986
|
+
//
|
|
987
|
+
// 提示消息不在这里追加:此刻 assistant(tool_calls) 与其 tool 结果
|
|
988
|
+
// 还没入队,插进去会破坏配对,被严格校验的厂商判为非法请求。
|
|
989
|
+
logger.warn(`工具调用轮次超过上限 ${index_js_2.appConfig.maxToolRounds},转入收尾`);
|
|
990
|
+
forceFinalize = true;
|
|
907
991
|
}
|
|
908
992
|
messages.push({
|
|
909
993
|
role: 'assistant',
|
|
@@ -1199,6 +1283,16 @@ class Session {
|
|
|
1199
1283
|
});
|
|
1200
1284
|
roundsSincePlanUpdate = 0;
|
|
1201
1285
|
}
|
|
1286
|
+
// 工具轮上限:工具结果已全部入队,此刻插入收尾指令是安全的
|
|
1287
|
+
if (forceFinalize) {
|
|
1288
|
+
messages.push({
|
|
1289
|
+
role: 'user',
|
|
1290
|
+
content: `[系统] 本轮工具调用已达上限(${index_js_2.appConfig.maxToolRounds} 轮)。`
|
|
1291
|
+
+ `请立即停止调用任何工具,基于已经获得的结果向用户汇报:已完成什么、`
|
|
1292
|
+
+ `当前结论是什么、还剩哪些未完成的步骤。`,
|
|
1293
|
+
attachments: [],
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1202
1296
|
// 继续下一轮(带上工具结果继续调用模型)
|
|
1203
1297
|
return await processModelResponse();
|
|
1204
1298
|
}
|
|
@@ -152,7 +152,7 @@ const editTool = {
|
|
|
152
152
|
},
|
|
153
153
|
handler: async (args) => {
|
|
154
154
|
try {
|
|
155
|
-
if (typeof args === 'string') {
|
|
155
|
+
if (typeof args === 'string' && args.length > 1024) {
|
|
156
156
|
return { success: false, errorMessage: `参数过大 (${(args.length / 1024).toFixed(1)}KB),大文件请分批进行edit 操作` };
|
|
157
157
|
}
|
|
158
158
|
const { path: filePath, edits } = args;
|
|
@@ -138,7 +138,7 @@ exports.fileTool = {
|
|
|
138
138
|
try {
|
|
139
139
|
// 如果 args 是原始 JSON 字符串(上游 parseAruments 解析失败时原样返回),
|
|
140
140
|
// 先做轻量级检查:大文件写入时字符串极长,直接拦截避免 JSON.parse 爆内存
|
|
141
|
-
if (typeof args === 'string') {
|
|
141
|
+
if (typeof args === 'string' && args.length > 1024) {
|
|
142
142
|
return { success: false, errorMessage: `参数过大 (${(args.length / 1024).toFixed(1)}KB),大文件写入请分批进行或使用多次 edit 操作` };
|
|
143
143
|
}
|
|
144
144
|
const { action, path: filePath, content, searchQuery, maxResults = 50, excludeDirs, caseSensitive = false, wholeWord = false } = args;
|