dsh-decision-engine 0.0.0-stage → 0.5.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.
@@ -0,0 +1,223 @@
1
+ # dsh-decision-engine 配置模板
2
+ #
3
+ # 用法:复制到数据目录,按需修改。
4
+ # mkdir -p ~/.dsh/dsh-decision-engine
5
+ # cp config.example.yml ~/.dsh/dsh-decision-engine/config.yml
6
+ #
7
+ # 文件名是 config.yml:本插件早已不是「只服务 startlux 一家」的插件,
8
+ # 而是任意 system-one 风格服务(system one / bamboonode / custom)的决策层,
9
+ # 名字里再挂某个服务商只会误导。旧名 startlux.yml 仍会被自动读取并迁移,
10
+ # 升级不需要重新填 key。
11
+ #
12
+ # 插件按以下顺序找配置,取第一个存在的:
13
+ # ~/.dsh/dsh-decision-engine/config.yml (首选位置)
14
+ # ~/.dsh/dsh-decision-engine/startlux.yml (旧名,首次启动自动改名)
15
+ # ~/.dsh/dsh-startlux-decision/startlux.yml (上一代数据目录;自动拷到新目录)
16
+ # ~/.dsh/plugins/dsh-startlux-decision/startlux.yml
17
+ # ~/.dsh/plugins/dsh-startlux-guard/startlux.yml
18
+ #
19
+ # 数据目录(插件自动读取):
20
+ # <上述目录>/config.yml 配置
21
+ # <上述目录>/telemetry.jsonl 决策记录
22
+ #
23
+ # 改完立即生效:每次工具调用 / 每一步前都会检查 mtime,无需重启 DSH。
24
+
25
+ # 总开关:true 才开始工作(默认 false,不会擅自介入你的会话)
26
+ enabled: false
27
+
28
+ # shadow = 只记录判断结果,绝不拦截(建议先用它观察一段时间)
29
+ # enforce = 真正执行各决策点的处置
30
+ #
31
+ # 注意:这一项现在只是「工具授权」这一个决策点的历史开关。
32
+ # 新写法是给每个决策点单独指定模式,见下面的 pointXxx。
33
+ # pointAuthorize 留 auto 时,就沿用这里的 mode(shadow→observe、enforce→enforce)。
34
+ mode: shadow
35
+
36
+ # ============================================================================
37
+ # 服务提供方(provider)
38
+ # ============================================================================
39
+ # 三选一:
40
+ # bamboonode 内置。StartLux 官方端点 POST /v1/decisions,本插件的原有默认值。
41
+ # systemone 内置。任意 System One 协议服务 POST /v1/systemone,
42
+ # 默认指向 TypeSafe(https://api.typesafe.ai/v1)。
43
+ # custom 自己填 base URL,接任何 system-one 风格服务
44
+ # (自建 llama.cpp / vLLM、企业网关、第三方中转)。
45
+ #
46
+ # 不写这一项 = bamboonode,老配置行为完全不变。
47
+ provider: bamboonode
48
+
49
+ # ---- bamboonode 预设的地址覆盖 ----------------------------------------------
50
+ # 只在 provider=bamboonode 时生效。老版本唯一的地址字段,继续支持,因此
51
+ # 已有的自建 bamboonode 兼容地址不会因为升级被改回官方。
52
+ # 接受多种写法(等价):
53
+ # https://api.bamboonode.dev/v1
54
+ # https://api.bamboonode.dev/v1/decisions
55
+ # api.bamboonode.dev/v1 (会补 https://)
56
+ # 末尾斜杠会被规范化,不会拼出 //decisions 或 /v1/v1/decisions。
57
+ baseUrl: https://api.bamboonode.dev/v1
58
+
59
+ # ---- 模型(按 provider 分开存,切换时互不覆盖)------------------------------
60
+ # bamboonode 的模型。留空 = 自动(由服务端选当前默认决策模型),
61
+ # 这样服务商将来换模型名时,本配置不会失效。
62
+ model:
63
+
64
+ # system one 的模型。留空 = 服务端默认(jev-latest)。
65
+ # 与上面的 model 分开:否则切到 system one 会把 bamboonode 的模型名发过去,
66
+ # 得到 400 model not served。
67
+ systemoneModel: ""
68
+
69
+ # ---- custom 预设(provider=custom 时才生效)--------------------------------
70
+ # base URL 必填,且必须是 http/https 地址;不带 scheme 会被拒绝(内置预设
71
+ # 允许省略 scheme,custom 不允许——自定义地址必须显式,避免猜错协议)。
72
+ # customBaseUrl: https://api.example.com/v1
73
+ #
74
+ # 端点风格:
75
+ # auto 地址已带 /decisions 或 /systemone 就原样用,否则按 decisions 拼
76
+ # decisions POST <base>/decisions
77
+ # systemone POST <base>/systemone
78
+ customEndpointStyle: auto
79
+ #
80
+ # custom 自己的模型(留空 = 服务端默认)。与内置预设的 model 分开存,
81
+ # 这样在两者之间来回切换不会互相覆盖。
82
+ customModel: ""
83
+ # custom 的 key;留空则读 customApiKeyEnv(环境变量或 .credentials.yaml 的 refs)
84
+ customApiKey: ""
85
+ customApiKeyEnv: ""
86
+ # 附加请求头,格式 `名称: 值`,多项用分号分隔。
87
+ # authorization / content-type / host 由插件固定,写了也会被忽略。
88
+ customHeaders: ""
89
+
90
+ # ============================================================================
91
+ # 密钥
92
+ # ============================================================================
93
+ # 每个 provider 有**自己的** key 名,各自保存各自的 key:
94
+ #
95
+ # system one -> TYPESAFE_API_KEY
96
+ # bamboonode -> BAMBOO_API_KEY
97
+ # custom -> customApiKeyEnv(没填时用 DECISION_ENGINE_CUSTOM_KEY)
98
+ #
99
+ # 三者互不覆盖,可以同时配好;切换 provider 不会串用别人的 key。
100
+
101
+ # 密钥存到哪里:
102
+ # credentials = ~/.dsh/.credentials.yaml 的 refs 段(默认,与其他服务统一托管)
103
+ # config = 本文件(自包含,便于随目录备份;注意这里是明文)
104
+ keyStorage: credentials
105
+
106
+ # 变量名可以覆盖(留空即用上面的默认名):
107
+ # systemoneApiKeyEnv: ""
108
+ # bamboonodeApiKeyEnv: ""
109
+ # customApiKeyEnv: ""
110
+
111
+ # 直接写明文 key 也可以(各 provider 一格,互不影响)。
112
+ # 若 keyStorage=credentials,在设置界面粘贴的 key 会写进 .credentials.yaml,
113
+ # 不会落到这里;只有 keyStorage=config 时下面这些字段才会被写入。
114
+ # systemoneApiKey: ""
115
+ # bamboonodeApiKey: ""
116
+ # customApiKey: ""
117
+
118
+ # .credentials.yaml 里的形态就是 refs 段:
119
+ # refs:
120
+ # BAMBOO_API_KEY: sk-你的key
121
+ # TYPESAFE_API_KEY: sk-另一个key
122
+
123
+ # 超时预算(毫秒)。超时自动降级放行,连续失败进入冷却。
124
+ timeoutMs: 8000
125
+
126
+ # 思考模式(startlux 的 think_mode),三档:
127
+ # never 不思考——最省延迟(实测约 1.9s vs 7.2s),默认值,热路径首选
128
+ # auto 仅在模型自己判断需要时思考——折中
129
+ # always 总是思考——判断更充分,但每次工具调用约多等 5 秒
130
+ # 该端点 output_tokens 恒为 0,思考开销不计入计量,所以省的主要是延迟。
131
+ thinkMode: never
132
+
133
+ # ============================================================================
134
+ # 决策模型参与点
135
+ # ============================================================================
136
+ # 三态:off 不参与 / observe 只记录不干预 / enforce 真正生效。
137
+ # 九个点全部已实装。新点默认 off —— 升级不会改变现有行为。
138
+ #
139
+ # 建议:先 observe 跑一段时间,对着 telemetry.jsonl 确认与人工判断一致,
140
+ # 再逐点切 enforce。热路径的点(route / delegate / cotTranslate)尤其如此。
141
+
142
+ # ---- 1. 工具授权:每次工具调用前判断能否放行 -------------------------------
143
+ # 替代 auto-review——后者把完整会话历史发给主模型,每次调用都跑一遍。
144
+ pointAuthorize: auto
145
+
146
+ # ---- 2. 模型路由:机械步骤下沉到便宜模型 -----------------------------------
147
+ # 热路径。三道闸控量:每轮最多 routeMaxStepsPerTurn 步、路由未变不问、
148
+ # 新用户输入后的步骤不问。
149
+ # 必须同时配好 routeCheapProvider + routeCheapModel 才会真的换模型;
150
+ # 两者留空时即便打开此点也只会记录「本该换」。
151
+ pointRoute: off
152
+ routeCheapProvider: ""
153
+ routeCheapModel: ""
154
+ # routeMaxStepsPerTurn: 3
155
+ # routeMaxScore: 0.4 # 能力需求归一化后超过此值就保留强模型
156
+ # routeMechanicalFloor: 0.6 # 「机械步骤」的最低置信
157
+ # routeRiskFloor: 0.3 # 风险达线一律保留强模型
158
+
159
+ # ---- 3. 子任务分派:接着做 / 开 subagent / 组 agent team -------------------
160
+ # 热路径。只在每轮第一步且带新用户输入时判断(分派只在这一刻可能改变)。
161
+ # 生效时注入一条提示,不替模型决定——工具选择仍归模型。
162
+ pointDelegate: off
163
+ # delegateConfidenceFloor: 0.5 # 三选项均分 0.33,实测需 ≥0.5 才算有倾向
164
+ # delegateBlockingFloor: 0.6 # 被本地事实阻塞时继续内联
165
+ # delegateParallelFloor: 0.5 # 组队还需真并行才有意义
166
+
167
+ # ---- 4. 上下文压缩:摘要 vs 只丢陈旧工具结果 -------------------------------
168
+ # 非热路径。只在实测 token 达触发线(compactThresholdRatio × 窗口)才问。
169
+ # 生效方式:先跑宿主的确定性工具结果裁剪;compaction-basic 随后重新测压,
170
+ # 低于阈值就跳过摘要——这才是真正省下的那一次调用。
171
+ pointCompact: off
172
+ # compactContextWindow: 0 # 0 = 用 compactTriggerTokens 绝对阈值
173
+ # compactThresholdRatio: 0.7 # 提前于 compaction-basic 的 0.8 介入
174
+ # compactTriggerTokens: 100000
175
+ # compactToolResultChars: 8192
176
+ # compactDistilledFloor: 0.6 # 结论已落地 → 允许只裁剪
177
+ # compactVerbatinFloor: 0.6 # 仍需逐字原文 → 一律摘要(安全否决优先)
178
+
179
+ # ---- 5. 轮次延续:再来一轮有没有增量价值 -----------------------------------
180
+ # 非热路径。生效方式是用 agent.steer() 续一轮,每轮最多 turnStopMaxSteers 次。
181
+ pointTurnStop: off
182
+ # turnStopMaxSteers: 1
183
+ # turnStopClosedFloor: 0.6
184
+ # turnStopUnfinishedFloor: 0.6
185
+ # turnStopValueFloor: 0.35 # 只作弱确认;主判据是 unfinished
186
+
187
+ # ---- 6. 检索必要性:这条信息是不是已经知道 ---------------------------------
188
+ # 非热路径。只对检索类工具生效,其余工具零开销。
189
+ pointRetrieval: off
190
+ # retrievalTools: [web_search, web_fetch, research, multi_search, advanced_search]
191
+ # retrievalCoveredFloor: 0.5 # 上下文已覆盖该事实
192
+ # retrievalWouldAddCeiling: 0.8 # 且再搜基本没有新信息
193
+
194
+ # ---- 7. 技能选择:该装哪个技能 ---------------------------------------------
195
+ # 非热路径。只对 skill 工具、且候选 ≥2 时判断。
196
+ # 生效时拦下不匹配的加载并指路,避免「装错再换」付两遍正文 token。
197
+ pointSkillPick: off
198
+ # skillPickCandidates: 8 # 送给模型挑选的候选上限(只带名称+描述)
199
+ # skillPickConfidenceFloor: 0.5
200
+
201
+ # ---- 8. 思维链翻译门槛:这条内容真的需要翻译吗 -----------------------------
202
+ # 热路径(每条消息)。本地先做免费预筛(去代码块/URL 后判断有无正文),
203
+ # 预筛不过的直接跳过,不发请求。生效时判为「不需要」则返回原文。
204
+ pointCotTranslate: off
205
+ # cotProseFloor: 0.5
206
+ # cotSameLanguageFloor: 0.8
207
+
208
+ # ============================================================================
209
+ # 自身开销护栏
210
+ # ============================================================================
211
+ # 决策模型自己也烧 token(实测约 1.9k/次,p50 约 0.5s)。
212
+ # 这是每小时上限,超出后所有决策点自动退回启发式并放行——
213
+ # 省钱的东西不能反过来变成主要消耗方。0 表示不限制。
214
+ budgetTokens: 400000
215
+ budgetWindowMs: 3600000
216
+
217
+ # ---- 以下为可选调优项 ----
218
+ # cacheSize: 256 # 缓存条数上限
219
+ # cacheTtlMs: 600000 # 裁决存活时间
220
+ # recentMessages: 4 # 带给模型的上下文条数
221
+ # guardReadOnlyTools: [read, grep, glob, todo_write]
222
+ # guardDenyConfidence: 0.85
223
+ # guardIrreversibleCeiling: 0.2
@@ -0,0 +1,14 @@
1
+ # dsh-decision-engine 的 bundle 层。
2
+ #
3
+ # 这里**刻意不写 config**。
4
+ #
5
+ # 原因:bundle patch 属于 profile 配置层,而 DSH 传给插件 apply() 的 config
6
+ # 是 Schemastery 解析后的完整默认值(enabled:false / mode:shadow ...),
7
+ # 并非用户显式配置。若在此硬编码,会盖掉用户在配置页保存的选择,
8
+ # 表现为「一按保存就自动弹回关闭」。
9
+ #
10
+ # 运行时配置的唯一来源是插件目录下的 config.yml(配置页保存的也是它)。
11
+ # 插件内的 DEFAULTS 只负责兜底,不在此重复声明。
12
+ - insert:
13
+ - id: decision-engine
14
+ name: dsh-decision-engine
package/icon.svg ADDED
@@ -0,0 +1,17 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="StartLux Decision">
2
+ <!-- Bamboo Relay 官方标识(bamboonode.dev),等比放大到插件图标尺寸 -->
3
+ <defs>
4
+ <linearGradient id="bamboo-grad" x1="0" y1="0" x2="1" y2="1">
5
+ <stop offset="0%" stop-color="#3AC251"/>
6
+ <stop offset="55%" stop-color="#2BAE7E"/>
7
+ <stop offset="100%" stop-color="#1481BF"/>
8
+ </linearGradient>
9
+ </defs>
10
+ <g transform="translate(4 4) scale(2)">
11
+ <rect x="3" y="10" width="3.2" height="16" rx="1.6" fill="url(#bamboo-grad)"/>
12
+ <rect x="8" y="4" width="3.2" height="22" rx="1.6" fill="url(#bamboo-grad)"/>
13
+ <rect x="13" y="0" width="3.2" height="26" rx="1.6" fill="url(#bamboo-grad)"/>
14
+ <rect x="18" y="6" width="3.2" height="20" rx="1.6" fill="url(#bamboo-grad)"/>
15
+ <rect x="23" y="12" width="3.2" height="14" rx="1.6" fill="url(#bamboo-grad)"/>
16
+ </g>
17
+ </svg>
@@ -0,0 +1,307 @@
1
+ /**
2
+ * 子任务分派、轮次延续、上下文压缩 —— 三个都挂在 `agent/pre-step` 与
3
+ * `agent/turn-stopping` 上的适配器。
4
+ *
5
+ * 为什么合在一个文件:三者的触发条件都依赖「这一步/这一轮到哪了」,
6
+ * 分成三个监听器会让同一份 inbox/step 状态被读三遍,也让「这一步到底问了
7
+ * 几个决策点」变得难以推断。这里用一个 pre-step 监听器内部分派。
8
+ *
9
+ * 各点的生效方式都刻意保守:
10
+ *
11
+ * 分派 只注入一条 runtime-context 建议,绝不替模型开子代理
12
+ * (开子代理是模型的决定,插件越权会让上下文分叉不可控)
13
+ * 轮次 用 `agent.steer()` 续一轮,且每轮最多一次
14
+ * 压缩 只在自己判定够便宜时先跑确定性裁剪;摘要交给 compaction-basic
15
+ *
16
+ * @module dsh-decision-engine/adapters/agent
17
+ */
18
+
19
+ /**
20
+ * 挂载分派 / 轮次 / 压缩三个监听器。
21
+ * @param ctx - cordis 插件上下文。
22
+ * @param deps - 运行期依赖。
23
+ */
24
+ export function mountAgentPoints(ctx, deps) {
25
+ const { runner, getConfig, digestRecent, telemetry, makeUserMessage } = deps;
26
+
27
+ /** 每会话的续轮计数:turn → 已 steer 次数。防止自续无限循环。 */
28
+ const steerCounts = new WeakMap();
29
+
30
+ const steersThisTurn = (session, turn) => {
31
+ const map = steerCounts.get(session);
32
+ return map?.get(turn) ?? 0;
33
+ };
34
+
35
+ const noteSteer = (session, turn) => {
36
+ let map = steerCounts.get(session);
37
+ if (map === undefined) {
38
+ map = new Map();
39
+ steerCounts.set(session, map);
40
+ }
41
+ map.set(turn, (map.get(turn) ?? 0) + 1);
42
+ // 只保留最近两轮,避免长会话里这个 Map 无界增长。
43
+ if (map.size > 2) {
44
+ const oldest = map.keys().next();
45
+ if (!oldest.done) map.delete(oldest.value);
46
+ }
47
+ };
48
+
49
+ /** 取最近一步的助手文本,作为「这一轮说了什么」的判断依据。 */
50
+ const lastAssistantText = (session, chars) => {
51
+ let events;
52
+ try {
53
+ events = session.events ?? [];
54
+ } catch {
55
+ return "";
56
+ }
57
+ for (let index = events.length - 1; index >= 0; index -= 1) {
58
+ const event = events[index];
59
+ if (event?.type !== "assistant/message") continue;
60
+ const content = event.data?.message?.content;
61
+ if (!Array.isArray(content)) continue;
62
+ const text = content
63
+ .map((block) => (block?.type === "text" && typeof block.text === "string" ? block.text : ""))
64
+ .filter((part) => part.length > 0)
65
+ .join(" ");
66
+ if (text.length > 0) return text.slice(-chars);
67
+ }
68
+ return "";
69
+ };
70
+
71
+ // ---------------------------------------------------------------- pre-step
72
+ const preStep = async (payload, next) => {
73
+ const decision = await next();
74
+ // 判 reject 说明这一步根本没进入,注入建议没有意义。
75
+ if (decision.kind === "reject") return decision;
76
+ const agent = payload?.agent;
77
+ if (agent?.session === undefined) return decision;
78
+ const cfg = getConfig();
79
+
80
+ try {
81
+ const additions = [];
82
+
83
+ // ---- 上下文压缩 ------------------------------------------------
84
+ if (runner.participates("compact")) {
85
+ const compacted = await maybePruneContext(ctx, agent, payload, cfg, runner, telemetry);
86
+ if (compacted !== undefined) additions.push(compacted);
87
+ }
88
+
89
+ // ---- 子任务分派 ------------------------------------------------
90
+ // 只在「每轮第一步 + 这一步确实带着新的用户输入」时判断:
91
+ // 分派决策只在这个时刻可能改变,后续步骤再问必然是 inline。
92
+ if (runner.participates("delegate") && payload.step === 1 && payload.messages.length > 0) {
93
+ const incoming = payload.messages
94
+ .map((message) => textOf(message))
95
+ .filter((text) => text.length > 0)
96
+ .join("\n")
97
+ .slice(0, 1200);
98
+ if (incoming.length > 0) {
99
+ const result = await runner.ask(
100
+ "delegate",
101
+ {
102
+ turn: payload.turn,
103
+ step: payload.step,
104
+ incoming,
105
+ recent: digestRecent(agent.session, cfg.recentMessages, cfg.recentMessageChars),
106
+ },
107
+ { signal: payload.signal },
108
+ );
109
+ // 只有明确建议「开子代理/组队」时才注入提示;inline 不打扰。
110
+ if (runner.enforces("delegate") && result.action?.kind === "advise") {
111
+ additions.push(
112
+ makeUserMessage(
113
+ `[decision-engine-delegate] ${result.action.reason}\n` +
114
+ `如果确实适合,请用 subagent / 团队工具把它分出去;否则直接在当前上下文里做完,不要为了分派而分派。`,
115
+ "delegate",
116
+ ),
117
+ );
118
+ }
119
+ }
120
+ }
121
+
122
+ if (additions.length === 0) return decision;
123
+ return { ...decision, messages: [...decision.messages, ...additions] };
124
+ } catch (error) {
125
+ telemetry.noteAdapterError("pre-step", error, ctx);
126
+ return decision;
127
+ }
128
+ };
129
+
130
+ ctx.on("agent/pre-step", preStep);
131
+
132
+ // ----------------------------------------------------------- turn-stopping
133
+ /**
134
+ * 轮次延续:`agent/turn-stopping` 是 serial 而不是 waterfall,返回值不传递,
135
+ * 唯一的生效方式是调用 `agent.steer(...)` 把消息塞回 inbox —— 机器随后
136
+ * 重新读一遍 inbox,有东西就再跑一步。
137
+ *
138
+ * 契约明确写了「数据决定,监听器顺序不改变结果」,因此这里唯一能做的就是
139
+ * 「steer 或不 steer」,不能否决别的监听器的意见。
140
+ */
141
+ const turnStopping = async (payload) => {
142
+ const agent = payload?.agent;
143
+ if (agent?.session === undefined) return;
144
+ if (!runner.participates("turnStop")) return;
145
+
146
+ try {
147
+ const cfg = getConfig();
148
+ // 每轮最多续一次:否则一轮里的空转会被无限放大成烧钱循环。
149
+ if (steersThisTurn(agent.session, payload.turn) >= Math.max(0, cfg.turnStopMaxSteers ?? 1)) return;
150
+
151
+ const last = lastAssistantText(agent.session, cfg.recentMessageChars * 4);
152
+ const result = await runner.ask(
153
+ "turnStop",
154
+ {
155
+ turn: payload.turn,
156
+ last,
157
+ pendingTools: 0,
158
+ steered: steersThisTurn(agent.session, payload.turn),
159
+ },
160
+ { signal: payload.signal },
161
+ );
162
+
163
+ if (!runner.enforces("turnStop")) return;
164
+ if (result.action?.kind !== "continue") return;
165
+ if (typeof agent.steer !== "function") return;
166
+
167
+ noteSteer(agent.session, payload.turn);
168
+ agent.steer(
169
+ makeUserMessage(
170
+ `[decision-engine-turnstop] ${result.action.reason}\n继续把没做完的部分做完;如果其实已经完成,直接说明结论即可。`,
171
+ "turnStop",
172
+ ),
173
+ );
174
+ ctx.logger?.info?.(`dsh-decision-engine: 第 ${payload.turn} 轮按决策续一轮`);
175
+ } catch (error) {
176
+ telemetry.noteAdapterError("turnStop", error, ctx);
177
+ }
178
+ };
179
+
180
+ ctx.on("agent/turn-stopping", turnStopping);
181
+ }
182
+
183
+ /** 把一条消息压成纯文本(只取 text block)。 */
184
+ function textOf(message) {
185
+ const content = message?.content;
186
+ if (typeof content === "string") return content;
187
+ if (!Array.isArray(content)) return "";
188
+ return content
189
+ .map((block) => (block?.type === "text" && typeof block.text === "string" ? block.text : ""))
190
+ .filter((part) => part.length > 0)
191
+ .join(" ");
192
+ }
193
+
194
+ /**
195
+ * 上下文压缩:判断「现在只丢陈旧工具结果够不够」。
196
+ *
197
+ * 生效方式是调用宿主的 `toolResultPruner.pruneSession(session)` —— 这是一个
198
+ * 纯确定性、无模型、replay 安全的裁剪,把超长工具结果的中间段换成标记。
199
+ * 裁完之后 compaction-basic 会在同一步重新测量压力:低于阈值它就**跳过摘要**,
200
+ * 于是真正省下一次摘要调用。
201
+ *
202
+ * 绝不在 observe 模式下裁剪(那会改变会话内容),也绝不在没超阈值时裁剪。
203
+ *
204
+ * @returns 需要追加进这一步的提示消息,或 undefined(不注入)。
205
+ */
206
+ async function maybePruneContext(ctx, agent, payload, cfg, runner, telemetry) {
207
+ const meter = ctx.get?.("tokenMeter");
208
+ const pruner = ctx.get?.("toolResultPruner");
209
+ if (meter === undefined || pruner === undefined) return undefined;
210
+
211
+ let measurement;
212
+ try {
213
+ measurement = meter.measure(agent.session);
214
+ } catch {
215
+ return undefined;
216
+ }
217
+ const total = Number(measurement?.totalTokens ?? 0);
218
+ if (total <= 0) return undefined;
219
+
220
+ // 触发线:compaction-basic 默认 thresholdRatio 0.8。它只在达到 0.8×contextWindow
221
+ // 时才动手,所以我们要在它之前判断,取 0.7 作为「提前介入」的位置。
222
+ // 拿不到 contextWindow 时退回绝对阈值,避免完全不动。
223
+ const contextWindow = Number(cfg.compactContextWindow ?? 0);
224
+ const threshold = contextWindow > 0 ? contextWindow * (cfg.compactThresholdRatio ?? 0.7) : (cfg.compactTriggerTokens ?? 100000);
225
+ if (total < threshold) return undefined;
226
+
227
+ // 先数一下有多少超长工具结果,没有就不必问模型。
228
+ const oversized = countOversizedToolResults(agent.session, cfg.compactToolResultChars ?? 8192);
229
+ if (oversized === 0) return undefined;
230
+
231
+ const result = await runner.ask(
232
+ "compact",
233
+ {
234
+ totalTokens: total,
235
+ triggerTokens: Math.round(threshold),
236
+ oversized,
237
+ nodes: Array.isArray(measurement?.nodes) ? measurement.nodes.length : 0,
238
+ recent: digestRecentSafe(agent.session, cfg),
239
+ },
240
+ { signal: payload.signal },
241
+ );
242
+
243
+ if (!runner.enforces("compact")) return undefined;
244
+ if (result.action?.kind !== "prune") return undefined;
245
+
246
+ let pruned;
247
+ try {
248
+ pruned = pruner.pruneSession(agent.session);
249
+ } catch (error) {
250
+ telemetry.noteAdapterError("compact", error, ctx);
251
+ return undefined;
252
+ }
253
+ if (pruned === undefined || pruned.pruned.length === 0) return undefined;
254
+
255
+ ctx.logger?.info?.(
256
+ `dsh-decision-engine: 裁剪 ${pruned.pruned.length} 条超长工具结果,省 ${pruned.charsRemoved} 字符(${result.action.reason})`,
257
+ );
258
+ return undefined;
259
+ }
260
+
261
+ /** 数当前 surface 上超长的 tool/result 数量。 */
262
+ function countOversizedToolResults(session, limit) {
263
+ let count = 0;
264
+ let nodes;
265
+ try {
266
+ nodes = session.surface?.nodes;
267
+ } catch {
268
+ return 0;
269
+ }
270
+ if (nodes === undefined) return 0;
271
+ for (const seq of nodes) {
272
+ let event;
273
+ try {
274
+ event = session.eventAt(seq);
275
+ } catch {
276
+ continue;
277
+ }
278
+ if (event?.type !== "tool/result") continue;
279
+ const content = event.data?.message?.content;
280
+ if (!Array.isArray(content)) continue;
281
+ let chars = 0;
282
+ for (const block of content) {
283
+ if (block?.type === "text" && typeof block.text === "string") chars += block.text.length;
284
+ }
285
+ if (chars > limit) count += 1;
286
+ }
287
+ return count;
288
+ }
289
+
290
+ /** 压缩判断里只需要很短的上下文;单独一个 helper 避免把大字符串传进 state。 */
291
+ function digestRecentSafe(session, cfg) {
292
+ try {
293
+ const count = Math.min(cfg.recentMessages ?? 0, 4);
294
+ if (count <= 0) return "";
295
+ const events = session.events ?? [];
296
+ const lines = [];
297
+ for (let index = events.length - 1; index >= 0 && lines.length < count; index -= 1) {
298
+ const event = events[index];
299
+ if (event?.type !== "user/message" && event?.type !== "assistant/message") continue;
300
+ const text = textOf(event.data?.message ?? event.data);
301
+ if (text.length > 0) lines.unshift(text.slice(0, 160));
302
+ }
303
+ return lines.join("\n");
304
+ } catch {
305
+ return "";
306
+ }
307
+ }