From f4057cc8142e46ec075ef37397cc5ca80c4b83ac Mon Sep 17 00:00:00 2001 From: awork Date: Sun, 16 Aug 2026 02:21:49 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20cooperative=20proxy=20mode=20-=20fo?= =?UTF-8?q?rward=20native=20tools=20to=20billion-context=20proxy=20(?= =?UTF-8?q?=E5=86=85=E5=A4=96=E5=91=BC=E5=BA=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the model baseUrl routes through a billion-context proxy (/bili/ zero-config prefix), the extension switches to cooperative mode per the protocol shipped in ranxianglei/billion-context#161: the 4 native tools forward to POST /__bili/plugin/tool under the proxy's session lock, every request announces x-bili-plugin: pi + x-bili-plugin-conversation (pi's real session id), and the in-process pipeline (processTurn, nudges, philosophy prompt) is skipped - the proxy owns compression end-to-end. Detection is a stateless per-call URL check; without a proxy (or with ACP_COOPERATIVE_PROXY=0) behavior is byte-identical. Tests 7/7 new. --- CONFIGURATION.md | 8 + README.md | 10 + .../DESIGN.md | 31 +++ .../2026-08-16_cooperative-proxy-mode/REQ.md | 25 +++ .../WORKLOG.md | 37 ++++ src/compress-tool.ts | 5 + src/cooperative.ts | 64 ++++++ src/decompress-tool.ts | 5 + src/index.ts | 29 ++- src/search-tool.ts | 5 + src/status-tool.ts | 5 + tests/cooperative.test.ts | 182 ++++++++++++++++++ 12 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 devlog/2026-08-16_cooperative-proxy-mode/DESIGN.md create mode 100644 devlog/2026-08-16_cooperative-proxy-mode/REQ.md create mode 100644 devlog/2026-08-16_cooperative-proxy-mode/WORKLOG.md create mode 100644 src/cooperative.ts create mode 100644 tests/cooperative.test.ts diff --git a/CONFIGURATION.md b/CONFIGURATION.md index de3cc57..756cd53 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -124,6 +124,7 @@ All keys below are currently **ACTIVE**. | `ACP_MODEL_CONTEXT_LIMIT` | Override the context limit (takes highest precedence). | | `ACP_DEBUG` | Set to `1` / `true` to enable debug logging. | | `ACP_LOG_FILE` | Override the log file path (default `~/.pi/acp.log`). | +| `ACP_COOPERATIVE_PROXY` | Set to `0` to disable cooperative proxy mode (auto-detected from a `/bili/` model baseUrl). | > **Only the documented keys are read from `acp.json`.** Other tuning knobs (`preserveRecentMessages`, `protectedTools`) are code-level and not user-overridable. The three compression thresholds form a three-tier escalation: growth-driven soft nudges β†’ forced nudges at `compress.maxContextLimit` β†’ emergency truncation at `compress.emergencyThresholdPercent`. @@ -326,3 +327,10 @@ Environment variables take precedence over the JSON config files. They are usefu - **Default:** `~/.pi/acp.log` - **Status:** 🟒 ACTIVE - **Description:** Override the path to the log file. By default, structured logs are written to `~/.pi/acp.log` (the file rotates to `~/.pi/acp.log.old` at 10 MB). Point this at a different location to keep per-project or per-run logs separate. + +### `ACP_COOPERATIVE_PROXY` + +- **Type:** string flag +- **Default:** *(unset β€” cooperative mode auto-detects from the model baseUrl)* +- **Status:** 🟒 ACTIVE +- **Description:** Set to `0` to **disable** cooperative proxy mode. When the model's `baseUrl` routes through a billion-context proxy (the `/bili/` zero-config prefix), the extension automatically switches to cooperative mode: the proxy owns compression (state, folding, ref tags, philosophy prompt, nudges β€” injected at the wire level), while this extension registers the 4 tools natively and forwards their execution to the proxy's `POST /__bili/plugin/tool` endpoint. Session identity is pi's real session id (sent as `x-bili-plugin-conversation`), which also fixes multi-session collisions. Setting `ACP_COOPERATIVE_PROXY=0` forces the standalone in-process behavior even behind a proxy (then make sure the proxy's own tool injection handles compression β€” double compression is otherwise possible). Protocol spec: [PLUGIN.md](https://github.com/ranxianglei/billion-context/blob/master/PLUGIN.md). diff --git a/README.md b/README.md index ec1d15a..75e7825 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,16 @@ Each message gets an invisible `` ref tag (`m00001`, `m00002`, ...) visible Pi's built-in auto-compaction is cancelled β€” billion-context is the sole context manager. +## Cooperative proxy mode (ε†…ε€–ε‘ΌεΊ”) + +When pi's model `baseUrl` routes through a [billion-context](https://github.com/ranxianglei/billion-context) proxy (the `/bili/` zero-config prefix), the extension automatically switches to **cooperative mode**: the proxy owns compression end-to-end (session state, history folding, ref tags, philosophy prompt, nudges β€” all injected at the wire level), while the extension becomes the "inside" half of [the plugin protocol](https://github.com/ranxianglei/billion-context/blob/master/PLUGIN.md): + +- the 4 tools (`compress` / `decompress` / `search_context` / `acp_status`) stay natively registered in pi β€” native tool UX, permissions and audit β€” and their execution is forwarded to the proxy (`POST /__bili/plugin/tool`), which runs them under its session lock; +- every model request carries `x-bili-plugin: pi` + `x-bili-plugin-conversation: `, so the proxy keys state by pi's **real** session identity (no more content-fingerprint collisions) and suppresses its own wire-level tool injection (no double compression); +- the extension's in-process pipeline (processTurn, nudges, philosophy prompt) is skipped for that session β€” the proxy does all of it. + +Without a proxy (or with `ACP_COOPERATIVE_PROXY=0`) behavior is byte-identical to the standalone extension. + ## Plugin compatibility & ordering billion-context takes over context management by intercepting Pi's `context` event. **Pi has no plugin priority mechanism** β€” when multiple extensions register handlers for the same event, they run in a fixed sequence (load order), with no `priority`/`weight` field and no way for the user to control the order. The `context` event specifically is a *pipeline*: every handler receives the previous handler's output, there is no short-circuit, and the **last** handler has the final say over what reaches the model. diff --git a/devlog/2026-08-16_cooperative-proxy-mode/DESIGN.md b/devlog/2026-08-16_cooperative-proxy-mode/DESIGN.md new file mode 100644 index 0000000..688dcdd --- /dev/null +++ b/devlog/2026-08-16_cooperative-proxy-mode/DESIGN.md @@ -0,0 +1,31 @@ +# DESIGN - Cooperative proxy mode + +- Task ID: `2026-08-16_cooperative-proxy-mode` +- Home Repo: `billion-context-pi` +- Created: 2026-08-16 +- Status: Accepted + +## 1. Goals & Non-Goals + +- **Goals**: proxy-owns-compression when behind the proxy; native tools forwarded; real session identity; zero change when standalone. +- **Non-Goals**: MITM detection; config keys; changes to delegate tools (they keep working β€” their prompt is injected locally even in cooperative mode). + +## 2. Mechanism + +Detection is **stateless and per-call**: `proxyBaseForContext(ctx)` reads `ctx.model.baseUrl` (typed on pi-ai's `Model`) and matches a `bili` path segment (`http://host[:port]/bili/...` β†’ `http://host[:port]`). No cached mode flag β†’ model switches mid-session are picked up on the very next call. `ACP_COOPERATIVE_PROXY=0` short-circuits detection off. + +Four integration points: + +1. **`before_provider_headers`** (pi SDK hook, types.d.ts:869 β€” headers mutate-in-place): sets `x-bili-plugin: pi` + `x-bili-plugin-conversation: ctx.sessionManager.getSessionId()`. No-op unless proxied. +2. **`context` event**: early return `{ messages: event.messages }` (identity) β€” the proxy runs processTurn (tags/folding/nudges) at the wire level; running it locally too would double-transform. +3. **`before_agent_start`**: skip `buildAcpSystemPrompt` (the proxy injects the philosophy); keep `ACP_DELEGATE_PROMPT` (pi-side feature) when delegate enabled; return `undefined` otherwise. +4. **Tool `execute` (Γ—4)**: `tryForwardTool(name, params, ctx)` β†’ POST `${proxyBase}/__bili/plugin/tool` `{conversationId, tool, args}` β†’ return `result` as the native tool-result text. `undefined` return = not proxied β†’ local handler runs unchanged. Proxy errors throw (consistent with local handlers, which re-throw after logThrow). + +`session_before_compact` cancel stays unconditional: pi's compaction must never run β€” the proxy manages context in cooperative mode too. + +## 3. Alternatives considered + +- **Cached mode flag set on `session_start`/`model_select`**: rejected β€” stale on mid-session model switches; per-call read is O(len(url)) and always fresh. +- **Fetch patching for headers**: rejected β€” pi-ai owns the HTTP stack; the SDK provides the exact hook (`before_provider_headers`). +- **Forwarding inside the `handleX` functions**: rejected β€” the forward must bypass local state entirely (the proxy executes against ITS remembered view); putting it at the top of `execute` makes that structural. +- **Keeping local compression when proxied**: rejected β€” double compression; exactly what the protocol exists to prevent. diff --git a/devlog/2026-08-16_cooperative-proxy-mode/REQ.md b/devlog/2026-08-16_cooperative-proxy-mode/REQ.md new file mode 100644 index 0000000..b73e14a --- /dev/null +++ b/devlog/2026-08-16_cooperative-proxy-mode/REQ.md @@ -0,0 +1,25 @@ +# REQ - Cooperative proxy mode (ε†…ε€–ε‘ΌεΊ”) + +- Task ID: `2026-08-16_cooperative-proxy-mode` +- Home Repo: `billion-context-pi` +- Created: 2026-08-16 +- Status: Done +- Priority: P1 +- Owner: awork +- References: dog/billion-context#1, ranxianglei/billion-context#161 (protocol half), PLUGIN.md spec in billion-context + +## 1. Background & Problem Statement + +- **Context**: billion-context issue #1 asks for inside/outside cooperation: keep the external proxy but install a plugin inside the agent so the combination feels native. The proxy-side protocol (`/__bili/plugin/manifest`, `/__bili/plugin/tool`, `x-bili-plugin` headers) landed in billion-context PR #161. This repo is the fully in-process implementation β€” the natural first "inside" adopter. +- **Current behavior (symptom)**: the extension is either/or: it runs its own in-process pipeline, and the only proxy-related behavior is mutual exclusion by convention (users must disable one side manually). +- **Expected behavior**: when the model baseUrl routes through the proxy (`/bili/` prefix), the extension keeps its 4 native tools but forwards execution to the proxy, skips its own in-process pipeline, and announces pi's real session identity. Without a proxy, byte-identical standalone behavior. +- **Impact**: native tool UX + real session identity + zero schema drift (proxy serves schemas), with the proxy as single compression authority. + +## 2. Reproduction + +N/A (feature). + +## 3. Constraints & Non-Goals + +- **Constraints**: no behavior change without a `/bili/` baseUrl; no `as any`; no comments unless necessary; pi SDK hooks only (no fetch patching). +- **Non-Goals**: changing the delegate subsystem (kept working in cooperative mode via its own prompt); MITM-mode proxy detection (no `/bili/` prefix to detect β€” out of scope for v1); a config-file key for cooperative mode (env kill-switch suffices for v1). diff --git a/devlog/2026-08-16_cooperative-proxy-mode/WORKLOG.md b/devlog/2026-08-16_cooperative-proxy-mode/WORKLOG.md new file mode 100644 index 0000000..81331ce --- /dev/null +++ b/devlog/2026-08-16_cooperative-proxy-mode/WORKLOG.md @@ -0,0 +1,37 @@ +# WORKLOG - Cooperative proxy mode + +- Task ID: `2026-08-16_cooperative-proxy-mode` +- Home Repo: `billion-context-pi` +- Status: Done +- Updated: 2026-08-16 02:55 + +## 1. Summary + +- **What was done**: Added cooperative proxy mode ("ζœ‰ε€–η”¨ε€–"): when the model baseUrl routes through a billion-context proxy (`/bili/` prefix), the extension forwards its 4 native tools to the proxy's tool endpoint, announces pi's session identity via `x-bili-plugin*` headers, and skips its own in-process pipeline. Standalone behavior unchanged. +- **Why**: billion-context issue #1 (ε†…ε€–ε‘ΌεΊ”) β€” native tool UX + real session identity with the proxy as single compression authority. First adopter of the plugin protocol shipped in ranxianglei/billion-context#161. +- **Behavior / compatibility changes**: Yes, additive. Without `/bili/` in the model baseUrl (or with `ACP_COOPERATIVE_PROXY=0`) every path is byte-identical to before. +- **Risk level**: Low (detection is a pure URL check per call; all gated branches early-return). + +## 2. Change Log + +### Key Files + +- `src/cooperative.ts` (new) β€” `proxyBaseFromUrl` / `proxyBaseForContext` (stateless per-call detection), `forwardToolToProxy` (POST `/__bili/plugin/tool`, error surfaces), `tryForwardTool` (undefined = run local handler). +- `src/index.ts` β€” `wireProviderHeaders` (`before_provider_headers` hook); cooperative early-return in `wireContextTransform` (identity messages) and `wireSystemPrompt` (philosophy from proxy, delegate prompt kept). +- `src/compress-tool.ts` / `src/decompress-tool.ts` / `src/search-tool.ts` / `src/status-tool.ts` β€” `tryForwardTool` branch at top of `execute`. +- `tests/cooperative.test.ts` (new, 7 tests) β€” URL detection matrix; header injection (+absence without proxy); context identity passthrough; system-prompt ownership; live HTTP forward (conversation id, tool name, args passthrough, result text); proxy error propagation; `ACP_COOPERATIVE_PROXY=0` kill switch (local pipeline runs). +- `README.md` β€” "Cooperative proxy mode (ε†…ε€–ε‘ΌεΊ”)" section. +- `CONFIGURATION.md` β€” `ACP_COOPERATIVE_PROXY` env var (summary table + full section). + +## 3. Verification + +- `npm run typecheck` clean. +- `npm test`: 300 tests, 299 pass + 1 **pre-existing failure on pristine master** (`e2e-compress-config.test.ts`: "a 2w limit fires the compress nudge at 2w tokens" β€” verified failing on clean master checkout before my changes; unrelated to this feature). +- `npm run build` OK (dist 477 KB). +- New suite: 7/7. + +## 4. Notes / Follow-ups + +- MITM-mode (transparent proxy, no `/bili/` prefix) is NOT detected β€” cooperative mode requires the zero-config prefix baseURL (documented). MITM + extension would still double-compress; users on MITM should disable the extension or set the prefix. +- `before_provider_headers` exists in the pi SDK as of this version; if an older pi host lacks it, registration is ignored harmlessly (extension `on` overloads). +- Cross-repo dependency: the proxy half must merge first (ranxianglei/billion-context#161) β€” the endpoints this forwards to only exist there. No code-level version coupling (protocol is HTTP + manifest). diff --git a/src/compress-tool.ts b/src/compress-tool.ts index 58fe2af..73a97ac 100644 --- a/src/compress-tool.ts +++ b/src/compress-tool.ts @@ -6,6 +6,7 @@ import type { } from "@earendil-works/pi-coding-agent"; import type { AcpRuntime } from "./runtime.js"; import { debug, logError, logInfo, logThrow } from "./log.js"; +import { tryForwardTool } from "./cooperative.js"; import { estimateTokens, collectCoveredMessageIds } from "./tokens.js"; import { defaultCountTokens } from "acp-kernel"; import { getSystemPromptText } from "./compat.js"; @@ -44,6 +45,10 @@ export function makeCompressTool(runtime: AcpRuntime): ToolDefinition> { + const forwarded = await tryForwardTool("compress", params, ctx); + if (forwarded !== undefined) { + return { details: undefined, content: [{ type: "text", text: forwarded }] }; + } let result: string; try { result = await handleCompress(params as CompressArgs, runtime, ctx, toolCallId); diff --git a/src/cooperative.ts b/src/cooperative.ts new file mode 100644 index 0000000..0889197 --- /dev/null +++ b/src/cooperative.ts @@ -0,0 +1,64 @@ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { logInfo } from "./log.js"; + +// Cooperative proxy mode ("ε†…ε€–ε‘ΌεΊ”", billion-context issue #1): when the +// model's baseUrl routes through a billion-context proxy (the `/bili/` +// zero-config prefix), the proxy owns compression end-to-end (state, folding, +// ref tags, philosophy prompt, nudges β€” it injects them at the wire level). +// This extension then becomes the "inside" half: it announces itself on every +// provider request (x-bili-plugin headers), skips its own in-process +// pipeline, and forwards the 4 native tools to the proxy's +// POST /__bili/plugin/tool. Protocol spec: billion-context PLUGIN.md. +// Without a `/bili/` baseUrl (or with ACP_COOPERATIVE_PROXY=0) behavior is +// byte-identical to the standalone extension. + +export const PLUGIN_AGENT_NAME = "pi"; + +const BILI_SEGMENT = "bili"; + +export function proxyBaseFromUrl(baseUrl: string | undefined): string | undefined { + if (!baseUrl) return undefined; + try { + const url = new URL(baseUrl); + const segments = url.pathname.split("/").filter((s) => s.length > 0); + if (!segments.includes(BILI_SEGMENT)) return undefined; + return `${url.protocol}//${url.host}`; + } catch { + return undefined; + } +} + +export function proxyBaseForContext(ctx: ExtensionContext): string | undefined { + if (process.env.ACP_COOPERATIVE_PROXY === "0") return undefined; + return proxyBaseFromUrl(ctx.model?.baseUrl); +} + +export async function forwardToolToProxy(proxyBase: string, conversationId: string, tool: string, args: unknown): Promise { + const resp = await fetch(`${proxyBase}/__bili/plugin/tool`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ conversationId, tool, args }), + }); + const text = await resp.text(); + let json: { ok?: boolean; result?: string; error?: string }; + try { + json = JSON.parse(text) as { ok?: boolean; result?: string; error?: string }; + } catch { + throw new Error(`bili proxy tool ${tool} failed (${resp.status}): ${text.slice(0, 200)}`); + } + if (!resp.ok || !json.ok) { + throw new Error(`bili proxy tool ${tool} failed (${resp.status}): ${json.error ?? "unknown error"}`); + } + return json.result ?? ""; +} + +/** Forward a tool execution to the proxy when this session is in cooperative + * mode. Returns undefined when NOT behind a proxy (caller runs the local + * handler); returns the proxy's result text otherwise. */ +export async function tryForwardTool(tool: string, params: unknown, ctx: ExtensionContext): Promise { + const proxyBase = proxyBaseForContext(ctx); + if (proxyBase === undefined) return undefined; + const conversationId = ctx.sessionManager.getSessionId(); + logInfo("cooperative", { event: "tool-forward", tool, conversationId, proxyBase }); + return forwardToolToProxy(proxyBase, conversationId, tool, params); +} diff --git a/src/decompress-tool.ts b/src/decompress-tool.ts index c799794..0550947 100644 --- a/src/decompress-tool.ts +++ b/src/decompress-tool.ts @@ -2,6 +2,7 @@ import { Type, type Static } from "typebox"; import type { AgentToolResult, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent"; import type { AcpRuntime } from "./runtime.js"; import { debug, logError, logInfo, logThrow } from "./log.js"; +import { tryForwardTool } from "./cooperative.js"; import { parseBlockIdArg, collectBlockContent, type CompressionBlock } from "acp-kernel"; import { entriesToCoreMessages } from "./messages.js"; import { writeFile, mkdir } from "node:fs/promises"; @@ -44,6 +45,10 @@ export function makeDecompressTool(runtime: AcpRuntime): ToolDefinition> { + const forwarded = await tryForwardTool("decompress", params, ctx); + if (forwarded !== undefined) { + return { details: undefined, content: [{ type: "text", text: forwarded }] }; + } let result: string; try { result = await handleDecompress(params as DecompressArgs, runtime, ctx); diff --git a/src/index.ts b/src/index.ts index c3bc12f..d7daebc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ import { runSetupAndNotify } from "./setup-subagent-tools.js"; import { loadUserConfig, applyUserConfig } from "./user-config.js"; import { defaultCountTokens } from "acp-kernel"; import { formatSystemPromptForEvent, getSystemPromptText } from "./compat.js"; +import { PLUGIN_AGENT_NAME, proxyBaseForContext, tryForwardTool } from "./cooperative.js"; type AgentMessage = SessionMessageEntry["message"]; @@ -35,6 +36,7 @@ export function createAcpExtension(adapter: AdapterConfig = {}): ExtensionFactor return (pi: ExtensionAPI) => { const runtime = createRuntime(adapter); wireCompactionDisable(pi); + wireProviderHeaders(pi); wireSessionLifecycle(pi, runtime); wireContextTransform(pi, runtime); wireSystemPrompt(pi, runtime); @@ -57,6 +59,18 @@ function wireCompactionDisable(pi: ExtensionAPI): void { pi.on("session_before_compact", () => ({ cancel: true })); } +// Cooperative proxy mode: announce this session to the billion-context proxy +// so it keys state by pi's real session id and switches the session to +// plugin mode (no wire tool injection β€” tools are native here). No-op unless +// the model's baseUrl routes through the proxy (`/bili/` prefix). +function wireProviderHeaders(pi: ExtensionAPI): void { + pi.on("before_provider_headers", (event, ctx) => { + if (proxyBaseForContext(ctx) === undefined) return; + event.headers["x-bili-plugin"] = PLUGIN_AGENT_NAME; + event.headers["x-bili-plugin-conversation"] = ctx.sessionManager.getSessionId(); + }); +} + // (acp_delegate injection is best-effort: sendUserMessage is fire-and-forget // in pi, and interactive/rpc sessions are long-lived so their main loop // consumes the follow-up queue naturally β€” no shutdown drain needed.) @@ -112,6 +126,12 @@ function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime): void { // nudge decision) and return the transformed AgentMessage[]. function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime): void { pi.on("context", async (event, ctx) => { + // Cooperative proxy mode: the proxy runs processTurn (ref tags, folding, + // nudges) at the wire level against its own session state. Doing it here + // too would double-transform β€” return pi's messages untouched. + if (proxyBaseForContext(ctx) !== undefined) { + return { messages: event.messages }; + } const sid = ctx.sessionManager.getSessionId(); const release = await runtime.acquireLock(sid); try { @@ -243,8 +263,15 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime): void { } function wireSystemPrompt(pi: ExtensionAPI, runtime: AcpRuntime): void { - pi.on("before_agent_start", (event) => { + pi.on("before_agent_start", (event, ctx) => { const delegate = runtime.adapter.delegate !== false; + // Cooperative proxy mode: the ACP context prompt is injected by the proxy + // at the wire level β€” only the delegate prompt (a pi-side feature) stays + // local. Returning undefined leaves the system prompt untouched. + if (proxyBaseForContext(ctx) !== undefined) { + if (!delegate) return undefined; + return { systemPrompt: formatSystemPromptForEvent(event.systemPrompt, ACP_DELEGATE_PROMPT) }; + } const acp = buildAcpSystemPrompt(runtime.prompts); const prompt = delegate ? `${acp}\n${ACP_DELEGATE_PROMPT}` : acp; return { systemPrompt: formatSystemPromptForEvent(event.systemPrompt, prompt) }; diff --git a/src/search-tool.ts b/src/search-tool.ts index 7abade8..e30c03f 100644 --- a/src/search-tool.ts +++ b/src/search-tool.ts @@ -4,6 +4,7 @@ import { searchBlocks, type SearchResult } from "acp-kernel"; import type { AcpRuntime } from "./runtime.js"; import { buildSearchDocs } from "./search-index.js"; import { logThrow } from "./log.js"; +import { tryForwardTool } from "./cooperative.js"; const SearchParams = Type.Object({ query: Type.String({ description: "Keywords to locate detail folded into compressed summaries or historical messages." }), @@ -26,6 +27,10 @@ export function makeSearchTool(runtime: AcpRuntime): ToolDefinition> { + const forwarded = await tryForwardTool("search_context", params, ctx); + if (forwarded !== undefined) { + return { details: undefined, content: [{ type: "text", text: forwarded }] }; + } let result: string; try { result = await handleSearch(params as SearchArgs, runtime, ctx); diff --git a/src/status-tool.ts b/src/status-tool.ts index f1aac16..017e5ff 100644 --- a/src/status-tool.ts +++ b/src/status-tool.ts @@ -6,6 +6,7 @@ import { estimateTokens, collectCoveredMessageIds } from "./tokens.js"; import { getSystemPromptText } from "./compat.js"; import { viableRanges } from "billion-context-kit"; import { logThrow } from "./log.js"; +import { tryForwardTool } from "./cooperative.js"; import { getDelegateUsage } from "./delegate-tool.js"; import { resolveDelegate } from "./config.js"; @@ -33,6 +34,10 @@ export function makeStatusTool(runtime: AcpRuntime): ToolDefinition> { + const forwarded = await tryForwardTool("acp_status", params, ctx); + if (forwarded !== undefined) { + return { details: undefined, content: [{ type: "text", text: forwarded }] }; + } let result: string; try { result = await handleStatus(params as StatusArgs, runtime, ctx); diff --git a/tests/cooperative.test.ts b/tests/cooperative.test.ts new file mode 100644 index 0000000..55511cf --- /dev/null +++ b/tests/cooperative.test.ts @@ -0,0 +1,182 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { createAcpExtension } from "../src/index.js"; +import { proxyBaseFromUrl, forwardToolToProxy } from "../src/cooperative.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 fakeCtx(entries: any[], baseUrl?: string) { + return { + mode: "rpc", + hasUI: false, + ui: { notify: () => {}, confirm: async () => true, select: async () => undefined, input: async () => "", setStatus: () => {} }, + model: { contextWindow: 200_000, baseUrl }, + sessionManager: { + getBranch: () => entries, + getSessionId: () => "test-session", + getSessionFile: () => "/tmp/nonexistent-pai-cooperative.session.json", + }, + }; +} + +function userMsg(id: string, text: string) { + return { type: "message", id, parentId: null, timestamp: "", message: { role: "user", content: text, timestamp: Date.now() } }; +} + +const PROXY_URL = "http://127.0.0.1:8787/bili/https://api.anthropic.com"; + +test("proxyBaseFromUrl extracts the proxy origin only from /bili/ base URLs", () => { + assert.equal(proxyBaseFromUrl(PROXY_URL), "http://127.0.0.1:8787"); + assert.equal(proxyBaseFromUrl("http://127.0.0.1:8787/bili/openai/https://api.openai.com/v1"), "http://127.0.0.1:8787"); + assert.equal(proxyBaseFromUrl("https://api.anthropic.com/v1/messages"), undefined); + assert.equal(proxyBaseFromUrl("http://127.0.0.1:8787/v1/messages"), undefined); + assert.equal(proxyBaseFromUrl(undefined), undefined); + assert.equal(proxyBaseFromUrl("not a url"), undefined); +}); + +test("before_provider_headers announces plugin mode with pi's session id", async () => { + const { api, handlers } = captureApi(); + createAcpExtension()(api as any); + assert.ok(handlers.has("before_provider_headers"), "before_provider_headers wired"); + + const headers: Record = {}; + await handlers.get("before_provider_headers")![0]!({ type: "before_provider_headers", headers }, fakeCtx([], PROXY_URL)); + assert.equal(headers["x-bili-plugin"], "pi"); + assert.equal(headers["x-bili-plugin-conversation"], "test-session"); + + const plain: Record = {}; + await handlers.get("before_provider_headers")![0]!({ type: "before_provider_headers", headers: plain }, fakeCtx([], "https://api.anthropic.com")); + assert.equal(plain["x-bili-plugin"], undefined); + assert.equal(plain["x-bili-plugin-conversation"], undefined); +}); + +test("context handler passes messages through untouched in cooperative mode", async () => { + const { api, handlers } = captureApi(); + createAcpExtension()(api as any); + + const entries = [userMsg("e1", "first"), userMsg("e2", "second")]; + const messages = entries.map((e) => e.message); + const result = await handlers.get("context")![0]!({ type: "context", messages }, fakeCtx(entries, PROXY_URL)); + assert.equal(result.messages, messages, "must return the SAME array untransformed β€” the proxy owns tags/folding/nudges"); +}); + +test("before_agent_start drops the local ACP prompt in cooperative mode (proxy injects it)", async () => { + const { api, handlers } = captureApi(); + createAcpExtension()(api as any); + + const result = await handlers.get("before_agent_start")![0]!({ systemPrompt: "BASE" }, fakeCtx([], PROXY_URL)); + assert.ok(!result.systemPrompt.includes("ACP context management"), "philosophy must come from the proxy, not locally"); + assert.ok(result.systemPrompt.startsWith("BASE")); + + const local = await handlers.get("before_agent_start")![0]!({ systemPrompt: "BASE" }, fakeCtx([], "https://api.anthropic.com")); + assert.ok(local.systemPrompt.includes("ACP context management"), "standalone mode keeps the local prompt"); +}); + +test("native tools forward to the proxy tool endpoint in cooperative mode", async () => { + const received: Array<{ conversationId?: string; tool?: string; args?: any }> = []; + const server = createServer((req, res) => { + let body = ""; + req.on("data", (c: string) => { body += c; }); + req.on("end", () => { + if (req.url === "/__bili/plugin/tool") { + received.push(JSON.parse(body)); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, tool: "compress", result: "[proxied] compressed m00001–m00002" })); + return; + } + res.writeHead(404); + res.end(); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as { port: number }).port; + + const { api } = captureApi(); + createAcpExtension()(api as any); + const compress = api.tools.find((t: any) => t.name === "compress"); + assert.ok(compress, "compress tool registered"); + + const args = { content: [{ startId: "m00001", endId: "m00002", summary: "x".repeat(60) }] }; + const out = await compress.execute( + "call_1", + args, + undefined, + undefined, + fakeCtx([], `http://127.0.0.1:${port}/bili/https://api.anthropic.com`), + ); + assert.equal(out.content[0].type, "text"); + assert.equal(out.content[0].text, "[proxied] compressed m00001–m00002"); + assert.equal(received.length, 1); + assert.equal(received[0].conversationId, "test-session"); + assert.equal(received[0].tool, "compress"); + assert.equal(received[0].args.content[0].startId, "m00001"); + + const localOut = await compress.execute( + "call_2", + args, + undefined, + undefined, + fakeCtx([], `http://127.0.0.1:${port}/no-bili/https://api.anthropic.com`), + ); + assert.notEqual(localOut.content[0].text, "[proxied] compressed m00001–m00002", "no /bili/ prefix β†’ local handler runs"); + + server.close(); +}); + +test("proxy tool errors surface as tool failures", async () => { + const server = createServer((req, res) => { + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "unknown plugin conversation" })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as { port: number }).port; + + await assert.rejects( + forwardToolToProxy(`http://127.0.0.1:${port}`, "missing-conv", "compress", {}), + /unknown plugin conversation/, + ); + server.close(); +}); + +test("ACP_COOPERATIVE_PROXY=0 disables cooperative mode entirely", async () => { + const prev = process.env.ACP_COOPERATIVE_PROXY; + process.env.ACP_COOPERATIVE_PROXY = "0"; + try { + const { api, handlers } = captureApi(); + createAcpExtension()(api as any); + + const headers: Record = {}; + await handlers.get("before_provider_headers")![0]!({ type: "before_provider_headers", headers }, fakeCtx([], PROXY_URL)); + assert.equal(headers["x-bili-plugin"], undefined); + + const entries = [userMsg("e1", "first"), userMsg("e2", "second")]; + const messages = entries.map((e) => e.message); + const result = await handlers.get("context")![0]!({ type: "context", messages }, fakeCtx(entries, PROXY_URL)); + assert.notEqual(result.messages, messages, "local pipeline must run (tagged output, not identity)"); + const first = (result.messages[0] as any).content; + const textBlocks = Array.isArray(first) ? first.filter((b: any) => b.type === "text") : []; + assert.ok(textBlocks.length > 0 && /m\d+/.test(textBlocks[0].text), "messages ref-tagged locally"); + } finally { + if (prev === undefined) delete process.env.ACP_COOPERATIVE_PROXY; + else process.env.ACP_COOPERATIVE_PROXY = prev; + } +}); From 9fa10c4060ece68469cd90862e5d69dc8ac4ad3f Mon Sep 17 00:00:00 2001 From: awork Date: Sun, 16 Aug 2026 02:58:35 +0800 Subject: [PATCH 2/3] feat: MITM cooperative mode via BILLION_CONTEXT_PROXY + context-window reporting proxyBaseForContext falls back to BILLION_CONTEXT_PROXY (exported by the billion-context launcher next to HTTPS_PROXY), so bili pi in MITM transparent mode gets the same native-tool cooperative experience as the /bili/ prefix. before_provider_headers now also reports the model's contextWindow (x-bili-plugin-context-window) - the agent-side value becomes the authoritative nudge denominator on the proxy. Tests 8/8 new (300 pass total; 1 pre-existing master failure unchanged). --- CONFIGURATION.md | 6 ++-- README.md | 3 ++ .../WORKLOG.md | 5 ++- src/cooperative.ts | 17 +++++++++- src/index.ts | 10 ++++-- tests/cooperative.test.ts | 33 ++++++++++++++++++- 6 files changed, 66 insertions(+), 8 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 756cd53..76308cf 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -124,7 +124,7 @@ All keys below are currently **ACTIVE**. | `ACP_MODEL_CONTEXT_LIMIT` | Override the context limit (takes highest precedence). | | `ACP_DEBUG` | Set to `1` / `true` to enable debug logging. | | `ACP_LOG_FILE` | Override the log file path (default `~/.pi/acp.log`). | -| `ACP_COOPERATIVE_PROXY` | Set to `0` to disable cooperative proxy mode (auto-detected from a `/bili/` model baseUrl). | +| `ACP_COOPERATIVE_PROXY` | Set to `0` to disable cooperative proxy mode (auto-detected from a `/bili/` model baseUrl or the launcher's `BILLION_CONTEXT_PROXY` env). | > **Only the documented keys are read from `acp.json`.** Other tuning knobs (`preserveRecentMessages`, `protectedTools`) are code-level and not user-overridable. The three compression thresholds form a three-tier escalation: growth-driven soft nudges β†’ forced nudges at `compress.maxContextLimit` β†’ emergency truncation at `compress.emergencyThresholdPercent`. @@ -331,6 +331,6 @@ Environment variables take precedence over the JSON config files. They are usefu ### `ACP_COOPERATIVE_PROXY` - **Type:** string flag -- **Default:** *(unset β€” cooperative mode auto-detects from the model baseUrl)* +- **Default:** *(unset β€” cooperative mode auto-detects from the model baseUrl or launcher env)* - **Status:** 🟒 ACTIVE -- **Description:** Set to `0` to **disable** cooperative proxy mode. When the model's `baseUrl` routes through a billion-context proxy (the `/bili/` zero-config prefix), the extension automatically switches to cooperative mode: the proxy owns compression (state, folding, ref tags, philosophy prompt, nudges β€” injected at the wire level), while this extension registers the 4 tools natively and forwards their execution to the proxy's `POST /__bili/plugin/tool` endpoint. Session identity is pi's real session id (sent as `x-bili-plugin-conversation`), which also fixes multi-session collisions. Setting `ACP_COOPERATIVE_PROXY=0` forces the standalone in-process behavior even behind a proxy (then make sure the proxy's own tool injection handles compression β€” double compression is otherwise possible). Protocol spec: [PLUGIN.md](https://github.com/ranxianglei/billion-context/blob/master/PLUGIN.md). +- **Description:** Set to `0` to **disable** cooperative proxy mode. Cooperative mode engages when the model's `baseUrl` routes through a billion-context proxy (the `/bili/` zero-config prefix) **or** when the billion-context launcher started pi (`BILLION_CONTEXT_PROXY` is exported next to `HTTPS_PROXY` in MITM mode). The proxy then owns compression (state, folding, ref tags, philosophy prompt, nudges β€” injected at the wire level), while this extension registers the 4 tools natively and forwards their execution to the proxy's `POST /__bili/plugin/tool` endpoint. Session identity is pi's real session id (sent as `x-bili-plugin-conversation`), which also fixes multi-session collisions; the model's context window is reported via `x-bili-plugin-context-window` and becomes the authoritative nudge denominator on the proxy side. Setting `ACP_COOPERATIVE_PROXY=0` forces the standalone in-process behavior even behind a proxy (then make sure the proxy's own tool injection handles compression β€” double compression is otherwise possible). Protocol spec: [PLUGIN.md](https://github.com/ranxianglei/billion-context/blob/master/PLUGIN.md). diff --git a/README.md b/README.md index 75e7825..30ba2c0 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,11 @@ When pi's model `baseUrl` routes through a [billion-context](https://github.com/ - the 4 tools (`compress` / `decompress` / `search_context` / `acp_status`) stay natively registered in pi β€” native tool UX, permissions and audit β€” and their execution is forwarded to the proxy (`POST /__bili/plugin/tool`), which runs them under its session lock; - every model request carries `x-bili-plugin: pi` + `x-bili-plugin-conversation: `, so the proxy keys state by pi's **real** session identity (no more content-fingerprint collisions) and suppresses its own wire-level tool injection (no double compression); +- the model's context window is reported from inside pi (`x-bili-plugin-context-window`) β€” pinned/overridden values the proxy's registry can't know become the authoritative nudge denominator; - the extension's in-process pipeline (processTurn, nudges, philosophy prompt) is skipped for that session β€” the proxy does all of it. +Detection works in both proxy modes: the `/bili/` prefix in the model baseUrl, **and MITM transparent mode** β€” `bili pi` (the billion-context launcher) exports `BILLION_CONTEXT_PROXY` next to `HTTPS_PROXY`, and the extension trusts it directly, so `bili pi` now gives you the native-tool cooperative experience too. + Without a proxy (or with `ACP_COOPERATIVE_PROXY=0`) behavior is byte-identical to the standalone extension. ## Plugin compatibility & ordering diff --git a/devlog/2026-08-16_cooperative-proxy-mode/WORKLOG.md b/devlog/2026-08-16_cooperative-proxy-mode/WORKLOG.md index 81331ce..e39b1a3 100644 --- a/devlog/2026-08-16_cooperative-proxy-mode/WORKLOG.md +++ b/devlog/2026-08-16_cooperative-proxy-mode/WORKLOG.md @@ -32,6 +32,9 @@ ## 4. Notes / Follow-ups -- MITM-mode (transparent proxy, no `/bili/` prefix) is NOT detected β€” cooperative mode requires the zero-config prefix baseURL (documented). MITM + extension would still double-compress; users on MITM should disable the extension or set the prefix. +- ~~MITM-mode (transparent proxy, no `/bili/` prefix) is NOT detected~~ β€” **landed in this PR (follow-up commit)**: the billion-context launcher now exports `BILLION_CONTEXT_PROXY` next to `HTTPS_PROXY` for `bili pi`/`codex`/`claude`; `proxyBaseForContext` falls back to it (trusted without probing β€” a stale value surfaces as a tool-forward error). MITM cooperative mode works end-to-end. +- `x-bili-plugin-context-window` (new): `before_provider_headers` reports `ctx.model.contextWindow` from inside pi; the proxy treats it as the authoritative native window (outranks its table/registry; operator `compress.modelContextLimit` still wins). +- `GET /__bili/plugin/status?conversationId=` (new proxy endpoint): context-level visibility (contextTokens = input+cache-read, contextLimit, blocks, requests) for plugin status UIs. +- Fixed a usage-application race in the proxy's plugin stream passthrough: usage is applied BEFORE `res.end()` so the client's next request (status fetch / follow-up turn reading `lastInputTokens` for nudges) always sees it. - `before_provider_headers` exists in the pi SDK as of this version; if an older pi host lacks it, registration is ignored harmlessly (extension `on` overloads). - Cross-repo dependency: the proxy half must merge first (ranxianglei/billion-context#161) β€” the endpoints this forwards to only exist there. No code-level version coupling (protocol is HTTP + manifest). diff --git a/src/cooperative.ts b/src/cooperative.ts index 0889197..8f63ec3 100644 --- a/src/cooperative.ts +++ b/src/cooperative.ts @@ -30,7 +30,22 @@ export function proxyBaseFromUrl(baseUrl: string | undefined): string | undefine export function proxyBaseForContext(ctx: ExtensionContext): string | undefined { if (process.env.ACP_COOPERATIVE_PROXY === "0") return undefined; - return proxyBaseFromUrl(ctx.model?.baseUrl); + return proxyBaseFromUrl(ctx.model?.baseUrl) ?? proxyBaseFromEnv(); +} + +/** MITM transparent-proxy mode has no `/bili/` prefix to detect (the baseUrl + * is the real provider URL). The proxy's own launcher (`bili pi`) exports + * BILLION_CONTEXT_PROXY alongside HTTPS_PROXY + the CA vars; trusting it + * requires no probe β€” a stale value surfaces as a tool-forward error. */ +export function proxyBaseFromEnv(): string | undefined { + const raw = process.env.BILLION_CONTEXT_PROXY?.trim(); + if (!raw) return undefined; + try { + const url = new URL(raw); + return url.protocol === "http:" || url.protocol === "https:" ? `${url.protocol}//${url.host}` : undefined; + } catch { + return undefined; + } } export async function forwardToolToProxy(proxyBase: string, conversationId: string, tool: string, args: unknown): Promise { diff --git a/src/index.ts b/src/index.ts index d7daebc..c865965 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,13 +61,19 @@ function wireCompactionDisable(pi: ExtensionAPI): void { // Cooperative proxy mode: announce this session to the billion-context proxy // so it keys state by pi's real session id and switches the session to -// plugin mode (no wire tool injection β€” tools are native here). No-op unless -// the model's baseUrl routes through the proxy (`/bili/` prefix). +// plugin mode (no wire tool injection β€” tools are native here). Also reports +// the model's context window from inside pi (pinned/overridden values the +// proxy's registry can't know, e.g. private relays in MITM mode). No-op +// unless proxied (`/bili/` baseUrl or BILLION_CONTEXT_PROXY from `bili pi`). function wireProviderHeaders(pi: ExtensionAPI): void { pi.on("before_provider_headers", (event, ctx) => { if (proxyBaseForContext(ctx) === undefined) return; event.headers["x-bili-plugin"] = PLUGIN_AGENT_NAME; event.headers["x-bili-plugin-conversation"] = ctx.sessionManager.getSessionId(); + const window = ctx.model?.contextWindow; + if (typeof window === "number" && Number.isFinite(window) && window > 0) { + event.headers["x-bili-plugin-context-window"] = String(Math.floor(window)); + } }); } diff --git a/tests/cooperative.test.ts b/tests/cooperative.test.ts index 55511cf..ae2e9cb 100644 --- a/tests/cooperative.test.ts +++ b/tests/cooperative.test.ts @@ -2,7 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { createServer } from "node:http"; import { createAcpExtension } from "../src/index.js"; -import { proxyBaseFromUrl, forwardToolToProxy } from "../src/cooperative.js"; +import { proxyBaseFromUrl, proxyBaseFromEnv, forwardToolToProxy } from "../src/cooperative.js"; function captureApi() { const handlers = new Map any)[]>(); @@ -62,11 +62,42 @@ test("before_provider_headers announces plugin mode with pi's session id", async await handlers.get("before_provider_headers")![0]!({ type: "before_provider_headers", headers }, fakeCtx([], PROXY_URL)); assert.equal(headers["x-bili-plugin"], "pi"); assert.equal(headers["x-bili-plugin-conversation"], "test-session"); + assert.equal(headers["x-bili-plugin-context-window"], "200000"); const plain: Record = {}; await handlers.get("before_provider_headers")![0]!({ type: "before_provider_headers", headers: plain }, fakeCtx([], "https://api.anthropic.com")); assert.equal(plain["x-bili-plugin"], undefined); assert.equal(plain["x-bili-plugin-conversation"], undefined); + assert.equal(plain["x-bili-plugin-context-window"], undefined); +}); + +test("BILLION_CONTEXT_PROXY enables cooperative mode without a /bili/ baseUrl (MITM launcher)", async () => { + const prev = process.env.BILLION_CONTEXT_PROXY; + process.env.BILLION_CONTEXT_PROXY = "http://127.0.0.1:8787"; + try { + const { api, handlers } = captureApi(); + createAcpExtension()(api as any); + + const headers: Record = {}; + await handlers.get("before_provider_headers")![0]!({ type: "before_provider_headers", headers }, fakeCtx([], "https://api.anthropic.com")); + assert.equal(headers["x-bili-plugin"], "pi"); + assert.equal(headers["x-bili-plugin-conversation"], "test-session"); + assert.equal(headers["x-bili-plugin-context-window"], "200000"); + + assert.equal(proxyBaseFromEnv(), "http://127.0.0.1:8787"); + process.env.BILLION_CONTEXT_PROXY = "http://127.0.0.1:9999/some/path"; + assert.equal(proxyBaseFromEnv(), "http://127.0.0.1:9999", "path is stripped to the origin"); + process.env.BILLION_CONTEXT_PROXY = "not a url"; + assert.equal(proxyBaseFromEnv(), undefined); + delete process.env.BILLION_CONTEXT_PROXY; + + const plain: Record = {}; + await handlers.get("before_provider_headers")![0]!({ type: "before_provider_headers", headers: plain }, fakeCtx([], "https://api.anthropic.com")); + assert.equal(plain["x-bili-plugin"], undefined, "without env or /bili/ prefix cooperative mode is off"); + } finally { + if (prev === undefined) delete process.env.BILLION_CONTEXT_PROXY; + else process.env.BILLION_CONTEXT_PROXY = prev; + } }); test("context handler passes messages through untouched in cooperative mode", async () => { From 17ed1d8566f750c702a4edcfbc20f3996b9a38ba Mon Sep 17 00:00:00 2001 From: awork Date: Sun, 16 Aug 2026 12:12:46 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(e2e):=20fix=20pre-existing=20master=20?= =?UTF-8?q?regression=20=E2=80=94=20fixture=20below=20minCompressRange=20s?= =?UTF-8?q?ince=20#148=20viable-ranges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The '2w limit fires the nudge' assertion (from #145) broke when #148 landed viable-ranges: the kernel now only counts merged ranges with tokens*4 >= minCompressRange (5000 chars) as effective T1 content. The 12x2000-char fixture's ranges all stayed under the gate, so the EMERGENCY nudge was correctly suppressed. Enlarge bulk text to 3000 chars/msg so the merged range clears the gate; the 100w-limit idle assertion is unaffected (2% usage, no pressure). Kernel behavior is intentional β€” fixture was stale. --- tests/e2e-compress-config.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/e2e-compress-config.test.ts b/tests/e2e-compress-config.test.ts index 014b748..d271651 100644 --- a/tests/e2e-compress-config.test.ts +++ b/tests/e2e-compress-config.test.ts @@ -103,6 +103,11 @@ test("e2e compress config: without a config file the kernel defaults apply", asy // Behavioral: feed the real configFor() output into runtime.core.processTurn() // (src/index.ts:142) and assert shouldInject flips with the limit. The nudge // needs recommendedRanges > 0, not just a high usage ratio β€” hence the bulk text. +// Bulk size matters: the kernel only counts merged ranges with tokens*4 >= +// minCompressRange (5000 chars, the #148 viable-ranges rule), so each message +// must be large enough for its merged range to clear the gate β€” with 2000-char +// messages every range stays under the minimum and the nudge is correctly +// suppressed ("no tier has effective compressible content"). function compressibleMessages(): CoreMessage[] { const msgs: CoreMessage[] = []; for (let i = 0; i < 12; i++) { @@ -110,7 +115,7 @@ function compressibleMessages(): CoreMessage[] { id: `h_${i}`, role: i % 2 === 0 ? "user" : "assistant", contentType: "text", - text: `historical detail ${i}. ${"x".repeat(2000)}`, + text: `historical detail ${i}. ${"x".repeat(3000)}`, }); } return msgs;