@zhushanwen/pi-smart-context 0.1.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/README.md +22 -0
- package/index.ts +1 -0
- package/package.json +54 -0
- package/skills/smart-context-ext-config/SKILL.md +56 -0
- package/src/__tests__/compact-handler.test.ts +141 -0
- package/src/__tests__/pure.test.ts +144 -0
- package/src/__tests__/reminder.test.ts +83 -0
- package/src/__tests__/sdk-contract.test.ts +66 -0
- package/src/__tests__/tool.test.ts +144 -0
- package/src/compact-handler.ts +321 -0
- package/src/index.ts +160 -0
- package/src/llm.ts +139 -0
- package/src/prompts.ts +70 -0
- package/src/pure.ts +311 -0
- package/src/reminder.ts +73 -0
- package/src/tool.ts +206 -0
- package/vitest.config.ts +7 -0
package/src/tool.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compact_context 工具(D6 + §3.5 接口契约)。
|
|
3
|
+
*
|
|
4
|
+
* execute 流程:门控校验(D5 拒绝态)→ 阈值保护(D6)→ ctx.compact() fire-and-forget
|
|
5
|
+
* (R2 实测 2026-08-22:tool execute 内 await ctx.compact() 不可行——AgentSession.compact
|
|
6
|
+
* 开头的 abort() 会中止当前 agent 循环,挂起的 Promise 永不兑现、session 无 toolResult。
|
|
7
|
+
* 故走 §3.2 降级态:execute 立即返回"压缩已启动",onComplete/onError 后经
|
|
8
|
+
* pi.sendUserMessage 注入结果消息——此时无进行中回合,abort 为 no-op)。
|
|
9
|
+
* 压缩生成由 session_before_compact 接管 handler 完成(工具只触发,不生成)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { Type } from "typebox";
|
|
14
|
+
|
|
15
|
+
import { debugLog } from "./compact-handler.js";
|
|
16
|
+
import { buildDegradationHintLine } from "./reminder.js";
|
|
17
|
+
import {
|
|
18
|
+
DEGRADATION_HINT_MIN_COMPACTIONS,
|
|
19
|
+
checkToolThresholdGuard,
|
|
20
|
+
countCompactions,
|
|
21
|
+
formatK,
|
|
22
|
+
getCurrentModelId,
|
|
23
|
+
isGatingActive,
|
|
24
|
+
loadSmartContextConfig,
|
|
25
|
+
pickMode,
|
|
26
|
+
type EntryLike,
|
|
27
|
+
} from "./pure.js";
|
|
28
|
+
|
|
29
|
+
/** 工具参数 schema(顶层 Type.Object,OpenAI 兼容红线)。 */
|
|
30
|
+
const CompactContextParams = Type.Object(
|
|
31
|
+
{
|
|
32
|
+
custom_instructions: Type.Optional(
|
|
33
|
+
Type.String({
|
|
34
|
+
description:
|
|
35
|
+
"给摘要生成器的指引:哪些信息必须在摘要中保留(如文件修改意图、关键决策、验证结果),哪些可以丢弃(如中间调试过程)",
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
},
|
|
39
|
+
{ additionalProperties: false },
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/** CompactionResult 的宽松形状(onComplete 回调参数消费字段)。 */
|
|
43
|
+
interface CompactionResultLike {
|
|
44
|
+
tokensBefore?: number;
|
|
45
|
+
estimatedTokensAfter?: number;
|
|
46
|
+
usage?: { input?: number; output?: number; cacheRead?: number };
|
|
47
|
+
details?: { engine?: string; mode?: string } & Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 工具 details(renderResult 数据源,规范:不依赖 content 文本解析)。R2 降级态:启动即返回。 */
|
|
51
|
+
export interface CompactContextDetails {
|
|
52
|
+
/** 启动时的模式判定(cross-model 时为配置的压缩模型 ref)。 */
|
|
53
|
+
mode: string;
|
|
54
|
+
compactModel: string;
|
|
55
|
+
compactionCount: number;
|
|
56
|
+
/** 压缩已触发(结果经注入消息送达,不在工具结果里)。 */
|
|
57
|
+
launched: boolean;
|
|
58
|
+
fellBack: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 工具描述(§3.5:三条件自查引导)。 */
|
|
62
|
+
const TOOL_DESCRIPTION =
|
|
63
|
+
"压缩当前会话上下文(释放早期对话占用的 token;压缩由配置的模型执行,不会切换你的主模型)。" +
|
|
64
|
+
"仅在同时满足以下条件时调用:1) 当前任务的一个阶段已完成并验证(如一批文件改完、测试通过);" +
|
|
65
|
+
"2) 后续工作不再依赖将被压缩的早期细节;3) 上下文已超过提醒阈值(你会收到 [smart-context 提示])。" +
|
|
66
|
+
"若任一条件不满足,不要调用。";
|
|
67
|
+
|
|
68
|
+
/** CompactionResult → 宽松形状的 guard 转换(onComplete 回调参数消费,避免 cast)。 */
|
|
69
|
+
function toCompactionResultLike(result: unknown): CompactionResultLike {
|
|
70
|
+
const r = result as { tokensBefore?: unknown; estimatedTokensAfter?: unknown; usage?: unknown; details?: unknown };
|
|
71
|
+
const usage = (r.usage ?? null) as { input?: unknown; output?: unknown; cacheRead?: unknown } | null;
|
|
72
|
+
const details = (r.details ?? null) as { engine?: unknown; mode?: unknown } | null;
|
|
73
|
+
const num = (v: unknown) => (typeof v === "number" ? v : undefined);
|
|
74
|
+
const str = (v: unknown) => (typeof v === "string" ? v : undefined);
|
|
75
|
+
return {
|
|
76
|
+
tokensBefore: num(r.tokensBefore),
|
|
77
|
+
estimatedTokensAfter: num(r.estimatedTokensAfter),
|
|
78
|
+
usage: usage
|
|
79
|
+
? {
|
|
80
|
+
input: num(usage.input),
|
|
81
|
+
output: num(usage.output),
|
|
82
|
+
cacheRead: num(usage.cacheRead),
|
|
83
|
+
}
|
|
84
|
+
: undefined,
|
|
85
|
+
details: details && typeof details === "object"
|
|
86
|
+
? {
|
|
87
|
+
engine: str(details.engine),
|
|
88
|
+
mode: str(details.mode),
|
|
89
|
+
}
|
|
90
|
+
: undefined,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 注册 compact_context 工具。
|
|
96
|
+
*
|
|
97
|
+
* gatingProbe/usageProbe 为依赖注入(单测 mock 点):
|
|
98
|
+
* - gatingProbe(ctx) → { active, modelId }(生产实现:现场读配置判定)
|
|
99
|
+
* - usageProbe(ctx) → { tokens, contextWindow }(生产实现:ctx.getContextUsage())
|
|
100
|
+
*/
|
|
101
|
+
export function registerCompactContextTool(
|
|
102
|
+
pi: ExtensionAPI,
|
|
103
|
+
deps?: {
|
|
104
|
+
gatingProbe?: (ctx: ExtensionContext) => { active: boolean; modelId: string };
|
|
105
|
+
usageProbe?: (ctx: ExtensionContext) => { tokens: number | null; contextWindow: number };
|
|
106
|
+
getEntries?: (ctx: ExtensionContext) => ReadonlyArray<EntryLike>;
|
|
107
|
+
},
|
|
108
|
+
): void {
|
|
109
|
+
const probeGating =
|
|
110
|
+
deps?.gatingProbe ??
|
|
111
|
+
((ctx: ExtensionContext) => {
|
|
112
|
+
const config = loadSmartContextConfig();
|
|
113
|
+
const modelId = getCurrentModelId(ctx.model);
|
|
114
|
+
return { active: isGatingActive(config, modelId), modelId };
|
|
115
|
+
});
|
|
116
|
+
const probeUsage =
|
|
117
|
+
deps?.usageProbe ??
|
|
118
|
+
((ctx: ExtensionContext) => {
|
|
119
|
+
const usage = ctx.getContextUsage();
|
|
120
|
+
return {
|
|
121
|
+
tokens: usage?.tokens ?? null,
|
|
122
|
+
contextWindow: usage?.contextWindow ?? 0,
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
const getEntries =
|
|
126
|
+
deps?.getEntries ??
|
|
127
|
+
((ctx: ExtensionContext) => ctx.sessionManager.getEntries() as ReadonlyArray<EntryLike>);
|
|
128
|
+
|
|
129
|
+
pi.registerTool({
|
|
130
|
+
name: "compact_context",
|
|
131
|
+
label: "compact_context",
|
|
132
|
+
description: TOOL_DESCRIPTION,
|
|
133
|
+
parameters: CompactContextParams,
|
|
134
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
135
|
+
// D5 门控现场校验(配置热改即时生效,不依赖注册时机)
|
|
136
|
+
const gating = probeGating(ctx);
|
|
137
|
+
const config = loadSmartContextConfig();
|
|
138
|
+
if (!gating.active) {
|
|
139
|
+
const reason = config.enabled
|
|
140
|
+
? `当前模型 ${gating.modelId} 已配置为排除(smart-context excludedModels),压缩工具不可用。可在 xyz-agent 设置页或 smart-context-ext-config skill 中调整。`
|
|
141
|
+
: `smart-context 已禁用。可在 xyz-agent 设置页开启,或经 smart-context-ext-config skill 修改配置。`;
|
|
142
|
+
throw new Error(reason);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// D6 阈值保护(含 null 分支)
|
|
146
|
+
const usage = probeUsage(ctx);
|
|
147
|
+
const guardMessage = checkToolThresholdGuard(config.reminderThresholds, usage.tokens);
|
|
148
|
+
if (guardMessage !== null) {
|
|
149
|
+
throw new Error(guardMessage);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const compactionCount = countCompactions(getEntries(ctx));
|
|
153
|
+
|
|
154
|
+
// R2 降级态:fire-and-forget。工具立即返回"已启动";压缩完成后(此时无进行中
|
|
155
|
+
// 回合,compact 内部的 abort 为 no-op)经 sendUserMessage 注入结果(steer:
|
|
156
|
+
// 下一个 LLM 调用前投递,agent 立即看到结果)
|
|
157
|
+
const mode = pickMode(config, gating.modelId);
|
|
158
|
+
ctx.compact({
|
|
159
|
+
customInstructions:
|
|
160
|
+
typeof params.custom_instructions === "string" && params.custom_instructions.trim() !== ""
|
|
161
|
+
? params.custom_instructions
|
|
162
|
+
: undefined,
|
|
163
|
+
onComplete: (r: unknown) => {
|
|
164
|
+
const result = toCompactionResultLike(r);
|
|
165
|
+
const resultMode = result.details?.mode ?? "native-fallback";
|
|
166
|
+
const fellBack = resultMode === "native-fallback";
|
|
167
|
+
if (fellBack) debugLog("compact_context: takeover fell back to native generation");
|
|
168
|
+
const cacheRead = result.usage?.cacheRead;
|
|
169
|
+
const cost = result.usage
|
|
170
|
+
? `${formatK(result.usage.input ?? 0)} input${cacheRead ? `(其中缓存命中 ${formatK(cacheRead)})` : ""} + ${formatK(result.usage.output ?? 0)} output`
|
|
171
|
+
: "未知";
|
|
172
|
+
const showHint = compactionCount + 1 >= DEGRADATION_HINT_MIN_COMPACTIONS;
|
|
173
|
+
const lines = [
|
|
174
|
+
`[smart-context] 压缩完成。模式:${resultMode}${fellBack ? "(压缩模型不可用,已回退当前模型——请检查配置:xyz-agent 设置页或 smart-context-ext-config skill)" : ""}。`,
|
|
175
|
+
`压缩前 ${formatK(result.tokensBefore ?? 0)} tokens → 压缩后约 ${formatK(result.estimatedTokensAfter ?? 0)} tokens;摘要生成成本:${cost}。`,
|
|
176
|
+
showHint ? buildDegradationHintLine() : "",
|
|
177
|
+
].filter((l) => l !== "");
|
|
178
|
+
pi.sendUserMessage(lines.join("\n"), { deliverAs: "steer" });
|
|
179
|
+
},
|
|
180
|
+
onError: (err: Error) => {
|
|
181
|
+
debugLog(`compact_context error: ${err.message}`);
|
|
182
|
+
pi.sendUserMessage(
|
|
183
|
+
`[smart-context] 压缩失败:${err.message}。上下文未变化,可稍后重试(若反复失败,检查 smart-context 配置或使用 /compact)。`,
|
|
184
|
+
{ deliverAs: "steer" },
|
|
185
|
+
);
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
content: [
|
|
191
|
+
{
|
|
192
|
+
type: "text",
|
|
193
|
+
text: `压缩已启动(${mode === "same-model" ? "same-model 模式,KV 缓存优化" : `cross-model 模式,使用 ${config.compactModel.ref}`})。压缩完成后你会收到一条结果消息;期间可以继续其他工作,但引用早期上下文的操作请等结果消息到达。`,
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
details: {
|
|
197
|
+
mode,
|
|
198
|
+
compactModel: mode === "cross-model" ? config.compactModel.ref : gating.modelId,
|
|
199
|
+
fellBack: false,
|
|
200
|
+
compactionCount: compactionCount + 1,
|
|
201
|
+
launched: true,
|
|
202
|
+
} satisfies CompactContextDetails,
|
|
203
|
+
};
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
}
|