@zooique/memora 3.0.0 → 4.0.0
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/CHANGELOG.md +721 -6
- package/README.en.md +6 -6
- package/README.md +303 -303
- package/config.example.json +1 -8
- package/dist/agent/agent.d.ts +48 -4
- package/dist/agent/agent.d.ts.map +1 -1
- package/dist/agent/agent.js +130 -12
- package/dist/agent/agent.js.map +1 -1
- package/dist/agent/assembler.d.ts +19 -5
- package/dist/agent/assembler.d.ts.map +1 -1
- package/dist/agent/assembler.js +79 -16
- package/dist/agent/assembler.js.map +1 -1
- package/dist/agent/backgroundTasks.d.ts +187 -0
- package/dist/agent/backgroundTasks.d.ts.map +1 -0
- package/dist/agent/backgroundTasks.js +307 -0
- package/dist/agent/backgroundTasks.js.map +1 -0
- package/dist/agent/builtinToolHandlers.d.ts +2 -1
- package/dist/agent/builtinToolHandlers.d.ts.map +1 -1
- package/dist/agent/builtinToolHandlers.js +8 -4
- package/dist/agent/builtinToolHandlers.js.map +1 -1
- package/dist/agent/builtinTools.d.ts +15 -1
- package/dist/agent/builtinTools.d.ts.map +1 -1
- package/dist/agent/builtinTools.js +107 -27
- package/dist/agent/builtinTools.js.map +1 -1
- package/dist/agent/constants.d.ts +31 -0
- package/dist/agent/constants.d.ts.map +1 -1
- package/dist/agent/constants.js +31 -0
- package/dist/agent/constants.js.map +1 -1
- package/dist/agent/guardRail.d.ts +11 -0
- package/dist/agent/guardRail.d.ts.map +1 -1
- package/dist/agent/guardRail.js +36 -1
- package/dist/agent/guardRail.js.map +1 -1
- package/dist/agent/loop.d.ts +276 -24
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +593 -93
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/managers/archiveCoordinator.d.ts +5 -2
- package/dist/agent/managers/archiveCoordinator.d.ts.map +1 -1
- package/dist/agent/managers/archiveCoordinator.js +17 -2
- package/dist/agent/managers/archiveCoordinator.js.map +1 -1
- package/dist/agent/managers/loopMetrics.d.ts +14 -0
- package/dist/agent/managers/loopMetrics.d.ts.map +1 -1
- package/dist/agent/managers/loopMetrics.js +15 -0
- package/dist/agent/managers/loopMetrics.js.map +1 -1
- package/dist/agent/managers/memoryInspector.d.ts +1 -8
- package/dist/agent/managers/memoryInspector.d.ts.map +1 -1
- package/dist/agent/managers/memoryInspector.js +6 -30
- package/dist/agent/managers/memoryInspector.js.map +1 -1
- package/dist/agent/managers/roundSummaryGenerator.d.ts +28 -0
- package/dist/agent/managers/roundSummaryGenerator.d.ts.map +1 -1
- package/dist/agent/managers/roundSummaryGenerator.js +67 -2
- package/dist/agent/managers/roundSummaryGenerator.js.map +1 -1
- package/dist/agent/managers/sessionArchiver.d.ts +21 -3
- package/dist/agent/managers/sessionArchiver.d.ts.map +1 -1
- package/dist/agent/managers/sessionArchiver.js +20 -3
- package/dist/agent/managers/sessionArchiver.js.map +1 -1
- package/dist/agent/managers/sessionManager.d.ts.map +1 -1
- package/dist/agent/managers/sessionManager.js +17 -0
- package/dist/agent/managers/sessionManager.js.map +1 -1
- package/dist/agent/managers/toolCallHelpers.d.ts +109 -28
- package/dist/agent/managers/toolCallHelpers.d.ts.map +1 -1
- package/dist/agent/managers/toolCallHelpers.js +93 -37
- package/dist/agent/managers/toolCallHelpers.js.map +1 -1
- package/dist/agent/messageHistory.d.ts.map +1 -1
- package/dist/agent/messageHistory.js +9 -5
- package/dist/agent/messageHistory.js.map +1 -1
- package/dist/agent/seed/orchestrator.d.ts +12 -1
- package/dist/agent/seed/orchestrator.d.ts.map +1 -1
- package/dist/agent/seed/orchestrator.js +12 -1
- package/dist/agent/seed/orchestrator.js.map +1 -1
- package/dist/agent/textSanitize.d.ts +36 -0
- package/dist/agent/textSanitize.d.ts.map +1 -0
- package/dist/agent/textSanitize.js +55 -0
- package/dist/agent/textSanitize.js.map +1 -0
- package/dist/agent/toolExecutor.d.ts +48 -22
- package/dist/agent/toolExecutor.d.ts.map +1 -1
- package/dist/agent/toolExecutor.js +372 -71
- package/dist/agent/toolExecutor.js.map +1 -1
- package/dist/agent/toolLedger.d.ts +93 -41
- package/dist/agent/toolLedger.d.ts.map +1 -1
- package/dist/agent/toolLedger.js +114 -42
- package/dist/agent/toolLedger.js.map +1 -1
- package/dist/agent/toolResultOffload.d.ts +8 -0
- package/dist/agent/toolResultOffload.d.ts.map +1 -1
- package/dist/agent/toolResultOffload.js +10 -3
- package/dist/agent/toolResultOffload.js.map +1 -1
- package/dist/agent/toolRunner.d.ts +14 -7
- package/dist/agent/toolRunner.d.ts.map +1 -1
- package/dist/agent/toolRunner.js +71 -29
- package/dist/agent/toolRunner.js.map +1 -1
- package/dist/agent/tracer.d.ts +13 -1
- package/dist/agent/tracer.d.ts.map +1 -1
- package/dist/agent/types.d.ts +67 -6
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/code-exec/types.d.ts +9 -0
- package/dist/code-exec/types.d.ts.map +1 -1
- package/dist/index.d.ts +21 -23
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +36 -28
- package/dist/index.js.map +1 -1
- package/dist/memory/roundStore.d.ts +67 -81
- package/dist/memory/roundStore.d.ts.map +1 -1
- package/dist/memory/roundStore.js +4 -15
- package/dist/memory/roundStore.js.map +1 -1
- package/dist/memory/types.d.ts +7 -0
- package/dist/memory/types.d.ts.map +1 -1
- package/dist/memory/types.js +4 -0
- package/dist/memory/types.js.map +1 -1
- package/dist/role-pack/rolePackManager.d.ts +42 -10
- package/dist/role-pack/rolePackManager.d.ts.map +1 -1
- package/dist/role-pack/rolePackManager.js +65 -12
- package/dist/role-pack/rolePackManager.js.map +1 -1
- package/dist/role-pack/strategyKeys.d.ts +36 -0
- package/dist/role-pack/strategyKeys.d.ts.map +1 -1
- package/dist/role-pack/strategyKeys.js +45 -0
- package/dist/role-pack/strategyKeys.js.map +1 -1
- package/dist/security/confirmEntries.d.ts +44 -0
- package/dist/security/confirmEntries.d.ts.map +1 -0
- package/dist/security/confirmEntries.js +59 -0
- package/dist/security/confirmEntries.js.map +1 -0
- package/dist/security/pathGuard.d.ts +41 -5
- package/dist/security/pathGuard.d.ts.map +1 -1
- package/dist/security/pathGuard.js +125 -4
- package/dist/security/pathGuard.js.map +1 -1
- package/dist/skill/skillScriptRunner.d.ts +147 -15
- package/dist/skill/skillScriptRunner.d.ts.map +1 -1
- package/dist/skill/skillScriptRunner.js +479 -68
- package/dist/skill/skillScriptRunner.js.map +1 -1
- package/dist/utils/configResourceManager.d.ts +6 -2
- package/dist/utils/configResourceManager.d.ts.map +1 -1
- package/dist/utils/configResourceManager.js +8 -2
- package/dist/utils/configResourceManager.js.map +1 -1
- package/dist/utils/errors.d.ts +9 -0
- package/dist/utils/errors.d.ts.map +1 -1
- package/dist/utils/errors.js +9 -0
- package/dist/utils/errors.js.map +1 -1
- package/dist/utils/eventEmitter.d.ts +57 -2
- package/dist/utils/eventEmitter.d.ts.map +1 -1
- package/dist/utils/eventEmitter.js +23 -0
- package/dist/utils/eventEmitter.js.map +1 -1
- package/dist/utils/time.d.ts +5 -2
- package/dist/utils/time.d.ts.map +1 -1
- package/dist/utils/time.js +5 -2
- package/dist/utils/time.js.map +1 -1
- package/docs/memora-api-reference.md +121 -20
- package/docs/memora-/346/216/245/345/205/245/346/214/207/345/215/227.md +6 -5
- package/package.json +4 -4
- package/role-packs//345/205/261/351/270/243/345/260/217/350/257/264/345/256/266/persona.md +46 -46
- package/role-packs//345/205/261/351/270/243/345/260/217/350/257/264/345/256/266/rules.md +16 -15
- package/role-packs//345/205/261/351/270/243/345/260/217/350/257/264/345/256/266/skills/emotion-wave.md +46 -46
- package/role-packs//345/205/261/351/270/243/345/260/217/350/257/264/345/256/266/skills/foreshadow.md +29 -29
- package/role-packs//345/205/261/351/270/243/345/260/217/350/257/264/345/256/266/skills/outline-expand.md +48 -46
- package/role-packs//345/205/261/351/270/243/345/260/217/350/257/264/345/256/266/skills/project-init/scripts/init-novel-project.mjs +441 -441
- package/role-packs//345/205/261/351/270/243/345/260/217/350/257/264/345/256/266/skills/story-bible.md +62 -60
package/dist/agent/loop.js
CHANGED
|
@@ -10,18 +10,47 @@ import { logger } from '../logging/logger.js';
|
|
|
10
10
|
import { ResultReplacementStrategy, ReplaceRoundsStrategy, DEFAULT_REPLACE_KEEP_RECENT_ROUNDS, } from '../agent/compaction.js';
|
|
11
11
|
import { offloadLargeToolResult } from '../agent/toolResultOffload.js';
|
|
12
12
|
import { deriveDialogueRounds } from '../agent/budget.js';
|
|
13
|
-
import { parseAskCalls, wrapToolResult, isRetryableToolError,
|
|
13
|
+
import { parseAskCalls, wrapToolResult, unwrapToolResultBody, isRetryableToolError,
|
|
14
|
+
// B4 判据切换:护栏/替身/compress/remember 的 outcome 就地构造用
|
|
15
|
+
blockedOutcome, okOutcome, failedOutcome, } from '../agent/managers/toolCallHelpers.js';
|
|
14
16
|
import { LlmCaller } from '../agent/managers/llmCaller.js';
|
|
15
17
|
import { LoopMetrics } from '../agent/managers/loopMetrics.js';
|
|
16
18
|
import { DefaultDuplicateCallInterceptor } from '../agent/duplicateInterceptor.js';
|
|
17
19
|
import { ToolResultCache, DEDUP_SUBJECT_EXTRACTORS, WRITE_PATH_EXTRACTORS, normalizePathKey, } from '../agent/toolResultCache.js';
|
|
18
20
|
import { createDefaultGuards, GUARD_THRESHOLDS } from '../agent/guardRail.js';
|
|
19
|
-
import { FileExposureLedger, parseReadFileCoverage, formatLedgerStub, shouldEchoLedgerStub, READ_DIGEST_CHARS, } from '../agent/toolLedger.js';
|
|
21
|
+
import { FileExposureLedger, parseReadFileCoverage, formatLedgerStub, formatLedgerStubRange, shouldEchoLedgerStub, READ_DIGEST_CHARS, READ_FILE_NOTICE_PREFIX, } from '../agent/toolLedger.js';
|
|
20
22
|
import { nowIso } from '../utils/time.js';
|
|
21
23
|
import { DEFAULT_L2_STRATEGY } from '../role-pack/strategyResolver.js';
|
|
22
24
|
import { DEFAULT_MAX_ITERATIONS } from '../role-pack/strategyKeys.js';
|
|
23
25
|
import { ToolRunner } from '../agent/toolRunner.js';
|
|
24
26
|
import { detectNeedsPlanning, PLAN_NUDGE_PROMPT } from '../agent/needsPlanning.js';
|
|
27
|
+
import { formatBackgroundTaskNotice, } from '../agent/backgroundTasks.js';
|
|
28
|
+
/** 合法 target 清单(工具描述与错误提示共用此真源,避免文案列举与判据漂移) */
|
|
29
|
+
export const COMPRESS_TARGETS = [
|
|
30
|
+
'earliest_round',
|
|
31
|
+
'earliest_steps',
|
|
32
|
+
'largest_tool_result',
|
|
33
|
+
];
|
|
34
|
+
/**
|
|
35
|
+
* target → 中文标签(宿主 notice 文案的真源,**穷举映射不做 else 兜底**)
|
|
36
|
+
*
|
|
37
|
+
* 穷举而非三元/else:三元在枚举增补时把新值静默落到最后一个旧标签上(说谎),
|
|
38
|
+
* TS 侧因 `Record<CompressTarget, string>` 穷尽键而在增补时**编译期报错**——
|
|
39
|
+
* 让「加枚举忘了改文案」变成一道红闸,而不是运行时的一句假话。
|
|
40
|
+
*/
|
|
41
|
+
export const COMPRESS_TARGET_LABELS = {
|
|
42
|
+
earliest_round: '最早轮次摘要',
|
|
43
|
+
earliest_steps: '最早执行步骤摘要',
|
|
44
|
+
largest_tool_result: '最大工具结果摘要',
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* 解析压缩目标(非法/缺失降级 `earliest_round`——保持既有行为,不扩大缺省面)
|
|
48
|
+
*
|
|
49
|
+
* 单点收口:判据只此一处,工具描述与回传提示的列举均引 `COMPRESS_TARGETS`。
|
|
50
|
+
*/
|
|
51
|
+
function parseCompressTarget(raw) {
|
|
52
|
+
return COMPRESS_TARGETS.find((t) => t === raw) ?? 'earliest_round';
|
|
53
|
+
}
|
|
25
54
|
/*
|
|
26
55
|
* 职责边界登记(暂不拆分)
|
|
27
56
|
*
|
|
@@ -56,7 +85,7 @@ export class AgentLoop {
|
|
|
56
85
|
opts;
|
|
57
86
|
messages = [];
|
|
58
87
|
maxIterations;
|
|
59
|
-
/** provider
|
|
88
|
+
/** provider 上下文窗口(原始声明值;缺省 = `AGENT_CONSTANTS.DEFAULT_MAX_CONTEXT_TOKENS`,数字不落账)。模型热切换经 setContextWindow 写入。 */
|
|
60
89
|
providerContextWindow;
|
|
61
90
|
/** **有效上下文窗口** = min(providerContextWindow, strategy.contextLimit>0 ? contextLimit : ∞)。
|
|
62
91
|
* 派生值(非独立真源):由 #recomputeEffectiveWindow 在「provider 窗口变更」与「角色策略变更」
|
|
@@ -143,10 +172,36 @@ export class AgentLoop {
|
|
|
143
172
|
this.interruptQueue.splice(idx, 1);
|
|
144
173
|
}
|
|
145
174
|
}
|
|
146
|
-
/** step 边界气口申请统一队列(pause/interject 统一入队,无独立 pauseRequested flag)。
|
|
147
|
-
*
|
|
148
|
-
*
|
|
175
|
+
/** step 边界气口申请统一队列(pause/interject/command-result 统一入队,无独立 pauseRequested flag)。
|
|
176
|
+
* 消费在 _handleInterrupt:先注入型(interject → appendUser,command-result → appendSystem),
|
|
177
|
+
* 后挂起型(pause → yield paused)。 */
|
|
149
178
|
interruptQueue = [];
|
|
179
|
+
/**
|
|
180
|
+
* 后台任务注册表(可选,装配层注入)
|
|
181
|
+
*
|
|
182
|
+
* loop 侧只做三件事,**不碰进程治理**(spawn / 杀树 / 内存护栏全在 skillScriptRunner):
|
|
183
|
+
* ① 注册完成监听 → 自然终态时把结果入队(`kind:'command-result'`),下个 step 边界注入 system 消息;
|
|
184
|
+
* ② turn 终态脱管存活进程(不杀,见 detachAll 注释),回流跨 turn 留存;
|
|
185
|
+
* ③ Agent 实例终态(close)真杀全部存活任务并注销 exit 兜底钩子(见 shutdownBackgroundTasks)——
|
|
186
|
+
* 跨 turn 但不跨宿主寿命,边界与 ② 不同;close 窗口赶不上时 exit 钩子同步兜底。
|
|
187
|
+
* 未注入时这些动作都是 no-op(无后台任务可管),不报错——纯 ToolExecutor 单测场景无注册表。
|
|
188
|
+
*/
|
|
189
|
+
backgroundTasks;
|
|
190
|
+
/** 后台任务**自然终态**时通知装配层(由 Agent 在装配期接到事件面上,loop 只发不处理 UI)。
|
|
191
|
+
* 脱管后 turn 已结束,宿主挂在 turn 生命周期上的推送时机覆盖不到「任务自然跑完」⇒ 缺此
|
|
192
|
+
* 出口 UI 会显示假活跃。挂注册表 completion listener(notify)单点链,仅 settle 自然终态
|
|
193
|
+
* 到达;主动 kill 刻意不回调 listener(防同份结果双份消费),不经此事件。 */
|
|
194
|
+
emitBackgroundTaskSettled;
|
|
195
|
+
/**
|
|
196
|
+
* 装配层在装配期注入「后台任务自然终态」发射回调(**仅装配期一次**,非公开 API)。
|
|
197
|
+
*
|
|
198
|
+
* 走 Agent 事件面(`backgroundTaskSettled`)而非独立回调字段:与既有宿主事件
|
|
199
|
+
* (sessionError / contextTruncated / …)同型,宿主 `bindAgentNoticeEvents` 的
|
|
200
|
+
* 「先 off 再 on」幂等模式天然适用。
|
|
201
|
+
*/
|
|
202
|
+
setBackgroundTaskSettledEmitter(fn) {
|
|
203
|
+
this.emitBackgroundTaskSettled = fn;
|
|
204
|
+
}
|
|
150
205
|
/** 主动提问回调(LLM 调 ask_user 工具时调用,Agent 注入,loop 只回调不处理 UI) */
|
|
151
206
|
onPendingQuestion;
|
|
152
207
|
/** 有效窗口变更回调(Agent 注入):把 loop 算出的**有效窗口**分发给 loop 之外仍持有窗口拷贝的
|
|
@@ -158,6 +213,15 @@ export class AgentLoop {
|
|
|
158
213
|
strategy = { ...DEFAULT_L2_STRATEGY };
|
|
159
214
|
/** 本 turn 是否已执行过自审查(单次终审:布尔状态,不再需要轮次计数) */
|
|
160
215
|
selfReviewDone = false;
|
|
216
|
+
/**
|
|
217
|
+
* 本 turn 是否已在 step 边界挂起(paused)——**turn 终态脱管的判据**(定案锚:ADR-036)。
|
|
218
|
+
*
|
|
219
|
+
* 置位点在 `_handleInterrupt` 的 pause 分支、**yield 之前**(宿主 break 会触发
|
|
220
|
+
* generator finally,时序敏感);复位点在 `resetTurnState`(两个 turn 入口都调)。
|
|
221
|
+
* 语义:挂起 = 同 turn 续跑,后台进程继续跑;不挂起的收场(done / interrupted /
|
|
222
|
+
* error)才是终态,对存活后台进程脱管(不杀,跨轮存活)。
|
|
223
|
+
*/
|
|
224
|
+
pauseBoundaryReached = false;
|
|
161
225
|
/** 本 turn(processUserInput)内是否实际执行过工具步。
|
|
162
226
|
* 自审查的唯一触发门槛:只有多轮 turn(发生过工具调用)才审查,
|
|
163
227
|
* 一遍过的纯文本问答不触发。由 processUserInput 入口重置(续跑 continueAfterPause 保留)。
|
|
@@ -369,7 +433,8 @@ export class AgentLoop {
|
|
|
369
433
|
}
|
|
370
434
|
// 单工具执行器:注入 loop 稳定能力窄面,strategy 经闭包读最新(setStrategy 动态生效)
|
|
371
435
|
this.toolRunner = new ToolRunner({
|
|
372
|
-
|
|
436
|
+
// 原生 outcome 回调逐跳透传:ToolRunner → opts.toolExecutor → toolExecutor.execute
|
|
437
|
+
execute: (name, args, emitOutcome) => this.opts.toolExecutor(name, args, emitOutcome),
|
|
373
438
|
builtinTools: opts.builtinTools,
|
|
374
439
|
preExecutionCheck: opts.preExecutionCheck,
|
|
375
440
|
onToolExecuted: opts.onToolExecuted,
|
|
@@ -473,6 +538,23 @@ export class AgentLoop {
|
|
|
473
538
|
throw err;
|
|
474
539
|
}
|
|
475
540
|
finally {
|
|
541
|
+
// turn 终态后台任务脱管(后台任务跨轮存活方案·脱管定案):done / interrupted / error / 达到最大迭代
|
|
542
|
+
// 四条收场路径都经过本 finally ⇒ 调用点唯一。
|
|
543
|
+
// 判据 = pauseBoundaryReached:挂起是同 turn 续跑(后台继续跑、其回流照常注入),
|
|
544
|
+
// 不算终态、不脱管。脱管 = 不杀进程(error 路径也不杀);进程寿命的终点在另一处:
|
|
545
|
+
// Agent 实例终态 close → shutdownBackgroundTasks 真杀 + exit 钩子同步兜底
|
|
546
|
+
// (观察点 ⑧ 真机定类后补,方案 §4.2)。
|
|
547
|
+
if (!this.pauseBoundaryReached) {
|
|
548
|
+
const bgReport = this.finalizeBackgroundTasksOnTurnEnd();
|
|
549
|
+
if (bgReport) {
|
|
550
|
+
// 收尾报告双通道:chunk 上屏(finally 中 yield 合法——推迟 generator 完成,
|
|
551
|
+
// 宿主照常 for-await 消费)+ system 消息进 LLM 历史(真源不因上屏形态漂移)。
|
|
552
|
+
// generator.return() 硬关闭路径下 yield 被丢弃不上屏(用户已主动停止),
|
|
553
|
+
// system 消息仍写入——报告不因上屏通道状态丢失。
|
|
554
|
+
yield { type: 'background_report', content: bgReport };
|
|
555
|
+
this.appendSystemMessage(bgReport, { executionTemp: true });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
476
558
|
const durationMs = Date.now() - taskStartAt;
|
|
477
559
|
this.metrics.taskTotalDurationMs += durationMs;
|
|
478
560
|
if (taskSucceeded) {
|
|
@@ -540,11 +622,149 @@ export class AgentLoop {
|
|
|
540
622
|
clearPauseRequest() {
|
|
541
623
|
this.cancelPauseEntry();
|
|
542
624
|
}
|
|
543
|
-
/**
|
|
544
|
-
*
|
|
545
|
-
*
|
|
625
|
+
/**
|
|
626
|
+
* 插话(申请入队):把用户补充输入作为 InterruptRequest{kind:'interject'} 入 interruptQueue。
|
|
627
|
+
* 与 requestPause(queueInterrupt pause)同为「申请 → 气口生效」——不中断当前 LLM/工具执行,
|
|
628
|
+
* 只在 step 边界统一消费(先注入型 → appendUser,后挂起型 → yield paused)。
|
|
629
|
+
*
|
|
630
|
+
* 上限裁决(内核资源不变量,值真源 = LOOP_CONSTANTS.MAX_PENDING_INTERJECTIONS):待注入插话达上限时
|
|
631
|
+
* **整条拒收**返回 false(插话是原子思路,截半条 = 语义破坏);宿主以此返回值做 UI
|
|
632
|
+
* 提示(webview 预检与其 shared 镜像常量由守卫测试与本常量锁同值)。pause 条目不受
|
|
633
|
+
* 本上限管辖(软暂停申请是内部控制语义,非用户输入洪流)。
|
|
634
|
+
*
|
|
635
|
+
* @returns true=已入队;false=待注入插话已满,本条未入队(调用方不应持久化/上屏)
|
|
636
|
+
*/
|
|
546
637
|
interject(content) {
|
|
638
|
+
if (this.getPendingInterjections().length >= LOOP_CONSTANTS.MAX_PENDING_INTERJECTIONS) {
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
547
641
|
this.interruptQueue.push({ kind: 'interject', content });
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* 后台命令完成回流入队(**生产者入口**,由装配层从注册表完成监听器调用)
|
|
646
|
+
*
|
|
647
|
+
* 走既有 `interruptQueue`(判别联合队列)新增一个 kind,零新基建:
|
|
648
|
+
* 与插话同机制(iteration 间检查点消费、挂起/恢复全链路复用),但**异质**——
|
|
649
|
+
* - 消费时进 `appendSystemMessage`(系统事件),不进 `appendUserMessage`(不伪装用户发言);
|
|
650
|
+
* - 不占插话满员计数(`interject()` 的容量裁决只数 kind==='interject');
|
|
651
|
+
* - **跨 turn 留存**(脱管定案,后台任务跨轮存活方案):turn 终态不丢弃,下个 turn 的 step 边界照常吸收——
|
|
652
|
+
* 与插话跨 turn 同标准,同性质数据不搞两套生命周期。
|
|
653
|
+
*
|
|
654
|
+
* @param content 已格式化的完成通知(含来源标记「后台命令完成」+ 命令 + 输出)
|
|
655
|
+
*/
|
|
656
|
+
enqueueCommandResult(content) {
|
|
657
|
+
this.interruptQueue.push({ kind: 'command-result', content });
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* 装配后台任务注册表(assembler 调用,loop 创建之后)
|
|
661
|
+
*
|
|
662
|
+
* 两件职责一次接线,避免装配层散写两处:
|
|
663
|
+
* ① 完成监听 → `enqueueCommandResult`(回流通道生产者);
|
|
664
|
+
* ② 持有引用供 turn 终态脱管报告。
|
|
665
|
+
*/
|
|
666
|
+
setBackgroundTasks(registry) {
|
|
667
|
+
this.backgroundTasks = registry;
|
|
668
|
+
registry.setCompletionListener((task) => {
|
|
669
|
+
const notice = formatBackgroundTaskNotice(task);
|
|
670
|
+
this.enqueueCommandResult(notice);
|
|
671
|
+
// 后台自然终局失败结算(METRICS-BGTASK-1 显式闭合 · SCRIPT-2 批次 B3):
|
|
672
|
+
// 本路径结构上不经工具结果通道——无 toolCallId、不产 tool_result,B4 判据切换
|
|
673
|
+
// 碰不到它,不在此显式施工则后台失败对 toolFailureCount 永久不可见。
|
|
674
|
+
// killed 不经本回调(注册表刻意不回调,防双份消费),只余 completed / timedOut:
|
|
675
|
+
// timedOut 或退出码非 0 = 执行事实失败,判定式与 run_command 同步路径同源
|
|
676
|
+
// (toolExecutor 的 commandFailed 同式,改一处必查另一处)。
|
|
677
|
+
// 即时结算、无 step 归属:metrics 是 loop 实例级累计(无 per-turn 重置),
|
|
678
|
+
// 脱管后终局照常落账;不产 ToolOutcome——无 toolCallId 可挂(工具结果通道
|
|
679
|
+
// 只覆盖经 runOne 的同步执行),后台失败经本点直接结算进 metrics。
|
|
680
|
+
// ⚠️ 口径变更:后台失败由不可见→计入 toolFailureCount,数字上升且历史不可比
|
|
681
|
+
// (论证:docs/方案-工具结果status字段化-20261006.md §三批次表 B3、§七)。
|
|
682
|
+
// task.result 类型可选,但自然终态必有(注册表 settle 先赋值后同步 notify),
|
|
683
|
+
// 判空即跳过、不计失败。
|
|
684
|
+
if (task.result && (task.result.timedOut || task.result.exitCode !== 0)) {
|
|
685
|
+
this.metrics.toolFailureCount++;
|
|
686
|
+
}
|
|
687
|
+
// 终态同时出宿主面(阶段二补漏 · 见方案文档 §5.2):脱管后 turn 已结束,
|
|
688
|
+
// 宿主周期性推送(step_boundary / background_report / kill 后回推)覆盖不到
|
|
689
|
+
// 「turn 结束后任务自然跑完」⇒ UI 会一直显示「运行中」。经 Agent 事件面转发
|
|
690
|
+
// (走既有 TypedEventEmitter,与 bindAgentNoticeEvents 的 off/on 幂等模式对齐),
|
|
691
|
+
// **不新增注册表 listener**。主动 kill 不经此回调(注册表刻意不回调,防双份消费)。
|
|
692
|
+
this.emitBackgroundTaskSettled?.(task);
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* 后台任务只读快照(宿主 UI 出口 · 见 `docs/方案-后台任务可见性与kill入口-20261004.md` §2.1)
|
|
697
|
+
*
|
|
698
|
+
* 纯透传注册表 `list()`——投影已剥离 `killNow` / `peek` 进程句柄,宿主无从触碰进程。
|
|
699
|
+
* 注册表未装配(后台能力未启用 / 装配期之前)返回空数组,不抛。
|
|
700
|
+
*/
|
|
701
|
+
getBackgroundTasks() {
|
|
702
|
+
return this.backgroundTasks?.list() ?? [];
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* 终止后台任务(宿主 UI 出口 · 同上 §2.1)
|
|
706
|
+
*
|
|
707
|
+
* 纯透传注册表 `kill()`:先取已捕获输出快照再走杀树原语,随后回读终态投影。
|
|
708
|
+
* 任务不存在 → `null`(`kill` 与 `get` 对未知 taskId 同为 null,无需二次判空)。
|
|
709
|
+
*
|
|
710
|
+
* ⚠️ 主动 kill 不触发回流——输出已直接返回调用方,再回流一次会让 LLM
|
|
711
|
+
* 收到同一份结果的第二份副本(重复消费)。
|
|
712
|
+
*/
|
|
713
|
+
killBackgroundTask(taskId) {
|
|
714
|
+
if (!this.backgroundTasks)
|
|
715
|
+
return null;
|
|
716
|
+
// kill 对未知 taskId 返回 null 时 get 同样为 null,无需二次判空(防冗余分支)
|
|
717
|
+
this.backgroundTasks.kill(taskId);
|
|
718
|
+
return this.backgroundTasks.get(taskId);
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Agent 实例终态收割(**唯一调用点 = `Agent.close`**):真杀全部存活后台命令任务
|
|
722
|
+
* 并注销其 exit 兜底钩子。
|
|
723
|
+
*
|
|
724
|
+
* 纯透传注册表 `shutdown()`,判据不复制。与 turn 终态 `finalizeBackgroundTasksOnTurnEnd`
|
|
725
|
+
* 的边界见注册表 `detachAll / shutdown` 注释:跨 turn 存活是刻意语义,实例销毁后
|
|
726
|
+
* 任务不可寻址,留着只能是 OS 层孤儿(观察点 ⑧ 真机实锤)。
|
|
727
|
+
*
|
|
728
|
+
* 不产 `background_report`、不推 UI 快照——宿主正在退出,界面与回流都已无消费方。
|
|
729
|
+
* 注册表未装配返回 0,不抛。
|
|
730
|
+
*
|
|
731
|
+
* @returns 实际收割的 running 任务数
|
|
732
|
+
*/
|
|
733
|
+
shutdownBackgroundTasks() {
|
|
734
|
+
return this.backgroundTasks?.shutdown() ?? 0;
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* turn 终态后台任务脱管(定案锚:ADR-036;论证见 `docs/方案-后台任务跨轮存活-20261004.md` §三)
|
|
738
|
+
*
|
|
739
|
+
* 判据 = 调用方传入的「本 turn 已收场」(挂起不算终态,见 `pauseBoundaryReached`):
|
|
740
|
+
* done / interrupted / error / 达到最大迭代 四条收场路径统一经此,调用点唯一
|
|
741
|
+
* (`_runWithSlo` 的 finally)。
|
|
742
|
+
*
|
|
743
|
+
* 此处只做脱管(detach)——把存活任务移出跟踪,**不杀进程、不丢未消费的 command-result**。
|
|
744
|
+
*
|
|
745
|
+
* 为什么不杀:turn 是「一问一答」的**归档单元**,不是进程的容器。进程属于**会话**,
|
|
746
|
+
* 活过 turn 是应有语义(Claude Code 的后台任务同样活到进程退出)。真机实锤过一个反例:
|
|
747
|
+
* 用户起 `ping -n 90`,turn 仅 6.3 秒结束就把进程杀了,UI 底部条从「运行中」变「已终止」
|
|
748
|
+
* 而用户从未点过——「后台」名不副实,用户视角完全不可理解。
|
|
749
|
+
*
|
|
750
|
+
* 为什么不丢 command-result:回流与插话性质相同(外部产生的事实、显式一条消息、分别经
|
|
751
|
+
* appendSystem / appendUser),只封杀 `command-result` 而放行 `interject` ⇒ 同一现象
|
|
752
|
+
* 两套标准 = 违反 SSOT;且 turn 内跑的 command-result 走的就是同一条通路(`_handleInterrupt`
|
|
753
|
+
* → `appendSystem`),**机制在 turn 边界突然失效是一致性缺口,不是保护**。
|
|
754
|
+
*
|
|
755
|
+
* 跨 turn 留存的边界(如实写):`interruptQueue` 挂在 Agent 级单例 `loop` 上
|
|
756
|
+
* (Agent 关闭即销毁);`MAX_PENDING_COMMAND_RESULTS=3` 只限**每步注入条数**(防一次性
|
|
757
|
+
* 灌爆上下文),**不限队列总量**——队列条数与注册表终态条目目前都无 GC,是已登记的
|
|
758
|
+
* 设计缺口(真机出现症状再立项,不在本次提前加机制)。
|
|
759
|
+
*
|
|
760
|
+
* @returns 脱管报告文本(null = 无后台任务,纯问答 turn 的常态——不产 chunk 不写 system 消息)
|
|
761
|
+
*/
|
|
762
|
+
finalizeBackgroundTasksOnTurnEnd() {
|
|
763
|
+
const detached = this.backgroundTasks?.detachAll() ?? [];
|
|
764
|
+
if (detached.length === 0)
|
|
765
|
+
return null;
|
|
766
|
+
const detail = detached.map((t) => `- ${t.taskId}(仍在运行):${t.command}`).join('\n');
|
|
767
|
+
return `[本轮结束 · 后台任务继续运行] 本轮结束时有 ${detached.length} 个后台命令在运行,已转为后台常驻(本轮不终止;正常退出 VS Code 时随扩展终止,崩溃/被强杀除外)。结果不会自动进入上下文:你可以在后续任意一轮用 kill_command 传入该 taskId 取回截至当时的输出。\n${detail}`;
|
|
548
768
|
}
|
|
549
769
|
/** 删除待注入的插话(宿主 UI 层用户后悔)。与 interject 对称,在 step 边界消费前可安全删除。
|
|
550
770
|
* 委托 interruptQueue 中 interject 条目的索引。
|
|
@@ -629,6 +849,8 @@ export class AgentLoop {
|
|
|
629
849
|
// 但 processUserInput 在 resetTurnState 之后会重判设值(续跑入口不复用因此不重判)
|
|
630
850
|
this.planNeedsNudge = false;
|
|
631
851
|
this.planNudgeInjected = false;
|
|
852
|
+
// 挂起标记复位:续跑入口(continueAfterPause)也走本方法,复位后本段跑到底即终态
|
|
853
|
+
this.pauseBoundaryReached = false;
|
|
632
854
|
}
|
|
633
855
|
/** 重置 askLimit 计数(turn 入口):仅 processUserInput 调用,continueAfterPause 不动,
|
|
634
856
|
* 保证暂停-续跑同属一次用户输入、提问次数跨续跑累计(askLimit 语义:按输入打扰防刷)。 */
|
|
@@ -694,9 +916,19 @@ export class AgentLoop {
|
|
|
694
916
|
// LLM 返回 done(纯文本完成)期间用户 interject() 入队的补充输入,若直接 return false 会被静默丢弃;
|
|
695
917
|
// 消费并继续迭代,下一轮 LLM 必看到插话内容。委托 _consumeInterjects(SSOT)。
|
|
696
918
|
// 主要消费发生在 _handleInterrupt(迭代开始前),此处覆盖「LLM 在收尾轮执行期间插话」的窗口。
|
|
919
|
+
//
|
|
920
|
+
// ⚠️ 后台回流(command-result)**同样**在此消费,理由与插话同源但多一层:
|
|
921
|
+
// 收尾轮期间完成的后台命令若随 turn 结束被丢弃,agent 就永远拿不到自己起的任务的输出
|
|
922
|
+
// ——LLM 会带着「任务大概在跑」的**错误信念交付答案**(进程虽已脱管继续跑,但结果永远不回流)。
|
|
923
|
+
// 故有后台结果就再跑一轮,让它看到。
|
|
697
924
|
const consumedCount = this._consumeInterjects(this.interruptQueue);
|
|
925
|
+
const consumedResults = this._consumeCommandResults(this.interruptQueue);
|
|
926
|
+
// ⚠️ 只清 interject:**不清 command-result** —— 限量注入(MAX_PENDING_COMMAND_RESULTS)
|
|
927
|
+
// 意味着本轮可能只投了前 N 条,剩下的必须**留队**等下个 step 边界续投;这里一并清掉
|
|
928
|
+
// 就等于「限量」退化成「截半丢弃」。turn 终态**也不再清**(脱管定案,见
|
|
929
|
+
// `finalizeBackgroundTasksOnTurnEnd`)——跨 turn 留存是刻意语义,不是遗漏。
|
|
698
930
|
this.interruptQueue = this.interruptQueue.filter((r) => r.kind !== 'interject');
|
|
699
|
-
return consumedCount > 0;
|
|
931
|
+
return consumedCount > 0 || consumedResults > 0;
|
|
700
932
|
}
|
|
701
933
|
/**
|
|
702
934
|
* 自审查注入判定(emit selfReview 通知与注入 SELF_REVIEW 提示共用单一真理源)。
|
|
@@ -760,11 +992,46 @@ export class AgentLoop {
|
|
|
760
992
|
}
|
|
761
993
|
return count;
|
|
762
994
|
}
|
|
763
|
-
/**
|
|
995
|
+
/**
|
|
996
|
+
* 消费注入型**系统事件**条目(command-result → appendSystemMessage)
|
|
997
|
+
*
|
|
998
|
+
* 与 `_consumeInterjects` 严格分开而非合并成「消费所有注入型」:
|
|
999
|
+
* 两者的注入通道(user / system)、role 语义、容量裁决(插话占满员名额 / 回流不占)不同;
|
|
1000
|
+
* **生命周期 = 跨 turn 留存**(turn 终态不丢弃,下个 turn 的 step 边界照常吸收,见 finalize 注释)。
|
|
1001
|
+
* 即便如此仍须分开:通道与容量判据各异,合并成一个泛化遍历等于把判据摊平到 if 分支里
|
|
1002
|
+
* ——将来任一判据变化都要在同一段代码里翻找,正是「同一语义两套判定」的开始。
|
|
1003
|
+
* 故:两个方法,各守一条通道。
|
|
1004
|
+
*
|
|
1005
|
+
* **限量注入**(`MAX_PENDING_COMMAND_RESULTS`):回流是已发生的事实、不可拒收,
|
|
1006
|
+
* 只能限量。每步只注入 N 条,其余留队列等下个 step 边界——避免 N 个后台任务同时完成时
|
|
1007
|
+
* 一次性灌爆上下文。
|
|
1008
|
+
*
|
|
1009
|
+
* `executionTemp: true` —— 回流是**本轮执行期事件**(进程已结束、结果已交付),
|
|
1010
|
+
* 下一轮闭环入口由 `cleanExecutionTemporary` 清掉,不进历史上下文。
|
|
1011
|
+
*
|
|
1012
|
+
* @param reqs 待消费的申请列表(只读遍历,不修改)
|
|
1013
|
+
* @returns 消费的条目数
|
|
1014
|
+
*/
|
|
1015
|
+
_consumeCommandResults(reqs) {
|
|
1016
|
+
const cap = LOOP_CONSTANTS.MAX_PENDING_COMMAND_RESULTS;
|
|
1017
|
+
let count = 0;
|
|
1018
|
+
for (const req of reqs) {
|
|
1019
|
+
if (req.kind !== 'command-result')
|
|
1020
|
+
continue;
|
|
1021
|
+
if (count >= cap)
|
|
1022
|
+
break; // 超量留队,下个 step 边界续投
|
|
1023
|
+
this.appendSystemMessage(req.content, { executionTemp: true });
|
|
1024
|
+
count++;
|
|
1025
|
+
}
|
|
1026
|
+
return count;
|
|
1027
|
+
}
|
|
1028
|
+
/** 中断检查:step 边界统一消费 interruptQueue(pause + interject + command-result)+ 硬中止检查。
|
|
764
1029
|
*
|
|
765
1030
|
* 单一气口出口——气口申请统一收在 interruptQueue(无独立 pause flag / 插话数组写位),
|
|
766
|
-
* 此处统一 queue.splice(0) 取出全部申请,按 kind
|
|
767
|
-
* -
|
|
1031
|
+
* 此处统一 queue.splice(0) 取出全部申请,按 kind 分三类处理:
|
|
1032
|
+
* - 注入型·用户意图(interject):appendUserMessage,不暂停 loop,让补充输入立刻进入下一轮 step
|
|
1033
|
+
* - 注入型·系统事件(command-result):appendSystemMessage(executionTemp),
|
|
1034
|
+
* 不暂停 loop;与 interject 的差别只在注入通道与 role 语义(系统事件 ≠ 用户发言)
|
|
768
1035
|
* - 挂起型(pause):yield {type:'paused'} + return 'paused',generator 在 step 边界挂起
|
|
769
1036
|
*
|
|
770
1037
|
* 顺序:先消费注入型 → 再检查挂起型 → 最后硬中止。注入型优先是为了让补充输入在 pause 生效前
|
|
@@ -774,8 +1041,10 @@ export class AgentLoop {
|
|
|
774
1041
|
async *_handleInterrupt(signal) {
|
|
775
1042
|
// 统一取出全部气口申请(queue.splice(0) 原子消费,消费后队列为空)
|
|
776
1043
|
const reqs = this.interruptQueue.splice(0);
|
|
777
|
-
// ① 先处理注入型气口(interject → appendUserMessage
|
|
1044
|
+
// ① 先处理注入型气口(interject → appendUserMessage;command-result → appendSystemMessage)
|
|
1045
|
+
// ——不暂停 loop,让补充输入 / 后台结果立刻进入下一轮 step
|
|
778
1046
|
this._consumeInterjects(reqs);
|
|
1047
|
+
this._consumeCommandResults(reqs);
|
|
779
1048
|
// ② 后处理挂起型气口(pause → yield paused)——如果队列里有 pause 申请,在 step 边界挂起
|
|
780
1049
|
// ⛔ 生命周期契约:yield paused 后本 generator 立即 return 结束,
|
|
781
1050
|
// **不再产出任何后续 chunk**(挂起即本流结束,续跑靠宿主另起 resumeExecution 新流)。
|
|
@@ -784,6 +1053,11 @@ export class AgentLoop {
|
|
|
784
1053
|
// 导致"暂停态输入补充"被误路由进 interject 排队而无人消费(卡死坑)。
|
|
785
1054
|
if (reqs.some((r) => r.kind === 'pause')) {
|
|
786
1055
|
// pause 消费后 clearPauseRequest 已由 splice(0) 自动完成——无需额外清 flag
|
|
1056
|
+
// 挂起**不是** turn 终态(后台任务跨轮存活方案):同 turn 续跑,后台进程应继续跑、其回流照常注入。
|
|
1057
|
+
// 标记必须在 yield 之前置位——宿主在 paused chunk 处 break 会让 generator 停在
|
|
1058
|
+
// 这个 yield 上并触发 finally(runIterationLoop 的收割判据),置位晚了就会
|
|
1059
|
+
// 把「续跑」误判成「终态」而错杀后台进程。
|
|
1060
|
+
this.pauseBoundaryReached = true;
|
|
787
1061
|
yield { type: 'paused' };
|
|
788
1062
|
return 'paused';
|
|
789
1063
|
}
|
|
@@ -895,10 +1169,10 @@ export class AgentLoop {
|
|
|
895
1169
|
* 比较 active 任务项是否已推进,推进才产 plan_item_boundary(宿主按任务项分组后续事件)。
|
|
896
1170
|
* 无任务表(null)或 planItemId 未变则不产(lastBoundaryPlanItemId 去噪,避免每迭代发一条空边界)。
|
|
897
1171
|
*
|
|
898
|
-
* **检出时机 = 迭代开始、LLM
|
|
1172
|
+
* **检出时机 = 迭代开始、LLM 调用之前(定案锚 ADR-034)**:边界的语义是「**以下内容**
|
|
899
1173
|
* 属于该任务项」,故必须产在它所罩住的思考与工具之前——放在工具落定之后,宿主
|
|
900
1174
|
* 「向前找最近边界」的判据对该迭代必然落空 → 每个任务项的首个迭代(思考 + 首批工具)恒掉出
|
|
901
|
-
*
|
|
1175
|
+
* 折叠块,且流式插入后不搬家。
|
|
902
1176
|
*
|
|
903
1177
|
* 语义后果(可接受,非缺陷):任务表若由本轮某迭代的工具**新建**,该迭代仍留在组外——
|
|
904
1178
|
* 那个时刻任务表还不存在。本产出点修的是「任务表在本轮开始前已存在」(续会/预置/上一 turn
|
|
@@ -1149,13 +1423,20 @@ export class AgentLoop {
|
|
|
1149
1423
|
};
|
|
1150
1424
|
return 'aborted';
|
|
1151
1425
|
}
|
|
1152
|
-
// Reflection:本轮工具结果含 retryable 错误时,追加反思提示帮 LLM
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
// tool
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1426
|
+
// Reflection:本轮工具结果含 retryable 错误时,追加反思提示帮 LLM 聚焦修正而非放弃。
|
|
1427
|
+
// 判据改读 executeToolCalls **回流的 outcome**(本轮专属,天然不含上一轮残留)——
|
|
1428
|
+
// 原实现按 effectiveToolCalls.length 反切 messages 取窗口,toolStepLimit 截断时切错
|
|
1429
|
+
// 会把上一轮残留的 tool 错误吸进判定窗口,误注入反思提示(坑,随回流一并消除)。
|
|
1430
|
+
//
|
|
1431
|
+
// 重试轴 vs 计数轴分离(两条**正交**轴,判据面都是结构化字段):
|
|
1432
|
+
// · 计数轴 —— `_processToolResults` 只读 `outcome.status` 三值(成/败/拦,不编码类别),零文本判据;
|
|
1433
|
+
// · 重试轴 —— 判「败因是否可重试」,读 `outcome.errorCode`;`isRetryableToolError` 传入
|
|
1434
|
+
// outcome 时**直接读该字段**,无码才回退解析文本前缀(兼容不入错误码体系的 `ABORTED`
|
|
1435
|
+
// 与造裸文本 outcome 的测试桩)。
|
|
1436
|
+
// `errorCode` 与文本前缀在 `failedOutcomeWithCode` **同源产出一次**,前缀是其渲染面
|
|
1437
|
+
// ⇒ 读字段与读文本同解、不存在漂移。禁再散落手抄前缀:改一处漏一处会让 Reflection
|
|
1438
|
+
// 静默失效(该重试的不再重试,无报错)。
|
|
1439
|
+
const hasRetryableError = execResult.outcomes.some((o) => isRetryableToolError(o));
|
|
1159
1440
|
if (hasRetryableError) {
|
|
1160
1441
|
// 反思次数用显式计数器限制,避免 messages 裁剪导致计数失真
|
|
1161
1442
|
if (this.reflectionCountThisTurn < this.maxReflectionRetries) {
|
|
@@ -1343,16 +1624,18 @@ export class AgentLoop {
|
|
|
1343
1624
|
for (const tc of toolCalls) {
|
|
1344
1625
|
this.appendToolMessage(wrapToolResult(tc.function.name, '[TOOL_ABORTED] 该调用因本轮被中止而未执行。如需其结果,请重新发起。'), tc.id);
|
|
1345
1626
|
}
|
|
1346
|
-
|
|
1627
|
+
// 一条工具都没跑 ⇒ 无 outcome(重试分类自然为空集,TOOL_ABORTED 本就不可重试)
|
|
1628
|
+
return { aborted: true, outcomes: [] };
|
|
1347
1629
|
}
|
|
1348
1630
|
// 实际执行工具步:标记"本 turn 发生过工具调用",作为自审查触发门槛(多轮 turn 才审查)
|
|
1349
1631
|
this.toolExecutedThisTurn = true;
|
|
1350
1632
|
// 标记进入自主工具步(供内核向宿主暴露"可续跑"信号)
|
|
1351
1633
|
this.inAutonomousStep = true;
|
|
1352
1634
|
// yield tool_start 并并发发起所有工具执行(不 await,由 Promise.all 统一等待)
|
|
1353
|
-
//
|
|
1354
|
-
//
|
|
1355
|
-
//
|
|
1635
|
+
// 每个工具一次 push 一个 { promise },结果按工具顺序对齐取出,
|
|
1636
|
+
// 保证拦截事实与执行结果永不错位(避免平行数组各自 push 导致索引漂移)。
|
|
1637
|
+
// B4 判据切换:promise 直接产出 ToolOutcome——护栏/替身在推送点构造 blockedOutcome,
|
|
1638
|
+
// 经 ToolRunner 的由 runOne 返回,loop 内自执行(compress/remember)返回结构化结果。
|
|
1356
1639
|
const toolExecs = [];
|
|
1357
1640
|
// 同路径写串行闸:键 = 写工具的规范化目标路径(`WRITE_PATH_EXTRACTORS`),值 = 该路径的写链尾。
|
|
1358
1641
|
// 为何需要——`write_file` 是「读盘 → 改 → 写盘」,同 step 内并行发起时两次都基于同一份旧快照,
|
|
@@ -1420,7 +1703,10 @@ export class AgentLoop {
|
|
|
1420
1703
|
this.appendSystemMessage(`[SEARCH_LIMIT] 联网搜索已达 ${LOOP_CONSTANTS.MAX_WEB_SEARCH_CALLS} 次上限,视为未检索到更多相关内容。` +
|
|
1421
1704
|
`请停止调用 web_search,直接基于已有信息继续下一步或作答。`, { executionTemp: true });
|
|
1422
1705
|
}
|
|
1423
|
-
toolExecs.push({
|
|
1706
|
+
toolExecs.push({
|
|
1707
|
+
// search_limit 护栏拦截 = 主动挡下 ⇒ blockedOutcome(原因 GuardHit 已带)
|
|
1708
|
+
promise: Promise.resolve(blockedOutcome(guardHit.reason, guardHit.message)),
|
|
1709
|
+
});
|
|
1424
1710
|
continue;
|
|
1425
1711
|
}
|
|
1426
1712
|
// 其余护栏命中 → 硬拦回填拒绝文案(blocked 与拒绝 promise 一体推出);未命中 → 下方正常执行。
|
|
@@ -1440,16 +1726,29 @@ export class AgentLoop {
|
|
|
1440
1726
|
}
|
|
1441
1727
|
// 拦截归属回喂:read_dedup 的撞墙升级计数只认自己拦的(onExec 分不清拦截归属)
|
|
1442
1728
|
this.guardier.notifyBlocked(guardHit.guardId, tc.function.name, tc.function.arguments);
|
|
1443
|
-
toolExecs.push({
|
|
1729
|
+
toolExecs.push({
|
|
1730
|
+
// 其余护栏(ask_limit/write_loop/read_failed/read_dedup)= 主动挡下 ⇒ blockedOutcome;
|
|
1731
|
+
// read_failed 是唯一「挡下背后藏真失败」的护栏(hasRealFailure 事实随 outcome 带出)
|
|
1732
|
+
promise: Promise.resolve(blockedOutcome(guardHit.reason, guardHit.message, guardHit.reason === 'read_failed')),
|
|
1733
|
+
});
|
|
1444
1734
|
continue;
|
|
1445
1735
|
}
|
|
1446
|
-
//
|
|
1447
|
-
//
|
|
1736
|
+
// 台账替身回显分支(不收口进 guardRail):**原文仍在上下文**时,同参/变体重读用**请求区间
|
|
1737
|
+
// 原文**(切不出则退化顶头摘要)替代;原文已被压缩链清出 → 放行真读(替身是死路,见 stillInContext 注)。
|
|
1738
|
+
// 其余保守放行分支:无摘要 / 区间超出覆盖 → 放行(宁可多读一次,不可死锁)。
|
|
1448
1739
|
// 文件被 write/delete 修改 → 结果处理循环 invalidate 台账,放行合法重读。
|
|
1449
1740
|
const ledgerSubject = DEDUP_SUBJECT_EXTRACTORS[tc.function.name]?.(tc.function.arguments);
|
|
1450
1741
|
if (ledgerSubject?.path) {
|
|
1451
1742
|
const cov = this.fileExposure.get(ledgerSubject.path);
|
|
1452
|
-
|
|
1743
|
+
// 前置:原文仍在上下文才拦。摘要替身只服务「手边有原文可回顾」的语境;原文已被压缩链
|
|
1744
|
+
// 清出时回显替身 = 死路(模型要的中段 token 永远不在顶头摘要里,同参重读被反复拦),
|
|
1745
|
+
// 必须放行真读。判据与 read_dedup 分支①共用 isMessageStillUnmodified(同源)。
|
|
1746
|
+
// 顺带取回原文:拦下后按请求区间回显(见下方 formatLedgerStubRange),一次 find 两处消费。
|
|
1747
|
+
const covContent = cov !== undefined
|
|
1748
|
+
? this.stillUnmodifiedContent(cov.lastToolCallId, cov.fingerprint)
|
|
1749
|
+
: undefined;
|
|
1750
|
+
const stillInContext = covContent !== undefined;
|
|
1751
|
+
if (cov && shouldEchoLedgerStub(ledgerSubject, cov, stillInContext)) {
|
|
1453
1752
|
// 观测「补缝过度拦截」候选:台账替身回显命中累加。
|
|
1454
1753
|
this.metrics.ledgerStubEchoCount++;
|
|
1455
1754
|
// 裁决证据落盘(悬案取证):计数判不了意图,「模型规避完整读取」vs「防重误拦合法
|
|
@@ -1471,8 +1770,16 @@ export class AgentLoop {
|
|
|
1471
1770
|
logger.debug({
|
|
1472
1771
|
path: ledgerSubject.path,
|
|
1473
1772
|
cov: `${cov.coverStart}-${cov.coverEnd}/${cov.totalLines}`,
|
|
1474
|
-
},
|
|
1475
|
-
|
|
1773
|
+
},
|
|
1774
|
+
// 文案只陈述两形态皆真的事实(未重新读取文件)——不再写「摘要顶替整读」:
|
|
1775
|
+
// 命中区间时回显的是请求的区间原文,写「摘要顶替」即与行为不符(改一处漏一处的老伤)。
|
|
1776
|
+
'read_file 台账替身回显:未重新读取文件');
|
|
1777
|
+
toolExecs.push({
|
|
1778
|
+
// 台账替身 = 主动挡下 ⇒ blockedOutcome(替身非护栏、不经 GuardHit:原因固定 ledger_stub)
|
|
1779
|
+
// 回显取材:优先按**请求区间**切原文(原文仍在上下文时零成本可取);切不出 → 退化顶头替身。
|
|
1780
|
+
// 旧版恒给顶头 400 字符,与模型请求的区间零重叠 → 模型绕道(生产轮 round-1791449684099)。
|
|
1781
|
+
promise: Promise.resolve(blockedOutcome('ledger_stub', formatLedgerStubRange(cov, ledgerSubject, covContent === undefined ? undefined : unwrapToolResultBody(covContent)))),
|
|
1782
|
+
});
|
|
1476
1783
|
continue;
|
|
1477
1784
|
}
|
|
1478
1785
|
}
|
|
@@ -1483,7 +1790,7 @@ export class AgentLoop {
|
|
|
1483
1790
|
const writeKey = pathExtractor?.(tc.function.arguments);
|
|
1484
1791
|
const useWriteBarrier = OPAQUE_WRITE_TOOL_NAMES.includes(tc.function.name) ||
|
|
1485
1792
|
(pathExtractor !== undefined && writeKey === undefined);
|
|
1486
|
-
//
|
|
1793
|
+
// 第二级压缩/情报区工具由 loop 拦截执行(决策四:直接返回 ToolOutcome,不经 ToolExecutor)
|
|
1487
1794
|
let promise;
|
|
1488
1795
|
if (tc.function.name === COMPRESS_CONTEXT_TOOL.name) {
|
|
1489
1796
|
promise = this.compressContext(tc.function.arguments, signal);
|
|
@@ -1495,53 +1802,61 @@ export class AgentLoop {
|
|
|
1495
1802
|
// 屏障:排在全部在途写链尾之后(同/异路径写与既有屏障都要等),执行后成为新屏障尾
|
|
1496
1803
|
const prior = [...writeChains.values(), ...(opaqueWriteTail ? [opaqueWriteTail] : [])];
|
|
1497
1804
|
promise = Promise.all(prior).then(() => this.toolRunner.runOne(tc, signal));
|
|
1498
|
-
// 链尾吞掉失败:一次写失败不得毒化后续写(本次真实结果仍由 promise
|
|
1499
|
-
|
|
1805
|
+
// 链尾吞掉失败:一次写失败不得毒化后续写(本次真实结果仍由 promise 原样上抛;
|
|
1806
|
+
// 链尾值从不被消费,仅作串行占位 ⇒ okOutcome('') 即可)
|
|
1807
|
+
opaqueWriteTail = promise.catch(() => okOutcome(''));
|
|
1500
1808
|
}
|
|
1501
1809
|
else if (writeKey) {
|
|
1502
1810
|
// 等同路径上一次写落地后再执行——这样本次「读盘」拿到的是上一次写后的最新内容,
|
|
1503
1811
|
// 不再基于旧快照、也就不会把上一次的改动覆盖掉;屏障在途时同样等它(目标可能就是本文件)。
|
|
1504
1812
|
const prior = [
|
|
1505
|
-
writeChains.get(writeKey) ?? Promise.resolve(''),
|
|
1813
|
+
writeChains.get(writeKey) ?? Promise.resolve(okOutcome('')),
|
|
1506
1814
|
...(opaqueWriteTail ? [opaqueWriteTail] : []),
|
|
1507
1815
|
];
|
|
1508
1816
|
promise = Promise.all(prior).then(() => this.toolRunner.runOne(tc, signal));
|
|
1509
1817
|
// 链尾吞掉失败:一次写失败不得毒化同路径的后续写(本次真实结果仍由 promise 原样上抛)
|
|
1510
|
-
writeChains.set(writeKey, promise.catch(() => ''));
|
|
1818
|
+
writeChains.set(writeKey, promise.catch(() => okOutcome('')));
|
|
1511
1819
|
}
|
|
1512
1820
|
else {
|
|
1513
1821
|
promise = this.toolRunner.runOne(tc, signal);
|
|
1514
1822
|
}
|
|
1515
|
-
toolExecs.push({
|
|
1823
|
+
toolExecs.push({ promise });
|
|
1516
1824
|
}
|
|
1517
1825
|
const results = await Promise.all(toolExecs.map((e) => e.promise));
|
|
1826
|
+
// 结果随 outcome 一并下传:status 是成败/拦截判据的唯一真源
|
|
1827
|
+
//(B4 判据切换:编排层与计数层读同一份 status,禁另立文本判据)
|
|
1518
1828
|
this._processToolResults(toolCalls, results);
|
|
1519
1829
|
// 按原始顺序 yield tool_result
|
|
1520
1830
|
for (let i = 0; i < toolCalls.length; i++) {
|
|
1521
1831
|
const tc = toolCalls[i];
|
|
1522
|
-
const
|
|
1523
|
-
//
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1832
|
+
const outcome = results[i];
|
|
1833
|
+
// 渲染面文本(给 LLM / 防重指纹 / 台账用);成败与拦截一律读 outcome.status,不再扫文本
|
|
1834
|
+
const result = outcome.text;
|
|
1835
|
+
// 第三态:主动挡下(loop 护栏 / 执行层闸门 / 旁路拒绝 / 台账替身)非成功亦非失败——
|
|
1836
|
+
// ok=false 且不计成功搜索数;失败与拦截区分开,UI 显示「已拦截」,
|
|
1837
|
+
// metrics 失败数不把拦截算作失败(B4 口径:执行层闸门也归 blocked,见方案 §六决策二)
|
|
1838
|
+
const blocked = outcome.status === 'blocked';
|
|
1839
|
+
const ok = outcome.status === 'ok';
|
|
1527
1840
|
// 搜索收敛护栏:累计本闭环成功 web_search 次数(LLM 反复搜索不收敛时据此注入收敛提示)
|
|
1528
1841
|
if (tc.function.name === 'web_search' && ok) {
|
|
1529
1842
|
this.successfulWebSearchCount++;
|
|
1530
1843
|
}
|
|
1531
1844
|
// 工具结果防重缓存:仅成功且是 info-fetch 类型的工具才写入(失败/拦截不缓存——
|
|
1532
1845
|
// 失败可能是临时问题,拦截是我们主动挡的)。
|
|
1533
|
-
// 一并记录 toolCallId + 内容指纹:拦截前据此判定「该结果是否仍在当前上下文中」。
|
|
1534
|
-
// 指纹按进入上下文的包裹口径计算(复用 wrapToolResult 同一函数 → 同源,不会因模板改动失配)。
|
|
1535
1846
|
const resultExtractor = DEDUP_SUBJECT_EXTRACTORS[tc.function.name];
|
|
1536
1847
|
if (ok && resultExtractor) {
|
|
1537
1848
|
const subject = resultExtractor(tc.function.arguments);
|
|
1538
1849
|
if (subject) {
|
|
1850
|
+
// 指纹按进入上下文的包裹口径计算(复用 wrapToolResult 同一函数 → 同源,不会因模板改动失配)。
|
|
1851
|
+
// 一次计算两处消费(缓存 + 台账):分支①分支②的「原文是否仍在上下文」比对同一份指纹。
|
|
1852
|
+
const fingerprint = sha256Fingerprint(wrapToolResult(tc.function.name, result));
|
|
1539
1853
|
this.toolResultCache.set(tc.function.name, subject, this.currentIteration, {
|
|
1540
1854
|
toolCallId: tc.id,
|
|
1541
|
-
fingerprint
|
|
1855
|
+
fingerprint,
|
|
1542
1856
|
});
|
|
1543
1857
|
// 文件覆盖度台账写侧:read_file 返回分段脚注(= 文件确实大/被截断,按需读取信号)
|
|
1544
|
-
// 时记录覆盖区间 +
|
|
1858
|
+
// 时记录覆盖区间 + 轻量替身摘要——原文**仍在上下文**时替身回显分支据此拦截变体重读
|
|
1859
|
+
// (防重读永动机);原文被压缩后条目随指纹失配自动失效为放行(台账不清、判据把关)。
|
|
1545
1860
|
if (tc.function.name === 'read_file' && subject.path) {
|
|
1546
1861
|
const cov = parseReadFileCoverage(result);
|
|
1547
1862
|
if (cov) {
|
|
@@ -1558,22 +1873,42 @@ export class AgentLoop {
|
|
|
1558
1873
|
// 替身 = 已读正文前 READ_DIGEST_CHARS 字符(轻量启发式,零 LLM 成本)
|
|
1559
1874
|
digest: cov.content.slice(0, READ_DIGEST_CHARS),
|
|
1560
1875
|
cachedAtIteration: this.currentIteration,
|
|
1876
|
+
// 在上下文判定三件套:定位锚 + 指纹与缓存同源(见上方 fingerprint 注)
|
|
1877
|
+
lastToolCallId: tc.id,
|
|
1878
|
+
fingerprint,
|
|
1561
1879
|
});
|
|
1562
1880
|
}
|
|
1563
|
-
else {
|
|
1564
|
-
//
|
|
1565
|
-
//
|
|
1566
|
-
//
|
|
1567
|
-
|
|
1568
|
-
|
|
1881
|
+
else if (!result.startsWith(READ_FILE_NOTICE_PREFIX)) {
|
|
1882
|
+
// 整读无脚注 = handler 不变量(sliceFileByLineBudget):请求区间读到文件末尾且
|
|
1883
|
+
// 预算足,返回**从请求 offset 到末尾**的完整尾段(正文首行 = 文件第 offset 行)。
|
|
1884
|
+
// 故覆盖区间按请求起点记:coverStart=offset、totalLines=offset+正文行数-1——
|
|
1885
|
+
// 错记成「1 起的整读」会把早段未覆盖误当已覆盖(真机 round-1791507775404:
|
|
1886
|
+
// offset=54 读到末尾被记 1–95/共95,随后 offset=1 早段读被假拦 + 回显行号错位)。
|
|
1887
|
+
// offset≤1 时与旧记账数值完全等价(起点即 1,总行数=正文行数)。
|
|
1888
|
+
const start = subject.offset ?? 1;
|
|
1889
|
+
const totalLines = start + result.split('\n').length - 1;
|
|
1890
|
+
logger.debug({
|
|
1891
|
+
path: subject.path,
|
|
1892
|
+
start,
|
|
1893
|
+
totalLines,
|
|
1894
|
+
src: 'whole-read',
|
|
1895
|
+
peek: result.slice(0, 80),
|
|
1896
|
+
}, 'read_file 台账写入(整读无脚注,按请求起点记账)');
|
|
1569
1897
|
this.fileExposure.record(subject.path, {
|
|
1570
1898
|
totalLines,
|
|
1571
|
-
coverStart:
|
|
1899
|
+
coverStart: start,
|
|
1572
1900
|
coverEnd: totalLines,
|
|
1573
1901
|
digest: result.slice(0, READ_DIGEST_CHARS),
|
|
1574
1902
|
cachedAtIteration: this.currentIteration,
|
|
1903
|
+
lastToolCallId: tc.id,
|
|
1904
|
+
fingerprint,
|
|
1575
1905
|
});
|
|
1576
1906
|
}
|
|
1907
|
+
else {
|
|
1908
|
+
// 提示性返回(越界等,READ_FILE_NOTICE_PREFIX 开头):非文件正文,不进台账——
|
|
1909
|
+
// 记了会把「不存在的覆盖」当真,随后再次越界读命中归一判据被假拦(宁少记放行)
|
|
1910
|
+
logger.debug({ path: subject.path, peek: result.slice(0, 80) }, 'read_file 提示性返回,不进覆盖度台账');
|
|
1911
|
+
}
|
|
1577
1912
|
}
|
|
1578
1913
|
}
|
|
1579
1914
|
}
|
|
@@ -1583,7 +1918,9 @@ export class AgentLoop {
|
|
|
1583
1918
|
toolName: tc.function.name,
|
|
1584
1919
|
argsJson: tc.function.arguments,
|
|
1585
1920
|
toolCallId: tc.id,
|
|
1586
|
-
|
|
1921
|
+
// B4 判据切换:执行层拒绝(宿主审批)从 'failed' 变 'blocked'——write_loop
|
|
1922
|
+
// 自持状态因此不再把「用户拒绝」当失败升级文案,语义对齐「主动挡下」
|
|
1923
|
+
outcome: outcome.status,
|
|
1587
1924
|
});
|
|
1588
1925
|
// 副作用型工具成功 → 主动失效关联的 read_file 缓存(放行后续合法重读)
|
|
1589
1926
|
// 覆盖面派生自 `diskWrite:'path'` 声明(与串行闸同一真源),目标提取复用同一提取器——
|
|
@@ -1608,10 +1945,12 @@ export class AgentLoop {
|
|
|
1608
1945
|
// 循环结束后再查 abort:最后一个工具执行期间被 abort 时返回 aborted:true,
|
|
1609
1946
|
// 否则 processUserInput 会进入下一轮 LLM 调用(浪费资源)
|
|
1610
1947
|
if (signal?.aborted) {
|
|
1611
|
-
|
|
1948
|
+
// 工具已跑完、本轮被中止:outcome 照常回流(重试分类仍基于真实结果,不因中止而失忆)
|
|
1949
|
+
return { aborted: true, outcomes: results };
|
|
1612
1950
|
}
|
|
1613
1951
|
this.inAutonomousStep = false;
|
|
1614
|
-
|
|
1952
|
+
// 回流本轮 outcome:重试分类据此读结构化 errorCode,不再回头解析 message 文本
|
|
1953
|
+
return { aborted: false, outcomes: results };
|
|
1615
1954
|
}
|
|
1616
1955
|
/**
|
|
1617
1956
|
* 第二级压缩(LLM 主动触发兜底):把最早的 turn 或超大工具结果现场压成临时摘要替换。
|
|
@@ -1621,28 +1960,31 @@ export class AgentLoop {
|
|
|
1621
1960
|
*
|
|
1622
1961
|
* @param args 工具参数 JSON({ target: 'earliest_round' | 'largest_tool_result' })
|
|
1623
1962
|
* @param signal 中止信号
|
|
1624
|
-
* @returns
|
|
1963
|
+
* @returns 结构化 outcome(B4 判据切换):无目标 → blocked(no_compress_target);
|
|
1964
|
+
* 真跑了但失败(摘要生成失败 / 压缩异常)→ **failed**(重归类:文本判据期这两串
|
|
1965
|
+
* 无失败前缀、曾被静默算成功,口径变更见方案 §七);成功 → ok。
|
|
1966
|
+
* text 仍是回传给 LLM 的提示串(失败/无目标不抛错阻断工具链)。
|
|
1625
1967
|
*/
|
|
1626
1968
|
async compressContext(args, signal) {
|
|
1627
1969
|
try {
|
|
1628
1970
|
// 解析目标(非法/缺失降级 earliest_round)
|
|
1629
1971
|
let target;
|
|
1630
1972
|
try {
|
|
1631
|
-
|
|
1632
|
-
target = parsed === 'largest_tool_result' ? 'largest_tool_result' : 'earliest_round';
|
|
1973
|
+
target = parseCompressTarget(JSON.parse(args).target);
|
|
1633
1974
|
}
|
|
1634
1975
|
catch {
|
|
1635
1976
|
target = 'earliest_round';
|
|
1636
1977
|
}
|
|
1637
|
-
//
|
|
1638
|
-
const targetMsgs =
|
|
1978
|
+
// 定位目标消息(各定位器只返回「可安全整段替换」的区间——配对完整性由定位器自身保证)
|
|
1979
|
+
const targetMsgs = this.findCompressTarget(target);
|
|
1639
1980
|
if (!targetMsgs || targetMsgs.length === 0) {
|
|
1640
|
-
return '[compress_context]
|
|
1981
|
+
return blockedOutcome('no_compress_target', `[compress_context] 无可压缩目标(上下文为空或目标不存在;可用 target=${COMPRESS_TARGETS.join(' | ')})`);
|
|
1641
1982
|
}
|
|
1642
1983
|
// LLM 现场压成临时摘要
|
|
1643
1984
|
const summary = await this.summarizeForCompression(targetMsgs, signal);
|
|
1644
1985
|
if (!summary) {
|
|
1645
|
-
|
|
1986
|
+
// B4 重归类:真跑了压缩但摘要生成为空 ⇒ failed(原无失败前缀,曾被算成功)
|
|
1987
|
+
return failedOutcome('[compress_context] 摘要生成失败,已跳过(不破坏上下文)');
|
|
1646
1988
|
}
|
|
1647
1989
|
// 替换为目标内容为临时摘要 system 消息(executionTemp:loop 收尾即弃)
|
|
1648
1990
|
const tempSummaryMsg = {
|
|
@@ -1654,23 +1996,30 @@ export class AgentLoop {
|
|
|
1654
1996
|
const startIdx = this.messages.indexOf(first);
|
|
1655
1997
|
const endIdx = last === first ? startIdx : this.messages.indexOf(last);
|
|
1656
1998
|
if (startIdx === -1 || endIdx === -1) {
|
|
1657
|
-
return '[compress_context] 目标已不在当前上下文,已跳过';
|
|
1999
|
+
return blockedOutcome('no_compress_target', '[compress_context] 目标已不在当前上下文,已跳过');
|
|
1658
2000
|
}
|
|
1659
2001
|
this.messages.splice(startIdx, endIdx - startIdx + 1, tempSummaryMsg);
|
|
1660
2002
|
this.executionTempSystem.add(tempSummaryMsg);
|
|
1661
2003
|
logger.info({ target, replacedCount: targetMsgs.length }, 'compress_context 已压缩为临时摘要(loop 收尾即弃)');
|
|
1662
2004
|
// 回调宿主:LLM 主动压缩完成(第二级压缩,与 contextTruncated 的内核自动截断区分)
|
|
1663
2005
|
this.onContextCompressed?.(target, targetMsgs.length, summary.length);
|
|
1664
|
-
return `[compress_context] 已把目标压缩为临时摘要(${summary.length} 字,loop 收尾即弃):${summary.slice(0, 80)}
|
|
2006
|
+
return okOutcome(`[compress_context] 已把目标压缩为临时摘要(${summary.length} 字,loop 收尾即弃):${summary.slice(0, 80)}`);
|
|
1665
2007
|
}
|
|
1666
2008
|
catch (err) {
|
|
1667
2009
|
logger.warn({ err: toError(err).message }, 'compress_context 压缩失败,已跳过');
|
|
1668
|
-
|
|
2010
|
+
// B4 重归类:压缩异常 = 真跑了但失败 ⇒ failed(原无失败前缀,曾被算成功)
|
|
2011
|
+
return failedOutcome('[compress_context] 压缩失败,已跳过(不阻断工具链)');
|
|
1669
2012
|
}
|
|
1670
2013
|
}
|
|
1671
2014
|
/**
|
|
1672
2015
|
* 定位当前触发输入(顶级锚点)之前最早的 turn;无旧轮次(新对话第一轮)返回 null
|
|
1673
2016
|
* (当前输入永不压缩——交软上限收尾而非压掉触发输入继续硬跑)。
|
|
2017
|
+
*
|
|
2018
|
+
* ⚠️ **单轮 turn 内本定位器恒返回 null**(CTX-WIN-2 实锤):锚点是
|
|
2019
|
+
* 「第一个 user vs 最后一个 user」,而单轮 turn 内**只有一个 user**(当前触发输入)
|
|
2020
|
+
* ⇒ `firstUserIdx >= lastUserIdx` 恒成立 ⇒ 无旧 turn 可压。
|
|
2021
|
+
* 这不是「长单轮不需要压缩」,而是**锚点粒度选错了**——单轮内的历史是 step 序列,
|
|
2022
|
+
* 由 `findEarliestSteps` 承接。两定位器互补,单轮长任务才有目标。
|
|
1674
2023
|
*/
|
|
1675
2024
|
findEarliestRound() {
|
|
1676
2025
|
// 最后一个 user = 当前触发输入(顶级锚点,永不压缩)
|
|
@@ -1698,7 +2047,16 @@ export class AgentLoop {
|
|
|
1698
2047
|
}
|
|
1699
2048
|
return out;
|
|
1700
2049
|
}
|
|
1701
|
-
/**
|
|
2050
|
+
/**
|
|
2051
|
+
* 定位最大的 tool 结果所在**整段 step**(超大 tool_result 的压缩目标)。
|
|
2052
|
+
*
|
|
2053
|
+
* 🔴 **配对完整性(CTX-WIN-2 实锤,原实现带伤)**:本定位器曾只返回
|
|
2054
|
+
* **单条 tool 消息**,而它所属的 `assistant.toolCalls` 留在上下文里
|
|
2055
|
+
* ⇒ 下一次请求命中发送边界守卫 `unpairedAssistantCall` **fail-fast 抛错,整个 turn 崩掉**
|
|
2056
|
+
* (实证:单轮 turn 内 `target=largest_tool_result` 必崩,非边缘情形)。
|
|
2057
|
+
* 故返回**整段**:该 tool 消息 + 所属 assistant 消息(toolCalls 全部摘除)
|
|
2058
|
+
* + 中间夹在两者之间的其他消息——保证替换后剩余上下文仍处处配对。
|
|
2059
|
+
*/
|
|
1702
2060
|
findLargestToolResult() {
|
|
1703
2061
|
let largest = null;
|
|
1704
2062
|
for (const m of this.messages) {
|
|
@@ -1708,7 +2066,97 @@ export class AgentLoop {
|
|
|
1708
2066
|
largest = m;
|
|
1709
2067
|
}
|
|
1710
2068
|
}
|
|
1711
|
-
|
|
2069
|
+
if (!largest)
|
|
2070
|
+
return null;
|
|
2071
|
+
const toolIdx = this.messages.indexOf(largest);
|
|
2072
|
+
// 向前找所属 assistant(最近的、带 toolCalls 且含该 toolCallId 者)
|
|
2073
|
+
let assistantIdx = -1;
|
|
2074
|
+
for (let i = toolIdx - 1; i >= 0; i--) {
|
|
2075
|
+
const m = this.messages[i];
|
|
2076
|
+
if (m.role === 'assistant' && m.toolCalls?.some((tc) => tc.id === largest.toolCallId)) {
|
|
2077
|
+
assistantIdx = i;
|
|
2078
|
+
break;
|
|
2079
|
+
}
|
|
2080
|
+
// 撞上 user/system 边界 ⇒ 该 tool 无所属 assistant(异常态),只取自身
|
|
2081
|
+
if (m.role === 'user')
|
|
2082
|
+
break;
|
|
2083
|
+
}
|
|
2084
|
+
const start = assistantIdx === -1 ? toolIdx : assistantIdx;
|
|
2085
|
+
return this.messages.slice(start, toolIdx + 1);
|
|
2086
|
+
}
|
|
2087
|
+
/**
|
|
2088
|
+
* 定位**最早的若干个已完成 step**(单轮 turn 内的压缩目标,CTX-WIN-2 新增)。
|
|
2089
|
+
*
|
|
2090
|
+
* 为什么需要它(第一性原理):单轮长任务(一个 user 输入跑几十个 step)的上下文增长
|
|
2091
|
+
* 全部来自 **step 序列**(assistant 思考 + tool 结果),而 `earliest_round` 的锚点是
|
|
2092
|
+
* 「user 消息」⇒ 单轮内只有一个 user ⇒ 恒无目标。上一轮三样本 38.5万 / 105.3万 / 72.0万
|
|
2093
|
+
* token 的长单轮就是这样把窗口顶满、却没有任何可压目标。
|
|
2094
|
+
*
|
|
2095
|
+
* **step 边界判据**(内核既有结构,零新概念):一个 step = 一条 `assistant` 消息
|
|
2096
|
+
* (思考/工具调用意图)到**下一条 assistant 消息之前**的全部消息(含其 tool 结果与
|
|
2097
|
+
* 夹在中间的 system 注入)。故本定位器 = 「跳过当前触发输入,取第一个 assistant 起的
|
|
2098
|
+
* 一个完整 step」。
|
|
2099
|
+
*
|
|
2100
|
+
* 边界纪律(与既有两定位器同源):
|
|
2101
|
+
* ① **当前触发输入(最后一个 user)永不压缩**——顶级锚点;
|
|
2102
|
+
* ② **只取「已完成」的 step**:末条 assistant 之后若还有 tool 结果,说明该 step 尚未走完
|
|
2103
|
+
* (结果可能是 `[ASK_SUSPENDED]` 等),留待下轮;
|
|
2104
|
+
* ③ **整段替换**——step 内 assistant.toolCalls 与其 tool 结果同进同出,配对恒成立。
|
|
2105
|
+
*/
|
|
2106
|
+
findEarliestSteps() {
|
|
2107
|
+
// 顶级锚点:当前触发输入位置(其后的消息才可压)
|
|
2108
|
+
let lastUserIdx = -1;
|
|
2109
|
+
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
2110
|
+
if (this.messages[i].role === 'user') {
|
|
2111
|
+
lastUserIdx = i;
|
|
2112
|
+
break;
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
if (lastUserIdx === -1)
|
|
2116
|
+
return null;
|
|
2117
|
+
// 第一个 assistant = 最早 step 的起点(必须晚于顶级锚点:user 之前的 assistant 属上一轮,
|
|
2118
|
+
// 由 findEarliestRound 负责,不在此越界)
|
|
2119
|
+
let firstAssistantIdx = -1;
|
|
2120
|
+
for (let i = lastUserIdx + 1; i < this.messages.length; i++) {
|
|
2121
|
+
if (this.messages[i].role === 'assistant') {
|
|
2122
|
+
firstAssistantIdx = i;
|
|
2123
|
+
break;
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
if (firstAssistantIdx === -1)
|
|
2127
|
+
return null;
|
|
2128
|
+
// 下一条 assistant 之前 = 本 step 的右边界
|
|
2129
|
+
let endIdx = this.messages.length;
|
|
2130
|
+
for (let i = firstAssistantIdx + 1; i < this.messages.length; i++) {
|
|
2131
|
+
if (this.messages[i].role === 'assistant') {
|
|
2132
|
+
endIdx = i;
|
|
2133
|
+
break;
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
const step = this.messages.slice(firstAssistantIdx, endIdx);
|
|
2137
|
+
if (step.length === 0)
|
|
2138
|
+
return null;
|
|
2139
|
+
// 「已完成」判据 = 本 step 内每个 assistant.toolCalls 的 id 都有配对的 tool 消息回齐。
|
|
2140
|
+
// 逐 id 核验(而非「区间内有无 tool」):后者被**后续** step 的 tool 结果误判为未完成
|
|
2141
|
+
// —— 那是拿「别的 step 的状态」当「本 step 的状态」,判据层级选错。
|
|
2142
|
+
const toolIdsInStep = new Set(step.filter((m) => m.role === 'tool' && m.toolCallId).map((m) => m.toolCallId));
|
|
2143
|
+
const allAnswered = step
|
|
2144
|
+
.flatMap((m) => (m.toolCalls ?? []).map((tc) => tc.id))
|
|
2145
|
+
.every((id) => toolIdsInStep.has(id));
|
|
2146
|
+
if (!allAnswered)
|
|
2147
|
+
return null;
|
|
2148
|
+
return step;
|
|
2149
|
+
}
|
|
2150
|
+
/** 压缩目标分派(三个定位器互补,各守一条通道,不合并成泛化遍历) */
|
|
2151
|
+
findCompressTarget(target) {
|
|
2152
|
+
switch (target) {
|
|
2153
|
+
case 'largest_tool_result':
|
|
2154
|
+
return this.findLargestToolResult();
|
|
2155
|
+
case 'earliest_steps':
|
|
2156
|
+
return this.findEarliestSteps();
|
|
2157
|
+
case 'earliest_round':
|
|
2158
|
+
return this.findEarliestRound();
|
|
2159
|
+
}
|
|
1712
2160
|
}
|
|
1713
2161
|
/** 用 provider 把目标内容压成临时摘要(走 summary 路由,轻量模型优先;失败降级空串) */
|
|
1714
2162
|
async summarizeForCompression(targetMsgs, signal) {
|
|
@@ -1940,10 +2388,10 @@ export class AgentLoop {
|
|
|
1940
2388
|
if (this.opts.chatOptions) {
|
|
1941
2389
|
Object.assign(baseOptions, this.opts.chatOptions);
|
|
1942
2390
|
}
|
|
1943
|
-
//
|
|
1944
|
-
//
|
|
1945
|
-
//
|
|
1946
|
-
|
|
2391
|
+
// 输出上限**取交集**(定案锚 ADR-035):per-LLM 配置与角色包策略都是「上限」性质,
|
|
2392
|
+
// 任何一侧声明「不能超过 X」都必须被满足 ⇒ 取更小值(对齐上下文窗口
|
|
2393
|
+
// `min(provider 窗口, 角色包 contextLimit)` 的同构语义)。直接覆盖(Object.assign 压过)
|
|
2394
|
+
// 与「上限」语义相反——它会让角色包把用户配的小值顶大,用户无从得知谁赢了。
|
|
1947
2395
|
const perLlmLimit = this.opts.defaultMaxTokens;
|
|
1948
2396
|
const strategyLimit = baseOptions.maxTokens;
|
|
1949
2397
|
if (perLlmLimit !== undefined && strategyLimit !== undefined) {
|
|
@@ -2128,6 +2576,9 @@ export class AgentLoop {
|
|
|
2128
2576
|
unparsedToolIntentCount: this.metrics.unparsedToolIntentCount,
|
|
2129
2577
|
ledgerStubEchoCount: this.metrics.ledgerStubEchoCount,
|
|
2130
2578
|
readDedupBlockCount: this.metrics.readDedupBlockCount,
|
|
2579
|
+
// SCRIPT-2 B4 口径:执行层闸门归 blocked(不再计入 failureCount),见字段 JSDoc
|
|
2580
|
+
toolBlockedCount: this.metrics.toolBlockedCount,
|
|
2581
|
+
toolBlockedWithFailureCount: this.metrics.toolBlockedWithFailureCount,
|
|
2131
2582
|
},
|
|
2132
2583
|
context: {
|
|
2133
2584
|
truncationCount: this.contextManager.truncationCount,
|
|
@@ -2382,7 +2833,11 @@ export class AgentLoop {
|
|
|
2382
2833
|
*/
|
|
2383
2834
|
appendToolMessage(content, toolCallId) {
|
|
2384
2835
|
const finalContent = this.offloadDir
|
|
2385
|
-
? offloadLargeToolResult(content, {
|
|
2836
|
+
? offloadLargeToolResult(content, {
|
|
2837
|
+
offloadDir: this.offloadDir,
|
|
2838
|
+
// 尾部预览:长输出的关键信息常在尾部(缺口 D);份额在既有预览预算内划分
|
|
2839
|
+
tailPreviewChars: LOOP_CONSTANTS.TOOL_RESULT_OFFLOAD_TAIL_PREVIEW_CHARS,
|
|
2840
|
+
}).content
|
|
2386
2841
|
: content;
|
|
2387
2842
|
this.messages.push({
|
|
2388
2843
|
role: 'tool',
|
|
@@ -2398,7 +2853,8 @@ export class AgentLoop {
|
|
|
2398
2853
|
* ack **不回填 note 原文**(工具结果只回「已记录」),note 原文只进情报区 system 消息 → 不在用户流。
|
|
2399
2854
|
*
|
|
2400
2855
|
* @param args remember_intel 的 JSON arguments
|
|
2401
|
-
* @returns
|
|
2856
|
+
* @returns 结构化 outcome(B4 判据切换):note 为空 → blocked(invalid_intel_note)
|
|
2857
|
+
* (主动挡下:没写就谈不上失败);已记录 → ok。text 仍为简短 ack(工具结果不回填 note 原文)。
|
|
2402
2858
|
*/
|
|
2403
2859
|
handleRememberIntel(args) {
|
|
2404
2860
|
let note;
|
|
@@ -2411,7 +2867,7 @@ export class AgentLoop {
|
|
|
2411
2867
|
note = undefined;
|
|
2412
2868
|
}
|
|
2413
2869
|
if (!note) {
|
|
2414
|
-
return '[情报区] 未写入:note 为空或缺少该参数。';
|
|
2870
|
+
return blockedOutcome('invalid_intel_note', '[情报区] 未写入:note 为空或缺少该参数。');
|
|
2415
2871
|
}
|
|
2416
2872
|
const prev = this.intelNote;
|
|
2417
2873
|
this.intelNote = prev ? `${prev}\n${note}` : note;
|
|
@@ -2419,7 +2875,7 @@ export class AgentLoop {
|
|
|
2419
2875
|
if (this.intelNote.length > LOOP_CONSTANTS.MAX_INTEL_PREFIX_LEN) {
|
|
2420
2876
|
this.intelNote = this.intelNote.slice(-LOOP_CONSTANTS.MAX_INTEL_PREFIX_LEN);
|
|
2421
2877
|
}
|
|
2422
|
-
return '[情报区] 已记录(对你私有,不会展示给用户)。';
|
|
2878
|
+
return okOutcome('[情报区] 已记录(对你私有,不会展示给用户)。');
|
|
2423
2879
|
}
|
|
2424
2880
|
/**
|
|
2425
2881
|
* 每轮装配把情报区注入为**单条尾部私有 system 消息**。
|
|
@@ -2449,13 +2905,41 @@ export class AgentLoop {
|
|
|
2449
2905
|
}
|
|
2450
2906
|
}
|
|
2451
2907
|
/**
|
|
2452
|
-
*
|
|
2908
|
+
* 指定 toolCallId 的 tool 消息是否仍在上下文中且内容未被改动 —— 「结果仍在上下文」的共用判据。
|
|
2453
2909
|
*
|
|
2454
|
-
*
|
|
2455
|
-
*
|
|
2910
|
+
* 两个消费语境(SSOT):① read_dedup 分支①(经 isCachedResultStillInContext);
|
|
2911
|
+
* ② 台账替身回显分支②(按 FileCoverage.lastToolCallId + fingerprint 直调)。
|
|
2912
|
+
* 二者比对口径必须一致(wrapped 后消息内容指纹),否则两路对「同一结果在不在」的理解分叉。
|
|
2456
2913
|
*
|
|
2457
|
-
*
|
|
2914
|
+
* 三道判据全过才算「在」:消息数组中仍有该 toolCallId 的 tool 消息,且内容指纹与记录一致
|
|
2915
|
+
* (内容未被压缩链替换成 `[Previous: used x]` 占位符)。
|
|
2916
|
+
*
|
|
2917
|
+
* **保守方向:宁可放行,不可误拦。** 找不到消息 / 指纹不符 → false(放行)。
|
|
2458
2918
|
* 误放行的代价是「多读一次」;误拦的代价是「内容已不在手上却读不回来 = 死锁」——两者不对称。
|
|
2919
|
+
*/
|
|
2920
|
+
isMessageStillUnmodified(toolCallId, fingerprint) {
|
|
2921
|
+
return this.stillUnmodifiedContent(toolCallId, fingerprint) !== undefined;
|
|
2922
|
+
}
|
|
2923
|
+
/**
|
|
2924
|
+
* 指定 toolCallId 的 tool 消息**内容**(判「在不在」与取内容同源,避免两处各自 find 各算一次)。
|
|
2925
|
+
*
|
|
2926
|
+
* 判据与 `isMessageStillUnmodified` 完全一致(布尔版委托本方法),差别只在返回原文:
|
|
2927
|
+
* 分支②拦截后要按请求区间回显原文,而「在不在」的判据本身已经把消息翻出来了——
|
|
2928
|
+
* 再翻一次既浪费,又容易让两处对「同一结果在不在」的理解分叉(SSOT:翻找+指纹比对只此一处)。
|
|
2929
|
+
*
|
|
2930
|
+
* @returns 仍在上下文且未被压缩链改动的原始消息内容;否则 undefined
|
|
2931
|
+
*/
|
|
2932
|
+
stillUnmodifiedContent(toolCallId, fingerprint) {
|
|
2933
|
+
const msg = this.messages.find((m) => m.role === 'tool' && m.toolCallId === toolCallId);
|
|
2934
|
+
if (!msg)
|
|
2935
|
+
return undefined;
|
|
2936
|
+
return sha256Fingerprint(msg.content) === fingerprint ? msg.content : undefined;
|
|
2937
|
+
}
|
|
2938
|
+
/**
|
|
2939
|
+
* 缓存条目对应的工具结果是否**仍在当前上下文中** —— read_dedup 分支①拦截的前提(调用点见 handleToolCalls)。
|
|
2940
|
+
*
|
|
2941
|
+
* 实际判定委托 `isMessageStillUnmodified`(与台账分支②共用判据);本方法只补条目元信息缺失的
|
|
2942
|
+
* 保守放行(条目没带 toolCallId/指纹 → 无从核验 → 视为「不在」放行)。
|
|
2459
2943
|
*
|
|
2460
2944
|
* 注:超阈结果经入口关落盘后,上下文里留的是「路径 + 预览」,与记录指纹(包裹后的原文)不符
|
|
2461
2945
|
* → 判为「不在」,重读放行。这是正确语义:原文确实已不在上下文中。
|
|
@@ -2463,26 +2947,42 @@ export class AgentLoop {
|
|
|
2463
2947
|
isCachedResultStillInContext(entry) {
|
|
2464
2948
|
if (entry.toolCallId === undefined || entry.fingerprint === undefined)
|
|
2465
2949
|
return false;
|
|
2466
|
-
|
|
2467
|
-
if (!msg)
|
|
2468
|
-
return false;
|
|
2469
|
-
return sha256Fingerprint(msg.content) === entry.fingerprint;
|
|
2950
|
+
return this.isMessageStillUnmodified(entry.toolCallId, entry.fingerprint);
|
|
2470
2951
|
}
|
|
2471
2952
|
/** 整体替换执行上下文(截断落盘 / 恢复历史 / 装配重排:传入的数组已是完整上下文) */
|
|
2472
2953
|
replaceContext(next) {
|
|
2473
2954
|
this.messages = next;
|
|
2474
2955
|
}
|
|
2475
2956
|
// ─── Reflection 辅助方法 ────────────────────────────────
|
|
2476
|
-
/**
|
|
2957
|
+
/**
|
|
2958
|
+
* 处理工具执行结果:push tool 消息 + 按结构化 `status` 结算计数。
|
|
2959
|
+
*
|
|
2960
|
+
* B4 判据切换:成败/拦截判据唯一真源 = `ToolOutcome.status`(三值契约),不再解析文本前缀。
|
|
2961
|
+
* 三路互斥穷尽:
|
|
2962
|
+
* · `blocked` → `toolBlockedCount`(主动挡下总数:loop 护栏 + 旁路拒绝 + 台账替身 + 执行层闸门)
|
|
2963
|
+
* 其中 `hasRealFailure=true`(当前唯一 = read_failed 护栏)→ 另计 `toolBlockedWithFailureCount`
|
|
2964
|
+
* · `failed` → `toolFailureCount`;`ok` → 无计数
|
|
2965
|
+
* **口径变更(Breaking,CHANGELOG 已登记)**:执行层闸门(宿主审批拒绝 / 只读拒绝 /
|
|
2966
|
+
* 幂等跳过)从 `toolFailureCount` 剔除、归入 `toolBlockedCount` ⇒ 失败数下降、挡下数上升,
|
|
2967
|
+
* 历史不可比(方案 §六决策二)。
|
|
2968
|
+
*/
|
|
2477
2969
|
_processToolResults(toolCalls, results) {
|
|
2478
2970
|
for (let i = 0; i < toolCalls.length; i++) {
|
|
2479
2971
|
const tc = toolCalls[i];
|
|
2480
|
-
const
|
|
2972
|
+
const outcome = results[i];
|
|
2481
2973
|
// 工具结果隔离:用 <tool_result> 包裹 + 指令前缀,防外部工具返回承载间接注入;
|
|
2482
|
-
//
|
|
2483
|
-
|
|
2974
|
+
// 重试分类主判据是 outcome.errorCode(结构化契约,toolCallHelpers),
|
|
2975
|
+
// ERR 前缀仅兜底 errorCode 缺失的旧形态文本(该正则不锚定行首,包裹内仍可识别)
|
|
2976
|
+
const wrapped = wrapToolResult(tc.function.name, outcome.text);
|
|
2484
2977
|
this.appendToolMessage(wrapped, tc.id);
|
|
2485
|
-
|
|
2978
|
+
// 计数读 status(禁另立文本判据——判据面与渲染面分离是 SCRIPT-2 的核心契约)
|
|
2979
|
+
if (outcome.status === 'blocked') {
|
|
2980
|
+
this.metrics.toolBlockedCount++;
|
|
2981
|
+
// blocked 背后藏真失败的子集计数(独立于失败总数,防「主动停手」与「真失败」混数)
|
|
2982
|
+
if (outcome.hasRealFailure === true)
|
|
2983
|
+
this.metrics.toolBlockedWithFailureCount++;
|
|
2984
|
+
}
|
|
2985
|
+
else if (outcome.status === 'failed') {
|
|
2486
2986
|
this.metrics.toolFailureCount++;
|
|
2487
2987
|
}
|
|
2488
2988
|
}
|