@zhushanwen/pi-smart-context 0.3.2 → 0.3.3
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 +10 -4
- package/package.json +2 -2
- package/skills/smart-context-ext-config/SKILL.md +1 -1
- package/src/__tests__/index-generation.test.ts +2 -2
- package/src/__tests__/index-reminder-delivery.test.ts +227 -0
- package/src/__tests__/pure.test.ts +47 -0
- package/src/__tests__/reminder.test.ts +7 -2
- package/src/__tests__/tool.test.ts +24 -18
- package/src/index.ts +86 -12
- package/src/pure.ts +63 -0
- package/src/reminder.ts +11 -11
- package/src/tool.ts +22 -9
package/README.md
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
- **双模式生成接管**(`session_before_compact`,覆盖工具 / `/compact` / 内建 auto 三条路径):
|
|
9
9
|
- same-model:完整原始上下文 + 会话原 system prompt + tools + 末尾追加压缩指令——前缀缓存全命中,成本≈增量指令+输出,且模型看全量上下文(质量上限最高)
|
|
10
10
|
- cross-model:调用 pi 原生 `compact()` 仅换模型与凭证——split-turn / fileOps / previousSummary 原生组装
|
|
11
|
-
- **3 档阈值提醒**(默认 400K/500K/600K):`agent_settled`
|
|
11
|
+
- **3 档阈值提醒**(默认 400K/500K/600K):`agent_settled` 越档检查,每档一次、多档合并、压缩后重置。投递是**静默注入**(`pi.sendMessage` custom message + `triggerTurn:false` + `display:false`)——只进 LLM 上下文,不触发新 turn、不进对话流,用户继续对话时模型在下一轮自然看到;已提醒档位经 `pi.appendEntry` 落在 session entries(`smart-context:fired`),reload / 进程重启后从 entries 重建,同一档不会重复提醒。提醒文案两行以内,措辞是数据投递不是指令
|
|
12
12
|
- **排除模型**(精准 `provider/modelId` 匹配):工具拒绝 + 不提醒 + 回落 pi 原生生成;切换跨界时注入一条可用性通知,downshift(切小窗模型将触线)时建议先压缩
|
|
13
|
-
- **健壮性**:摘要收缩校验、max-tokens 截断 fail-closed、接管失败 3
|
|
14
|
-
- **subagent 进程**自动静默(`
|
|
13
|
+
- **健壮性**:摘要收缩校验、max-tokens 截断 fail-closed、接管失败 3 次熔断(本 session 内停止接管)、transcript 回查指针、压缩后最近文件内容重注入(≤5 文件/50K)、多轮压缩降智提示(累计压缩 ≥2 次后附加)
|
|
14
|
+
- **subagent 进程**自动静默(`TAIJI_AGENT_SUBAGENT` 标记)
|
|
15
15
|
|
|
16
16
|
## 行为门控
|
|
17
17
|
|
|
@@ -23,7 +23,13 @@
|
|
|
23
23
|
| compactModel 未配置/无效 | 放行(same-model 不依赖该配置) | 生效 | same-model 生效;cross-model 回退当前模型,压缩不失败 |
|
|
24
24
|
| 模型切换跨界(model_select) | 常驻不变 | 注入一条可用性变化通知(仅跨界时) | 按新模型即时重判 |
|
|
25
25
|
|
|
26
|
-
行为正确性由 execute / handler
|
|
26
|
+
行为正确性由 execute / handler 现场校验兜底,每次事件回调重新读配置(热加载,改完下一次压缩 / 提醒 / 工具调用即生效,无需重启)。
|
|
27
|
+
|
|
28
|
+
## 安装
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pi install npm:@zhushanwen/pi-smart-context
|
|
32
|
+
```
|
|
27
33
|
|
|
28
34
|
## 配置
|
|
29
35
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-smart-context",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"taiji": {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@zhushanwen/pi-ext-guards": "0.4.1",
|
|
40
40
|
"@zhushanwen/pi-extension-logger": "0.6.1",
|
|
41
|
-
"@zhushanwen/pi-llm-shared": "0.
|
|
41
|
+
"@zhushanwen/pi-llm-shared": "0.9.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@vitest/coverage-v8": "^4.1.9",
|
|
@@ -9,7 +9,7 @@ description: "配置 @zhushanwen/pi-smart-context(智能上下文压缩:agen
|
|
|
9
9
|
|
|
10
10
|
- `compact_context` 工具:agent 在「任务阶段性完成 && 压缩不影响后续 && 上下文超阈值」时自决调用
|
|
11
11
|
- 双模式摘要生成:压缩模型 = 当前模型 → same-model 模式(KV 缓存命中,成本最低质量最高);不同 → cross-model 模式(廉价模型 + 最小输入)
|
|
12
|
-
- 3 档阈值提醒:越过档位时 agent
|
|
12
|
+
- 3 档阈值提醒:越过档位时 agent 收到一次性提示(自行判断,不强制)。投递为静默注入——只进下一轮 LLM 上下文,不触发新 turn、不进对话流;同一档位不会重复提醒(已提醒档位随 session 持久化,reload / 重启后不丢)
|
|
13
13
|
- 排除模型:命中列表的会话模型整体关闭本功能(回落 pi 原生压缩)
|
|
14
14
|
- pi 内建自动压缩的触发线保留为最后防线,其压缩执行也走本扩展逻辑
|
|
15
15
|
|
|
@@ -38,7 +38,7 @@ vi.mock("../tool.js", () => ({
|
|
|
38
38
|
|
|
39
39
|
import smartContextExtension from "../index.js";
|
|
40
40
|
|
|
41
|
-
/** 最小 fake pi:装配路径只消费 on(事件接线);registerTool/
|
|
41
|
+
/** 最小 fake pi:装配路径只消费 on(事件接线);registerTool/sendMessage 兜底。 */
|
|
42
42
|
function createMockPi(): {
|
|
43
43
|
pi: ExtensionAPI
|
|
44
44
|
events: Map<string, (...args: unknown[]) => void>
|
|
@@ -47,7 +47,7 @@ function createMockPi(): {
|
|
|
47
47
|
const pi = {
|
|
48
48
|
registerTool: vi.fn(),
|
|
49
49
|
on: (event: string, handler: (...args: unknown[]) => void) => events.set(event, handler),
|
|
50
|
-
|
|
50
|
+
sendMessage: vi.fn(),
|
|
51
51
|
} as unknown as ExtensionAPI;
|
|
52
52
|
return { pi, events };
|
|
53
53
|
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// src/__tests__/index-reminder-delivery.test.ts
|
|
2
|
+
//
|
|
3
|
+
// D4' + D15 装配回归:越档提醒的「静默注入 + 档位持久化」两条语义在 index.ts 装配点成立。
|
|
4
|
+
//
|
|
5
|
+
// 现场(2026-09-19 会话 01a0b8b3):taiji skill-reload 发 `/__taiji_reload__` → pi ctx.reload()
|
|
6
|
+
// 重跑 extension factory(jiti moduleCache:false 重新 import)→ firedThresholds 归零,且 reload
|
|
7
|
+
// 补发的 session_start reason 是 `startup`(区分不了重载与新会话)→ 同一档被提醒两次;同时
|
|
8
|
+
// sendUserMessage(followUp) 恒触发一轮(pi 语义),用户收工后仍被唤醒烧掉一整轮全量上下文。
|
|
9
|
+
//
|
|
10
|
+
// 本文件锁两件事:
|
|
11
|
+
// ① 投递形态 = sendMessage({customType, display:false}, {triggerTurn:false}),且不再用 sendUserMessage;
|
|
12
|
+
// ② fired 档位写入 session entries(appendEntry marker),工厂重跑(reload 等价拓扑)后
|
|
13
|
+
// 从 entries 重建 → 同档不重复、跨档照常提醒。
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
|
+
|
|
18
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
19
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
22
|
+
getLogger: () => loggerMock,
|
|
23
|
+
createLogger: () => loggerMock,
|
|
24
|
+
setPiHandle: vi.fn(),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
// 工具注册短路(本文件只测提醒链路装配;compact 工具装配由 index-generation.test.ts 覆盖)
|
|
28
|
+
vi.mock("../tool.js", () => ({
|
|
29
|
+
registerCompactContextTool: vi.fn(),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// 配置冻结:loadSmartContextConfig 走 llm-shared loadConfig(读真实 agentDir 磁盘文件),
|
|
33
|
+
// 测试必须确定(不随本机 ~/.taiji 配置漂移)→ 只覆写该函数,pure.js 其余纯函数保持实装。
|
|
34
|
+
const TEST_CONFIG = {
|
|
35
|
+
enabled: true,
|
|
36
|
+
compactModel: { type: "ref" as const, ref: "" },
|
|
37
|
+
reminderThresholds: [200_000, 400_000, 600_000],
|
|
38
|
+
excludedModels: [] as string[],
|
|
39
|
+
};
|
|
40
|
+
vi.mock("../pure.js", async (importOriginal) => ({
|
|
41
|
+
...(await importOriginal<typeof import("../pure.js")>()),
|
|
42
|
+
loadSmartContextConfig: () => structuredClone(TEST_CONFIG),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
import { FIRED_ENTRY_CUSTOM_TYPE, THRESHOLD_REMINDER_CUSTOM_TYPE } from "../pure.js";
|
|
46
|
+
import smartContextExtension from "../index.js";
|
|
47
|
+
|
|
48
|
+
interface MockPi {
|
|
49
|
+
pi: ExtensionAPI
|
|
50
|
+
events: Map<string, (...args: unknown[]) => void>
|
|
51
|
+
sendMessage: ReturnType<typeof vi.fn>
|
|
52
|
+
sendUserMessage: ReturnType<typeof vi.fn>
|
|
53
|
+
appendEntry: ReturnType<typeof vi.fn>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 最小 fake pi:本文件消费 on / sendMessage / sendUserMessage / appendEntry。 */
|
|
57
|
+
function createMockPi(): MockPi {
|
|
58
|
+
const events = new Map<string, (...args: unknown[]) => void>();
|
|
59
|
+
const sendMessage = vi.fn();
|
|
60
|
+
const sendUserMessage = vi.fn();
|
|
61
|
+
const appendEntry = vi.fn();
|
|
62
|
+
const pi = {
|
|
63
|
+
registerTool: vi.fn(),
|
|
64
|
+
on: (event: string, handler: (...args: unknown[]) => void) => events.set(event, handler),
|
|
65
|
+
sendMessage,
|
|
66
|
+
sendUserMessage,
|
|
67
|
+
appendEntry,
|
|
68
|
+
} as unknown as ExtensionAPI;
|
|
69
|
+
return { pi, events, sendMessage, sendUserMessage, appendEntry };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 最小 fake ctx:提醒链路只消费 model / getContextUsage / sessionManager.getEntries。 */
|
|
73
|
+
function makeCtx(tokens: number, entries: ReadonlyArray<unknown> = []): ExtensionContext {
|
|
74
|
+
return {
|
|
75
|
+
model: { provider: "zai", id: "glm" },
|
|
76
|
+
getContextUsage: () => ({ tokens, contextWindow: 1_000_000 }),
|
|
77
|
+
sessionManager: { getEntries: () => entries, getSessionId: () => "s1" },
|
|
78
|
+
} as unknown as ExtensionContext;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** fired marker entry 的持久形态(与 index.ts 写入口径一致)。 */
|
|
82
|
+
function markerEntry(tiers: number[], tokens: number) {
|
|
83
|
+
return { type: "custom", customType: FIRED_ENTRY_CUSTOM_TYPE, data: { tiers, tokens } };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 触发 agent_settled(提醒唯一触发点)。 */
|
|
87
|
+
function settle(pi: MockPi, ctx: ExtensionContext): void {
|
|
88
|
+
pi.events.get("agent_settled")!({ type: "agent_settled" }, ctx);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 触发 session_start(factory 重跑后的状态装配点)。 */
|
|
92
|
+
function fireSessionStart(pi: MockPi, ctx: ExtensionContext): void {
|
|
93
|
+
pi.events.get("session_start")!({ type: "session_start", reason: "startup" }, ctx);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe("D4': 越档提醒静默注入(不触发 turn、不进对话流)", () => {
|
|
97
|
+
it("命中档位 → sendMessage(customType, display:false) + {triggerTurn:false};不再 sendUserMessage", () => {
|
|
98
|
+
const mock = createMockPi();
|
|
99
|
+
smartContextExtension(mock.pi);
|
|
100
|
+
fireSessionStart(mock, makeCtx(0));
|
|
101
|
+
settle(mock, makeCtx(250_000));
|
|
102
|
+
|
|
103
|
+
expect(mock.sendUserMessage).not.toHaveBeenCalled();
|
|
104
|
+
expect(mock.sendMessage).toHaveBeenCalledTimes(1);
|
|
105
|
+
const [message, options] = mock.sendMessage.mock.calls[0]!;
|
|
106
|
+
expect(message).toMatchObject({ customType: THRESHOLD_REMINDER_CUSTOM_TYPE, display: false });
|
|
107
|
+
expect(String(message.content)).toContain("[smart-context]");
|
|
108
|
+
expect(options).toEqual({ triggerTurn: false });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("命中档位 → 先写 fired marker(session entries 持久化)", () => {
|
|
112
|
+
const mock = createMockPi();
|
|
113
|
+
smartContextExtension(mock.pi);
|
|
114
|
+
fireSessionStart(mock, makeCtx(0));
|
|
115
|
+
settle(mock, makeCtx(250_000));
|
|
116
|
+
|
|
117
|
+
expect(mock.appendEntry).toHaveBeenCalledTimes(1);
|
|
118
|
+
expect(mock.appendEntry.mock.calls[0]![0]).toBe(FIRED_ENTRY_CUSTOM_TYPE);
|
|
119
|
+
expect(mock.appendEntry.mock.calls[0]![1]).toEqual({ tiers: [200_000], tokens: 250_000 });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("未达首档 → 不投递不写 marker", () => {
|
|
123
|
+
const mock = createMockPi();
|
|
124
|
+
smartContextExtension(mock.pi);
|
|
125
|
+
fireSessionStart(mock, makeCtx(0));
|
|
126
|
+
settle(mock, makeCtx(150_000));
|
|
127
|
+
|
|
128
|
+
expect(mock.sendMessage).not.toHaveBeenCalled();
|
|
129
|
+
expect(mock.appendEntry).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("同档二次 settle 不重复投递;跨下一档只带新增档位", () => {
|
|
133
|
+
const mock = createMockPi();
|
|
134
|
+
smartContextExtension(mock.pi);
|
|
135
|
+
fireSessionStart(mock, makeCtx(0));
|
|
136
|
+
|
|
137
|
+
settle(mock, makeCtx(250_000));
|
|
138
|
+
settle(mock, makeCtx(260_000)); // 同档 → 静默
|
|
139
|
+
expect(mock.sendMessage).toHaveBeenCalledTimes(1);
|
|
140
|
+
|
|
141
|
+
settle(mock, makeCtx(450_000)); // 跨 400K → 只报新档
|
|
142
|
+
expect(mock.sendMessage).toHaveBeenCalledTimes(2);
|
|
143
|
+
expect(mock.appendEntry.mock.calls[1]![1]).toEqual({ tiers: [400_000], tokens: 450_000 });
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("D15: 档位持久化跨 factory 重跑(reload 等价拓扑)", () => {
|
|
148
|
+
it("marker 已在 entries → 新实例同档不重复提醒,跨下一档仍提醒", () => {
|
|
149
|
+
// 第一代:命中 200K 档并落 marker
|
|
150
|
+
const first = createMockPi();
|
|
151
|
+
smartContextExtension(first.pi);
|
|
152
|
+
fireSessionStart(first, makeCtx(0));
|
|
153
|
+
settle(first, makeCtx(250_000));
|
|
154
|
+
expect(first.sendMessage).toHaveBeenCalledTimes(1);
|
|
155
|
+
|
|
156
|
+
// 第二代(reload 等价:factory 重跑 + 新模块态 + session_start)——entries 里带着 marker
|
|
157
|
+
const entries = [markerEntry([200_000], 250_000)];
|
|
158
|
+
const second = createMockPi();
|
|
159
|
+
smartContextExtension(second.pi);
|
|
160
|
+
fireSessionStart(second, makeCtx(250_000, entries));
|
|
161
|
+
|
|
162
|
+
settle(second, makeCtx(255_000, entries)); // 同档 → 不重复
|
|
163
|
+
expect(second.sendMessage).not.toHaveBeenCalled();
|
|
164
|
+
expect(second.appendEntry).not.toHaveBeenCalled();
|
|
165
|
+
|
|
166
|
+
settle(second, makeCtx(450_000, entries)); // 跨 400K → 照常提醒
|
|
167
|
+
expect(second.sendMessage).toHaveBeenCalledTimes(1);
|
|
168
|
+
expect(second.appendEntry.mock.calls[0]![1]).toEqual({ tiers: [400_000], tokens: 450_000 });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("session_start 未送达新实例时,首次 settle 兜底 seed(reload 先 settle 后 start 的窗口)", () => {
|
|
172
|
+
const entries = [markerEntry([200_000, 400_000], 450_000)];
|
|
173
|
+
const mock = createMockPi();
|
|
174
|
+
smartContextExtension(mock.pi); // 只重跑 factory,不 fire session_start
|
|
175
|
+
settle(mock, makeCtx(460_000, entries));
|
|
176
|
+
|
|
177
|
+
expect(mock.sendMessage).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("compaction entry 之后档位重置(同档再次提醒)", () => {
|
|
181
|
+
const entries = [markerEntry([200_000], 250_000), { type: "compaction" }];
|
|
182
|
+
const mock = createMockPi();
|
|
183
|
+
smartContextExtension(mock.pi);
|
|
184
|
+
fireSessionStart(mock, makeCtx(0, entries));
|
|
185
|
+
settle(mock, makeCtx(250_000, entries));
|
|
186
|
+
|
|
187
|
+
expect(mock.sendMessage).toHaveBeenCalledTimes(1);
|
|
188
|
+
expect(mock.appendEntry.mock.calls[0]![1]).toEqual({ tiers: [200_000], tokens: 250_000 });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("session_compact 事件清空内存档位(D3 既有契约不回退)", () => {
|
|
192
|
+
const mock = createMockPi();
|
|
193
|
+
smartContextExtension(mock.pi);
|
|
194
|
+
fireSessionStart(mock, makeCtx(0));
|
|
195
|
+
settle(mock, makeCtx(250_000));
|
|
196
|
+
expect(mock.sendMessage).toHaveBeenCalledTimes(1);
|
|
197
|
+
|
|
198
|
+
mock.events.get("session_compact")!({ type: "session_compact" }, makeCtx(250_000));
|
|
199
|
+
settle(mock, makeCtx(255_000));
|
|
200
|
+
expect(mock.sendMessage).toHaveBeenCalledTimes(2);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("D15: entries 读取异常降级(不阻断提醒链路)", () => {
|
|
205
|
+
beforeEach(() => {
|
|
206
|
+
loggerMock.debug.mockClear();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("getEntries 抛错 → seed 降级为空集,提醒照常投递", () => {
|
|
210
|
+
const mock = createMockPi();
|
|
211
|
+
smartContextExtension(mock.pi);
|
|
212
|
+
const brokenCtx = {
|
|
213
|
+
model: { provider: "zai", id: "glm" },
|
|
214
|
+
getContextUsage: () => ({ tokens: 250_000, contextWindow: 1_000_000 }),
|
|
215
|
+
sessionManager: {
|
|
216
|
+
getEntries: () => {
|
|
217
|
+
throw new Error("boom");
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
} as unknown as ExtensionContext;
|
|
221
|
+
|
|
222
|
+
fireSessionStart(mock, brokenCtx);
|
|
223
|
+
settle(mock, brokenCtx);
|
|
224
|
+
|
|
225
|
+
expect(mock.sendMessage).toHaveBeenCalledTimes(1);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
DEFAULT_SMART_CONTEXT_CONFIG,
|
|
5
|
+
FIRED_ENTRY_CUSTOM_TYPE,
|
|
5
6
|
buildReinjectSection,
|
|
6
7
|
checkToolThresholdGuard,
|
|
7
8
|
computeFileListsLike,
|
|
9
|
+
deriveFiredThresholds,
|
|
8
10
|
estimateShadowedTokens,
|
|
9
11
|
estimateTextTokens,
|
|
10
12
|
findCrossedThresholds,
|
|
@@ -136,6 +138,51 @@ describe("摘要后处理纯函数(D11/D13)", () => {
|
|
|
136
138
|
});
|
|
137
139
|
});
|
|
138
140
|
|
|
141
|
+
describe("deriveFiredThresholds(D15:session entries → 已提醒档位)", () => {
|
|
142
|
+
const marker = (tiers: unknown, extra?: Record<string, unknown>) => ({
|
|
143
|
+
type: "custom",
|
|
144
|
+
customType: FIRED_ENTRY_CUSTOM_TYPE,
|
|
145
|
+
data: { tiers, tokens: 1, ...extra },
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("累加 marker 的 tiers(跨多条 marker)", () => {
|
|
149
|
+
const fired = deriveFiredThresholds([marker([200_000]), { type: "message" }, marker([400_000])]);
|
|
150
|
+
expect([...fired].sort((a, b) => a - b)).toEqual([200_000, 400_000]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("compaction entry 清空此前 marker(压缩后档位重置,D3)", () => {
|
|
154
|
+
const fired = deriveFiredThresholds([marker([200_000]), { type: "compaction" }]);
|
|
155
|
+
expect(fired.size).toBe(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("compaction 之后的新 marker 重新生效(压缩后再跨档仍会提醒)", () => {
|
|
159
|
+
const fired = deriveFiredThresholds([marker([200_000]), { type: "compaction" }, marker([200_000])]);
|
|
160
|
+
expect([...fired]).toEqual([200_000]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("只认本扩展的 marker:其他 custom entry / custom_message 不参与", () => {
|
|
164
|
+
const fired = deriveFiredThresholds([
|
|
165
|
+
{ type: "custom", customType: "goal-state", data: { tiers: [200_000] } },
|
|
166
|
+
{ type: "custom_message", customType: FIRED_ENTRY_CUSTOM_TYPE, data: { tiers: [200_000] } },
|
|
167
|
+
]);
|
|
168
|
+
expect(fired.size).toBe(0);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("畸形态逐条降级:data 非对象 / tiers 非数组 / 元素非数字", () => {
|
|
172
|
+
const fired = deriveFiredThresholds([
|
|
173
|
+
{ type: "custom", customType: FIRED_ENTRY_CUSTOM_TYPE },
|
|
174
|
+
{ type: "custom", customType: FIRED_ENTRY_CUSTOM_TYPE, data: "x" },
|
|
175
|
+
{ type: "custom", customType: FIRED_ENTRY_CUSTOM_TYPE, data: { tiers: "x" } },
|
|
176
|
+
{ type: "custom", customType: FIRED_ENTRY_CUSTOM_TYPE, data: { tiers: [200_000, "x", null, Number.NaN] } },
|
|
177
|
+
]);
|
|
178
|
+
expect([...fired]).toEqual([200_000]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("空 entries → 空集", () => {
|
|
182
|
+
expect(deriveFiredThresholds([]).size).toBe(0);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
139
186
|
describe("estimateShadowedTokens 口径契约(D2:与 pi estimateTokens 同源)", () => {
|
|
140
187
|
// 合法 AssistantMessage 形状的 fixture(block 数组 content,禁止 as 硬塞 string content——
|
|
141
188
|
// estimateTokens 对 string content 计 0,口径用例会静默失去判别力;设计 §8.3)。
|
|
@@ -9,14 +9,18 @@ import {
|
|
|
9
9
|
import { buildSameModelInstruction, buildTranscriptPointer, CHECKPOINT_PREAMBLE } from "../prompts.js";
|
|
10
10
|
|
|
11
11
|
describe("buildThresholdReminder(D3/D4:数据投递不强制)", () => {
|
|
12
|
-
it("
|
|
12
|
+
it("含用量数据、档位、三条件自查、可忽略出口,且固定两行(D4' 短文约束)", () => {
|
|
13
13
|
const msg = buildThresholdReminder([200_000], 215_000, 1_000_000, 0);
|
|
14
14
|
expect(msg).toContain("215K / 1000K");
|
|
15
15
|
expect(msg).toContain("21.5%");
|
|
16
16
|
expect(msg).toContain("200K");
|
|
17
17
|
expect(msg).toContain("compact_context");
|
|
18
|
-
|
|
18
|
+
// 三条件压缩进一行序号列表,语义不减
|
|
19
|
+
expect(msg).toContain("①当前任务的一个阶段已完成并验证");
|
|
20
|
+
expect(msg).toContain("②后续工作不再依赖将被压缩的早期细节");
|
|
21
|
+
expect(msg).toContain("③上下文已超阈值");
|
|
19
22
|
expect(msg).toContain("忽略本提示继续工作");
|
|
23
|
+
expect(msg.split("\n")).toHaveLength(2);
|
|
20
24
|
});
|
|
21
25
|
|
|
22
26
|
it("多档合并为一条消息", () => {
|
|
@@ -29,6 +33,7 @@ describe("buildThresholdReminder(D3/D4:数据投递不强制)", () => {
|
|
|
29
33
|
const with_ = buildThresholdReminder([200_000], 215_000, 1_000_000, 2);
|
|
30
34
|
expect(without).not.toContain("compacted multiple times");
|
|
31
35
|
expect(with_).toContain("compacted multiple times");
|
|
36
|
+
expect(with_.split("\n")).toHaveLength(3);
|
|
32
37
|
});
|
|
33
38
|
});
|
|
34
39
|
|
|
@@ -9,7 +9,7 @@ vi.mock("../pure.js", async (importOriginal) => {
|
|
|
9
9
|
};
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
-
import { DEFAULT_SMART_CONTEXT_CONFIG, loadSmartContextConfig } from "../pure.js";
|
|
12
|
+
import { DEFAULT_SMART_CONTEXT_CONFIG, COMPACT_RESULT_CUSTOM_TYPE, loadSmartContextConfig } from "../pure.js";
|
|
13
13
|
import { registerCompactContextTool } from "../tool.js";
|
|
14
14
|
import { countCompactions } from "../pure.js";
|
|
15
15
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -26,12 +26,12 @@ type CompactOpts = {
|
|
|
26
26
|
onError: (e: Error) => void;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
function makePi(): { pi: ExtensionAPI & {
|
|
29
|
+
function makePi(): { pi: ExtensionAPI & { sendMessage: ReturnType<typeof vi.fn> }; tools: RegisteredTool[] } {
|
|
30
30
|
const tools: RegisteredTool[] = [];
|
|
31
31
|
const pi = {
|
|
32
32
|
registerTool: (t: RegisteredTool) => tools.push(t),
|
|
33
|
-
|
|
34
|
-
} as unknown as ExtensionAPI & {
|
|
33
|
+
sendMessage: vi.fn(),
|
|
34
|
+
} as unknown as ExtensionAPI & { sendMessage: ReturnType<typeof vi.fn> };
|
|
35
35
|
return { pi, tools };
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -94,18 +94,20 @@ describe("compact_context 工具(R2 降级态:fire-and-forget + 结果注入
|
|
|
94
94
|
expect(result.details).toMatchObject({ mode: "same-model", launched: true, fellBack: false, compactionCount: 1 });
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
-
it("onComplete 兑现后
|
|
97
|
+
it("onComplete 兑现后 sendMessage custom message 注入结果(customType/display/triggerTurn 三要素;模式/前后 tokens/成本;降智提示按次数)", async () => {
|
|
98
98
|
const { pi, tools } = makePi();
|
|
99
99
|
registerCompactContextTool(pi, { getEntries: () => [{ type: "compaction" }, { type: "compaction" }] });
|
|
100
100
|
await tools[0].execute("t1", {}, undefined, undefined, makeCtx());
|
|
101
|
-
expect(pi.
|
|
102
|
-
const [message, options] = pi.
|
|
103
|
-
expect(message).
|
|
104
|
-
expect(message).toContain("
|
|
105
|
-
expect(message).toContain("
|
|
106
|
-
expect(message).toContain("
|
|
107
|
-
expect(message).toContain("
|
|
108
|
-
expect(
|
|
101
|
+
expect(pi.sendMessage).toHaveBeenCalledTimes(1);
|
|
102
|
+
const [message, options] = pi.sendMessage.mock.calls[0]!;
|
|
103
|
+
expect(message).toMatchObject({ customType: COMPACT_RESULT_CUSTOM_TYPE, display: true });
|
|
104
|
+
expect(String(message.content)).toContain("压缩完成");
|
|
105
|
+
expect(String(message.content)).toContain("same-model");
|
|
106
|
+
expect(String(message.content)).toContain("500K");
|
|
107
|
+
expect(String(message.content)).toContain("24K");
|
|
108
|
+
expect(String(message.content)).toContain("compacted multiple times");
|
|
109
|
+
// A6 投递时机:triggerTurn:true 唤醒轮次消费压缩结果(streaming 时 pi 缺省走 steer 队列)
|
|
110
|
+
expect(options).toEqual({ triggerTurn: true });
|
|
109
111
|
});
|
|
110
112
|
|
|
111
113
|
it("onComplete 无 engine 标记 → 注入消息含回退说明与修复指引(D7)", async () => {
|
|
@@ -113,17 +115,21 @@ describe("compact_context 工具(R2 降级态:fire-and-forget + 结果注入
|
|
|
113
115
|
registerCompactContextTool(pi);
|
|
114
116
|
const ctx = makeCtx((options) => options.onComplete({ tokensBefore: 1, estimatedTokensAfter: 1 }));
|
|
115
117
|
await tools[0].execute("t1", {}, undefined, undefined, ctx);
|
|
116
|
-
expect(pi.
|
|
117
|
-
expect(pi.
|
|
118
|
+
expect(pi.sendMessage).toHaveBeenCalledTimes(1);
|
|
119
|
+
expect(String(pi.sendMessage.mock.calls[0]![0].content)).toContain("回退");
|
|
118
120
|
});
|
|
119
121
|
|
|
120
|
-
it("onError →
|
|
122
|
+
it("onError → 注入失败消息带重试指引(display:true 失败必须用户可见 + triggerTurn:true)", async () => {
|
|
121
123
|
const { pi, tools } = makePi();
|
|
122
124
|
registerCompactContextTool(pi);
|
|
123
125
|
const ctx = makeCtx((options) => options.onError(new Error("Nothing to compact")));
|
|
124
126
|
await tools[0].execute("t1", {}, undefined, undefined, ctx);
|
|
125
|
-
expect(pi.
|
|
126
|
-
|
|
127
|
+
expect(pi.sendMessage).toHaveBeenCalledTimes(1);
|
|
128
|
+
const [message, options] = pi.sendMessage.mock.calls[0]!;
|
|
129
|
+
expect(message).toMatchObject({ customType: COMPACT_RESULT_CUSTOM_TYPE, display: true });
|
|
130
|
+
expect(String(message.content)).toContain("压缩失败");
|
|
131
|
+
expect(String(message.content)).toContain("Nothing to compact");
|
|
132
|
+
expect(options).toEqual({ triggerTurn: true });
|
|
127
133
|
});
|
|
128
134
|
|
|
129
135
|
it("custom_instructions 透传给 ctx.compact", async () => {
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zhushanwen/pi-smart-context 入口:事件接线 + 门控。
|
|
3
3
|
*
|
|
4
|
-
* - session_start:session 级闭包状态重建(规范 Session
|
|
4
|
+
* - session_start:session 级闭包状态重建(规范 Session 隔离:熔断计数不跨 session)
|
|
5
|
+
* + fired 档位从 session entries 重建(D15:reload/进程重启同 session 不重复提醒)
|
|
5
6
|
* - subagent 进程(R6):不注册工具、不提醒(宁缺勿污)
|
|
6
7
|
* - session_before_compact:双模式接管(compact-handler)
|
|
7
8
|
* - session_compact:重置提醒 fired(D3)
|
|
8
|
-
* - agent_settled:越档检查 +
|
|
9
|
+
* - agent_settled:越档检查 + marker 持久化 + 静默注入(D3/D4/D15)
|
|
9
10
|
* - model_select:跨界通知 + downshift 提醒(D5)
|
|
10
11
|
*/
|
|
11
12
|
|
|
@@ -23,10 +24,15 @@ import { buildDownshiftNotice, buildSwitchNotice, buildThresholdReminder } from
|
|
|
23
24
|
import { registerCompactContextTool } from "./tool.js";
|
|
24
25
|
import {
|
|
25
26
|
countCompactions,
|
|
27
|
+
deriveFiredThresholds,
|
|
26
28
|
findCrossedThresholds,
|
|
29
|
+
FIRED_ENTRY_CUSTOM_TYPE,
|
|
27
30
|
getCurrentModelId,
|
|
28
31
|
isGatingActive,
|
|
29
32
|
loadSmartContextConfig,
|
|
33
|
+
SWITCH_NOTICE_CUSTOM_TYPE,
|
|
34
|
+
DOWNSHIFT_NOTICE_CUSTOM_TYPE,
|
|
35
|
+
THRESHOLD_REMINDER_CUSTOM_TYPE,
|
|
30
36
|
type EntryLike,
|
|
31
37
|
} from "./pure.js";
|
|
32
38
|
|
|
@@ -35,11 +41,13 @@ interface SessionState {
|
|
|
35
41
|
takeover: TakeoverState;
|
|
36
42
|
/** 已提醒档位(token 值为键;session_compact 清空,D3)。 */
|
|
37
43
|
firedThresholds: Set<number>;
|
|
44
|
+
/** fired 是否已从 session entries seed 过(D15:每次状态重建恰好 seed 一次)。 */
|
|
45
|
+
firedSeeded: boolean;
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
/** session 级状态唯一构造点(初始态与 session_start 重建同源,新增字段不落两处)。 */
|
|
41
49
|
function createSessionState(): SessionState {
|
|
42
|
-
return { takeover: createTakeoverState(), firedThresholds: new Set() };
|
|
50
|
+
return { takeover: createTakeoverState(), firedThresholds: new Set(), firedSeeded: false };
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
// G1 代际检测(crash-resilience D1,同 scheduler/src/index.ts 的模块级代数计数器范式):
|
|
@@ -76,12 +84,51 @@ export default function smartContextExtension(pi: ExtensionAPI): void {
|
|
|
76
84
|
// pi 错误文案。首个 session_start 前无 session,恒 false 为安全默认。
|
|
77
85
|
let isCtxStale: () => boolean = () => false;
|
|
78
86
|
|
|
79
|
-
|
|
87
|
+
/**
|
|
88
|
+
* fired 档位 seed(D15):从 session entries 重建已提醒档位。
|
|
89
|
+
*
|
|
90
|
+
* reload 会重跑 factory(+ jiti 重新 import)清零闭包/模块态,且 pi 发的 session_start
|
|
91
|
+
* reason 是 `startup`(见 pure.ts FIRED_ENTRY_CUSTOM_TYPE 注释)——只能从 session 自身
|
|
92
|
+
* 的 marker entry 恢复。`firedSeeded` 保证每次状态重建只 seed 一次(settle 热路径不重复扫 entries)。
|
|
93
|
+
*
|
|
94
|
+
* ctx 可能缺 sessionManager(测试 / 极早期窗口)→ 逐层降级为空集,不阻断提醒链路。
|
|
95
|
+
*/
|
|
96
|
+
const seedFiredThresholds = (ctx: ExtensionContext): void => {
|
|
97
|
+
if (state.firedSeeded) return;
|
|
98
|
+
state.firedSeeded = true;
|
|
99
|
+
try {
|
|
100
|
+
const entries = ctx.sessionManager.getEntries() as ReadonlyArray<EntryLike>;
|
|
101
|
+
state.firedThresholds = deriveFiredThresholds(entries);
|
|
102
|
+
if (state.firedThresholds.size > 0) {
|
|
103
|
+
debugLog(`fired thresholds restored from session entries: ${[...state.firedThresholds].join(",")}`);
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// 恢复失败降级为空集(最坏结果 = 同档多提醒一次,不阻断主流程)
|
|
107
|
+
debugLog(`fired thresholds restore failed: ${toErrorMessage(error)}`);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* compaction 计数读取(D13-12 降智提示判据):entries 读取异常 → 0。
|
|
113
|
+
* 拆函数是为了不走同一条 try:提醒投递本身不依赖 entries(marker 写入 / 静默注入都能成),
|
|
114
|
+
* 降智提示只是附加行——读失败不得把整条提醒一起拆掉。
|
|
115
|
+
*/
|
|
116
|
+
const readCompactionCount = (ctx: ExtensionContext): number => {
|
|
117
|
+
try {
|
|
118
|
+
return countCompactions(ctx.sessionManager.getEntries() as ReadonlyArray<EntryLike>);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
debugLog(`compaction count read failed: ${toErrorMessage(error)}`);
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
pi.on("session_start", (_event: unknown, ctx: ExtensionContext) => {
|
|
80
126
|
// 先递增模块级代数再装配:自此同模块环境内所有前代闭包的 isCtxStale 返回 true
|
|
81
127
|
sessionGeneration += 1;
|
|
82
128
|
const myGeneration = sessionGeneration;
|
|
83
129
|
isCtxStale = () => sessionGeneration !== myGeneration;
|
|
84
130
|
state = createSessionState();
|
|
131
|
+
seedFiredThresholds(ctx);
|
|
85
132
|
});
|
|
86
133
|
|
|
87
134
|
// ── 压缩生成接管(D1/D12)──
|
|
@@ -104,27 +151,45 @@ export default function smartContextExtension(pi: ExtensionAPI): void {
|
|
|
104
151
|
// 守卫的前置代际检查恒不生效。wrapper 每次调用读闭包当前绑定。
|
|
105
152
|
registerCompactContextTool(pi, { isCtxStale: () => isCtxStale() });
|
|
106
153
|
|
|
107
|
-
// ── 阈值提醒(D3/D4):agent_settled 越档检查 +
|
|
154
|
+
// ── 阈值提醒(D3/D4/D4'):agent_settled 越档检查 + 静默注入(不触发 turn) ──
|
|
108
155
|
pi.on("agent_settled", (_event, ctx) => {
|
|
109
156
|
const config = loadSmartContextConfig();
|
|
110
157
|
const modelId = getCurrentModelId(ctx.model);
|
|
111
158
|
if (!isGatingActive(config, modelId)) return;
|
|
112
159
|
|
|
160
|
+
// D15 兜底 seed:session_start 未送达本代实例(reload 后先 settle 后 start 的窗口)时补上
|
|
161
|
+
seedFiredThresholds(ctx);
|
|
162
|
+
|
|
113
163
|
const usage = ctx.getContextUsage();
|
|
114
164
|
if (!usage) return; // R7:tokens 可能 null(压缩后首响应前)——findCrossedThresholds 容错
|
|
115
165
|
const crossed = findCrossedThresholds(config.reminderThresholds, usage.tokens, state.firedThresholds);
|
|
116
166
|
if (crossed.length === 0) return;
|
|
117
167
|
|
|
118
168
|
for (const t of crossed) state.firedThresholds.add(t);
|
|
119
|
-
const compactionCount =
|
|
169
|
+
const compactionCount = readCompactionCount(ctx);
|
|
120
170
|
const message = buildThresholdReminder(crossed, usage.tokens ?? 0, usage.contextWindow, compactionCount);
|
|
121
171
|
debugLog(`reminder fired: tiers=${crossed.join(",")} tokens=${usage.tokens}`);
|
|
122
|
-
//
|
|
123
|
-
//
|
|
172
|
+
// D15:marker 落 session entries(先写,fire-once 语义优先于投递)——下次
|
|
173
|
+
// reload/进程重启从这里重建 fired,同一档不再重复提醒。
|
|
174
|
+
guardStaleCtx(() => {
|
|
175
|
+
pi.appendEntry(FIRED_ENTRY_CUSTOM_TYPE, { tiers: crossed, tokens: usage.tokens ?? 0 });
|
|
176
|
+
}, {
|
|
177
|
+
isCtxStale,
|
|
178
|
+
label: "smart-context:fired-marker",
|
|
179
|
+
onStale: (error) => debugLog(`fired marker skipped (stale ctx): ${toErrorMessage(error)}`),
|
|
180
|
+
});
|
|
181
|
+
// D4':静默注入——custom message + triggerTurn:false = 只进 LLM 上下文,不触发新 turn、
|
|
182
|
+
// 不进对话流(display:false)。不再用 sendUserMessage(pi 语义:user message 恒触发
|
|
183
|
+
// 一轮),避免用户已收工时被提醒强行唤醒烧一整轮全量上下文。
|
|
184
|
+
// 用户继续对话时,模型在下一轮自然看到本提醒并自行决定是否 compact。
|
|
185
|
+
// 防循环:crossed 全部已标记 fired,marker 也不会产生新 turn。
|
|
124
186
|
// 事件回调内直接调用捕获的 pi——session 替换窗口可能 stale(crash-resilience D1
|
|
125
187
|
// 普查接入点),守卫 stale 静默降级(不杀 pi 进程),非 stale 错误原样上抛。
|
|
126
188
|
guardStaleCtx(() => {
|
|
127
|
-
pi.
|
|
189
|
+
pi.sendMessage(
|
|
190
|
+
{ customType: THRESHOLD_REMINDER_CUSTOM_TYPE, content: message, display: false },
|
|
191
|
+
{ triggerTurn: false },
|
|
192
|
+
);
|
|
128
193
|
}, {
|
|
129
194
|
isCtxStale,
|
|
130
195
|
label: "smart-context:threshold-reminder",
|
|
@@ -147,9 +212,14 @@ export default function smartContextExtension(pi: ExtensionAPI): void {
|
|
|
147
212
|
if (config.enabled && nowExcluded !== wasExcluded) {
|
|
148
213
|
const notice = buildSwitchNotice(nowExcluded ? "unavailable" : "available", modelId);
|
|
149
214
|
debugLog(`switch notice: ${nowExcluded ? "unavailable" : "available"} (${modelId})`);
|
|
215
|
+
// 状态通知(非紧急):triggerTurn:false 不唤醒轮次——LLM 在下一轮自然看到并自行
|
|
216
|
+
// 调整(compact 工具 execute 有运行时门控校验兜底),不为通知烧一整轮全量上下文。
|
|
150
217
|
// session 替换窗口可能 stale(D1 普查接入点)——守卫 stale 静默降级
|
|
151
218
|
guardStaleCtx(() => {
|
|
152
|
-
pi.
|
|
219
|
+
pi.sendMessage(
|
|
220
|
+
{ customType: SWITCH_NOTICE_CUSTOM_TYPE, content: notice, display: false },
|
|
221
|
+
{ triggerTurn: false },
|
|
222
|
+
);
|
|
153
223
|
}, {
|
|
154
224
|
isCtxStale,
|
|
155
225
|
label: "smart-context:switch-notice",
|
|
@@ -167,9 +237,13 @@ export default function smartContextExtension(pi: ExtensionAPI): void {
|
|
|
167
237
|
);
|
|
168
238
|
if (downshift && isGatingActive(config, modelId)) {
|
|
169
239
|
debugLog("downshift notice fired");
|
|
170
|
-
//
|
|
240
|
+
// "建议"非紧急(同 S1 裁决):triggerTurn:false 不唤醒轮次,用户继续对话时
|
|
241
|
+
// LLM 自行决策是否先压缩。session 替换窗口可能 stale(D1 普查接入点)——守卫 stale 静默降级
|
|
171
242
|
guardStaleCtx(() => {
|
|
172
|
-
pi.
|
|
243
|
+
pi.sendMessage(
|
|
244
|
+
{ customType: DOWNSHIFT_NOTICE_CUSTOM_TYPE, content: downshift, display: false },
|
|
245
|
+
{ triggerTurn: false },
|
|
246
|
+
);
|
|
173
247
|
}, {
|
|
174
248
|
isCtxStale,
|
|
175
249
|
label: "smart-context:downshift-notice",
|
package/src/pure.ts
CHANGED
|
@@ -249,6 +249,10 @@ export function buildReinjectSection(contents: ReadonlyArray<{ path: string; con
|
|
|
249
249
|
/** sessionManager entries 的宽松形状(降智计数)。 */
|
|
250
250
|
export interface EntryLike {
|
|
251
251
|
type: string;
|
|
252
|
+
/** custom entry 的类型标识(deriveFiredThresholds 读 marker 用;非 custom entry 缺省)。 */
|
|
253
|
+
customType?: string;
|
|
254
|
+
/** custom entry 的载荷(同上;形状由写入方定义)。 */
|
|
255
|
+
data?: unknown;
|
|
252
256
|
}
|
|
253
257
|
|
|
254
258
|
/** 累计 compaction 次数(D13-12 判据)。 */
|
|
@@ -256,6 +260,65 @@ export function countCompactions(entries: ReadonlyArray<EntryLike>): number {
|
|
|
256
260
|
return entries.filter((e) => e.type === "compaction").length;
|
|
257
261
|
}
|
|
258
262
|
|
|
263
|
+
/**
|
|
264
|
+
* 已提醒档位 marker 的 custom entry 类型(session entries 持久化)。
|
|
265
|
+
*
|
|
266
|
+
* 为什么必须持久化:pi reload(taiji 的 skill reload 走 `/__taiji_reload__` → ctx.reload())会
|
|
267
|
+
* 重建 extension runner 并**重新 import 扩展模块**(pi loader `moduleCache: false`)——闭包态与
|
|
268
|
+
* 模块级态一律归零;reload 随后补发的 `session_start` 的 reason 还是 `startup`(pi
|
|
269
|
+
* `_buildRuntime` → bindExtensions 发的是 `_sessionStartEvent` 默认值),用它区分不了
|
|
270
|
+
* 「真新 session」与「同 session 重载」。故 fired 档位以 custom entry 写入 session 自身,
|
|
271
|
+
* 每次重建从 entries 推导:同 session 的 reload / 进程重启 / 切回都不再重复提醒同一档。
|
|
272
|
+
*/
|
|
273
|
+
export const FIRED_ENTRY_CUSTOM_TYPE = "smart-context:fired";
|
|
274
|
+
|
|
275
|
+
/** 越档提醒注入的 custom message 类型(静默通道:只进 LLM 上下文,不进对话流)。 */
|
|
276
|
+
export const THRESHOLD_REMINDER_CUSTOM_TYPE = "smart-context:threshold-reminder";
|
|
277
|
+
|
|
278
|
+
/** 模型跨界通知注入的 custom message 类型(静默通道:不触发 turn,LLM 下轮自然可见)。 */
|
|
279
|
+
export const SWITCH_NOTICE_CUSTOM_TYPE = "smart-context:switch-notice";
|
|
280
|
+
|
|
281
|
+
/** downshift 建议压缩注入的 custom message 类型(静默通道:不触发 turn,用户继续对话时 LLM 自行决策)。 */
|
|
282
|
+
export const DOWNSHIFT_NOTICE_CUSTOM_TYPE = "smart-context:downshift-notice";
|
|
283
|
+
|
|
284
|
+
/** compact 完成/失败注入的 custom message 类型(用户可见系统消息 + 唤醒一轮消费压缩结果)。 */
|
|
285
|
+
export const COMPACT_RESULT_CUSTOM_TYPE = "smart-context:compact-result";
|
|
286
|
+
|
|
287
|
+
/** fired marker 的载荷形状(写入方 index.ts 与本文件推导函数共用的唯一定义点)。 */
|
|
288
|
+
export interface FiredMarkerData {
|
|
289
|
+
/** 本次标记已提醒的档位(token 值)。 */
|
|
290
|
+
tiers: number[];
|
|
291
|
+
/** 标记时刻的上下文用量(排查用,不参与语义)。 */
|
|
292
|
+
tokens: number;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 已提醒档位重建(D15:session entries → Set)。
|
|
297
|
+
*
|
|
298
|
+
* 语义与内存态一致:遇 `compaction` entry **清空**(压缩后档位重置,D3 既有契约),
|
|
299
|
+
* 其上方的 fired marker 累加 tiers。marker 载荷畸形(非对象 / tiers 非数组 / 元素非数)
|
|
300
|
+
* 逐条降级跳过——session JSONL 被截断或手改过时不抛错。
|
|
301
|
+
*
|
|
302
|
+
* 调用点:session_start(本代状态装配)+ agent_settled 首次使用前的兜底 seed
|
|
303
|
+
* (reload 后新模块实例可能先收到 settle 再收到 session_start)。
|
|
304
|
+
*/
|
|
305
|
+
export function deriveFiredThresholds(entries: ReadonlyArray<EntryLike>): Set<number> {
|
|
306
|
+
const fired = new Set<number>();
|
|
307
|
+
for (const entry of entries) {
|
|
308
|
+
if (entry.type === "compaction") {
|
|
309
|
+
fired.clear();
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (entry.type !== "custom" || entry.customType !== FIRED_ENTRY_CUSTOM_TYPE) continue;
|
|
313
|
+
const data = isRecord(entry.data) ? entry.data : undefined;
|
|
314
|
+
const tiers = Array.isArray(data?.tiers) ? data.tiers : [];
|
|
315
|
+
for (const tier of tiers) {
|
|
316
|
+
if (typeof tier === "number" && Number.isFinite(tier)) fired.add(tier);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return fired;
|
|
320
|
+
}
|
|
321
|
+
|
|
259
322
|
/**
|
|
260
323
|
* 保留段已 Read 的文件集合(D13-11 去重:重注入跳过保留段已有的 Read 结果)。
|
|
261
324
|
* 保留段 = branchEntries 中 firstKeptEntryId 之后的 message entries;从其 toolCall 参数提取 path。
|
package/src/reminder.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 提醒与通知文案构造(D3/D4/D5/D13-12)。
|
|
3
3
|
*
|
|
4
|
-
* 全部为纯函数:文案 + 越档判定。注入由 src/index.ts 用 pi.
|
|
4
|
+
* 全部为纯函数:文案 + 越档判定。注入由 src/index.ts / src/tool.ts 用 pi.sendMessage
|
|
5
|
+
* custom message 执行。
|
|
5
6
|
* 措辞原则(目标 3):提醒是数据投递不是指令——给三条件自查清单 + 明确的"可忽略"出口,
|
|
6
7
|
* 避免 agent 见提醒就压缩。
|
|
7
8
|
*/
|
|
@@ -17,8 +18,13 @@ const PERCENT_SCALE = 100;
|
|
|
17
18
|
const PI_DEFAULT_RESERVE_TOKENS = 16_384;
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
|
-
* 阈值提醒消息(D3/D4):越档信息 +
|
|
21
|
+
* 阈值提醒消息(D3/D4/D4'):越档信息 + 工具名 + 三条件自查 + 可忽略出口。
|
|
21
22
|
* 多档同时越过合并为一条(D3 去重规则)。
|
|
23
|
+
*
|
|
24
|
+
* D4' 短文约束:提醒是静默注入(不触发 turn、不进对话流),长度直接计入每次请求的
|
|
25
|
+
* 上下文——固定两行(降智提示另加一行),三条件压进一行序号列表,不再分点铺开。
|
|
26
|
+
* 语义不减:三条件、工具名、「可忽略」出口与「同档只提醒一次」全部保留(措辞仍是
|
|
27
|
+
* 数据投递不是指令——避免 agent 见提醒就压缩)。
|
|
22
28
|
*/
|
|
23
29
|
export function buildThresholdReminder(
|
|
24
30
|
crossedThresholds: readonly number[],
|
|
@@ -31,17 +37,11 @@ export function buildThresholdReminder(
|
|
|
31
37
|
.map((t, index) => `${formatK(t)}(第 ${index + 1} 档)`)
|
|
32
38
|
.join("、");
|
|
33
39
|
const lines = [
|
|
34
|
-
`[smart-context
|
|
35
|
-
|
|
36
|
-
`compact_context 工具可用于压缩上下文。请自行判断是否现在压缩——仅当以下三个条件同时满足时才调用:`,
|
|
37
|
-
`1. 当前任务的一个阶段已完成并验证(如一批文件改完、测试通过);`,
|
|
38
|
-
`2. 后续工作不再依赖将被压缩的早期细节;`,
|
|
39
|
-
`3. 上下文已超过阈值(本提示即第 3 条的数据依据)。`,
|
|
40
|
-
``,
|
|
41
|
-
`若任务仍在进行中、或近期仍需引用早期上下文,忽略本提示继续工作即可(本档位不会重复提醒,压缩后会重置)。`,
|
|
40
|
+
`[smart-context] 上下文 ${formatK(tokens)} / ${formatK(contextWindow)}(${percent}%),已越提醒阈值 ${tiers}。`,
|
|
41
|
+
`达阶段边界且后续不依赖早期细节时才调用 compact_context(三条件同时满足:①当前任务的一个阶段已完成并验证 ②后续工作不再依赖将被压缩的早期细节 ③上下文已超阈值);否则忽略本提示继续工作(同档只提醒一次,压缩后重置)。`,
|
|
42
42
|
];
|
|
43
43
|
if (compactionCount >= DEGRADATION_HINT_MIN_COMPACTIONS) {
|
|
44
|
-
lines.push(
|
|
44
|
+
lines.push(buildDegradationHintLine());
|
|
45
45
|
}
|
|
46
46
|
return lines.join("\n");
|
|
47
47
|
}
|
package/src/tool.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* (R2 实测 2026-08-22:tool execute 内 await ctx.compact() 不可行——AgentSession.compact
|
|
6
6
|
* 开头的 abort() 会中止当前 agent 循环,挂起的 Promise 永不兑现、session 无 toolResult。
|
|
7
7
|
* 故走 §3.2 降级态:execute 立即返回"压缩已启动",onComplete/onError 后经
|
|
8
|
-
* pi.
|
|
8
|
+
* pi.sendMessage custom message 注入结果——此时无进行中回合,abort 为 no-op)。
|
|
9
9
|
* 压缩生成由 session_before_compact 接管 handler 完成(工具只触发,不生成)。
|
|
10
10
|
*/
|
|
11
11
|
|
|
@@ -18,6 +18,7 @@ import { buildDegradationHintLine } from "./reminder.js";
|
|
|
18
18
|
import {
|
|
19
19
|
DEGRADATION_HINT_MIN_COMPACTIONS,
|
|
20
20
|
checkToolThresholdGuard,
|
|
21
|
+
COMPACT_RESULT_CUSTOM_TYPE,
|
|
21
22
|
countCompactions,
|
|
22
23
|
formatK,
|
|
23
24
|
getCurrentModelId,
|
|
@@ -160,8 +161,9 @@ export function registerCompactContextTool(
|
|
|
160
161
|
const compactionCount = countCompactions(getEntries(ctx));
|
|
161
162
|
|
|
162
163
|
// R2 降级态:fire-and-forget。工具立即返回"已启动";压缩完成后(此时无进行中
|
|
163
|
-
// 回合,compact 内部的 abort 为 no-op)经
|
|
164
|
-
//
|
|
164
|
+
// 回合,compact 内部的 abort 为 no-op)经 sendMessage custom message 注入结果
|
|
165
|
+
// (triggerTurn:true 唤醒一轮让 agent 立即消费结果——工具已承诺"完成后你会收到
|
|
166
|
+
// 结果消息";若此刻仍在 streaming,pi 缺省走 steer 队列,下一 LLM 调用前投递)
|
|
165
167
|
const mode = pickMode(config, gating.modelId);
|
|
166
168
|
ctx.compact({
|
|
167
169
|
customInstructions:
|
|
@@ -170,8 +172,10 @@ export function registerCompactContextTool(
|
|
|
170
172
|
: undefined,
|
|
171
173
|
// E1 实锤崩溃点(9/3 pi-crash log):两个回调由 compact 的内部 Promise 链异步
|
|
172
174
|
// 调用,不在 pi runner emit() 的 try/catch 内——session 替换窗口(GUI 切
|
|
173
|
-
// session/新建/重载高频触发)下 pi.
|
|
174
|
-
// 杀死 pi
|
|
175
|
+
// session/新建/重载高频触发)下 pi.sendMessage 命中 stale ctx 同步抛错即
|
|
176
|
+
// 杀死 pi 进程(pi 0.84.4 实装:dist/core/extensions/loader.js 的 assertActive
|
|
177
|
+
// 214-219 wrapper → 142-146 staleMessage throw,在 runtime 调用前同步 throw)。
|
|
178
|
+
// 守卫 stale 静默降级(结果不投递,用户可重试 /compact),非
|
|
175
179
|
// stale 错误原样上抛(守卫不吞真实 bug)。
|
|
176
180
|
onComplete: (r: unknown) => {
|
|
177
181
|
guardStaleCtx(() => {
|
|
@@ -189,7 +193,12 @@ export function registerCompactContextTool(
|
|
|
189
193
|
`压缩前 ${formatK(result.tokensBefore ?? 0)} tokens → 压缩后约 ${formatK(result.estimatedTokensAfter ?? 0)} tokens;摘要生成成本:${cost}。`,
|
|
190
194
|
showHint ? buildDegradationHintLine() : "",
|
|
191
195
|
].filter((l) => l !== "");
|
|
192
|
-
|
|
196
|
+
// display:true 进对话流(系统消息形态,非用户气泡);对齐
|
|
197
|
+
// base-tool-enhance 完成通知先例(失败必须用户可见,不压 display:false)
|
|
198
|
+
pi.sendMessage(
|
|
199
|
+
{ customType: COMPACT_RESULT_CUSTOM_TYPE, content: lines.join("\n"), display: true },
|
|
200
|
+
{ triggerTurn: true },
|
|
201
|
+
);
|
|
193
202
|
}, {
|
|
194
203
|
isCtxStale: deps?.isCtxStale,
|
|
195
204
|
label: "smart-context:compact-onComplete",
|
|
@@ -200,9 +209,13 @@ export function registerCompactContextTool(
|
|
|
200
209
|
// 压缩本身的失败信息先落日志(守卫体外——stale 降级时该观测保留)
|
|
201
210
|
debugLog(`compact_context error: ${err.message}`);
|
|
202
211
|
guardStaleCtx(() => {
|
|
203
|
-
pi.
|
|
204
|
-
|
|
205
|
-
|
|
212
|
+
pi.sendMessage(
|
|
213
|
+
{
|
|
214
|
+
customType: COMPACT_RESULT_CUSTOM_TYPE,
|
|
215
|
+
content: `[smart-context] 压缩失败:${err.message}。上下文未变化,可稍后重试(若反复失败,检查 smart-context 配置或使用 /compact)。`,
|
|
216
|
+
display: true,
|
|
217
|
+
},
|
|
218
|
+
{ triggerTurn: true },
|
|
206
219
|
);
|
|
207
220
|
}, {
|
|
208
221
|
isCtxStale: deps?.isCtxStale,
|