From 0c80a8b53753e6966f46a207b204790e56e4eb93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E6=9C=8D=E5=8A=A1=E9=83=A8-=E5=8F=B6=E6=9E=97?= =?UTF-8?q?=E7=AB=8B?= <1361666059@qq.com> Date: Fri, 28 Aug 2026 12:14:19 +0800 Subject: [PATCH] feat(condense): tune default pruning policy --- AGENTS.md | 2 +- README.md | 4 ++- extensions/condense-config.ts | 24 ++++++++++--- extensions/condense.ts | 4 +-- tests/condense-config.test.ts | 68 ++++++++++++++++++++++++++++------- 5 files changed, 81 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9cfa0ef..802e980 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ - Exa MCP 使用托管 Streamable HTTP 端点和 `eager` lifecycle;`EXA_API_KEY` 通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。所有在线搜索工具统一采用“来源名 + 原始语义工具名”的命名形式,例如 `tavily_web_search`、`exa_web_search`、`keenable_search`。Exa 高级搜索必须显式约束结果数量和文本长度;Tavily 输出默认控制 `max_results` 且非必要不请求 raw content;Keenable 优先利用中文、站点和日期筛选能力。 - 搜索 key 默认保存在 `${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/search.env`,文件必须为 `600` 且不得提交;`search_config.sh` 只输出配置状态,禁止回显 key。 - `pi-context-view` 只观察上下文占用,不参与压缩策略。 -- `pi-condense` 负责压缩已经进入会话的历史工具结果,与 Context Mode 的输入隔离职责互补;`extensions/condense.ts` 只在 `settings.json` 尚无 `contextPrune.enabled` 时写入 `true`,必须保留用户显式设置的 `false`,配置无效时不得覆盖原文件。其他参数沿用上游默认,包括 `agent-message` 触发模式和 skill 路径保护。 +- `pi-condense` 负责压缩已经进入会话的历史工具结果,与 Context Mode 的输入隔离职责互补;`extensions/condense.ts` 只为 `settings.json` 的 `contextPrune` 补入缺失的组合包默认字段,必须保留全部用户显式值(包括 `enabled: false`),配置无效时不得覆盖原文件,配置完整时不得重复改写。组合包默认开启,使用 `agent-message` 触发、`turn` 批处理、73.5% 总预算与 4% 单轮增量触发,摘要模型和 thinking 均为 `default`,空闲/总超时为 90 秒/5 分钟,`quietOversizedSkips` 为 `false`;未显式部署的参数继续沿用上游默认,包括 skill 路径保护。 - Codex fast mode 只为符合条件的 `openai-codex-responses` 请求设置 priority service tier。`/fast-mode on|off` 同时更新当前分支记录与 Pi agent 目录中的 owner-only 全局默认值;新会话继承全局值,已有分支记录优先,配置缺失或无效时回退为关闭。 - Kitty 通知只在 TUI 模式的 `agent_settled` 后发送;自动重试、自动 compaction 和 follow-up 期间不得提前通知或重置总耗时。默认 `o=always`、`a=focus`,每个 Pi Session 使用独立稳定 ID,标题和正文必须 Base64 编码。 - `/notify on|off` 只覆盖当前 Session;持久默认来自 `PI_NOTIFY_*` 环境变量。`PI_NOTIFY_MESSAGE_SOURCE=none` 必须继续提供不泄露回复正文的隐私模式,非 TUI 模式不得写入 OSC 序列。 diff --git a/README.md b/README.md index 5c2718a..02cb5c5 100644 --- a/README.md +++ b/README.md @@ -361,7 +361,9 @@ macOS 通知中心。Kitty 位于 tmux 内时需要 `set -g allow-passthrough al ### Pi Condense -组合包首次加载时会在 `contextPrune.enabled` 尚未配置的情况下写入 `true`,因此 `pi-condense` 默认开启;用户通过 `/pruner off` 写入的显式 `false` 会被保留,后续启动或升级不会重新覆盖。它使用上游默认的 `agent-message` 触发模式:同一用户任务中的多轮工具调用会在最终文本回复后统一触发整理,默认仍按每个 assistant turn 分别生成摘要;后续上下文只保留摘要和 `t1`、`t2` 等引用。需要原文时,模型可调用 `context_tree_query({ toolCallIds: ["t1"] })` 恢复。 +组合包加载时会只为 `contextPrune` 中缺失的字段补入默认策略,任何用户显式值(包括 `/pruner off` 写入的 `enabled: false`)均优先且不会被覆盖。默认使用 `agent-message` 触发模式和 `turn` 批处理;正常上下文达到窗口的 73.5% 时中途整理,单轮增长达到窗口的 4% 时提前整理。对于 272K 上下文模型,两者约为 200K 和 11K tokens;其他模型按各自窗口比例计算。摘要使用当前默认模型和默认 thinking,连续 90 秒无输出才判定空闲超时,每次最长运行 5 分钟,并保留未压缩或摘要过大等跳过提示。 + +同一用户任务中的多轮工具调用通常在最终文本回复后触发整理,预算触发器则可在长任务中提前处理;后续上下文只保留摘要和 `t1`、`t2` 等引用。需要原文时,模型可调用 `context_tree_query({ toolCallIds: ["t1"] })` 恢复。 常用命令:`/pruner status` 查看状态,`/pruner settings` 调整配置,`/pruner now` 立即处理待总结批次,`/pruner tree` 浏览已归档调用,`/pruner stats` 查看累计成本和节省,`/pruner off` 关闭。配置保存在 Pi agent 目录 `settings.json` 的 `contextPrune` 字段。建议保留默认的 skill 路径保护,并把必须逐字复用输出的工具加入 `protectedTools`。 diff --git a/extensions/condense-config.ts b/extensions/condense-config.ts index bfe56d6..e5c9c6b 100644 --- a/extensions/condense-config.ts +++ b/extensions/condense-config.ts @@ -3,12 +3,25 @@ import { dirname, join } from "node:path"; export type CondenseDefaultResult = "updated" | "unchanged" | "skipped-invalid"; +const CONDENSE_BUNDLE_DEFAULTS = { + enabled: true, + pruneOn: "agent-message", + batchingMode: "turn", + autoBudgetThreshold: 0.735, + budgetTurnDelta: 0.04, + summarizerModel: "default", + summarizerThinking: "default", + summarizerIdleTimeoutMs: 90_000, + summarizerMaxTimeoutMs: 300_000, + quietOversizedSkips: false, +} as const; + function isObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } -/** Enable pi-condense only when the user has not already chosen an enabled state. */ -export function ensureCondenseEnabledDefault(agentDir: string): CondenseDefaultResult { +/** Fill missing pi-condense settings with bundle defaults while preserving every explicit user choice. */ +export function ensureCondenseDefaults(agentDir: string): CondenseDefaultResult { const targetPath = join(agentDir, "settings.json"); let settings: Record = {}; @@ -22,9 +35,12 @@ export function ensureCondenseEnabledDefault(agentDir: string): CondenseDefaultR const existing = settings.contextPrune; if (existing !== undefined && !isObject(existing)) return "skipped-invalid"; - if (isObject(existing) && Object.hasOwn(existing, "enabled")) return "unchanged"; - settings.contextPrune = { ...(existing ?? {}), enabled: true }; + const contextPrune = existing ?? {}; + const alreadyComplete = Object.keys(CONDENSE_BUNDLE_DEFAULTS).every((key) => Object.hasOwn(contextPrune, key)); + if (alreadyComplete) return "unchanged"; + + settings.contextPrune = { ...CONDENSE_BUNDLE_DEFAULTS, ...contextPrune }; mkdirSync(dirname(targetPath), { recursive: true }); const temporaryPath = `${targetPath}.my-pi.tmp`; writeFileSync(temporaryPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); diff --git a/extensions/condense.ts b/extensions/condense.ts index 2c84227..f976a85 100644 --- a/extensions/condense.ts +++ b/extensions/condense.ts @@ -1,9 +1,9 @@ import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { ensureCondenseEnabledDefault } from "./condense-config.ts"; +import { ensureCondenseDefaults } from "./condense-config.ts"; /** Apply the bundle default before pi-condense reads settings on session_start. */ export default async function bundledCondenseExtension(pi: ExtensionAPI): Promise { - const result = ensureCondenseEnabledDefault(getAgentDir()); + const result = ensureCondenseDefaults(getAgentDir()); if (result === "skipped-invalid") { console.warn("my-pi: skipped the pi-condense default because settings.json or contextPrune is invalid"); } diff --git a/tests/condense-config.test.ts b/tests/condense-config.test.ts index 21d414c..656a821 100644 --- a/tests/condense-config.test.ts +++ b/tests/condense-config.test.ts @@ -4,7 +4,20 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { ensureCondenseEnabledDefault } from "../extensions/condense-config.ts"; +import { ensureCondenseDefaults } from "../extensions/condense-config.ts"; + +const EXPECTED_DEFAULTS = { + enabled: true, + pruneOn: "agent-message", + batchingMode: "turn", + autoBudgetThreshold: 0.735, + budgetTurnDelta: 0.04, + summarizerModel: "default", + summarizerThinking: "default", + summarizerIdleTimeoutMs: 90_000, + summarizerMaxTimeoutMs: 300_000, + quietOversizedSkips: false, +}; async function withAgentDir(run: (agentDir: string) => Promise): Promise { const agentDir = await mkdtemp(join(tmpdir(), "my-pi-condense-")); @@ -15,42 +28,71 @@ async function withAgentDir(run: (agentDir: string) => Promise): Promise { +test("pi-condense writes the complete bundle defaults when contextPrune is absent", async () => { await withAgentDir(async (agentDir) => { - assert.equal(ensureCondenseEnabledDefault(agentDir), "updated"); + assert.equal(ensureCondenseDefaults(agentDir), "updated"); assert.deepEqual(JSON.parse(await readFile(join(agentDir, "settings.json"), "utf8")), { - contextPrune: { enabled: true }, + contextPrune: EXPECTED_DEFAULTS, }); }); }); -test("pi-condense default preserves existing settings and contextPrune fields", async () => { +test("pi-condense fills only missing defaults and preserves unrelated settings", async () => { await withAgentDir(async (agentDir) => { const path = join(agentDir, "settings.json"); - await writeFile(path, JSON.stringify({ theme: "dark", contextPrune: { minBatchChars: 4000 } }), "utf8"); - assert.equal(ensureCondenseEnabledDefault(agentDir), "updated"); + await writeFile( + path, + JSON.stringify({ theme: "dark", contextPrune: { minBatchChars: 4000, budgetTurnDelta: 0.03 } }), + "utf8", + ); + assert.equal(ensureCondenseDefaults(agentDir), "updated"); assert.deepEqual(JSON.parse(await readFile(path, "utf8")), { theme: "dark", - contextPrune: { minBatchChars: 4000, enabled: true }, + contextPrune: { ...EXPECTED_DEFAULTS, minBatchChars: 4000, budgetTurnDelta: 0.03 }, }); }); }); -test("pi-condense default respects an explicit enabled choice", async () => { +test("pi-condense preserves an explicit disabled choice while filling other defaults", async () => { await withAgentDir(async (agentDir) => { const path = join(agentDir, "settings.json"); - const original = `${JSON.stringify({ contextPrune: { enabled: false } }, null, 2)}\n`; + await writeFile(path, JSON.stringify({ contextPrune: { enabled: false } }), "utf8"); + assert.equal(ensureCondenseDefaults(agentDir), "updated"); + assert.deepEqual(JSON.parse(await readFile(path, "utf8")), { + contextPrune: { ...EXPECTED_DEFAULTS, enabled: false }, + }); + }); +}); + +test("pi-condense does not rewrite a complete explicit configuration", async () => { + await withAgentDir(async (agentDir) => { + const path = join(agentDir, "settings.json"); + const original = `${JSON.stringify( + { contextPrune: { ...EXPECTED_DEFAULTS, enabled: false, quietOversizedSkips: true } }, + null, + 2, + )}\n`; await writeFile(path, original, "utf8"); - assert.equal(ensureCondenseEnabledDefault(agentDir), "unchanged"); + assert.equal(ensureCondenseDefaults(agentDir), "unchanged"); assert.equal(await readFile(path, "utf8"), original); }); }); -test("pi-condense default does not overwrite malformed settings", async () => { +test("pi-condense defaults do not overwrite malformed settings", async () => { await withAgentDir(async (agentDir) => { const path = join(agentDir, "settings.json"); await writeFile(path, "{not-json", "utf8"); - assert.equal(ensureCondenseEnabledDefault(agentDir), "skipped-invalid"); + assert.equal(ensureCondenseDefaults(agentDir), "skipped-invalid"); assert.equal(await readFile(path, "utf8"), "{not-json"); }); }); + +test("pi-condense defaults reject a non-object contextPrune value", async () => { + await withAgentDir(async (agentDir) => { + const path = join(agentDir, "settings.json"); + const original = `${JSON.stringify({ contextPrune: false }, null, 2)}\n`; + await writeFile(path, original, "utf8"); + assert.equal(ensureCondenseDefaults(agentDir), "skipped-invalid"); + assert.equal(await readFile(path, "utf8"), original); + }); +});