Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- **fix(compress): 摘要保真 — 入库解码双重转义摘要 + 标记无 ref 用户引言 (closes #309)** — 小模型会随工具输出模仿产出 `\uXXXX` 字面转义写进 compress 参数,内核原样存储后每轮 prompt 携带乱码、模型再模仿再产出(正反馈污染,事故会话实测)。新增 `src/summary-sanitize.ts`:`sanitizeSummary` 转义数严格 >20 才解码(仅 `\uXXXX` 含代理对,`\n`/`\\` 不动,合法少量转义示例存活);`findUnverifiableUserQuote` 检测“声称用户原话但无 mNNNNN ref”的引用(只记日志不改写,含中文“原话”模式);挂载 `compress-tool.ts` 的 `handleCompress` 在 `applyCompression` 之前逐 range 清洗,打 `summary-unescaped` / `summary-unverifiable-quote` 事件日志
- **feat(delegate): `acp_delegate` 新增 per-call `timeoutMinutes` 硬超时覆盖 (#286)** — 主 agent 可按任务体量单独设定该次 run 的硬墙钟预算,不再依赖全局默认(`async=true` 覆盖 async 硬限、sync 覆盖 sync 限;无输出 idle watchdog 仍用全局值);缺省/非法值回退配置默认,封顶 1440m(24h)防坏值长期占用并发槽位;`asyncWatchdogDescription` 反映生效值。实现:`resolvePerCallTimeoutMs`(`src/delegate-tool.ts`)
- docs: AGENTS.md 新增问题报告规范 — 发现/修复的问题必须建 issue 追踪 (#313)
- **fix(proxy): 手动指向 bili 代理时自动让位,消除双重压缩(closes #296)** — bcp 此前只认 launcher 注入的 `BILLION_CONTEXT_PROXY` 环境变量;用户单独跑代理并手动把 pi 的 `models.json` baseUrl 指到 `http://127.0.0.1:PORT/bili/https://upstream…`(或仅设 `HTTPS_PROXY`)时 env 未设 → bcp 全功能激活 + 代理照常压缩 → 每轮请求两侧各压一遍。现在:`src/proxy-detect.ts` 新增 `isBiliProxyBaseUrl`(镜像 billion-context `proxyBaseFromUrl`:http(s) scheme、首段路径为 `bili`、余下部分匹配 `/^/https?:///`,避免普通 `/foo/bili/` 路径误判),session_start 惰性检测 `ctx.model.baseUrl`,命中即按 env 相同方式让位(进程内一次性警告、不取消宿主原生压缩、四个 ACP 工具返回让位原因而非 OMP 文案);context 事件作为兜底检测点(防 session_start 未先于首个 LLM 调用触发)。env 检查保留为主信号——MITM 透明模式 URL 无 `/bili/` 前缀、只能靠 env 识别;README(en/zh)提示手动用 `HTTPS_PROXY` 的用户同时导出 `BILLION_CONTEXT_PROXY=1`。测试:URL 矩阵 14 例 + 集成 6 例(stand-down / warn-once / headless stderr / 直连 endpoint 保持激活 / context 兜底)
- **feat(delegate): 按角色配置默认模型与 thinking level (closes #117)** — `acp_delegate` 现支持为不同角色持久化配置默认模型与 reasoning/thinking level:`delegate.agents.<role>.model`(`"provider/id"`)、`delegate.agents.<role>.thinkingLevel`,以及全局 `delegate.thinkingLevel`。模型解析优先级 per-call `model` > 角色默认 > 父 Agent 当前模型;thinking level 优先级 per-call > 角色 > 全局 > Pi 默认。角色配置的模型经 `ctx.modelRegistry.find()` 校验,不存在时回退到父模型并记警告(**绝不失败**,omo 教训);per-call / 继承的模型原样透传(允许自定义非目录模型)。无任何配置时行为完全不变(继承父模型 + Pi 默认)。子进程 CLI 新增 `--thinking <level>` 透传(pi 支持 off|minimal|low|medium|high|xhigh|max,非法值告警不失败);`acp_delegate` 新增可选 `thinkingLevel` 参数
- **feat(delegate): `delegate.maxConcurrent` 后台子代理并发上限 / 强制串行开关(closes #294)** — 此前每个 `acp_delegate` async 调用立即 spawn 独立 pi 进程,无并发上限;低性能机器上并行子代理争抢 CPU 导致全部 watchdog 超时退出,主代理被迫自己干完。新增数值配置 `delegate.maxConcurrent`(默认 unlimited):限制**同时运行**的后台(async)delegate 数量,达上限后后续启动进入 FIFO 队列、有空位自动开始(不丢弃、只是等待)。`1` = 强制严格串行。优先级 env `PI_ACP_DELEGATE_MAX_CONCURRENT` > acp.json > unlimited,无效值(非整数 / `<1`)带警告回退而非失败。新增 `RunStatus:"queued"`:排队 run 立即可被 wait(挂起 waiter 跨排队→终态)与 cancel(释放槽位并显式唤醒挂起的 waiter,因其无子进程触发 finalize);watchdog 计时从实际 spawn 起算(不含排队时间);remaining-counts 计入 queued。sync 调用始终立即运行、不受影响。实现:`ConcurrencyGate`(provider 容量、FIFO、cancel 只标记不删除以防槽位泄漏)+ `spawnDelegateChild` 抽取;并发上限解析并入 `resolveDelegate`/`DelegatePolicy`;工具描述在有限制时动态提示;CONFIGURATION.md(+zh-CN) 文档。测试:`resolveDelegate().maxConcurrent` 解析 + ConcurrencyGate(FIFO/容量 N/cancel-跳过/无限)+ env>acp.json 解析与无效回退
- **fix(guardrail): 通用工具调用重复熔断 — 打断字节级相同调用的死循环(closes #308)** — 贪心解码小模型会把完全一致的 `(工具调用 → 工具返回)` 对逐轮自我强化(序列级吸引子,token 级惩罚跨轮无效),实测 `acp_status` 同参连发 30 次、上下文 57K→130K。现有防线全部失效:`compress-retry-capped` 熔断只统计 compress 失败(acp_status 永远成功 → 零计数)、nudge 去重按 turnKey(循环期无新用户消息 → 全程 `nudge-suppressed`)、`tool-guardrails.ts` 只有 bash 输出上限/超时检测。新增 `repetitionGuard`(默认开启,可配 `{enabled,warn,abort}`):每次调用指纹化 `sha1(toolName + 键排序 canonical JSON(args))`,只比参数不比结果(结果里可见消息数会微变也不影响命中);会话内跟踪连续相同次数,达 `warn`(默认 3) 在该 toolResult 尾部追加强警告,达 `abort`(默认 5) 拦截该调用不执行 + 中止本轮 + 终端通知;参数变化 / 换工具 / 真实用户消息重置计数(扩展自发消息不重置)。实现挂 `src/tool-guardrails.ts`(`RepetitionTracker` / `canonicalStringify` / `repetitionFingerprint`),配置解析在 `src/config.ts`(`resolveRepetitionGuard` / `REPETITION_GUARD_DEFAULTS`)
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ billion-context-pi is built for the **Pi** coding agent (`@earendil-works/pi-cod

Full details: [docs/omp.md](./docs/omp.md).

- **Coexisting with the [billion-context](https://github.com/ranxianglei/billion-context) wire proxy** — running both on the same session double-compresses every request (wasted tokens, nested summaries, two ref coordinate systems). This is prevented automatically: launcher paths (`bili pi`, …) export `BILLION_CONTEXT_PROXY`, and models whose `baseUrl` routes through the proxy (`…/bili/https://upstream…`) are detected at session start — in both cases billion-context-pi stands down with a warning and leaves the proxy as the sole compressor. One exception: transparent mode, where traffic reaches the proxy via `HTTPS_PROXY` so the URL carries no `/bili/` prefix — that is undetectable from the URL, so export `BILLION_CONTEXT_PROXY=1` before starting pi in that case.

## Model-facing tools

| Tool | What it does |
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ billion-context-pi 面向 **Pi** 编码代理(`@earendil-works/pi-coding-agent`)

完整说明:[docs/omp.zh-CN.md](./docs/omp.zh-CN.md)。

- **与 [billion-context](https://github.com/ranxianglei/billion-context) 线代理共存** —— 两者同时作用于同一会话会对每个请求双重压缩(token 浪费、嵌套摘要、两套 ref 坐标系)。这会被自动防止:launcher 路径(`bili pi` 等)导出 `BILLION_CONTEXT_PROXY`;模型 `baseUrl` 经代理路由(`…/bili/https://upstream…`)时在会话开始即被检测到 —— 两种情况下 billion-context-pi 都会带警告让位,由代理独占压缩。一个例外:透明模式(流量经 `HTTPS_PROXY` 到达代理、URL 无 `/bili/` 前缀)无法从 URL 识别 —— 此时请在启动 pi 前导出 `BILLION_CONTEXT_PROXY=1`。

## 模型工具

| 工具 | 作用 |
Expand Down
2 changes: 1 addition & 1 deletion src/compress-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function makeCompressTool(runtime: AcpRuntime): ToolDefinition<typeof Com
],
parameters: CompressParams,
async execute(toolCallId, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<unknown>> {
if (runtime.refused) return { details: undefined, content: [{ type: "text", text: OMP_UNSUPPORTED_MESSAGE }] };
if (runtime.refused) return { details: undefined, content: [{ type: "text", text: runtime.refusalMessage ?? OMP_UNSUPPORTED_MESSAGE }] };
let result: string;
try {
result = await handleCompress(params as CompressArgs, runtime, ctx, toolCallId);
Expand Down
2 changes: 1 addition & 1 deletion src/decompress-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function makeDecompressTool(runtime: AcpRuntime): ToolDefinition<typeof D
],
parameters: DecompressParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<unknown>> {
if (runtime.refused) return { details: undefined, content: [{ type: "text", text: OMP_UNSUPPORTED_MESSAGE }] };
if (runtime.refused) return { details: undefined, content: [{ type: "text", text: runtime.refusalMessage ?? OMP_UNSUPPORTED_MESSAGE }] };
let result: string;
try {
result = await handleDecompress(params as DecompressArgs, runtime, ctx);
Expand Down
40 changes: 33 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { defaultCountTokens } from "acp-kernel";
import { formatSystemPromptForEvent, getSystemPromptText } from "./compat.js";
import { inspectOverflowMessage, reserveOutputHeadroom, shouldReserveOutputHeadroom } from "./overflow-selfheal.js";
import { isOmpHost, OMP_UNSUPPORTED_MESSAGE } from "./omp.js";
import { isBiliProxyBaseUrl, PROXY_STAND_DOWN_MESSAGE } from "./proxy-detect.js";

type AgentMessage = SessionMessageEntry["message"];

Expand All @@ -56,10 +57,31 @@ export function createAcpExtension(adapter: AdapterConfig = {}): ExtensionFactor
return;
}
const runtime = createRuntime(adapter);
// Manual-wiring double-compression guard (issue #296): the launcher path
// exports BILLION_CONTEXT_PROXY (checked above), but a user who starts the
// proxy standalone (`bili start`) and points models.json baseUrl at
// http://127.0.0.1:PORT/bili/<scheme>://upstream... never sets the env var —
// without this check bcp and the proxy both compress every request. Yields
// exactly like the env path: stand down, let the proxy own compression.
// Checked lazily because ctx.model only exists on events, not in the
// factory; warn once per process like the OMP refusal.
let proxyWarned = false;
const standDownIfProxied = (ctx: ExtensionContext): boolean => {
if (!isBiliProxyBaseUrl((ctx.model as { baseUrl?: string } | undefined)?.baseUrl)) return false;
runtime.refused = true;
runtime.refusalMessage = PROXY_STAND_DOWN_MESSAGE;
if (!proxyWarned) {
proxyWarned = true;
logWarn("host", { event: "proxy-baseurl-detected", sid: ctx.sessionManager.getSessionId(), action: "refused" });
if (ctx.hasUI) ctx.ui.notify(PROXY_STAND_DOWN_MESSAGE, "warning");
else console.error(PROXY_STAND_DOWN_MESSAGE);
}
return true;
};
wireCompactionDisable(pi, runtime);
wireDelegateReadTracking(pi);
wireSessionLifecycle(pi, runtime);
wireContextTransform(pi, runtime);
wireSessionLifecycle(pi, runtime, standDownIfProxied);
wireContextTransform(pi, runtime, standDownIfProxied);
wireSystemPrompt(pi, runtime);
wireToolGuardrails(pi, runtime);
wireOverflowSelfHeal(pi, runtime);
Expand Down Expand Up @@ -131,7 +153,7 @@ function wireDelegateReadTracking(pi: ExtensionAPI): void {
});
}

function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime): void {
function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime, standDownIfProxied: (ctx: ExtensionContext) => boolean): void {
let ompWarned = false;
pi.on("session_start", async (_event, ctx) => {
// OMP (oh-my-pi) is not supported: its in-process live-entries integration
Expand All @@ -151,6 +173,7 @@ function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime): void {
}
return;
}
if (standDownIfProxied(ctx)) return;
runtime.store.invalidate();
runtime.clearNudgeTracking();
runtime.throttleFor(ctx.sessionManager.getSessionId()).reset();
Expand Down Expand Up @@ -217,12 +240,15 @@ function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime): void {
// The core integration: Pi's `context` event fires before every LLM call with the
// messages about to be sent. We run acp-kernel's processTurn (prune + ref-tag +
// nudge decision) and return the transformed AgentMessage[].
function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime): void {
function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIfProxied: (ctx: ExtensionContext) => boolean): void {
pi.on("context", async (event, ctx) => {
// Refused host (OMP): leave the context completely untouched — no ref tags,
// no compression, no nudge. Returning undefined makes pi send the original
// messages verbatim.
// Refused host (OMP / proxied baseUrl): leave the context completely
// untouched — no ref tags, no compression, no nudge. Returning undefined
// makes pi send the original messages verbatim.
if (runtime.refused) return;
// Fallback for hosts where session_start did not fire before the first LLM
// call: detect the proxied baseUrl here instead.
if (standDownIfProxied(ctx)) return;
const sid = ctx.sessionManager.getSessionId();
const release = await runtime.acquireLock(sid);
try {
Expand Down
36 changes: 36 additions & 0 deletions src/proxy-detect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Manual-wiring detection for the billion-context wire proxy (issue #296):
// a user who starts the proxy standalone (`bili start`) and points pi's
// models.json baseUrl at `http://127.0.0.1:PORT/bili/<scheme>://upstream...`
// never gets the BILLION_CONTEXT_PROXY env var (only the `bili pi` launcher
// exports it), so without this check bcp and the proxy both compress every
// request. Mirrors proxyBaseFromUrl in billion-context (src/agent/shared.ts):
// the real prefix embeds the full upstream URL, so require `bili` as the FIRST
// path segment followed by an http(s) URL — a plain `/foo/bili/` path is NOT a
// bili proxy. MITM transparent mode (HTTPS_PROXY) has no /bili/ prefix and
// stays undetectable from the URL; those users must export the env var.
export function isBiliProxyBaseUrl(baseUrl: string | undefined): boolean {
if (!baseUrl) return false;
try {
const url = new URL(baseUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
const segments = url.pathname.split("/").filter((s) => s.length > 0);
if (segments[0] !== "bili") return false;
const rest = url.pathname.slice(url.pathname.indexOf("bili") + "bili".length);
return /^\/https?:\/\//.test(rest);
} catch {
return false;
}
}

/**
* Shown (and logged) when the extension detects that the model's baseUrl
* routes through the billion-context wire proxy and stands down. The proxy
* runs compression server-side and owns the ref coordinate space, so running
* ACP in-process on top of it would double-compress every request.
*/
export const PROXY_STAND_DOWN_MESSAGE = [
"[billion-context-pi] Model baseUrl routes through the billion-context wire proxy (/bili/ prefix) — ACP client-side compression has been disabled for this session to avoid double compression.",
"The proxy runs the same compression pipeline server-side and owns the ref coordinate space; nothing else to configure.",
"If your traffic reaches the proxy via HTTPS_PROXY instead (no /bili/ prefix in the URL), export BILLION_CONTEXT_PROXY=1 before starting pi.",
"Docs: https://github.com/ranxianglei/billion-context",
].join("\n");
15 changes: 10 additions & 5 deletions src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,15 @@ export function isPiHost(sm: ExtensionContext["sessionManager"]): boolean {

export interface AcpRuntime {
core: CompressionCore;
/** Set when the host is unsupported (currently: OMP / oh-my-pi). Once true,
* the extension stands down: the context transform, system-prompt injection,
* compaction-cancel and ACP tools all no-op so the host runs untouched.
* Set at session_start (see wireOmpRefusal in src/index.ts). */
/** Set when the host is unsupported (currently: OMP / oh-my-pi) or when the
* model's baseUrl routes through the billion-context wire proxy (#296).
* Once true, the extension stands down: the context transform, system-prompt
* injection, compaction-cancel and ACP tools all no-op so the host runs
* untouched. Set at session_start (first context event as fallback). */
refused: boolean;
/** User-facing reason shown when a refused ACP tool is invoked; null means
* the default OMP refusal text applies. Set together with `refused`. */
refusalMessage: string | null;
/** Per-session provider-throttle retry episode (attempt budget + kick
* pacing), keyed by session id so concurrent sessions in one extension
* instance cannot share an episode. Reset on session_start and on any
Expand Down Expand Up @@ -405,4 +409,5 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime {
}

let refused = false;
return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k) => { nudgeShownTurns.add(k); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); }, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop };}
let refusalMessage: string | null = null;
return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k) => { nudgeShownTurns.add(k); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); }, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop };}
2 changes: 1 addition & 1 deletion src/search-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function makeSearchTool(runtime: AcpRuntime): ToolDefinition<typeof Searc
],
parameters: SearchParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<unknown>> {
if (runtime.refused) return { details: undefined, content: [{ type: "text", text: OMP_UNSUPPORTED_MESSAGE }] };
if (runtime.refused) return { details: undefined, content: [{ type: "text", text: runtime.refusalMessage ?? OMP_UNSUPPORTED_MESSAGE }] };
let result: string;
try {
result = await handleSearch(params as SearchArgs, runtime, ctx);
Expand Down
2 changes: 1 addition & 1 deletion src/status-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function makeStatusTool(runtime: AcpRuntime): ToolDefinition<typeof Statu
],
parameters: StatusParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<unknown>> {
if (runtime.refused) return { details: undefined, content: [{ type: "text", text: OMP_UNSUPPORTED_MESSAGE }] };
if (runtime.refused) return { details: undefined, content: [{ type: "text", text: runtime.refusalMessage ?? OMP_UNSUPPORTED_MESSAGE }] };
let result: string;
try {
result = await handleStatus(params as StatusArgs, runtime, ctx);
Expand Down
Loading
Loading