pi-distill 1.1.2 → 1.2.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 +11 -5
- package/README.zh-CN.md +11 -5
- package/config.example.json +3 -0
- package/locales/index.json +40 -0
- package/package.json +1 -1
- package/src/index.ts +122 -20
- package/src/output-limit.ts +57 -0
- package/src/summary-utils.ts +58 -1
package/README.md
CHANGED
|
@@ -110,7 +110,7 @@ Agent consumes a result suited to the current decision, with auditable diagnosti
|
|
|
110
110
|
2. The `tool_call` handler captures the parameter and removes it before forwarding the call, so the underlying tool never receives the extension-only field.
|
|
111
111
|
3. The `tool_result` handler sees the actual output and decides what to do; it does not rely on the agent predicting the output size.
|
|
112
112
|
4. Every tool call must include a non-empty `outputRequest`. A prompt containing only `RAW` explicitly requests the original. Any other non-empty prompt permits distillation once the configured threshold is reached.
|
|
113
|
-
5. If
|
|
113
|
+
5. A timed-out attempt is retried according to `timeoutRetryCount`, while other failures use `errorRetryCount` (both default to one retry). If all attempts fail, no model is available, or compression is ineffective, the original facts are retained and the status is exposed through details and the audit card. JSON responses wrapped in Markdown fences such as `````json … ````` are also accepted.
|
|
114
114
|
|
|
115
115
|
## Output contract
|
|
116
116
|
|
|
@@ -118,7 +118,7 @@ Agent consumes a result suited to the current decision, with auditable diagnosti
|
|
|
118
118
|
| --- | --- | --- |
|
|
119
119
|
| Omitted | Invalid tool call; Pi rejects the call before the underlying tool runs | Never omit it; use `RAW` when no compression is explicitly requested |
|
|
120
120
|
| Exactly `RAW` (case-insensitive) | Skip the distillation model and keep the complete original text; oversized text is handled by Pi's own output-limiting mechanism | You need to inspect, copy, or verify exact output |
|
|
121
|
-
| Any non-empty value other than `RAW` | Call the model
|
|
121
|
+
| Any non-empty value other than `RAW` | Call the model when the output reaches the threshold; timed-out and other failed attempts use separate retry budgets, and the prompt defines what to retain | “Keep errors, warnings, and final status” workflows |
|
|
122
122
|
| Any non-text content such as images or audio | Preserve the result as-is; do not send it to the distillation model or apply text truncation | Image reads, binary results, and mixed text/media results |
|
|
123
123
|
|
|
124
124
|
`RAW` is the deterministic completeness signal. The distillation prompt tells the summarizer to return exactly `RAW` when the request clearly asks for complete extraction without omissions, especially for syntax, parameters, SQL, API calls, or other text that must be copied. Passing `RAW` directly remains the preferred option when the tool caller can control the parameter.
|
|
@@ -157,7 +157,10 @@ Start from [`config.example.json`](./config.example.json):
|
|
|
157
157
|
"model": "",
|
|
158
158
|
"minChars": 200,
|
|
159
159
|
"maxChars": 100000,
|
|
160
|
+
"maxOutputChars": 10000,
|
|
160
161
|
"timeoutSeconds": 10,
|
|
162
|
+
"timeoutRetryCount": 1,
|
|
163
|
+
"errorRetryCount": 1,
|
|
161
164
|
"missedCompressionRatio": 10,
|
|
162
165
|
"summarizeErrors": true,
|
|
163
166
|
"render": {
|
|
@@ -174,8 +177,11 @@ Configuration-file fields take precedence over environment variables. Unspecifie
|
|
|
174
177
|
| --- | --- |
|
|
175
178
|
| `model` | Optional `provider/model`; empty uses the current Pi session model. |
|
|
176
179
|
| `minChars` | Minimum output size before a summary is requested. |
|
|
177
|
-
| `maxChars` |
|
|
178
|
-
| `
|
|
180
|
+
| `maxChars` | Distilled text is written to a temporary file when it exceeds this length. |
|
|
181
|
+
| `maxOutputChars` | Maximum text returned to the Agent. Oversized text is written to a temporary file and replaced with a file pointer. |
|
|
182
|
+
| `timeoutSeconds` | Maximum time allowed for each distillation model attempt. |
|
|
183
|
+
| `timeoutRetryCount` | Number of additional attempts after a timeout. Defaults to `1`; set to `0` to disable timeout retries. |
|
|
184
|
+
| `errorRetryCount` | Number of additional attempts after any non-timeout error. Defaults to `1`; set to `0` to disable other error retries. |
|
|
179
185
|
| `missedCompressionRatio` | Long-output threshold for a diagnostic when no summary prompt was supplied. |
|
|
180
186
|
| `summarizeErrors` | Whether error results that meet `minChars` should still be sent to the distillation model. |
|
|
181
187
|
| `tools.<name>.enabled` | Enables or disables `outputRequest` injection and result distillation for one tool. `edit` and `write` default to disabled; other unconfigured tools default to enabled. It can also be changed from `/config:distill`. |
|
|
@@ -183,7 +189,7 @@ Configuration-file fields take precedence over environment variables. Unspecifie
|
|
|
183
189
|
`/pi-distill` remains available as a compatibility alias.
|
|
184
190
|
| `render.*` | Controls the audit card, prompt preview, and result preview. |
|
|
185
191
|
|
|
186
|
-
The main environment variables are `PI_DISTILL_MODEL`, `PI_DISTILL_MIN_CHARS`, `PI_DISTILL_MAX_CHARS`, `PI_DISTILL_TIMEOUT_SECONDS`, `PI_DISTILL_MISSED_COMPRESSION_RATIO`, and `PI_DISTILL_SUMMARIZE_ERRORS`.
|
|
192
|
+
The main environment variables are `PI_DISTILL_MODEL`, `PI_DISTILL_MIN_CHARS`, `PI_DISTILL_MAX_CHARS`, `PI_DISTILL_MAX_OUTPUT_CHARS`, `PI_DISTILL_TIMEOUT_SECONDS`, `PI_DISTILL_TIMEOUT_RETRY_COUNT`, `PI_DISTILL_ERROR_RETRY_COUNT`, `PI_DISTILL_MISSED_COMPRESSION_RATIO`, and `PI_DISTILL_SUMMARIZE_ERRORS`.
|
|
187
193
|
|
|
188
194
|
## Requirements
|
|
189
195
|
|
package/README.zh-CN.md
CHANGED
|
@@ -112,7 +112,7 @@ Agent 消费更适合当前决策的结果,并获得可审计的处理诊断
|
|
|
112
112
|
2. `tool_call` 事件捕获这个参数,并在交给底层工具前移除它,因此原工具不会收到扩展专用字段。
|
|
113
113
|
3. `tool_result` 事件拿到真实输出后再做判断,不依赖 Agent 对输出长度的预测。
|
|
114
114
|
4. 每次工具调用都必须包含非空的 `outputRequest`;严格的 `RAW` 表示明确要求原文;其他非空 prompt 才允许进入提炼流程。
|
|
115
|
-
5.
|
|
115
|
+
5. 单次提炼超时后按照 `timeoutRetryCount` 重试,其他异常按照 `errorRetryCount` 重试(两者默认都重试 1 次);全部尝试失败、没有可用模型或结果收益过低时,扩展保留原始事实,并通过 details 和审计卡片暴露状态;模型用 Markdown 的 JSON 代码围栏(如 `````json … `````)包裹响应时也会兼容解析。
|
|
116
116
|
|
|
117
117
|
## 输出处理契约
|
|
118
118
|
|
|
@@ -120,7 +120,7 @@ Agent 消费更适合当前决策的结果,并获得可审计的处理诊断
|
|
|
120
120
|
| --- | --- | --- |
|
|
121
121
|
| 未提供 | 工具调用无效;Pi 会在底层工具执行前拒绝该调用 | 不要省略;未明确要求压缩时使用 `RAW` |
|
|
122
122
|
| 严格为 `RAW`(大小写不敏感) | 不调用提炼模型,保留完整原始文本;超长时由 Pi 自身的输出限制机制处理 | 逐字核对、复制内容、需要完整日志时 |
|
|
123
|
-
| 任意非空且非 `RAW` |
|
|
123
|
+
| 任意非空且非 `RAW` | 输出达到阈值后调用模型,超时与其他异常分别使用独立重试次数,具体保留内容由 prompt 决定 | “只保留错误、警告和最终状态”等场景 |
|
|
124
124
|
| 包含图片、音频或其他非文本内容 | 原样保留,不发送给提炼模型,不做文本长度截断 | 图片读取、二进制结果、混合文本与图片结果 |
|
|
125
125
|
|
|
126
126
|
`RAW` 是确定性的完整输出信号。提炼 prompt 会要求总结模型在用户明确要求“不遗漏地完整提取”时直接返回 `RAW`,尤其适用于语法、参数、SQL、API 调用或其他需要复制的精确文本。工具调用方可以控制参数时,直接传 `RAW` 仍然是首选方式。
|
|
@@ -159,7 +159,10 @@ Agent 消费更适合当前决策的结果,并获得可审计的处理诊断
|
|
|
159
159
|
"model": "",
|
|
160
160
|
"minChars": 200,
|
|
161
161
|
"maxChars": 100000,
|
|
162
|
+
"maxOutputChars": 10000,
|
|
162
163
|
"timeoutSeconds": 10,
|
|
164
|
+
"timeoutRetryCount": 1,
|
|
165
|
+
"errorRetryCount": 1,
|
|
163
166
|
"missedCompressionRatio": 10,
|
|
164
167
|
"summarizeErrors": true,
|
|
165
168
|
"tools": {},
|
|
@@ -177,8 +180,11 @@ Agent 消费更适合当前决策的结果,并获得可审计的处理诊断
|
|
|
177
180
|
| --- | --- |
|
|
178
181
|
| `model` | 可选的 `provider/model`;为空时使用当前 Pi 会话模型。 |
|
|
179
182
|
| `minChars` | 达到此输出长度后才请求提炼。 |
|
|
180
|
-
| `maxChars` |
|
|
181
|
-
| `
|
|
183
|
+
| `maxChars` | 提炼结果超过此字符数时写入临时文件。 |
|
|
184
|
+
| `maxOutputChars` | 最终返回给 Agent 的文本上限。超出后写入临时文件,只返回文件指针。 |
|
|
185
|
+
| `timeoutSeconds` | 每次提炼模型尝试的最长等待时间。 |
|
|
186
|
+
| `timeoutRetryCount` | 超时后的额外重试次数。默认 `1`;设为 `0` 时不重试超时。 |
|
|
187
|
+
| `errorRetryCount` | 非超时异常后的额外重试次数。默认 `1`;设为 `0` 时不重试其他异常。 |
|
|
182
188
|
| `missedCompressionRatio` | 没有提供摘要 prompt 时,用于长输出诊断的倍数阈值。 |
|
|
183
189
|
| `summarizeErrors` | 工具返回错误且达到 `minChars` 时,是否仍发送给提炼模型。 |
|
|
184
190
|
| `tools.<name>.enabled` | 按工具开启或关闭 `outputRequest` 注入和结果提炼。`edit` 和 `write` 默认关闭,其他未配置工具默认开启,也可以通过 `/config:distill` 修改。 |
|
|
@@ -186,7 +192,7 @@ Agent 消费更适合当前决策的结果,并获得可审计的处理诊断
|
|
|
186
192
|
`/pi-distill` 仍作为兼容别名保留。
|
|
187
193
|
| `render.*` | 控制审计卡片、prompt 预览和结果预览。 |
|
|
188
194
|
|
|
189
|
-
主要环境变量包括 `PI_DISTILL_MODEL`、`PI_DISTILL_MIN_CHARS`、`PI_DISTILL_MAX_CHARS`、`PI_DISTILL_TIMEOUT_SECONDS`、`PI_DISTILL_MISSED_COMPRESSION_RATIO` 和 `PI_DISTILL_SUMMARIZE_ERRORS
|
|
195
|
+
主要环境变量包括 `PI_DISTILL_MODEL`、`PI_DISTILL_MIN_CHARS`、`PI_DISTILL_MAX_CHARS`、`PI_DISTILL_MAX_OUTPUT_CHARS`、`PI_DISTILL_TIMEOUT_SECONDS`、`PI_DISTILL_TIMEOUT_RETRY_COUNT`、`PI_DISTILL_ERROR_RETRY_COUNT`、`PI_DISTILL_MISSED_COMPRESSION_RATIO` 和 `PI_DISTILL_SUMMARIZE_ERRORS`。
|
|
190
196
|
|
|
191
197
|
## 要求
|
|
192
198
|
|
package/config.example.json
CHANGED
package/locales/index.json
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
"zh-CN": "请输入大于 0 的整数。",
|
|
12
12
|
"en-US": "Enter a positive integer."
|
|
13
13
|
},
|
|
14
|
+
"nonNegativeInteger": {
|
|
15
|
+
"zh-CN": "请输入大于或等于 0 的整数。",
|
|
16
|
+
"en-US": "Enter a non-negative integer."
|
|
17
|
+
},
|
|
14
18
|
"modelInput": {
|
|
15
19
|
"zh-CN": "模型(provider/model;留空使用当前会话模型)",
|
|
16
20
|
"en-US": "Model (provider/model; leave empty to use the current session model)"
|
|
@@ -71,6 +75,14 @@
|
|
|
71
75
|
"zh-CN": "超时:{value}s",
|
|
72
76
|
"en-US": "Timeout: {value}s"
|
|
73
77
|
},
|
|
78
|
+
"timeoutRetryCount": {
|
|
79
|
+
"zh-CN": "超时重试:{value} 次",
|
|
80
|
+
"en-US": "Timeout retries: {value}"
|
|
81
|
+
},
|
|
82
|
+
"errorRetryCount": {
|
|
83
|
+
"zh-CN": "异常重试:{value} 次",
|
|
84
|
+
"en-US": "Error retries: {value}"
|
|
85
|
+
},
|
|
74
86
|
"threshold": {
|
|
75
87
|
"zh-CN": "长输出阈值:{value}x",
|
|
76
88
|
"en-US": "Long-output threshold: {value}x"
|
|
@@ -119,10 +131,38 @@
|
|
|
119
131
|
"zh-CN": "最终输出字符上限",
|
|
120
132
|
"en-US": "Final output character limit"
|
|
121
133
|
},
|
|
134
|
+
"outputLimitExceeded": {
|
|
135
|
+
"zh-CN": "输出超过 {maxChars} 个字符,完整内容已写入:{path}",
|
|
136
|
+
"en-US": "Output exceeded {maxChars} chars and was written to: {path}"
|
|
137
|
+
},
|
|
138
|
+
"outputLimitWriteFailed": {
|
|
139
|
+
"zh-CN": "无法写入超长输出临时文件,将返回截断内容:{error}",
|
|
140
|
+
"en-US": "Failed to write oversized output to a temp file; returning truncated content: {error}"
|
|
141
|
+
},
|
|
122
142
|
"timeoutTitle": {
|
|
123
143
|
"zh-CN": "提炼模型超时(秒)",
|
|
124
144
|
"en-US": "Summarizer timeout (seconds)"
|
|
125
145
|
},
|
|
146
|
+
"timeoutRetryCountTitle": {
|
|
147
|
+
"zh-CN": "提炼超时重试次数",
|
|
148
|
+
"en-US": "Distillation timeout retry count"
|
|
149
|
+
},
|
|
150
|
+
"errorRetryCountTitle": {
|
|
151
|
+
"zh-CN": "提炼非超时异常重试次数",
|
|
152
|
+
"en-US": "Distillation non-timeout error retry count"
|
|
153
|
+
},
|
|
154
|
+
"retryingSummary": {
|
|
155
|
+
"zh-CN": "提炼发生{kind},正在进行第 {retry}/{limit} 次重试:{error}",
|
|
156
|
+
"en-US": "Distillation hit {kind}; starting retry {retry}/{limit}: {error}"
|
|
157
|
+
},
|
|
158
|
+
"retryKindTimeout": {
|
|
159
|
+
"zh-CN": "超时",
|
|
160
|
+
"en-US": "a timeout"
|
|
161
|
+
},
|
|
162
|
+
"retryKindError": {
|
|
163
|
+
"zh-CN": "非超时异常",
|
|
164
|
+
"en-US": "a non-timeout error"
|
|
165
|
+
},
|
|
126
166
|
"thresholdTitle": {
|
|
127
167
|
"zh-CN": "长输出阈值",
|
|
128
168
|
"en-US": "Long-output threshold"
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* 所有工具统一使用 outputRequest:严格传入 RAW 时返回原始输出;其他非空
|
|
8
8
|
* outputRequest 表示调用提炼模型,具体保留内容由 outputRequest 决定。
|
|
9
|
-
* 提炼结果超过 maxChars
|
|
9
|
+
* 提炼结果超过 maxChars 时写入临时文件,只返回文件路径;最终返回内容
|
|
10
|
+
* 超过 maxOutputChars 时同样写入临时文件,只把文件指针交给 Agent。
|
|
10
11
|
*
|
|
11
12
|
* 配置文件优先;旧环境变量继续兼容:
|
|
12
13
|
* - ~/.pi/agent/extensions/pi-distill/config.json
|
|
@@ -15,6 +16,8 @@
|
|
|
15
16
|
* - PI_DISTILL_MAX_CHARS=提炼结果超过此字符数时写入文件,默认 100000
|
|
16
17
|
* - PI_DISTILL_MAX_OUTPUT_CHARS=最终返回内容超过此字符数时写入文件,默认 10000
|
|
17
18
|
* - PI_DISTILL_TIMEOUT_SECONDS=模型调用最长等待秒数,默认 10
|
|
19
|
+
* - PI_DISTILL_TIMEOUT_RETRY_COUNT=提炼超时后的额外重试次数,默认 1;0 表示不重试
|
|
20
|
+
* - PI_DISTILL_ERROR_RETRY_COUNT=其他异常后的额外重试次数,默认 1;0 表示不重试
|
|
18
21
|
* - PI_DISTILL_MISSED_COMPRESSION_RATIO=长输出提醒倍数,默认 10
|
|
19
22
|
* - 旧 PI_BASH_SUMMARY_* 变量作为兼容回退
|
|
20
23
|
*/
|
|
@@ -36,7 +39,7 @@ import {
|
|
|
36
39
|
isDistillToolDisplayMiddlewareActive,
|
|
37
40
|
registerDistillToolDisplayMiddleware,
|
|
38
41
|
} from "./tool-display-bridge.ts";
|
|
39
|
-
import { getTextContent, hasNonTextContent } from "./output-limit.ts";
|
|
42
|
+
import { getTextContent, hasNonTextContent, limitReturnedToolResult } from "./output-limit.ts";
|
|
40
43
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
41
44
|
import { tmpdir } from "node:os";
|
|
42
45
|
import { dirname, join } from "node:path";
|
|
@@ -279,9 +282,10 @@ async function writeSummaryFile(summary: string): Promise<string> {
|
|
|
279
282
|
}
|
|
280
283
|
|
|
281
284
|
function parseSummaryResponse(text: string, summaryModel: string): SummaryResult {
|
|
285
|
+
const normalizedText = unwrapJsonCodeFence(text);
|
|
282
286
|
let payload: unknown;
|
|
283
287
|
try {
|
|
284
|
-
payload = JSON.parse(
|
|
288
|
+
payload = JSON.parse(normalizedText);
|
|
285
289
|
} catch (error) {
|
|
286
290
|
throw new Error(`Summarizer returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
287
291
|
}
|
|
@@ -336,6 +340,13 @@ function parseSummaryResponse(text: string, summaryModel: string): SummaryResult
|
|
|
336
340
|
};
|
|
337
341
|
}
|
|
338
342
|
|
|
343
|
+
/** 兼容模型用 Markdown JSON 代码围栏包裹结构化响应的常见输出格式。 */
|
|
344
|
+
function unwrapJsonCodeFence(text: string): string {
|
|
345
|
+
const trimmed = text.trim();
|
|
346
|
+
const match = trimmed.match(/^(`{3,})[ \t]*(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n\1[ \t]*$/i);
|
|
347
|
+
return match?.[2]?.trim() ?? trimmed;
|
|
348
|
+
}
|
|
349
|
+
|
|
339
350
|
async function summarizeOutput(
|
|
340
351
|
prompt: string,
|
|
341
352
|
output: string,
|
|
@@ -407,6 +418,63 @@ async function summarizeOutput(
|
|
|
407
418
|
};
|
|
408
419
|
}
|
|
409
420
|
|
|
421
|
+
async function summarizeOutputWithRetries(
|
|
422
|
+
prompt: string,
|
|
423
|
+
output: string,
|
|
424
|
+
config: BashSummaryConfig,
|
|
425
|
+
context: DistillExecutionContext,
|
|
426
|
+
completion: SummaryCompletion,
|
|
427
|
+
): Promise<SummaryResult> {
|
|
428
|
+
let timeoutRetries = 0;
|
|
429
|
+
let errorRetries = 0;
|
|
430
|
+
|
|
431
|
+
while (true) {
|
|
432
|
+
if (context.signal?.aborted) {
|
|
433
|
+
throw new Error("Summarization aborted");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const attemptController = new AbortController();
|
|
437
|
+
const abortFromParent = () => attemptController.abort();
|
|
438
|
+
context.signal?.addEventListener("abort", abortFromParent, { once: true });
|
|
439
|
+
if (context.signal?.aborted) attemptController.abort();
|
|
440
|
+
let timedOut = false;
|
|
441
|
+
const timeout = setTimeout(
|
|
442
|
+
() => {
|
|
443
|
+
timedOut = true;
|
|
444
|
+
attemptController.abort();
|
|
445
|
+
},
|
|
446
|
+
config.timeoutSeconds * 1000,
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
return await summarizeOutput(
|
|
451
|
+
prompt,
|
|
452
|
+
output,
|
|
453
|
+
config,
|
|
454
|
+
context,
|
|
455
|
+
attemptController.signal,
|
|
456
|
+
completion,
|
|
457
|
+
);
|
|
458
|
+
} catch (error) {
|
|
459
|
+
if (context.signal?.aborted) throw error;
|
|
460
|
+
const retryLimit = timedOut ? config.timeoutRetryCount : config.errorRetryCount;
|
|
461
|
+
const retriesUsed = timedOut ? timeoutRetries : errorRetries;
|
|
462
|
+
if (retriesUsed >= retryLimit) throw error;
|
|
463
|
+
if (timedOut) timeoutRetries += 1;
|
|
464
|
+
else errorRetries += 1;
|
|
465
|
+
console.warn(i18n.t("retryingSummary", {
|
|
466
|
+
kind: i18n.t(timedOut ? "retryKindTimeout" : "retryKindError"),
|
|
467
|
+
retry: retriesUsed + 1,
|
|
468
|
+
limit: retryLimit,
|
|
469
|
+
error: error instanceof Error ? error.message : String(error),
|
|
470
|
+
}));
|
|
471
|
+
} finally {
|
|
472
|
+
clearTimeout(timeout);
|
|
473
|
+
context.signal?.removeEventListener("abort", abortFromParent);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
410
478
|
function getOutputRequest(params: Record<string, unknown>): string {
|
|
411
479
|
return typeof params.outputRequest === "string"
|
|
412
480
|
? params.outputRequest.trim()
|
|
@@ -423,8 +491,10 @@ export async function processToolResult(
|
|
|
423
491
|
const loaded = loadDistillConfig();
|
|
424
492
|
const config = loaded.config;
|
|
425
493
|
const outputSummaryRender = { ...loaded.render };
|
|
426
|
-
//
|
|
427
|
-
|
|
494
|
+
// Pi may persist raw tool output before this hook runs. This second limit
|
|
495
|
+
// protects the post-distillation result, including RAW and fallbacks.
|
|
496
|
+
const maxReturnedChars = config?.maxOutputChars ?? 10_000;
|
|
497
|
+
const finish = (candidate: ToolResult) => limitReturnedToolResult(candidate, maxReturnedChars);
|
|
428
498
|
if (loaded.warnings.length > 0) {
|
|
429
499
|
console.warn(`[pi-distill] ${loaded.warnings.join(" | ")}`);
|
|
430
500
|
}
|
|
@@ -504,17 +574,12 @@ export async function processToolResult(
|
|
|
504
574
|
}
|
|
505
575
|
|
|
506
576
|
const summaryStartedAt = performance.now();
|
|
507
|
-
const timeoutController = new AbortController();
|
|
508
|
-
const abortFromParent = () => timeoutController.abort();
|
|
509
|
-
context.signal?.addEventListener("abort", abortFromParent, { once: true });
|
|
510
|
-
const timeout = setTimeout(() => timeoutController.abort(), config.timeoutSeconds * 1000);
|
|
511
577
|
try {
|
|
512
|
-
const summarized = await
|
|
578
|
+
const summarized = await summarizeOutputWithRetries(
|
|
513
579
|
prompt,
|
|
514
580
|
output,
|
|
515
581
|
config,
|
|
516
582
|
context,
|
|
517
|
-
timeoutController.signal,
|
|
518
583
|
completion,
|
|
519
584
|
);
|
|
520
585
|
const summaryDurationMs = Math.round(performance.now() - summaryStartedAt);
|
|
@@ -649,9 +714,6 @@ export async function processToolResult(
|
|
|
649
714
|
: result.content,
|
|
650
715
|
};
|
|
651
716
|
return finish(candidate);
|
|
652
|
-
} finally {
|
|
653
|
-
clearTimeout(timeout);
|
|
654
|
-
context.signal?.removeEventListener("abort", abortFromParent);
|
|
655
717
|
}
|
|
656
718
|
}
|
|
657
719
|
|
|
@@ -745,7 +807,7 @@ function toToolResultEventResult(result: ToolResult): ToolResultEventPatch {
|
|
|
745
807
|
};
|
|
746
808
|
}
|
|
747
809
|
|
|
748
|
-
type DistillUiConfig = Required<Pick<DistillConfigFile, "enabled" | "model" | "minChars" | "maxChars" | "maxOutputChars" | "timeoutSeconds" | "missedCompressionRatio" | "summarizeErrors">> & {
|
|
810
|
+
type DistillUiConfig = Required<Pick<DistillConfigFile, "enabled" | "model" | "minChars" | "maxChars" | "maxOutputChars" | "timeoutSeconds" | "timeoutRetryCount" | "errorRetryCount" | "missedCompressionRatio" | "summarizeErrors">> & {
|
|
749
811
|
tools: DistillToolConfig;
|
|
750
812
|
render: DistillRenderConfig;
|
|
751
813
|
};
|
|
@@ -762,6 +824,8 @@ function getDistillUiConfig(): DistillUiConfig {
|
|
|
762
824
|
maxChars: config?.maxChars ?? 100_000,
|
|
763
825
|
maxOutputChars: config?.maxOutputChars ?? 10_000,
|
|
764
826
|
timeoutSeconds: config?.timeoutSeconds ?? 10,
|
|
827
|
+
timeoutRetryCount: config?.timeoutRetryCount ?? 1,
|
|
828
|
+
errorRetryCount: config?.errorRetryCount ?? 1,
|
|
765
829
|
missedCompressionRatio: config?.missedCompressionRatio ?? 10,
|
|
766
830
|
summarizeErrors: config?.summarizeErrors ?? true,
|
|
767
831
|
tools: Object.fromEntries(
|
|
@@ -785,6 +849,22 @@ async function editDistillNumber(
|
|
|
785
849
|
return Number(value);
|
|
786
850
|
}
|
|
787
851
|
|
|
852
|
+
async function editDistillNonNegativeInteger(
|
|
853
|
+
ctx: ExtensionCommandContext,
|
|
854
|
+
title: string,
|
|
855
|
+
current: number,
|
|
856
|
+
): Promise<number | undefined> {
|
|
857
|
+
const value = await ctx.ui.input(title, String(current));
|
|
858
|
+
if (value === undefined) return undefined;
|
|
859
|
+
const normalized = value.trim();
|
|
860
|
+
const parsed = Number(normalized);
|
|
861
|
+
if (!/^\d+$/.test(normalized) || !Number.isSafeInteger(parsed)) {
|
|
862
|
+
ctx.ui.notify(i18n.t("nonNegativeInteger"), "error");
|
|
863
|
+
return undefined;
|
|
864
|
+
}
|
|
865
|
+
return parsed;
|
|
866
|
+
}
|
|
867
|
+
|
|
788
868
|
async function editDistillModel(
|
|
789
869
|
ctx: ExtensionCommandContext,
|
|
790
870
|
current: string,
|
|
@@ -873,6 +953,8 @@ async function runDistillConfigUi(
|
|
|
873
953
|
i18n.t("summaryLimit", { value: config.maxChars }),
|
|
874
954
|
i18n.t("finalLimit", { value: config.maxOutputChars }),
|
|
875
955
|
i18n.t("timeout", { value: config.timeoutSeconds }),
|
|
956
|
+
i18n.t("timeoutRetryCount", { value: config.timeoutRetryCount }),
|
|
957
|
+
i18n.t("errorRetryCount", { value: config.errorRetryCount }),
|
|
876
958
|
i18n.t("threshold", { value: config.missedCompressionRatio }),
|
|
877
959
|
i18n.t("summarizeErrors", { value: config.summarizeErrors ? i18n.t("on") : i18n.t("off") }),
|
|
878
960
|
i18n.t("auditRenderer", { value: config.render.enabled ? i18n.t("on") : i18n.t("off") }),
|
|
@@ -917,24 +999,44 @@ async function runDistillConfigUi(
|
|
|
917
999
|
await saveDistillConfigFile(ctx, config, configPath, onSaved);
|
|
918
1000
|
}
|
|
919
1001
|
} else if (choice === choices[6]) {
|
|
1002
|
+
const value = await editDistillNonNegativeInteger(
|
|
1003
|
+
ctx,
|
|
1004
|
+
i18n.t("timeoutRetryCountTitle"),
|
|
1005
|
+
config.timeoutRetryCount,
|
|
1006
|
+
);
|
|
1007
|
+
if (value !== undefined) {
|
|
1008
|
+
config.timeoutRetryCount = value;
|
|
1009
|
+
await saveDistillConfigFile(ctx, config, configPath, onSaved);
|
|
1010
|
+
}
|
|
1011
|
+
} else if (choice === choices[7]) {
|
|
1012
|
+
const value = await editDistillNonNegativeInteger(
|
|
1013
|
+
ctx,
|
|
1014
|
+
i18n.t("errorRetryCountTitle"),
|
|
1015
|
+
config.errorRetryCount,
|
|
1016
|
+
);
|
|
1017
|
+
if (value !== undefined) {
|
|
1018
|
+
config.errorRetryCount = value;
|
|
1019
|
+
await saveDistillConfigFile(ctx, config, configPath, onSaved);
|
|
1020
|
+
}
|
|
1021
|
+
} else if (choice === choices[8]) {
|
|
920
1022
|
const value = await editDistillNumber(ctx, i18n.t("thresholdTitle"), config.missedCompressionRatio);
|
|
921
1023
|
if (value !== undefined) {
|
|
922
1024
|
config.missedCompressionRatio = value;
|
|
923
1025
|
await saveDistillConfigFile(ctx, config, configPath, onSaved);
|
|
924
1026
|
}
|
|
925
|
-
} else if (choice === choices[
|
|
1027
|
+
} else if (choice === choices[9]) {
|
|
926
1028
|
config.summarizeErrors = !config.summarizeErrors;
|
|
927
1029
|
await saveDistillConfigFile(ctx, config, configPath, onSaved);
|
|
928
|
-
} else if (choice === choices[
|
|
1030
|
+
} else if (choice === choices[10]) {
|
|
929
1031
|
config.render.enabled = !config.render.enabled;
|
|
930
1032
|
await saveDistillConfigFile(ctx, config, configPath, onSaved);
|
|
931
|
-
} else if (choice === choices[
|
|
1033
|
+
} else if (choice === choices[11]) {
|
|
932
1034
|
config.render.showPrompt = !config.render.showPrompt;
|
|
933
1035
|
await saveDistillConfigFile(ctx, config, configPath, onSaved);
|
|
934
|
-
} else if (choice === choices[
|
|
1036
|
+
} else if (choice === choices[12]) {
|
|
935
1037
|
config.render.showResult = !config.render.showResult;
|
|
936
1038
|
await saveDistillConfigFile(ctx, config, configPath, onSaved);
|
|
937
|
-
} else if (choice === choices[
|
|
1039
|
+
} else if (choice === choices[13]) {
|
|
938
1040
|
await runDistillToolConfigUi(ctx, pi, config, configPath, onSaved);
|
|
939
1041
|
}
|
|
940
1042
|
}
|
package/src/output-limit.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createTranslator, loadCatalog } from "pi-extensions-i18n";
|
|
5
|
+
|
|
6
|
+
const i18n = createTranslator(loadCatalog(new URL("../locales/index.json", import.meta.url)));
|
|
7
|
+
|
|
1
8
|
export type OutputLimitToolResult = {
|
|
2
9
|
content: Array<{ type?: string; text?: string }>;
|
|
3
10
|
details?: {
|
|
@@ -15,3 +22,53 @@ export function getTextContent(result: OutputLimitToolResult): string {
|
|
|
15
22
|
export function hasNonTextContent(result: OutputLimitToolResult): boolean {
|
|
16
23
|
return result.content.some((content) => content.type !== "text" || typeof content.text !== "string");
|
|
17
24
|
}
|
|
25
|
+
|
|
26
|
+
async function writeOutputFile(text: string): Promise<string> {
|
|
27
|
+
const directory = join(tmpdir(), "pi-distill");
|
|
28
|
+
await mkdir(directory, { recursive: true });
|
|
29
|
+
const filePath = join(
|
|
30
|
+
directory,
|
|
31
|
+
`output-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`,
|
|
32
|
+
);
|
|
33
|
+
await writeFile(filePath, text, "utf8");
|
|
34
|
+
return filePath;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Limit text entering the next Agent context; preserve non-text content. */
|
|
38
|
+
export async function limitReturnedToolResult(
|
|
39
|
+
result: OutputLimitToolResult,
|
|
40
|
+
maxChars: number,
|
|
41
|
+
): Promise<OutputLimitToolResult> {
|
|
42
|
+
if (hasNonTextContent(result)) return result;
|
|
43
|
+
|
|
44
|
+
const text = getTextContent(result);
|
|
45
|
+
if (text.length <= maxChars) return result;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const filePath = await writeOutputFile(text);
|
|
49
|
+
const pointer = i18n.t("outputLimitExceeded", { maxChars, path: filePath });
|
|
50
|
+
return {
|
|
51
|
+
...result,
|
|
52
|
+
content: [{ type: "text", text: pointer.slice(0, maxChars) }],
|
|
53
|
+
details: {
|
|
54
|
+
...(result.details ?? {}),
|
|
55
|
+
fullOutputPath: filePath,
|
|
56
|
+
outputTruncated: true,
|
|
57
|
+
outputLimitChars: maxChars,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
62
|
+
console.warn(i18n.t("outputLimitWriteFailed", { error: message }));
|
|
63
|
+
return {
|
|
64
|
+
...result,
|
|
65
|
+
content: [{ type: "text", text: text.slice(0, maxChars) }],
|
|
66
|
+
details: {
|
|
67
|
+
...(result.details ?? {}),
|
|
68
|
+
outputTruncated: true,
|
|
69
|
+
outputLimitChars: maxChars,
|
|
70
|
+
outputFileError: message,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/summary-utils.ts
CHANGED
|
@@ -9,6 +9,8 @@ const DEFAULT_MIN_CHARS = 200;
|
|
|
9
9
|
const DEFAULT_MAX_CHARS = 100_000;
|
|
10
10
|
const DEFAULT_MAX_OUTPUT_CHARS = 10_000;
|
|
11
11
|
const DEFAULT_TIMEOUT_SECONDS = 10;
|
|
12
|
+
const DEFAULT_TIMEOUT_RETRY_COUNT = 1;
|
|
13
|
+
const DEFAULT_ERROR_RETRY_COUNT = 1;
|
|
12
14
|
const DEFAULT_MISSED_COMPRESSION_RATIO = 10;
|
|
13
15
|
const DEFAULT_SUMMARIZE_ERRORS = true;
|
|
14
16
|
const DEFAULT_RENDER_ENABLED = true;
|
|
@@ -30,6 +32,10 @@ export interface BashSummaryConfig {
|
|
|
30
32
|
maxOutputChars: number;
|
|
31
33
|
/** 模型调用最长等待时间。 */
|
|
32
34
|
timeoutSeconds: number;
|
|
35
|
+
/** 单次提炼因超时失败后的额外重试次数。 */
|
|
36
|
+
timeoutRetryCount: number;
|
|
37
|
+
/** 单次提炼因非超时异常失败后的额外重试次数。 */
|
|
38
|
+
errorRetryCount: number;
|
|
33
39
|
/** 无 prompt 的长输出触发 missed-compression 提醒所需的倍数。 */
|
|
34
40
|
missedCompressionRatio: number;
|
|
35
41
|
/** 工具返回错误且达到最小长度时是否仍调用提炼模型。 */
|
|
@@ -60,6 +66,8 @@ export interface DistillConfigFile {
|
|
|
60
66
|
maxChars?: number;
|
|
61
67
|
maxOutputChars?: number;
|
|
62
68
|
timeoutSeconds?: number;
|
|
69
|
+
timeoutRetryCount?: number;
|
|
70
|
+
errorRetryCount?: number;
|
|
63
71
|
missedCompressionRatio?: number;
|
|
64
72
|
summarizeErrors?: boolean;
|
|
65
73
|
tools?: DistillToolConfig;
|
|
@@ -109,6 +117,8 @@ export function parseBashSummaryConfig(
|
|
|
109
117
|
const timeoutSecondsValue = (
|
|
110
118
|
env.PI_DISTILL_TIMEOUT_SECONDS ?? env.PI_BASH_SUMMARY_TIMEOUT_SECONDS
|
|
111
119
|
)?.trim();
|
|
120
|
+
const timeoutRetryCountValue = env.PI_DISTILL_TIMEOUT_RETRY_COUNT?.trim();
|
|
121
|
+
const errorRetryCountValue = env.PI_DISTILL_ERROR_RETRY_COUNT?.trim();
|
|
112
122
|
const missedCompressionRatioValue = (
|
|
113
123
|
env.PI_DISTILL_MISSED_COMPRESSION_RATIO ?? env.PI_BASH_SUMMARY_MISSED_COMPRESSION_RATIO
|
|
114
124
|
)?.trim();
|
|
@@ -127,6 +137,12 @@ export function parseBashSummaryConfig(
|
|
|
127
137
|
const timeoutSeconds = timeoutSecondsValue
|
|
128
138
|
? parsePositiveInteger(timeoutSecondsValue)
|
|
129
139
|
: DEFAULT_TIMEOUT_SECONDS;
|
|
140
|
+
const timeoutRetryCount = timeoutRetryCountValue
|
|
141
|
+
? parseNonNegativeInteger(timeoutRetryCountValue)
|
|
142
|
+
: DEFAULT_TIMEOUT_RETRY_COUNT;
|
|
143
|
+
const errorRetryCount = errorRetryCountValue
|
|
144
|
+
? parseNonNegativeInteger(errorRetryCountValue)
|
|
145
|
+
: DEFAULT_ERROR_RETRY_COUNT;
|
|
130
146
|
const missedCompressionRatio = missedCompressionRatioValue
|
|
131
147
|
? parsePositiveNumber(missedCompressionRatioValue)
|
|
132
148
|
: DEFAULT_MISSED_COMPRESSION_RATIO;
|
|
@@ -138,12 +154,14 @@ export function parseBashSummaryConfig(
|
|
|
138
154
|
minChars === undefined ||
|
|
139
155
|
maxChars === undefined ||
|
|
140
156
|
timeoutSeconds === undefined ||
|
|
157
|
+
timeoutRetryCount === undefined ||
|
|
158
|
+
errorRetryCount === undefined ||
|
|
141
159
|
maxOutputChars === undefined ||
|
|
142
160
|
missedCompressionRatio === undefined ||
|
|
143
161
|
summarizeErrors === undefined
|
|
144
162
|
) {
|
|
145
163
|
console.warn(
|
|
146
|
-
"[pi-distill] Invalid distillation config; distillation disabled. Check PI_DISTILL_MIN_CHARS, PI_DISTILL_MAX_CHARS, PI_DISTILL_MAX_OUTPUT_CHARS, PI_DISTILL_TIMEOUT_SECONDS, PI_DISTILL_MISSED_COMPRESSION_RATIO, and PI_DISTILL_SUMMARIZE_ERRORS (legacy PI_BASH_SUMMARY_* variables remain supported).",
|
|
164
|
+
"[pi-distill] Invalid distillation config; distillation disabled. Check PI_DISTILL_MIN_CHARS, PI_DISTILL_MAX_CHARS, PI_DISTILL_MAX_OUTPUT_CHARS, PI_DISTILL_TIMEOUT_SECONDS, PI_DISTILL_TIMEOUT_RETRY_COUNT, PI_DISTILL_ERROR_RETRY_COUNT, PI_DISTILL_MISSED_COMPRESSION_RATIO, and PI_DISTILL_SUMMARIZE_ERRORS (legacy PI_BASH_SUMMARY_* variables remain supported).",
|
|
147
165
|
);
|
|
148
166
|
return undefined;
|
|
149
167
|
}
|
|
@@ -154,6 +172,8 @@ export function parseBashSummaryConfig(
|
|
|
154
172
|
maxChars,
|
|
155
173
|
maxOutputChars,
|
|
156
174
|
timeoutSeconds,
|
|
175
|
+
timeoutRetryCount,
|
|
176
|
+
errorRetryCount,
|
|
157
177
|
missedCompressionRatio,
|
|
158
178
|
summarizeErrors,
|
|
159
179
|
};
|
|
@@ -174,6 +194,8 @@ export function parseBashSummaryConfig(
|
|
|
174
194
|
maxChars,
|
|
175
195
|
maxOutputChars,
|
|
176
196
|
timeoutSeconds,
|
|
197
|
+
timeoutRetryCount,
|
|
198
|
+
errorRetryCount,
|
|
177
199
|
missedCompressionRatio,
|
|
178
200
|
summarizeErrors,
|
|
179
201
|
};
|
|
@@ -189,6 +211,12 @@ function parsePositiveInteger(value: string | undefined): number | undefined {
|
|
|
189
211
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
190
212
|
}
|
|
191
213
|
|
|
214
|
+
function parseNonNegativeInteger(value: string | undefined): number | undefined {
|
|
215
|
+
if (!value || !/^\d+$/.test(value)) return undefined;
|
|
216
|
+
const parsed = Number(value);
|
|
217
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
192
220
|
function parsePositiveNumber(value: string): number | undefined {
|
|
193
221
|
if (!/^\d+(?:\.\d+)?$/.test(value)) return undefined;
|
|
194
222
|
const parsed = Number(value);
|
|
@@ -279,6 +307,19 @@ function appendFileValueToEnv(
|
|
|
279
307
|
return;
|
|
280
308
|
}
|
|
281
309
|
|
|
310
|
+
if (
|
|
311
|
+
key === "timeoutRetryCount" ||
|
|
312
|
+
key === "errorRetryCount"
|
|
313
|
+
) {
|
|
314
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
315
|
+
warnings.push(`Config field ${key} must be a non-negative integer.`);
|
|
316
|
+
env[envKey] = "__invalid_file_value__";
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
env[envKey] = String(value);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
282
323
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
283
324
|
warnings.push(`Config field ${key} must be a positive number.`);
|
|
284
325
|
env[envKey] = "__invalid_file_value__";
|
|
@@ -323,6 +364,20 @@ export function loadDistillConfig(
|
|
|
323
364
|
appendFileValueToEnv(effectiveEnv, file, "maxChars", "PI_DISTILL_MAX_CHARS", warnings);
|
|
324
365
|
appendFileValueToEnv(effectiveEnv, file, "maxOutputChars", "PI_DISTILL_MAX_OUTPUT_CHARS", warnings);
|
|
325
366
|
appendFileValueToEnv(effectiveEnv, file, "timeoutSeconds", "PI_DISTILL_TIMEOUT_SECONDS", warnings);
|
|
367
|
+
appendFileValueToEnv(
|
|
368
|
+
effectiveEnv,
|
|
369
|
+
file,
|
|
370
|
+
"timeoutRetryCount",
|
|
371
|
+
"PI_DISTILL_TIMEOUT_RETRY_COUNT",
|
|
372
|
+
warnings,
|
|
373
|
+
);
|
|
374
|
+
appendFileValueToEnv(
|
|
375
|
+
effectiveEnv,
|
|
376
|
+
file,
|
|
377
|
+
"errorRetryCount",
|
|
378
|
+
"PI_DISTILL_ERROR_RETRY_COUNT",
|
|
379
|
+
warnings,
|
|
380
|
+
);
|
|
326
381
|
appendFileValueToEnv(
|
|
327
382
|
effectiveEnv,
|
|
328
383
|
file,
|
|
@@ -357,6 +412,8 @@ export function defaultDistillConfigFile(): DistillConfigFile {
|
|
|
357
412
|
maxChars: DEFAULT_MAX_CHARS,
|
|
358
413
|
maxOutputChars: DEFAULT_MAX_OUTPUT_CHARS,
|
|
359
414
|
timeoutSeconds: DEFAULT_TIMEOUT_SECONDS,
|
|
415
|
+
timeoutRetryCount: DEFAULT_TIMEOUT_RETRY_COUNT,
|
|
416
|
+
errorRetryCount: DEFAULT_ERROR_RETRY_COUNT,
|
|
360
417
|
missedCompressionRatio: DEFAULT_MISSED_COMPRESSION_RATIO,
|
|
361
418
|
summarizeErrors: DEFAULT_SUMMARIZE_ERRORS,
|
|
362
419
|
tools: {},
|