dsh-whale-loli 0.5.6 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/cordis.patch.yml +39 -18
- package/package.json +2 -2
- package/src/history-window.js +73 -182
package/README.md
CHANGED
|
@@ -70,6 +70,22 @@ session.append('developer/message', 提示条, {
|
|
|
70
70
|
|
|
71
71
|
挂载点为 `agent/pre-step`:循环在**派生历史之前**运行它,是唯一既在请求前、又拿得到本 agent 的时机。
|
|
72
72
|
|
|
73
|
+
#### 现在的做法:交给宿主压缩,不自己写事件
|
|
74
|
+
|
|
75
|
+
> **历史改动**:曾经用 `session.append(...)` + `surfaceOp: replace` 自己做纯删除,连崩四次
|
|
76
|
+
> (`turn must be positive` → `requires id, role, content, and a producer-owned source` …),
|
|
77
|
+
> 因为 `dsh-session-format-v3-to-v4` 对事件形状的要求极严,**少一项就是整个回合失败**。
|
|
78
|
+
|
|
79
|
+
现在改为调用宿主自己的压缩服务:
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
ctx.compaction.compactRegion(start, end, agent) // 切点由我们算,摘要/写事件/校验由宿主做
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- 触发时机:`agent/status === 'idle'`(压缩服务拒绝 active 会话),且**不 await**,绝不拖住对话。
|
|
86
|
+
- 结果:`[start, end]` 这段旧节点被替换成**一条摘要**,原始旧消息不再计入输入。
|
|
87
|
+
- 天然限流:裁完轮数掉回 20,要再长到超过 20 才会触发,不会每轮都调摘要。
|
|
88
|
+
- 失败只跳过本次,`try/catch` + `.catch(() => {})` 双保险,绝不影响对话。
|
|
73
89
|
#### ⚠️ `agent/pre-step` 是 waterfall,必须 `return next()`
|
|
74
90
|
|
|
75
91
|
Cordis 的实现是这样的:
|
package/cordis.patch.yml
CHANGED
|
@@ -107,12 +107,19 @@
|
|
|
107
107
|
maxChars: 16000
|
|
108
108
|
turnLimit: 80
|
|
109
109
|
detailLimit: 5
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
110
|
+
# 历史窗口:让「最近 N 轮」以外的内容**不计入输入**。
|
|
111
|
+
# 关键:**不自己写事件**。改用宿主自己的压缩服务
|
|
112
|
+
# ctx.compaction.compactRegion(start, end, agent)
|
|
113
|
+
# 由宿主做摘要、写事件、自己校验 —— 之前自己拼 surfaceOp:replace 连崩四次,
|
|
114
|
+
# 因为 dsh-session-format-v3-to-v4 对事件形状的要求(turn 正整数、id、role、
|
|
115
|
+
# content、producer-owned source…)少一项就是整回合失败。
|
|
116
|
+
# 代价:每次裁剪跑一次摘要 LLM 调用;只在 agent 空闲时做,且裁完轮数掉回 N,
|
|
117
|
+
# 要再长到超过 N 才会触发,天然限流。出错只跳过本次,绝不影响对话。
|
|
118
|
+
- id: whale-history-window
|
|
119
|
+
name: 'dsh-whale-loli/history-window'
|
|
120
|
+
config:
|
|
121
|
+
enable: true
|
|
122
|
+
keepTurns: 20
|
|
116
123
|
- id: agent-instructions
|
|
117
124
|
name: '@deepseek-ai/dsh-agent-instructions'
|
|
118
125
|
config:
|
|
@@ -309,12 +316,19 @@
|
|
|
309
316
|
maxChars: 16000
|
|
310
317
|
turnLimit: 80
|
|
311
318
|
detailLimit: 5
|
|
312
|
-
#
|
|
313
|
-
#
|
|
314
|
-
#
|
|
315
|
-
#
|
|
316
|
-
#
|
|
317
|
-
#
|
|
319
|
+
# 历史窗口:让「最近 N 轮」以外的内容**不计入输入**。
|
|
320
|
+
# 关键:**不自己写事件**。改用宿主自己的压缩服务
|
|
321
|
+
# ctx.compaction.compactRegion(start, end, agent)
|
|
322
|
+
# 由宿主做摘要、写事件、自己校验 —— 之前自己拼 surfaceOp:replace 连崩四次,
|
|
323
|
+
# 因为 dsh-session-format-v3-to-v4 对事件形状的要求(turn 正整数、id、role、
|
|
324
|
+
# content、producer-owned source…)少一项就是整回合失败。
|
|
325
|
+
# 代价:每次裁剪跑一次摘要 LLM 调用;只在 agent 空闲时做,且裁完轮数掉回 N,
|
|
326
|
+
# 要再长到超过 N 才会触发,天然限流。出错只跳过本次,绝不影响对话。
|
|
327
|
+
- id: whale-history-window
|
|
328
|
+
name: 'dsh-whale-loli/history-window'
|
|
329
|
+
config:
|
|
330
|
+
enable: true
|
|
331
|
+
keepTurns: 20
|
|
318
332
|
- id: agent-instructions
|
|
319
333
|
name: '@deepseek-ai/dsh-agent-instructions'
|
|
320
334
|
config:
|
|
@@ -456,12 +470,19 @@
|
|
|
456
470
|
maxChars: 16000
|
|
457
471
|
turnLimit: 80
|
|
458
472
|
detailLimit: 5
|
|
459
|
-
#
|
|
460
|
-
#
|
|
461
|
-
#
|
|
462
|
-
#
|
|
463
|
-
#
|
|
464
|
-
#
|
|
473
|
+
# 历史窗口:让「最近 N 轮」以外的内容**不计入输入**。
|
|
474
|
+
# 关键:**不自己写事件**。改用宿主自己的压缩服务
|
|
475
|
+
# ctx.compaction.compactRegion(start, end, agent)
|
|
476
|
+
# 由宿主做摘要、写事件、自己校验 —— 之前自己拼 surfaceOp:replace 连崩四次,
|
|
477
|
+
# 因为 dsh-session-format-v3-to-v4 对事件形状的要求(turn 正整数、id、role、
|
|
478
|
+
# content、producer-owned source…)少一项就是整回合失败。
|
|
479
|
+
# 代价:每次裁剪跑一次摘要 LLM 调用;只在 agent 空闲时做,且裁完轮数掉回 N,
|
|
480
|
+
# 要再长到超过 N 才会触发,天然限流。出错只跳过本次,绝不影响对话。
|
|
481
|
+
- id: whale-history-window
|
|
482
|
+
name: 'dsh-whale-loli/history-window'
|
|
483
|
+
config:
|
|
484
|
+
enable: true
|
|
485
|
+
keepTurns: 20
|
|
465
486
|
- id: agent-instructions
|
|
466
487
|
name: '@deepseek-ai/dsh-agent-instructions'
|
|
467
488
|
config:
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-whale-loli",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "鲸鱼娘 Agent 预设(三个)— 极简模式工具面 + 鲸鱼娘·小鱼人设 + 跨会话长期记忆(提示变量通道,per-preset 独立文件)+
|
|
3
|
+
"version": "0.5.7",
|
|
4
|
+
"description": "鲸鱼娘 Agent 预设(三个)— 极简模式工具面 + 鲸鱼娘·小鱼人设 + 跨会话长期记忆(提示变量通道,per-preset 独立文件)+ 最近 20 轮历史窗口:调宿主 ctx.compaction.compactRegion() 把更早的内容压成一条摘要,让它们不再计入输入;自己不写任何会话事件(宿主摘要+写入+校验)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
package/src/history-window.js
CHANGED
|
@@ -1,80 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* history-window ——
|
|
2
|
+
* history-window —— 让「最近 N 轮」以外的内容**不计入输入**。
|
|
3
3
|
*
|
|
4
|
-
* ──
|
|
4
|
+
* ── 为什么是这个做法 ──────────────────────────────────────────────────
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* 之前试过自己往会话里写替换事件(`surfaceOp: replace`)来做纯删除,连崩四次:
|
|
7
|
+
* `dsh-session-format-v3-to-v4` 的校验器对消息事件极其苛刻 ——
|
|
8
|
+
* 先要求 `developer/message` 的 turn 为正整数,再要求 `id`/`role`/`content`/
|
|
9
|
+
* producer-owned `source`,改 `user/message` 也照样被拒;而一旦被拒就是**整个回合失败**。
|
|
10
|
+
* 换句话说:**事件形状不是插件该自己拼的东西**。
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
+
* 现在改成调用宿主自己的压缩服务:
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
* surfaceOp: { op: 'replace', startSeq, endSeq },
|
|
15
|
-
* })
|
|
14
|
+
* ctx.compaction.compactRegion(start, end, agent, signal)
|
|
16
15
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* 它把 `[start, end]` 这段表层节点**替换成一条摘要**(由宿主写事件、宿主校验),
|
|
17
|
+
* 于是原始旧消息真的不再计入输入,只有那条摘要计入。切点仍然由我算(保留最近 N 轮),
|
|
18
|
+
* 但合法性判断与写入全部交给宿主。
|
|
19
19
|
*
|
|
20
|
-
* ──
|
|
20
|
+
* ── 代价与边界 ────────────────────────────────────────────────────────
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* 3. **默认关闭**:改写会话历史是强动作,必须显式 `enable: true`。
|
|
27
|
-
*
|
|
28
|
-
* ── 挂在哪里 ──────────────────────────────────────────────────────────
|
|
29
|
-
*
|
|
30
|
-
* `agent/pre-step`:循环在每个步骤**派生历史之前**运行它,是唯一既在请求前、
|
|
31
|
-
* 又能拿到本 agent 的时机。(`turn/start` 是 session 事件而非 ctx 事件,挂不上。)
|
|
22
|
+
* · 每次裁剪会跑**一次摘要 LLM 调用**(宿主用当前路由模型)。
|
|
23
|
+
* · 只能在 agent 空闲时做(压缩服务拒绝 active 会话),所以挂在 `agent/status === 'idle'`。
|
|
24
|
+
* · 裁完轮数会掉到 N 上下,必须等到再次超过 N 才会触发 —— 天然限流,不会每轮都调。
|
|
25
|
+
* · 任何失败都被吞掉并跳过本次,**绝不影响正常对话**。
|
|
32
26
|
*/
|
|
33
|
-
import { randomUUID } from 'node:crypto';
|
|
34
27
|
import { homedir } from 'node:os';
|
|
35
28
|
import { join } from 'node:path';
|
|
36
29
|
|
|
37
30
|
/** Cordis 插件名。 */
|
|
38
31
|
export const name = 'whale-history-window';
|
|
39
32
|
|
|
40
|
-
/**
|
|
33
|
+
/** 需要会话表(用来拿 agent.session)。 */
|
|
41
34
|
export const inject = ['sessions'];
|
|
42
35
|
|
|
43
36
|
/** 默认保留轮数。 */
|
|
44
37
|
export const DEFAULT_KEEP_TURNS = 20;
|
|
45
38
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
return (
|
|
49
|
-
`【已裁剪更早的对话】为了只保留最近 ${keptTurns} 轮,` +
|
|
50
|
-
`${removedTurns} 轮更早的对话已从上下文中移除(仍保存在会话日志里,可回溯)。` +
|
|
51
|
-
`结合小鱼自己的记忆文件继续就好。`
|
|
52
|
-
);
|
|
53
|
-
}
|
|
39
|
+
/** 不算「用户开口」的 source.kind:宿主注入的运行时上下文。 */
|
|
40
|
+
const NON_TURN_SOURCE_KINDS = new Set(['runtime-context']);
|
|
54
41
|
|
|
55
|
-
/** dsh
|
|
42
|
+
/** dsh 家目录(仅用于自检输出)。 */
|
|
56
43
|
function dshHome() {
|
|
57
44
|
return process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
58
45
|
}
|
|
59
46
|
|
|
60
47
|
/**
|
|
61
|
-
*
|
|
62
|
-
* 宿主只要求 message.source.kind 是非空字符串且不是 `'plugin'`,
|
|
63
|
-
* 但我们必须能把自己的提示条和「真实用户开口」区分开,否则每裁一次就多算一轮。
|
|
64
|
-
*/
|
|
65
|
-
export const NOTICE_SOURCE_KIND = 'history-window';
|
|
66
|
-
|
|
67
|
-
/** 不算「用户开口」的 source.kind:本插件的提示条 + 宿主注入的运行时上下文。 */
|
|
68
|
-
const NON_TURN_SOURCE_KINDS = new Set([NOTICE_SOURCE_KIND, 'runtime-context']);
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* 一个「轮」的边界 = 一条**真实用户**发出的 user 消息。
|
|
48
|
+
* 一条事件算不算「用户开口」。
|
|
72
49
|
*
|
|
73
50
|
* 不能只看 `type === 'user/message'`:宿主的运行时上下文(`source.kind = 'runtime-context'`)
|
|
74
|
-
*
|
|
75
|
-
* 来源未知(老日志没有 source)时保守地当成用户开口。
|
|
51
|
+
* 也是 user/message,会把轮数算多、导致过早压缩。来源未知时保守地算作一轮。
|
|
76
52
|
*/
|
|
77
|
-
function
|
|
53
|
+
export function isUserTurn(event) {
|
|
78
54
|
if (event?.type !== 'user/message') return false;
|
|
79
55
|
const kind = event.data?.source?.kind;
|
|
80
56
|
return typeof kind !== 'string' || !NON_TURN_SOURCE_KINDS.has(kind);
|
|
@@ -82,7 +58,7 @@ function isUserNode(event) {
|
|
|
82
58
|
|
|
83
59
|
/**
|
|
84
60
|
* 切点之前是否工具配对平衡(没有未回应的 tool-call 跨过切点)。
|
|
85
|
-
*
|
|
61
|
+
* 压缩服务要求两端都平衡,否则拒绝该范围。
|
|
86
62
|
*/
|
|
87
63
|
export function balancedBefore(session, nodes, cutIndex) {
|
|
88
64
|
let pending = 0;
|
|
@@ -100,188 +76,103 @@ export function balancedBefore(session, nodes, cutIndex) {
|
|
|
100
76
|
}
|
|
101
77
|
|
|
102
78
|
/**
|
|
103
|
-
*
|
|
79
|
+
* 计算压缩范围(纯函数,不动会话)。
|
|
104
80
|
*
|
|
105
81
|
* @param session - 需要 `surface.nodes` 与 `eventAt()`。
|
|
106
82
|
* @param keepTurns - 保留最近多少轮。
|
|
107
|
-
* @returns `{ startSeq, endSeq, removedTurns, keptTurns
|
|
83
|
+
* @returns `{ startSeq, endSeq, removedTurns, keptTurns }`;无需压缩返回 null。
|
|
108
84
|
*/
|
|
109
|
-
export function
|
|
85
|
+
export function planCompact(session, keepTurns = DEFAULT_KEEP_TURNS) {
|
|
110
86
|
const nodes = session?.surface?.nodes ?? [];
|
|
111
87
|
if (nodes.length === 0) return null;
|
|
112
88
|
|
|
113
|
-
//
|
|
114
|
-
let
|
|
89
|
+
// 表层第一个 user 节点:系统提示(节点 0)永不进入范围
|
|
90
|
+
let firstTurn = -1;
|
|
115
91
|
for (let i = 0; i < nodes.length; i++) {
|
|
116
92
|
if (session.eventAt(nodes[i])?.type === 'user/message') {
|
|
117
|
-
|
|
93
|
+
firstTurn = i;
|
|
118
94
|
break;
|
|
119
95
|
}
|
|
120
96
|
}
|
|
97
|
+
if (firstTurn < 0) return null;
|
|
121
98
|
|
|
122
|
-
const
|
|
123
|
-
for (let i =
|
|
124
|
-
if (
|
|
99
|
+
const turnIdx = [];
|
|
100
|
+
for (let i = firstTurn; i < nodes.length; i++) {
|
|
101
|
+
if (isUserTurn(session.eventAt(nodes[i]))) turnIdx.push(i);
|
|
125
102
|
}
|
|
126
|
-
if (
|
|
103
|
+
if (turnIdx.length <= keepTurns) return null;
|
|
127
104
|
|
|
128
|
-
// 保留最近 keepTurns
|
|
129
|
-
|
|
105
|
+
// 保留最近 keepTurns 轮:切点 = 第 (总数 - keepTurns) 个用户轮
|
|
106
|
+
let cut = turnIdx[turnIdx.length - keepTurns];
|
|
107
|
+
// 往前挪到最近的工具配对平衡位置
|
|
108
|
+
while (cut > firstTurn && !balancedBefore(session, nodes, cut)) cut--;
|
|
109
|
+
if (cut <= firstTurn) return null;
|
|
130
110
|
|
|
131
|
-
let cut = userIdx[userIdx.length - keepTurns];
|
|
132
|
-
while (cut > firstUser && !balancedBefore(session, nodes, cut)) cut--;
|
|
133
|
-
if (cut <= firstUser) return null;
|
|
134
|
-
|
|
135
|
-
// 关键:startSeq 必须是第一个 user 节点,**绝不能是 nodes[0]**,
|
|
136
|
-
// 否则表层第 0 个 system/message 会被一起 replace 掉。
|
|
137
111
|
return {
|
|
138
|
-
startSeq: nodes[
|
|
112
|
+
startSeq: nodes[firstTurn],
|
|
139
113
|
endSeq: nodes[cut - 1],
|
|
140
|
-
removedTurns:
|
|
114
|
+
removedTurns: turnIdx.length - keepTurns,
|
|
141
115
|
keptTurns: keepTurns,
|
|
142
|
-
cutIndex: cut,
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* 收集被替换范围覆盖的**当前表层节点**。
|
|
148
|
-
*
|
|
149
|
-
* 宿主的 `assertSourceEventReferences` 硬性要求:`sourceEventSeqs` 必须列全每一个
|
|
150
|
-
* 被 shadow 的表层节点,否则抛
|
|
151
|
-
* `surface replace: sourceEventSeqs must include every shadowed surface node; missing …`
|
|
152
|
-
* 漏了这个参数 = 每次裁剪都失败(错误又被 try/catch 吞掉,表现为「裁剪从来没生效」)。
|
|
153
|
-
*/
|
|
154
|
-
export function shadowedNodes(session, startSeq, endSeq) {
|
|
155
|
-
const nodes = session?.surface?.nodes ?? [];
|
|
156
|
-
return nodes.filter((seq) => seq >= startSeq && seq <= endSeq);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** 提示条事件的唯一构造点。 */
|
|
160
|
-
function noticePayload(plan) {
|
|
161
|
-
return {
|
|
162
|
-
id: randomUUID(),
|
|
163
|
-
role: 'user',
|
|
164
|
-
content: [{ type: 'text', text: noticeText(plan.removedTurns, plan.keptTurns) }],
|
|
165
|
-
source: { kind: NOTICE_SOURCE_KIND },
|
|
166
116
|
};
|
|
167
117
|
}
|
|
168
118
|
|
|
169
119
|
/**
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* 为什么需要:连踩四次都是「写出去才被宿主拒收」,而拒收会直接打死整个回合
|
|
173
|
-
* (`turn/end {kind:'error'}`),用户看到的就是一句看不懂的校验错误。预检之后,
|
|
174
|
-
* 形状不对只会让我们**跳过这次裁剪**,对话继续。
|
|
175
|
-
*
|
|
176
|
-
* 校验项(对 `user/message`):
|
|
177
|
-
* 1. `assertOrdinaryMessage` —— `role !== 'developer'`、`content` 必须是数组
|
|
178
|
-
* 2. `assertV4MessageSources` —— `source` 是对象、`source.kind` 非空且 ≠ `'plugin'`
|
|
179
|
-
* 3. 本插件额外要求 `id` 非空(真实消息都有,便于溯源)
|
|
180
|
-
*/
|
|
181
|
-
export function noticeIsValid(message) {
|
|
182
|
-
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
183
|
-
if (!isObject(message)) return false;
|
|
184
|
-
if (typeof message.id !== 'string' || message.id.length === 0) return false;
|
|
185
|
-
if (message.role === 'developer') return false;
|
|
186
|
-
if (!Array.isArray(message.content)) return false;
|
|
187
|
-
const source = message.source;
|
|
188
|
-
if (!isObject(source)) return false;
|
|
189
|
-
if (typeof source.kind !== 'string' || source.kind.length === 0) return false;
|
|
190
|
-
if (source.kind === 'plugin') return false;
|
|
191
|
-
return true;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* 执行裁剪。返回计划;无需裁剪返回 null。
|
|
196
|
-
*
|
|
197
|
-
* 替换消息用 **`user/message`**,形状照 compaction 的 checkpoint 抄 —— 这是宿主
|
|
198
|
-
* 已经在跑、必然通过全部校验的那条路径。相比之下 `developer/message` 的规则苛刻得多
|
|
199
|
-
* (`assertDeveloperMessage` 要求 `id` / `role` / `content` / **producer-owned `source`**,
|
|
200
|
-
* 且 `turn`、`step` 必须是正整数),少一项就抛:
|
|
201
|
-
* format v4 developer message requires id, role, content, and a producer-owned source
|
|
120
|
+
* 执行压缩。成功返回计划,跳过返回 null。
|
|
202
121
|
*
|
|
203
|
-
* `
|
|
204
|
-
*
|
|
205
|
-
* 2. `assertV4MessageSources`:`source.kind` 是非空字符串且不等于 `'plugin'`
|
|
206
|
-
* 所以带上 `id` 和 `source: { kind: 'history-window' }` 即可,且能被 {@link isUserNode}
|
|
207
|
-
* 识别出来、不计入轮数。
|
|
122
|
+
* 真正的替换由 `ctx.compaction.compactRegion()` 完成:它负责摘要、写事件、写日志。
|
|
123
|
+
* 本函数只负责「算范围」和「不让异常外泄」。
|
|
208
124
|
*/
|
|
209
|
-
export function
|
|
125
|
+
export async function compactHistory(ctx, agent, keepTurns = DEFAULT_KEEP_TURNS, signal) {
|
|
210
126
|
const session = agent?.session;
|
|
211
127
|
if (session === undefined) return null;
|
|
212
128
|
|
|
213
|
-
const
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
const shadowed = shadowedNodes(session, plan.startSeq, plan.endSeq);
|
|
217
|
-
if (shadowed.length === 0) return null;
|
|
129
|
+
const compaction = ctx.get?.('compaction') ?? ctx.compaction;
|
|
130
|
+
if (compaction?.compactRegion === undefined) return null;
|
|
218
131
|
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
if (!noticeIsValid(message)) return null;
|
|
132
|
+
const plan = planCompact(session, keepTurns);
|
|
133
|
+
if (plan === null) return null;
|
|
222
134
|
|
|
223
|
-
|
|
224
|
-
'user/message',
|
|
225
|
-
message,
|
|
226
|
-
{
|
|
227
|
-
surfaceOp: { op: 'replace', startSeq: plan.startSeq, endSeq: plan.endSeq },
|
|
228
|
-
sourceEventSeqs: shadowed,
|
|
229
|
-
},
|
|
230
|
-
);
|
|
135
|
+
await compaction.compactRegion(plan.startSeq, plan.endSeq, agent, signal);
|
|
231
136
|
return plan;
|
|
232
137
|
}
|
|
233
138
|
|
|
139
|
+
/** 每个 agent 同时只允许一次压缩(压缩服务本身也拒绝并发,这里提前避开)。 */
|
|
140
|
+
const inFlight = new WeakSet();
|
|
141
|
+
|
|
234
142
|
/**
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* ⚠️ `agent/pre-step` 是 Cordis 的 **waterfall**。宿主实现:
|
|
238
|
-
*
|
|
239
|
-
* waterfall(...args) {
|
|
240
|
-
* const cbs = this.dispatch("waterfall", args)
|
|
241
|
-
* const inner = args.pop()
|
|
242
|
-
* const next = () => (cbs.shift() ?? inner)(...args)
|
|
243
|
-
* args.push(next)
|
|
244
|
-
* return next()
|
|
245
|
-
* }
|
|
246
|
-
*
|
|
247
|
-
* 也就是说:**不调用 `next()` 的监听器会否决整条链**,并把它自己的返回值当作最终结果。
|
|
248
|
-
* 曾经这里写成 `async (payload) => { …裁剪… }`(不接 next、返回 undefined),结果
|
|
249
|
-
* `agent/pre-step` 的 decision 变成 undefined,agent-loop 里 `decision.kind` 直接抛
|
|
250
|
-
* `Cannot read properties of undefined (reading 'kind')`
|
|
251
|
-
* —— 整个回合在 step 之前就崩掉,而且没有任何 step/start 事件,极难定位。
|
|
252
|
-
*
|
|
253
|
-
* 所以:无论裁剪成功、失败还是被跳过,**必须 `return next()`**,把下游的行为(默认的
|
|
254
|
-
* `{kind:'enter'}` 以及 compaction 等其它监听器)原样放行。
|
|
143
|
+
* 挂载:agent 转为空闲时检查轮数,超过 keepTurns 就压缩掉更早的部分。
|
|
255
144
|
*
|
|
256
145
|
* config:
|
|
257
|
-
* - `enable` 置
|
|
146
|
+
* - `enable` 置 false 完全不挂(默认**开启**;出问题一行改回)
|
|
258
147
|
* - `keepTurns` 保留最近多少轮(默认 20)
|
|
259
148
|
*/
|
|
260
149
|
export function apply(ctx, config = {}) {
|
|
261
|
-
if (config.enable
|
|
150
|
+
if (config.enable === false) return;
|
|
262
151
|
|
|
263
152
|
const keepTurns = typeof config.keepTurns === 'number' ? config.keepTurns : DEFAULT_KEEP_TURNS;
|
|
153
|
+
if (typeof ctx.on !== 'function') return;
|
|
264
154
|
|
|
265
|
-
// agent/pre-step 在派生历史之前运行,且拿得到本 agent。
|
|
266
155
|
ctx.effect(
|
|
267
156
|
() =>
|
|
268
|
-
ctx.on('agent/
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
157
|
+
ctx.on('agent/status', (payload) => {
|
|
158
|
+
if (payload?.status !== 'idle') return;
|
|
159
|
+
const agent = payload?.agent;
|
|
160
|
+
if (agent?.session === undefined) return;
|
|
161
|
+
if (inFlight.has(agent)) return;
|
|
162
|
+
inFlight.add(agent);
|
|
163
|
+
// 不 await:这是空闲期的后台整理,绝不能拖住/影响对话。
|
|
164
|
+
void Promise.resolve()
|
|
165
|
+
.then(() => compactHistory(ctx, agent, keepTurns))
|
|
166
|
+
.catch(() => {})
|
|
167
|
+
.finally(() => inFlight.delete(agent));
|
|
277
168
|
}),
|
|
278
|
-
'whale-history-window.
|
|
169
|
+
'whale-history-window.idle-compact',
|
|
279
170
|
);
|
|
280
171
|
}
|
|
281
172
|
|
|
282
|
-
/**
|
|
173
|
+
/** 给文档/自检用的配置快照。 */
|
|
283
174
|
export function windowConfig() {
|
|
284
|
-
return { defaultKeepTurns: DEFAULT_KEEP_TURNS,
|
|
175
|
+
return { defaultKeepTurns: DEFAULT_KEEP_TURNS, home: dshHome() };
|
|
285
176
|
}
|
|
286
177
|
|
|
287
178
|
export default { name, inject, apply };
|