From 1759f7826439bed0bc3a897c1d3206974264721a Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Sun, 23 Aug 2026 18:46:17 +0800 Subject: [PATCH] feat(absorb): instant tool-result compression for small contexts (#14) Registers the absorb tool when acp.json enables it (absorb: true or an absorb object). Large tool results get a forced [ACP absorb] prompt via the kernel pipeline; the model calls absorb({ ref, summary }) and the original tool-call+result pair is hidden from later turns, the summary becomes the durable record. Absorb calls stay ordinarily compressible. - src/absorb-tool.ts: tool handler (kernel applyAbsorb, parseAbsorbInput) - src/config.ts: AbsorbSettings + resolveAbsorb + resolveConfig mapping - src/index.ts: registration, session-start re-registration, system prompt - src/user-config.ts: acp.json key (boolean or object) - tests/absorb-tool.test.ts: 6 tests (config, registration, prompt gating, hide+keep-summary, error paths, system prompt section) - CONFIGURATION.md / CONFIGURATION.zh-CN.md / CHANGELOG.md Requires acp-kernel with the absorb API (branch 2026-08-23_absorb-tool, to be released before the adapter release bumps its pin). --- CHANGELOG.md | 2 + CONFIGURATION.md | 51 +++++++++++ CONFIGURATION.zh-CN.md | 51 +++++++++++ src/absorb-tool.ts | 75 ++++++++++++++++ src/config.ts | 53 +++++++++++ src/index.ts | 21 +++-- src/user-config.ts | 5 +- tests/absorb-tool.test.ts | 185 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 436 insertions(+), 7 deletions(-) create mode 100644 src/absorb-tool.ts create mode 100644 tests/absorb-tool.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d6ed9fa..71580ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased (master, since v0.1.38) +- **feat(absorb): 工具结果即时压缩(#14)** — 小上下文场景(如 1w 窗口)下,常规"到阈值再催促压缩"来不及;开启 `absorb: true` 后,大工具结果(默认 ≥1000 tokens)尾部追加强制 `[ACP absorb]` 提示,模型立即调用 `absorb({ ref, summary })` 蒸馏关键信息,原工具调用+结果对在后续轮次隐藏,摘要成为持久记录。absorb 调用是普通工具调用,仍可被常规压缩折叠(两机制正交)。配置:`absorb.minToolTokens` / `absorb.contextThresholdPct` / `absorb.excludeTools` / `absorb.toolName`;内核实现(acp-kernel `2026-08-23_absorb-tool` 分支),本仓库适配 + - **fix(compress): 接受 JSON 字符串形式的 `content` 参数** — 非严格工具 provider(vLLM openai-completions,`supportsStrictTools:false`)会把嵌套数组参数字符串化,pi 的 typebox 校验直接拒掉(`content.0: must be object`)。实测会话 01a00a38 全部唯一一次 compress 调用即死于此,3 小时会话零压缩。schema 改为 `Type.Union([Array, String])`,字符串自动 `JSON.parse` 并校验(错误信息引导模型传数组) - **feat(nudge): compress 失败即时重试提示** — 失败的 compress toolResult 不再白白吃掉本轮 nudge 预算:下一次 context 事件立即注入重试提示(引用被截短的错误文本、给出正确调用格式)。仅统计**当前用户轮**的失败(旧轮失败不再复发),封顶按**失败调用次数**计:每轮最多 3 次(`MAX_COMPRESS_ATTEMPTS`),成功重置;中性结果(非错误非面板文本,如 "No ranges provided.")不重置也不递增——混合失败模式无法绕过封顶。参数类错误改为 throw(pi 仅对 throw 的工具错误标 `isError:true`,return 字符串会被当成成功并重置计数)。提示在重试前每次 LLM 调用都重新注入(pi 每次重建上下文,一次性 append 会消失) - **fix(context)**: 上下文窗口自愈 — 上游 overflow 时从错误信息学习真实窗口、重新校准 nudge/truncate 阈值,并预留模型输出 headroom(Anthropic 除外);下一轮强制 usage≥95% 触发紧急截断(#177) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 60e9bc3..31ffae4 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -49,6 +49,12 @@ Create `~/.pi/acp.json` (or `/.pi/acp.json`) and drop in whichever keys "maxContextLimit": "75%", "emergencyThresholdPercent": "95%", "nudgeGrowthTokens": 50000 + }, + + "absorb": { + "minToolTokens": 1000, + "contextThresholdPct": 0, + "excludeTools": ["read"] } } ``` @@ -125,6 +131,16 @@ All keys below are currently **ACTIVE**. | `compress.emergencyThresholdPercent` | number \| string | `"95%"` | 🟢 ACTIVE | Context threshold that triggers emergency truncation. | | `compress.nudgeGrowthTokens` | number | `50000` | 🟢 ACTIVE | Token growth step for soft compression nudges. | +**Absorb keys** + +| Key | Type | Default | Status | Description | +|-----|------|---------|--------|-------------| +| `absorb` | boolean \| object | `false` | 🟢 ACTIVE | `true` enables instant tool-result absorption with defaults; an object tunes it. | +| `absorb.toolName` | string | `"absorb"` | 🟢 ACTIVE | Name of the absorb tool exposed to the model. | +| `absorb.minToolTokens` | number | `1000` | 🟢 ACTIVE | Tool results below this estimated size never get the absorb prompt. | +| `absorb.contextThresholdPct` | number \| string | `0` | 🟢 ACTIVE | Only prompt when context usage is at or above this fraction (`0.3` / `"30%"`); `0` = size alone decides. | +| `absorb.excludeTools` | string[] | `[]` | 🟢 ACTIVE | Tool names whose results are never absorbable. | + **Prompts keys** | Key | Type | Default | Status | Description | @@ -184,6 +200,41 @@ All keys below are currently **ACTIVE**. --- +## Absorb + +The `absorb` sub-object enables **instant tool-result compression** — designed for small-context setups (e.g. a 10K–20K window) where waiting for a regular compression nudge starves the model of working room. Tool calls are the biggest context consumer; absorption makes the model pay that cost back immediately after every large tool result. + +How it works: + +1. When a tool result is large enough (≥ `absorb.minToolTokens` estimated tokens) and not excluded/protected, a forced `[ACP absorb]` instruction is appended to it: the model must immediately call the `absorb` tool with the result's ref and a distilled summary. +2. Once absorbed, the original tool-call + tool-result pair is **hidden from all later turns**; the `absorb` call (carrying your summary) becomes the durable record. +3. `absorb` calls are ordinary tool calls — the regular compression system can fold them into blocks later, so the two mechanisms stay orthogonal. + +Shorthand forms (like `delegate`): `absorb: true` enables with defaults; an object tunes it. + +### `absorb.minToolTokens` + +- **Type:** `number` +- **Default:** `1000` +- **Status:** 🟢 ACTIVE +- **Description:** Tool results estimated below this many tokens never get the absorb prompt. Keep it high enough that only genuinely bulky outputs demand a distill step. + +### `absorb.contextThresholdPct` + +- **Type:** `number | string` +- **Default:** `0` +- **Status:** 🟢 ACTIVE +- **Description:** Only append absorb prompts when context usage is at or above this fraction of the window (`0.3` or `"30%"`). With the default `0`, size alone decides — every qualifying result is absorbed immediately, which is what small-context setups want. + +### `absorb.excludeTools` + +- **Type:** `string[]` +- **Default:** `[]` +- **Status:** 🟢 ACTIVE +- **Description:** Tool names whose results are never absorbable. ACP's own tool results (`compress`, `decompress`, `search_context`, `acp_status`, …) and protected tools are always excluded automatically. + +--- + ## Delegate The `delegate` sub-object controls the `acp_delegate` sub-agent tool family (`acp_delegate`, `acp_delegate_wait`, `acp_delegate_cancel`) and how their token usage is reported. diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index 02d4783..b922b61 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -49,6 +49,12 @@ "maxContextLimit": "75%", "emergencyThresholdPercent": "95%", "nudgeGrowthTokens": 50000 + }, + + "absorb": { + "minToolTokens": 1000, + "contextThresholdPct": 0, + "excludeTools": ["read"] } } ``` @@ -125,6 +131,16 @@ | `compress.emergencyThresholdPercent` | number \| string | `"95%"` | 🟢 ACTIVE | 触发紧急截断的上下文阈值。 | | `compress.nudgeGrowthTokens` | number | `50000` | 🟢 ACTIVE | 软压缩 nudge 的 token 增长步长。 | +**Absorb 键** + +| 键 | 类型 | 默认值 | 状态 | 说明 | +|-----|------|--------|------|------| +| `absorb` | boolean \| object | `false` | 🟢 ACTIVE | `true` 以默认参数开启工具结果即时吸收;传对象可细调。 | +| `absorb.toolName` | string | `"absorb"` | 🟢 ACTIVE | 暴露给模型的 absorb 工具名。 | +| `absorb.minToolTokens` | number | `1000` | 🟢 ACTIVE | 低于该估算规模的工具结果不触发吸收提示。 | +| `absorb.contextThresholdPct` | number \| string | `0` | 🟢 ACTIVE | 仅当上下文占用达到该比例(`0.3` / `"30%"`)时提示;`0` 表示只看大小。 | +| `absorb.excludeTools` | string[] | `[]` | 🟢 ACTIVE | 永不参与吸收的工具名列表。 | + **prompts 键** | 键 | 类型 | 默认值 | 状态 | 说明 | @@ -184,6 +200,41 @@ --- +## Absorb(工具即时压缩) + +`absorb` 子对象开启**工具结果即时压缩**——面向小上下文场景(例如 1w–2w 窗口):此时常规的"到阈值再催促压缩"来不及,模型没有干活的空间。工具调用是上下文的最大消耗者;即时吸收让模型在每次大工具输出之后立刻把这笔开销还回去。 + +工作方式: + +1. 当工具结果足够大(估算 ≥ `absorb.minToolTokens`)且未被排除/保护时,内核在其后追加一条强制的 `[ACP absorb]` 指令:要求模型立即调用 `absorb` 工具,带上该结果的 ref 和蒸馏摘要。 +2. 吸收完成后,原来的工具调用+工具结果对在**后续轮次中被隐藏**;携带摘要的 `absorb` 调用成为持久记录。 +3. `absorb` 调用本身是普通工具调用——之后仍可被常规压缩系统折叠进 block,两个机制彼此正交。 + +支持简写(同 `delegate`):`absorb: true` 以默认值开启;传对象可细调。 + +### `absorb.minToolTokens` + +- **类型:** `number` +- **默认值:** `1000` +- **状态:** 🟢 ACTIVE +- **说明:** 估算低于该 token 数的工具结果不触发吸收提示。保持足够高,只让真正的大输出走蒸馏步骤。 + +### `absorb.contextThresholdPct` + +- **类型:** `number | string` +- **默认值:** `0` +- **状态:** 🟢 ACTIVE +- **说明:** 仅当上下文占用达到窗口的该比例时(`0.3` 或 `"30%"`)才追加吸收提示。默认 `0` 表示只看大小——每个达标结果都立即吸收,这正是小上下文场景想要的。 + +### `absorb.excludeTools` + +- **类型:** `string[]` +- **默认值:** `[]` +- **状态:** 🟢 ACTIVE +- **说明:** 永不参与吸收的工具名。ACP 自身工具的结果(`compress`、`decompress`、`search_context`、`acp_status` 等)与受保护工具始终自动排除。 + +--- + ## Delegate `delegate` 子对象控制 `acp_delegate` 子代理工具族(`acp_delegate`、`acp_delegate_wait`、`acp_delegate_cancel`)及其 token 用量的报告方式。 diff --git a/src/absorb-tool.ts b/src/absorb-tool.ts new file mode 100644 index 0000000..1065272 --- /dev/null +++ b/src/absorb-tool.ts @@ -0,0 +1,75 @@ +import { Type, type Static } from "typebox"; +import type { + AgentToolResult, + ExtensionContext, + ToolDefinition, +} from "@earendil-works/pi-coding-agent"; +import type { AcpRuntime } from "./runtime.js"; +import { logInfo, logThrow, logWarn } from "./log.js"; +import { parseAbsorbInput, applyAbsorb } from "acp-kernel"; + +const AbsorbParams = Type.Object({ + ref: Type.String({ description: 'Ref of the tool result being absorbed, e.g. "m00042" (the mNNNNN from its acp tag).' }), + summary: Type.String({ description: "Distilled key results that REPLACE the tool output in context. Keep: outcome, key data/values, exact paths:lines, errors verbatim, decisions. You will NOT see the original again." }), +}); + +type AbsorbArgs = Static; + +export function makeAbsorbTool(runtime: AcpRuntime, name = "absorb"): ToolDefinition { + return { + name, + label: "Absorb", + description: + 'Distill a large tool result you just read into a compact summary. Once absorbed, the original tool output is removed from context and only your summary remains. Call as: absorb({ ref: "m00042", summary: "..." }).', + promptSnippet: 'absorb({ ref: "m00042", summary: "key results" }) — checkpoint a big tool result', + promptGuidelines: [ + "When a tool result carries a forced [ACP absorb] prompt, call absorb with its ref IMMEDIATELY, before any other tool call.", + "The summary must be self-contained — the original output disappears from context after absorption.", + ], + parameters: AbsorbParams, + async execute(toolCallId, params, _signal, _onUpdate, ctx): Promise> { + let result: string; + try { + result = await handleAbsorb(params as AbsorbArgs, runtime, ctx, toolCallId); + } catch (e) { + logThrow("absorb", e, { sid: ctx.sessionManager.getSessionId(), ref: String((params as AbsorbArgs).ref ?? "") }); + throw e; + } + return { details: undefined, content: [{ type: "text", text: result }] }; + }, + }; +} + +async function handleAbsorb(args: AbsorbArgs, runtime: AcpRuntime, ctx: ExtensionContext, toolCallId?: string): Promise { + const parsed = parseAbsorbInput(args, toolCallId, (message) => logWarn("absorb", { sid: ctx.sessionManager.getSessionId(), event: "lenient-parse", message })); + if (!parsed || !parsed.ref || !parsed.summary.trim()) { + throw new Error( + "Invalid absorb arguments: provide ref (the mNNNNN from the tool result's acp tag) and summary (the distilled key results that replace it). " + + `Example: absorb({ ref: "m00042", summary: "..." })`, + ); + } + const { state: initialState, coreMessages } = await runtime.stateFor(ctx); + const config = runtime.configFor(ctx); + if (!config.absorb?.enabled) return "absorb is disabled — nothing changed."; + const turn = runtime.core.processTurn({ messages: coreMessages, state: initialState, config, tokenCount: 0 }); + const outcome = applyAbsorb({ + ref: parsed.ref, + summary: parsed.summary, + absorbCallId: toolCallId, + messages: turn.messages, + state: turn.state, + config, + }); + if (!outcome.ok) throw new Error(outcome.resultText); + await runtime.save(outcome.state, ctx); + const record = outcome.state.absorbed?.[outcome.state.absorbed.length - 1]; + logInfo("absorb", { + sid: ctx.sessionManager.getSessionId(), + event: "applied", + ref: parsed.ref, + summaryLen: parsed.summary.length, + tokensReclaimed: record?.tokensReclaimed ?? null, + totalAbsorbed: outcome.state.stats?.absorbedTokens ?? null, + }); + return outcome.resultText; +} diff --git a/src/config.ts b/src/config.ts index e19e411..31879ae 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,28 @@ import { defaultConfig, type Config, type Prompts } from "acp-kernel"; import type { ThrottleRetryConfig } from "./throttle-retry.js"; +/** Instant tool-result absorption settings (small-context mode). Mirrors + * acp-kernel's absorb feature: when enabled, every eligible large tool + * result gets a forced [ACP absorb] prompt and the model must distill it + * via the absorb tool; the original tool-call + tool-result pair is then + * hidden from subsequent turns. */ +export interface AbsorbSettings { + /** Enable instant absorption. Default: false. */ + enabled?: boolean; + /** Registered name of the absorb tool. Default: "absorb". */ + toolName?: string; + /** Minimum estimated tokens of a tool result before it demands + * absorption. Smaller results stay as-is. Default: 1000. */ + minToolTokens?: number; + /** Context usage percentage (ratio 0.3 or percent string "30%") above + * which absorption prompts fire. 0 = fire on size alone regardless of + * usage. Default: 0. */ + contextThresholdPct?: number | string; + /** Tool names whose results are never absorb-prompted (in addition to + * ACP's own tools and protectedTools). */ + excludeTools?: string[]; +} + /** Delegate sub-agent configuration. */ export interface DelegateConfig { /** Enable acp_delegate tools (delegate/wait/cancel) and their system-prompt @@ -94,6 +116,10 @@ export interface AdapterConfig { delegate?: boolean | DelegateConfig; /** Compression tuning. */ compress?: CompressConfig; + /** Instant tool-result absorption (small-context mode). Accepts a boolean + * shorthand (`true` → enabled with defaults) or an AbsorbSettings object. + * Default: disabled. */ + absorb?: boolean | AbsorbSettings; /** Provider token-throttle (Bedrock "Too many tokens, please wait before * trying again.") auto-retry. Accepts a boolean shorthand (`false` * disables) or a ThrottleRetryConfig object. Default: enabled, 10 retries, @@ -164,6 +190,23 @@ export function resolveCompress( return mergeCompress(compress, prov, model); } +/** Resolve absorb settings from the adapter config, handling the boolean + * shorthand. contextThresholdPct is parsed to a ratio when provided. */ +export function resolveAbsorb(adapter: AdapterConfig): { enabled: boolean; toolName?: string; minToolTokens?: number; contextThresholdPct?: number; excludeTools?: string[] } { + const a = adapter.absorb; + if (a === true) return { enabled: true }; + if (typeof a === "object" && a !== null) { + return { + enabled: a.enabled !== false, + toolName: a.toolName, + minToolTokens: a.minToolTokens, + contextThresholdPct: a.contextThresholdPct !== undefined ? parsePercent(a.contextThresholdPct) : undefined, + excludeTools: a.excludeTools, + }; + } + return { enabled: false }; +} + export function resolveConfig(adapter: AdapterConfig, liveContextLimit: number, provider?: string, modelId?: string): Config { const envLimit = process.env.ACP_MODEL_CONTEXT_LIMIT; const envLimitNum = envLimit ? Number(envLimit) : NaN; @@ -192,6 +235,16 @@ export function resolveConfig(adapter: AdapterConfig, liveContextLimit: number, config.nudge.growthFloor = c.nudgeGrowthTokens; config.nudge.growthCap = c.nudgeGrowthTokens; } + const absorb = resolveAbsorb(adapter); + if (absorb.enabled) { + config.absorb = { + enabled: true, + toolName: absorb.toolName ?? "absorb", + minToolTokens: absorb.minToolTokens ?? 1000, + contextThresholdPct: absorb.contextThresholdPct ?? 0, + excludeTools: absorb.excludeTools ?? [], + }; + } return config; } diff --git a/src/index.ts b/src/index.ts index a041e4e..b89a845 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,10 +5,11 @@ import type { SessionMessageEntry, } from "@earendil-works/pi-coding-agent"; import type { CoreMessage, NudgeDecision, CompressionBlock, Prompts } from "acp-kernel"; -import { renderNudgeText, resolvePrompts, defaultPrompts, viableRanges } from "acp-kernel"; -import { type AdapterConfig, resolveDelegate } from "./config.js"; +import { renderNudgeText, resolvePrompts, defaultPrompts, viableRanges, buildAbsorbSystemPrompt } from "acp-kernel"; +import { type AdapterConfig, resolveDelegate, resolveAbsorb } from "./config.js"; import { createRuntime, type AcpRuntime, MAX_COMPRESS_ATTEMPTS } from "./runtime.js"; import { makeCompressTool, isCompressSuccessText, isCompressNoopText } from "./compress-tool.js"; +import { makeAbsorbTool } from "./absorb-tool.js"; import { makeDecompressTool } from "./decompress-tool.js"; import { makeSearchTool } from "./search-tool.js"; import { makeStatusTool } from "./status-tool.js"; @@ -52,12 +53,19 @@ export function createAcpExtension(adapter: AdapterConfig = {}): ExtensionFactor pi.registerTool(makeDecompressTool(runtime)); pi.registerTool(makeSearchTool(runtime)); pi.registerTool(makeStatusTool(runtime)); + registerAbsorbIfEnabled(pi, runtime, adapter); for (const { name, options } of makeCommands(runtime)) { pi.registerCommand(name, options); } }; } +function registerAbsorbIfEnabled(pi: ExtensionAPI, runtime: AcpRuntime, adapter: AdapterConfig): void { + const absorb = resolveAbsorb(adapter); + if (!absorb.enabled) return; + pi.registerTool(makeAbsorbTool(runtime, absorb.toolName)); +} + export default createAcpExtension(); // ACP owns compression; cancel Pi's built-in auto-compaction entirely (mirrors @@ -106,6 +114,7 @@ function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime): void { pi.registerTool(makeDelegateWaitTool(pi)); pi.registerTool(makeDelegateCancelTool(pi)); } + registerAbsorbIfEnabled(pi, runtime, runtime.adapter); // Headless hosts exit as soon as the turn ends; awaiting the check keeps // the process alive until a running install finishes. TUI stays // fire-and-forget so interactive startup is never blocked by npm. @@ -379,9 +388,11 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime): void { function wireSystemPrompt(pi: ExtensionAPI, runtime: AcpRuntime): void { pi.on("before_agent_start", (event) => { const delegate = runtime.adapter.delegate !== false; - const acp = buildAcpSystemPrompt(runtime.prompts); - const prompt = delegate ? `${acp}\n${ACP_DELEGATE_PROMPT}` : acp; - return { systemPrompt: formatSystemPromptForEvent(event.systemPrompt, prompt) }; + const sections = [buildAcpSystemPrompt(runtime.prompts)]; + const absorb = resolveAbsorb(runtime.adapter); + if (absorb.enabled) sections.push(buildAbsorbSystemPrompt(absorb.toolName)); + if (delegate) sections.push(ACP_DELEGATE_PROMPT); + return { systemPrompt: formatSystemPromptForEvent(event.systemPrompt, sections.join("\n")) }; }); } diff --git a/src/user-config.ts b/src/user-config.ts index 46dd035..796c8ad 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -3,7 +3,7 @@ import * as path from "node:path"; import { homedir } from "node:os"; import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"; import type { Prompts } from "acp-kernel"; -import type { AdapterConfig, CompressConfig, DelegateConfig } from "./config.js"; +import type { AdapterConfig, CompressConfig, DelegateConfig, AbsorbSettings } from "./config.js"; import type { ThrottleRetryConfig } from "./throttle-retry.js"; import { debug, logWarn } from "./log.js"; @@ -18,6 +18,7 @@ export interface UserAcpConfig { toolOutputMaxBytes?: number; delegate?: boolean | DelegateConfig; compress?: CompressConfig; + absorb?: boolean | AbsorbSettings; throttleRetry?: boolean | ThrottleRetryConfig; displayUsage?: "merged" | "separate"; prompts?: Partial; @@ -55,7 +56,7 @@ function join(... parts: string[]): string { const KNOWN = new Set([ "debug", "autoUpdate", "modelContextLimit", "toolBashDefaultTimeout", "toolOutputMaxBytes", - "delegate", "compress", "displayUsage", "throttleRetry", + "delegate", "compress", "displayUsage", "throttleRetry", "absorb", "prompts", "acknowledgePromptsRisk", ]); diff --git a/tests/absorb-tool.test.ts b/tests/absorb-tool.test.ts new file mode 100644 index 0000000..71b5a17 --- /dev/null +++ b/tests/absorb-tool.test.ts @@ -0,0 +1,185 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createAcpExtension } from "../src/index.js"; +import { resolveConfig } from "../src/config.js"; + +function captureApi() { + const handlers = new Map any)[]>(); + const api = { + on(event: string, handler: (e: any, ctx: any) => any) { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + }, + tools: [] as any[], + commands: new Map(), + registerTool(tool: any) { this.tools.push(tool); }, + registerCommand(name: string, options: any) { this.commands.set(name, options); }, + }; + return { api, handlers }; +} + +function msgEntry(id: string, message: object): any { + return { type: "message", id, parentId: null, timestamp: new Date().toISOString(), message }; +} +function user(text: string): object { + return { role: "user", content: text, timestamp: Date.now() }; +} +function assistantToolCall(callId: string, name: string, args: Record): object { + return { role: "assistant", content: [{ type: "toolCall", id: callId, name, arguments: args }], api: "anthropic", provider: "anthropic", model: "claude", usage: {}, stopReason: "toolUse", timestamp: Date.now() }; +} +function toolResult(callId: string, name: string, text: string): object { + return { role: "toolResult", toolCallId: callId, toolName: name, content: [{ type: "text", text }], isError: false, timestamp: Date.now() }; +} + +function fakeCtx(entries: any[], stateFile: string) { + return { + mode: "rpc", + hasUI: false, + ui: { notify: () => {}, confirm: async () => true, select: async () => undefined, input: async () => "", setStatus: () => {} }, + model: { contextWindow: 200_000 }, + sessionManager: { + getBranch: () => entries, + getSessionId: () => "test-session", + getSessionFile: () => stateFile, + }, + }; +} + +async function fireContext(handlers: Map any)[]>, ctx: any): Promise { + const handler = handlers.get("context")?.[0]; + assert.ok(handler, "context handler not registered"); + const out = await handler({ type: "context", messages: [] }, ctx); + return out.messages; +} + +function resultText(messages: any[], marker: string): string { + for (const m of messages) { + if (m.role !== "toolResult") continue; + const text = (m.content as Array<{ type: string; text: string }>).map((b) => b.text).join(""); + if (text.includes(marker)) return text; + } + throw new Error(`marker ${marker} not found in tool results`); +} + +test("resolveConfig maps absorb settings", () => { + const off = resolveConfig({}, 200_000); + assert.notEqual(off.absorb?.enabled, true); + const on = resolveConfig({ absorb: true }, 200_000); + assert.deepEqual(on.absorb, { enabled: true, toolName: "absorb", minToolTokens: 1000, contextThresholdPct: 0, excludeTools: [] }); + const tuned = resolveConfig({ absorb: { minToolTokens: 500, contextThresholdPct: "30%", excludeTools: ["read"] } }, 200_000); + assert.deepEqual(tuned.absorb, { enabled: true, toolName: "absorb", minToolTokens: 500, contextThresholdPct: 0.3, excludeTools: ["read"] }); +}); + +test("absorb tool is registered only when enabled", () => { + const off = captureApi(); + createAcpExtension({ autoUpdate: false })(off.api); + assert.equal(off.api.tools.find((t: any) => t.name === "absorb"), undefined); + const on = captureApi(); + createAcpExtension({ autoUpdate: false, absorb: true })(on.api); + assert.ok(on.api.tools.find((t: any) => t.name === "absorb")); + const named = captureApi(); + createAcpExtension({ autoUpdate: false, absorb: { toolName: "takeaway" } })(named.api); + assert.ok(named.api.tools.find((t: any) => t.name === "takeaway")); + assert.equal(named.api.tools.find((t: any) => t.name === "absorb"), undefined); +}); + +test("large tool result gets forced absorb prompt; small ones do not", async () => { + const dir = mkdtempSync(join(tmpdir(), "acp-absorb-")); + try { + const stateFile = join(dir, "session.jsonl"); + const big = "UNIQUE-BIG-MARKER " + "x".repeat(8000); + const entries = [ + msgEntry("e1", user("run it")), + msgEntry("e2", assistantToolCall("tc1", "bash", { command: "ls" })), + msgEntry("e3", toolResult("tc1", "bash", big)), + msgEntry("e4", user("continue")), + ]; + const { api, handlers } = captureApi(); + createAcpExtension({ autoUpdate: false, absorb: true })(api); + const out = await fireContext(handlers, fakeCtx(entries, stateFile)); + const bigText = resultText(out, "UNIQUE-BIG-MARKER"); + assert.ok(bigText.includes("[ACP absorb]"), "forced prompt missing on large result"); + assert.ok(bigText.includes("UNIQUE-BIG-MARKER"), "original content stays in the same turn"); + const ref = bigText.match(/m\d{4,}/)?.[0]; + assert.ok(ref, "ref tag present on big result"); + const smallFile = join(dir, "s2.jsonl"); + const smallEntries = [ + msgEntry("f1", user("hi")), + msgEntry("f2", toolResult("tc9", "bash", "tiny output")), + msgEntry("f3", user("go")), + ]; + const smallOut = await fireContext(handlers, fakeCtx(smallEntries, smallFile)); + assert.equal(resultText(smallOut, "tiny output").includes("[ACP absorb]"), false); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("absorb hides the original pair and keeps the summary", async () => { + const dir = mkdtempSync(join(tmpdir(), "acp-absorb-")); + try { + const stateFile = join(dir, "session.jsonl"); + const big = "UNIQUE-BIG-MARKER " + "x".repeat(8000); + const entries = [ + msgEntry("e1", user("run it")), + msgEntry("e2", assistantToolCall("tc1", "bash", { command: "ls" })), + msgEntry("e3", toolResult("tc1", "bash", big)), + msgEntry("e4", user("continue")), + ]; + const { api, handlers } = captureApi(); + createAcpExtension({ autoUpdate: false, absorb: true })(api); + const ctx = fakeCtx(entries, stateFile); + const out = await fireContext(handlers, ctx); + const ref = resultText(out, "UNIQUE-BIG-MARKER").match(/m\d{4,}/)?.[0]!; + const absorb = (api as any).tools.find((t: any) => t.name === "absorb"); + assert.ok(absorb, "absorb tool registered"); + const ok = await absorb.execute("call-1", { ref, summary: "ls output listed 3 files: a.ts, b.ts, c.ts (distilled summary)" }, {}, undefined, ctx); + assert.match(ok.content[0].text, /^absorbed m\d+/); + const followup = [ + ...entries, + msgEntry("e5", assistantToolCall("tc2", "absorb", { ref, summary: "distilled summary" })), + msgEntry("e6", toolResult("tc2", "absorb", ok.content[0].text)), + ]; + const after = await fireContext(handlers, fakeCtx(followup, stateFile)); + const dump = JSON.stringify(after); + assert.equal(dump.includes("UNIQUE-BIG-MARKER"), false, "original tool result should be hidden"); + assert.equal(dump.includes("distilled summary"), true, "summary should survive"); + const again = await absorb.execute("call-2", { ref, summary: "distilled summary" }, {}, undefined, ctx); + assert.match(again.content[0].text, /already absorbed/); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("absorb rejects bad refs and empty summaries", async () => { + const dir = mkdtempSync(join(tmpdir(), "acp-absorb-")); + try { + const stateFile = join(dir, "session.jsonl"); + const entries = [ + msgEntry("e1", user("hi")), + msgEntry("e2", toolResult("tc1", "bash", "out")), + msgEntry("e3", user("go")), + ]; + const { api, handlers } = captureApi(); + createAcpExtension({ autoUpdate: false, absorb: true })(api); + const ctx = fakeCtx(entries, stateFile); + fireContext(handlers, ctx); + const absorb = (api as any).tools.find((t: any) => t.name === "absorb"); + await assert.rejects(absorb.execute("c1", { ref: "m99999", summary: "s" }, {}, undefined, ctx), /does not exist in this session/); + await assert.rejects(absorb.execute("c2", { ref: "", summary: "s" }, {}, undefined, ctx), /Invalid absorb arguments/); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("system prompt gains absorb section when enabled", () => { + const on = captureApi(); + createAcpExtension({ autoUpdate: false, absorb: { toolName: "takeaway" } })(on.api); + const handler = on.handlers.get("before_agent_start")![0] as (e: { systemPrompt: string }) => { systemPrompt: string }; + const result = handler({ systemPrompt: "BASE" }); + assert.ok(result.systemPrompt.includes("BASE")); + assert.ok(result.systemPrompt.includes("takeaway"), "absorb section should mention tool name"); + const off = captureApi(); + createAcpExtension({ autoUpdate: false })(off.api); + const offHandler = off.handlers.get("before_agent_start")![0] as (e: { systemPrompt: string }) => { systemPrompt: string }; + const offResult = offHandler({ systemPrompt: "BASE" }); + assert.ok(!offResult.systemPrompt.toLowerCase().includes("absorb"), "no absorb section when disabled"); +});