From d57265757f2eccfe8eb8a240554a5a703e2336f3 Mon Sep 17 00:00:00 2001 From: yanhuojiyusheng <2398249916@qq.com> Date: Mon, 26 Oct 2026 21:29:09 +0800 Subject: [PATCH 1/4] fix(hermes): bind session id and model budget when tool_search defers the ACP tools (#4855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes defaults tools.tool_search to "auto": model_tools.py:517-534 rewrites the plugin toolset into the tool_search/describe/call bridge, so the outgoing request never carries compress/decompress/acp_status. _plugin_tools_visible() therefore returns False and on_llm_request bailed out entirely — dropping x-bili-plugin, x-bili-plugin-conversation, the model and the context-window headers. The proxy then minted a pfa-* anonymous session (session.ts:1330): /acp's status lookup 404'd and read as "No model request yet", and the window fell back to 200K. Decouple identity/budget from whoever executes compression. The proxy advertises x-bili-plugin-compression in the manifest headers; the plugin stamps "proxy" only when that capability is advertised AND the ACP tools are absent from the request, and the proxy honors it only alongside the x-bili-plugin marker (same degradation shape as #2155's plugin-channel death and #2268's pi-subagent role whitelist). Identity and budget now bind to the conversation; the proxy keeps owning the compress injection while the tools stay deferred. Tests: plugin-protocol gains a 4-leg case (deferred/subset/no-override/no-marker — the last one must still 404) and hermes-native a defer-fallback driver scenario. --- hermes-plugin/__init__.py | 32 ++++++++++++-- src/plugin.ts | 18 +++++++- src/server/handle.ts | 14 ++++++- tests/hermes-native.test.ts | 51 +++++++++++++++++++++- tests/plugin-protocol.test.ts | 79 +++++++++++++++++++++++++++++++++++ 5 files changed, 187 insertions(+), 7 deletions(-) diff --git a/hermes-plugin/__init__.py b/hermes-plugin/__init__.py index e99b0bb5ec..270a602dba 100644 --- a/hermes-plugin/__init__.py +++ b/hermes-plugin/__init__.py @@ -123,6 +123,10 @@ "stop_event": threading.Event(), "warned_direct": False, "snapshot_warned": False, + # #4657: header name the proxy advertised for the per-request compression-channel override + # (manifest headers.compression). Empty/None on an older proxy — then a request whose tool + # snapshot lacks the ACP tools stays unstamped, exactly as before. + "compression_header": None, # #4644 self-healing: watchdog_gen invalidates a previous generation's watchdog thread on # load/unload; recovering marks an origin-death recovery in flight (single-flight guard); # env_backup holds what apply_env overwrote so _unroute_env RESTORES it instead of deleting it. @@ -158,6 +162,7 @@ def _reset_for_test() -> None: _state["stop_event"] = threading.Event() _state["warned_direct"] = False _state["snapshot_warned"] = False + _state["compression_header"] = None _state["watchdog_gen"] = 0 _state["watchdog_thread"] = None _state["recovering"] = False @@ -1458,6 +1463,13 @@ def _bootstrap(sidecar: Dict[str, str], ctx: Any, skip_register: bool = False) - else: manifest = http_get_json(origin.rstrip("/") + "/__bili/plugin/manifest", MANIFEST_TIMEOUT_S) tools = manifest.get("tools", {}).get("anthropic") if isinstance(manifest, dict) else None + # #4657: the proxy names the header that flips compression to its own channel for a + # request whose tool snapshot lacks the native tools. Absent on older proxies. + declared = manifest.get("headers") if isinstance(manifest, dict) else None + compression_header = declared.get("compression") if isinstance(declared, dict) else None + _state["compression_header"] = (_stampable_header_value(compression_header) + if isinstance(compression_header, str) and compression_header + else None) if not isinstance(tools, list) or not tools: if attempt < max_attempts and _sleep_backoff(attempt): logger.warning("billion-context: proxy manifest had no tool definitions " @@ -1599,16 +1611,30 @@ def on_llm_request(request: Optional[Dict[str, Any]] = None, **context: Any) -> logger.warning("billion-context: proxy not ready — this process's model traffic goes " "direct (uncompressed); see the bootstrap warning above") return None - if not _plugin_tools_visible(request): + visible = _plugin_tools_visible(request) + if not visible: + compression_header = _state.get("compression_header") + if not compression_header: + # Older proxy: it would suppress wire injection on the marker alone, so an invisible + # tool snapshot must stay unstamped (anonymous proxy mode). + if not _state.get("snapshot_warned"): + _state["snapshot_warned"] = True + logger.info("billion-context: native tools are not in this session's tool list — " + "compressing in proxy mode until a new session") + return None + # #4657: Hermes defers every plugin toolset behind tool_search, so the tools exist but + # never reach the outgoing request. Keep the plugin identity + budget (that is what + # /acp and the window header read) and hand compression to the proxy explicitly. if not _state.get("snapshot_warned"): _state["snapshot_warned"] = True logger.info("billion-context: native tools are not in this session's tool list — " - "compressing in proxy mode until a new session") - return None + "keeping the plugin identity and compressing in proxy mode") headers = request.get("extra_headers") merged = dict(headers) if isinstance(headers, dict) else {} merged["x-bili-plugin"] = AGENT_NAME merged["x-bili-plugin-conversation"] = str(session_id) + if not visible: + merged[_state["compression_header"]] = "proxy" model = context.get("model") if model: model_header = _stampable_header_value(model) diff --git a/src/plugin.ts b/src/plugin.ts index 5f70c8a9f2..4130647ce0 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -113,6 +113,15 @@ const PLUGIN_BYPASS_HEADER = "x-bili-plugin-bypass"; const PLUGIN_CONTEXT_WINDOW_HEADER = "x-bili-plugin-context-window"; const PLUGIN_MAX_OUTPUT_HEADER = "x-bili-plugin-max-output"; const PLUGIN_MODEL_HEADER = "x-bili-plugin-model"; +/** #4657: per-request compression-channel override, only honored from an announced plugin + * (same gate as window/model above). A host that registers the ACP tools natively but keeps them + * OUT of the outgoing request — Hermes defers every plugin toolset behind tool_search — still + * needs its identity and budget bound to the plugin conversation. Stamping "proxy" says exactly + * that: keep the plugin identity, but serve compression through the proxy's wire channel (inject + * the tools, execute compress server-side) instead of suppressing injection for a caller that + * cannot run the tools. Without it the request falls back to anonymous proxy mode (pfa-* fork id, + * default window, no /acp binding), which is the defect it exists for. */ +const PLUGIN_COMPRESSION_HEADER = "x-bili-plugin-compression"; /** #1102/#1106: stamped "1" by plugins whose host mints one conversation id per * persona (opencode: subagents get their own child session ids). Since the * instructions fingerprint became an allowlist (#1106 — exempt is the @@ -253,6 +262,13 @@ export function pluginRequestAgentHeader(headers: Record): boolean { + if (pluginAgentHeader(headers) === undefined) return false; + return headerValue(headers, PLUGIN_COMPRESSION_HEADER) === "proxy"; +} + /** #956 hardening: per-request plugin window/max-output headers describe the * model the plugin CONFIGURED — when the request body carries a different * model (mid-switch race, or a provider-model composite like @@ -959,7 +975,7 @@ export function handlePluginManifest(res: import("node:http").ServerResponse, co openai: [...acpOpenai, ...(absorbTools ? [absorbTools.openai] : []), ...(rulesOn ? [RULE_TOOL_OPENAI] : []), ...(ccrTools ? [ccrTools.openai] : []), ...(rescaleOn ? [READ_ORIGINAL_IMAGE_TOOL_OPENAI] : [])], responses: [...acpResponses, ...(absorbTools ? [absorbTools.responses] : []), ...(rulesOn ? [RULE_TOOL_RESPONSES] : []), ...(rescaleOn ? [READ_ORIGINAL_IMAGE_TOOL_RESPONSES] : [])], }, - headers: { agent: PLUGIN_AGENT_HEADER, conversation: PLUGIN_CONVERSATION_HEADER, contextWindow: PLUGIN_CONTEXT_WINDOW_HEADER, maxOutput: PLUGIN_MAX_OUTPUT_HEADER, model: PLUGIN_MODEL_HEADER, instructionsMutable: PLUGIN_INSTRUCTIONS_MUTABLE_HEADER }, + headers: { agent: PLUGIN_AGENT_HEADER, conversation: PLUGIN_CONVERSATION_HEADER, contextWindow: PLUGIN_CONTEXT_WINDOW_HEADER, maxOutput: PLUGIN_MAX_OUTPUT_HEADER, model: PLUGIN_MODEL_HEADER, instructionsMutable: PLUGIN_INSTRUCTIONS_MUTABLE_HEADER, compression: PLUGIN_COMPRESSION_HEADER }, toolEndpoint: "/__bili/plugin/tool", statusEndpoint: "/__bili/plugin/status", runtimeInfoEndpoint: "/__bili/plugin/runtime-info", diff --git a/src/server/handle.ts b/src/server/handle.ts index 1ec676fcd4..58d7985fd4 100644 --- a/src/server/handle.ts +++ b/src/server/handle.ts @@ -40,7 +40,7 @@ import { maybeAdoptForkBlocks, maybeAdoptResume, maybeAdoptDerivedFork } from ". import { executeThreadsReforge } from "./threads-lane.js"; import { resetThreadTailTracking } from "./thread-tracking.js"; import { resolveClaudeTranscriptLineage } from "../claude-transcript-lineage.js"; -import { consumePluginRegisterFor, pluginAgentHeader, pluginConversationHeader, pluginHeadersMatchModel, pluginReportedContextWindow, pluginReportedMaxOutput, pluginReportedModel, pluginRequestAgentHeader, pluginRuntimeInfoFor, pluginRuntimeInfoForConversation, publicForkInputCheck, recordChainVerdict, recordPluginSession, rememberPluginMessages, resolveConversation, runtimeConversationId, takePendingPluginRegister } from "../plugin.js"; +import { consumePluginRegisterFor, pluginAgentHeader, pluginCompressionProxyHeader, pluginConversationHeader, pluginHeadersMatchModel, pluginReportedContextWindow, pluginReportedMaxOutput, pluginReportedModel, pluginRequestAgentHeader, pluginRuntimeInfoFor, pluginRuntimeInfoForConversation, publicForkInputCheck, recordChainVerdict, recordPluginSession, rememberPluginMessages, resolveConversation, runtimeConversationId, takePendingPluginRegister } from "../plugin.js"; import { scheduleAffinityPersist } from "../affinity-persist.js"; import { evaluateChain, extractChainCarriers } from "../chain-checkpoint.js"; import { BILI_PASSTHROUGH_HEADER, BILI_PLUGIN_BYPASS_HEADER, isLoopbackAddress, normalizeUpstreamOrigin, provenCompletionReserve, reserveOutputHeadroom, resolveOutputHeadroomCap, shouldReserveOutputHeadroom, type WireProtocol } from "../util.js"; @@ -1788,7 +1788,17 @@ async function handleRequest( log("info", `[${sessionId}] [pi-subagents] child "${subagentFallback.agent ?? "unknown"}" role allowlist lacks ACP context tools — serving proxy-style compression channel (#2268)`); } } - const pluginMode = pluginAgent !== undefined && !pluginLaneDegraded(session) && !subagentFallback.present; + // #4657: a plugin whose host declines to put the ACP tools in the outgoing request (Hermes + // defers every plugin toolset behind tool_search) asks for the proxy channel explicitly. + // Same demotion as the two signals above — identity/budget stay plugin-bound, compression + // moves server-side — so a deferred toolset stops degenerating into anonymous proxy mode. + const compressionProxy = pluginCompressionProxyHeader(req.headers); + if (compressionProxy && session.metadata.compressionProxyNotified !== true) { + session.metadata.compressionProxyNotified = true; + markDirty(session); + log("info", `[${sessionId}] [plugin] "${pluginAgent}" asked for the proxy compression channel (native tools are not in this request) — identity and budget stay plugin-bound`); + } + const pluginMode = pluginAgent !== undefined && !pluginLaneDegraded(session) && !subagentFallback.present && !compressionProxy; // [#1097/#1271] Stamp the resolved CCR policy. acp_retrieve needs a tool // channel that can round-trip the full original, so CCR arms only where // that channel exists and is resolvable: proxy mode always (the proxy diff --git a/tests/hermes-native.test.ts b/tests/hermes-native.test.ts index 08c7855a75..184c04cb76 100644 --- a/tests/hermes-native.test.ts +++ b/tests/hermes-native.test.ts @@ -309,7 +309,11 @@ class Handler(BaseHTTPRequestHandler): self._send({"version": "test-1", "tools": {"anthropic": [ {"name": "compress", "description": "compress a range", "input_schema": {"type": "object"}}, {"name": "decompress", "description": "decompress a block", "input_schema": {"type": "object"}}, - {"name": "acp_status", "description": "context status", "input_schema": {"type": "object"}}]}}) + {"name": "acp_status", "description": "context status", "input_schema": {"type": "object"}}]}, + # A proxy that supports the compression-channel override names its header here; an + # older proxy omits the map entry (empty string), and the plugin must stay legacy. + "headers": {"agent": "x-bili-plugin", "conversation": "x-bili-plugin-conversation", + "compression": os.environ.get("BC_MANIFEST_COMPRESSION") or ""}}) elif self.path.startswith("/__bili/plugin/status"): from urllib.parse import urlparse, parse_qs q = parse_qs(urlparse(self.path).query) @@ -752,6 +756,28 @@ elif SCENARIO.startswith("attach"): "no_session_result": no_session_result, "transcribed_result": transcribed_result, }) +elif SCENARIO == "defer-fallback": + # Hermes registers the ACP tools natively but its tool_search layer DEFERS the toolset, so the + # outgoing snapshot carries none of them — the session would otherwise ride anonymous proxy mode + # (pfa-* fork ids, default window, /acp dead). The proxy advertises the compression-channel + # override: identity + budget stay plugin-stamped, compression moves server-side. + install_sidecar(False) + os.environ["BILLION_CONTEXT_ATTACH"] = ORIGIN + os.environ["BC_MANIFEST_COMPRESSION"] = "x-bili-plugin-compression" + mod.register(ctx) + mw = ctx.middlewares["llm_request"] + deferred = mw(request=mw_request("test-model", with_tools=False), session_id="sess-defer", + model="test-model", provider="p", api_mode="chat") + partial = mw(request=mw_request("test-model", names=("compress",)), session_id="sess-defer", + model="test-model", provider="p", api_mode="chat") + visible = mw(request=mw_request("test-model"), session_id="sess-defer", + model="test-model", provider="p", api_mode="chat") + out.update({ + "defer_none": deferred is None, + "defer_headers": deferred["request"]["extra_headers"] if isinstance(deferred, dict) else None, + "partial_headers": partial["request"]["extra_headers"] if isinstance(partial, dict) else None, + "visible_headers": visible["request"]["extra_headers"] if isinstance(visible, dict) else None, + }) elif SCENARIO.startswith("cmd-acp"): # no-proxy leg: no sidecar installed -> register() stays inert, origin unset if SCENARIO != "cmd-acp-no-proxy": @@ -1108,6 +1134,10 @@ interface DriverOut { stale_headers?: Record | null; no_tools_none?: boolean; partial_tools_none?: boolean; + defer_none?: boolean; + defer_headers?: Record | null; + partial_headers?: Record | null; + visible_headers?: Record | null; anthropic_headers?: Record | null; register_elapsed?: number; state_after_register?: string; @@ -1252,6 +1282,25 @@ describe("python plugin runtime (subprocess)", () => { assert.deepEqual(out!.watcher_calls, [{ pid: out!.pid }], "attached session registers its host pid as a watchdog owner"); }); + // A toolset the host DEFERS (Hermes tool_search) is invisible in the outgoing snapshot even + // though the plugin registered it. Once the proxy advertises the compression-channel override, + // the request keeps its plugin identity + budget and the proxy owns compression — the pure + // plugin lane (no override header) stays untouched. + test("deferred toolset: identity/budget stay plugin-stamped, compression falls back to the proxy", () => { + if (!PY) { + console.warn("skip: no python3/python interpreter on this machine (hermes requires Python 3.11+)"); + return; + } + const { out, err } = runDriver("defer-fallback"); + assert.ok(out, err); + assert.equal(out!.defer_none, false, "a deferred toolset must not drop the request into anonymous proxy mode"); + assert.equal(out!.defer_headers!["x-bili-plugin"], "hermes"); + assert.equal(out!.defer_headers!["x-bili-plugin-conversation"], "sess-defer"); + assert.equal(out!.defer_headers!["x-bili-plugin-compression"], "proxy", "proxy owns compression for this request"); + assert.equal(out!.partial_headers!["x-bili-plugin-compression"], "proxy", "a partial snapshot also runs proxy-side"); + assert.equal(out!.visible_headers!["x-bili-plugin-compression"], undefined, "a visible toolset keeps the pure plugin lane"); + }); + // #3261: the host knows the model's window (HERMES_HOME/context_length_cache.yaml) but the // plugin never carried it up — now stamped on the header channel AND reported in // runtime-info. The header leg resolves the base URL via the provider entry in config.yaml diff --git a/tests/plugin-protocol.test.ts b/tests/plugin-protocol.test.ts index 4a9bf278e5..3deba4eb93 100644 --- a/tests/plugin-protocol.test.ts +++ b/tests/plugin-protocol.test.ts @@ -848,6 +848,85 @@ test("plugin-reported context window becomes the nudge denominator and is visibl } }); +// Hermes defers plugin toolsets through tool_search, so a healthy plugin session sends requests +// whose tools array carries NONE of the ACP names. Those requests must still bind the plugin +// identity and budget (that is exactly what /acp reads) while compression falls back to the proxy — +// signalled by x-bili-plugin-compression: proxy. The pure plugin lane (no such header) keeps +// suppressing wire injection, and a plain client cannot reach the override without the agent marker. +test("x-bili-plugin-compression: proxy keeps plugin identity, moves compression server-side", async () => { + const h = await startHarness([textScript(), textScript(), textScript(), textScript()]); + try { + const post = async (headers: Record, tools: unknown[] | undefined): Promise => { + const resp = await fetch(`http://127.0.0.1:${h.proxyPort}/bili/http://127.0.0.1:${h.upstreamPort}/v1/messages`, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify({ + model: "claude-test", + max_tokens: 1024, + stream: true, + system: UPSTREAM_SYSTEM, + messages: [{ role: "user", content: "hello" }], + ...(tools ? { tools } : {}), + }), + }); + for await (const _ of resp.body ?? []) { /* drain */ } + assert.equal(resp.status, 200); + }; + const forwarded = (index: number): string[] => { + const parsed = JSON.parse(h.captured[index]!.body) as { tools?: Array> }; + return (parsed.tools ?? []).map((t) => String(t.name ?? "")); + }; + const roleTool = [{ name: "read_file", description: "read a file", input_schema: { type: "object" } }]; + const CORE = ["compress", "decompress", "acp_status"]; + + // A: deferred toolset + override -> wire injection, plugin identity and budget intact. + await post({ + "x-bili-plugin": "hermes", + "x-bili-plugin-conversation": "sess-defer", + "x-bili-plugin-compression": "proxy", + "x-bili-plugin-context-window": "500000", + }, roleTool); + const a = forwarded(0); + for (const name of CORE) assert.ok(a.includes(name), `proxy must inject ${name}; got ${a.join(",")}`); + assert.ok(a.includes("read_file"), "the client's own tools survive"); + const status = (await (await fetch( + `http://127.0.0.1:${h.proxyPort}/__bili/plugin/status?conversationId=sess-defer`, + )).json()) as { ok: boolean; contextLimit: number | null; pluginAgent: string | null; requests: number }; + assert.equal(status.ok, true, "the hermes session id resolves — /acp works in fallback mode"); + assert.equal(status.pluginAgent, "hermes"); + assert.equal(status.contextLimit, 500000, "the plugin-reported window still sizes the budget"); + assert.equal(status.requests, 1); + + // B: partial snapshot (one ACP name present) — nothing is injected twice. + await post({ + "x-bili-plugin": "hermes", + "x-bili-plugin-conversation": "sess-defer-partial", + "x-bili-plugin-compression": "proxy", + }, [{ name: "compress", description: "x", input_schema: { type: "object" } }]); + const b = forwarded(1); + assert.equal(b.filter((n) => n === "compress").length, 1, "compress not duplicated"); + for (const name of ["decompress", "acp_status"]) assert.ok(b.includes(name), `${name} still injected`); + + // C: no override header -> pure plugin lane, unchanged (no injection). + await post({ + "x-bili-plugin": "hermes", + "x-bili-plugin-conversation": "sess-pure", + }, roleTool); + for (const name of CORE) assert.ok(!forwarded(2).includes(name), `pure plugin lane must not inject ${name}`); + + // D: the override without the agent marker is inert — an anonymous proxy-mode request (which + // injects anyway) must not be bound to a plugin conversation by the header alone. + await post({ + "x-bili-plugin-conversation": "sess-plain", + "x-bili-plugin-compression": "proxy", + }, roleTool); + const plain = await fetch(`http://127.0.0.1:${h.proxyPort}/__bili/plugin/status?conversationId=sess-plain`); + assert.equal(plain.status, 404, "marker-less conversation id is never bound"); + } finally { + await h.close(); + } +}); + test("headless launcher registrations expire after the TTL (no stale poisoning)", () => { _resetPluginStateForTest(); const t0 = Date.now(); From 17e16f3316ba651385d19bcc948ebd706edcc685 Mon Sep 17 00:00:00 2001 From: ci-check Date: Mon, 26 Oct 2026 22:38:18 +0800 Subject: [PATCH 2/4] fix(tests): regenerate wire-contract manifest goldens for headers.compression (#4855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR added the optional protocol header x-bili-plugin-compression, broadcast in the plugin manifest under headers.compression, but did not regenerate the wire-contract golden snapshots, so tests/wire-contract-golden.test.ts went red on manifest-default.json and manifest-optin.json (CI: 3 of 6 test shards failing on exactly those two tests; parent commit was fully green). Regenerated with scripts/update-wire-contract-goldens.ts; the diff is additive only — one new key per manifest snapshot. Ledger grows, nothing shrinks. --- tests/golden/wire-contract/manifest-default.json | 3 ++- tests/golden/wire-contract/manifest-optin.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/golden/wire-contract/manifest-default.json b/tests/golden/wire-contract/manifest-default.json index 89503ad65f..1ddfeb761b 100644 --- a/tests/golden/wire-contract/manifest-default.json +++ b/tests/golden/wire-contract/manifest-default.json @@ -356,7 +356,8 @@ "contextWindow": "x-bili-plugin-context-window", "maxOutput": "x-bili-plugin-max-output", "model": "x-bili-plugin-model", - "instructionsMutable": "x-bili-plugin-instructions-mutable" + "instructionsMutable": "x-bili-plugin-instructions-mutable", + "compression": "x-bili-plugin-compression" }, "toolEndpoint": "/__bili/plugin/tool", "statusEndpoint": "/__bili/plugin/status", diff --git a/tests/golden/wire-contract/manifest-optin.json b/tests/golden/wire-contract/manifest-optin.json index 5998bf5479..0e54b44279 100644 --- a/tests/golden/wire-contract/manifest-optin.json +++ b/tests/golden/wire-contract/manifest-optin.json @@ -544,7 +544,8 @@ "contextWindow": "x-bili-plugin-context-window", "maxOutput": "x-bili-plugin-max-output", "model": "x-bili-plugin-model", - "instructionsMutable": "x-bili-plugin-instructions-mutable" + "instructionsMutable": "x-bili-plugin-instructions-mutable", + "compression": "x-bili-plugin-compression" }, "toolEndpoint": "/__bili/plugin/tool", "statusEndpoint": "/__bili/plugin/status", From 44d69ef1f845f28f56c12c9db4e165cdf7ddf083 Mon Sep 17 00:00:00 2001 From: ci-check Date: Mon, 26 Oct 2026 22:38:18 +0800 Subject: [PATCH 3/4] docs: correct issue anchors #4657 to #4855 in compression-channel comments (#4855) All six comments added by this PR cite #4657, which is an unrelated closed PR (hermes proxy-misdetection fix, Fixes #4656). The anchor must point at the issue being fixed, #4855. Comment text otherwise untouched. --- hermes-plugin/__init__.py | 6 +++--- src/plugin.ts | 4 ++-- src/server/handle.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/hermes-plugin/__init__.py b/hermes-plugin/__init__.py index 270a602dba..f506170887 100644 --- a/hermes-plugin/__init__.py +++ b/hermes-plugin/__init__.py @@ -123,7 +123,7 @@ "stop_event": threading.Event(), "warned_direct": False, "snapshot_warned": False, - # #4657: header name the proxy advertised for the per-request compression-channel override + # #4855: header name the proxy advertised for the per-request compression-channel override # (manifest headers.compression). Empty/None on an older proxy — then a request whose tool # snapshot lacks the ACP tools stays unstamped, exactly as before. "compression_header": None, @@ -1463,7 +1463,7 @@ def _bootstrap(sidecar: Dict[str, str], ctx: Any, skip_register: bool = False) - else: manifest = http_get_json(origin.rstrip("/") + "/__bili/plugin/manifest", MANIFEST_TIMEOUT_S) tools = manifest.get("tools", {}).get("anthropic") if isinstance(manifest, dict) else None - # #4657: the proxy names the header that flips compression to its own channel for a + # #4855: the proxy names the header that flips compression to its own channel for a # request whose tool snapshot lacks the native tools. Absent on older proxies. declared = manifest.get("headers") if isinstance(manifest, dict) else None compression_header = declared.get("compression") if isinstance(declared, dict) else None @@ -1622,7 +1622,7 @@ def on_llm_request(request: Optional[Dict[str, Any]] = None, **context: Any) -> logger.info("billion-context: native tools are not in this session's tool list — " "compressing in proxy mode until a new session") return None - # #4657: Hermes defers every plugin toolset behind tool_search, so the tools exist but + # #4855: Hermes defers every plugin toolset behind tool_search, so the tools exist but # never reach the outgoing request. Keep the plugin identity + budget (that is what # /acp and the window header read) and hand compression to the proxy explicitly. if not _state.get("snapshot_warned"): diff --git a/src/plugin.ts b/src/plugin.ts index 4130647ce0..f22c315eee 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -113,7 +113,7 @@ const PLUGIN_BYPASS_HEADER = "x-bili-plugin-bypass"; const PLUGIN_CONTEXT_WINDOW_HEADER = "x-bili-plugin-context-window"; const PLUGIN_MAX_OUTPUT_HEADER = "x-bili-plugin-max-output"; const PLUGIN_MODEL_HEADER = "x-bili-plugin-model"; -/** #4657: per-request compression-channel override, only honored from an announced plugin +/** #4855: per-request compression-channel override, only honored from an announced plugin * (same gate as window/model above). A host that registers the ACP tools natively but keeps them * OUT of the outgoing request — Hermes defers every plugin toolset behind tool_search — still * needs its identity and budget bound to the plugin conversation. Stamping "proxy" says exactly @@ -262,7 +262,7 @@ export function pluginRequestAgentHeader(headers: Record): boolean { if (pluginAgentHeader(headers) === undefined) return false; diff --git a/src/server/handle.ts b/src/server/handle.ts index 58d7985fd4..86de22cfa8 100644 --- a/src/server/handle.ts +++ b/src/server/handle.ts @@ -1788,7 +1788,7 @@ async function handleRequest( log("info", `[${sessionId}] [pi-subagents] child "${subagentFallback.agent ?? "unknown"}" role allowlist lacks ACP context tools — serving proxy-style compression channel (#2268)`); } } - // #4657: a plugin whose host declines to put the ACP tools in the outgoing request (Hermes + // #4855: a plugin whose host declines to put the ACP tools in the outgoing request (Hermes // defers every plugin toolset behind tool_search) asks for the proxy channel explicitly. // Same demotion as the two signals above — identity/budget stay plugin-bound, compression // moves server-side — so a deferred toolset stops degenerating into anonymous proxy mode. From 75c8170524ebfb0038146881f383996104b9fc3e Mon Sep 17 00:00:00 2001 From: ci-check Date: Mon, 26 Oct 2026 22:38:18 +0800 Subject: [PATCH 4/4] docs: complete the PLUGIN.md manifest headers example (maxOutput/model/compression) (#4855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest example listed only agent/conversation/contextWindow/instructionsMutable — it already missed maxOutput and model before this PR, and now also misses headers.compression. Complete the example to match the real manifest and add one sentence documenting the optional per-request compression override semantics. --- PLUGIN.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/PLUGIN.md b/PLUGIN.md index d12ef260a1..1985e674c1 100644 --- a/PLUGIN.md +++ b/PLUGIN.md @@ -43,7 +43,10 @@ Fetch once at plugin startup. "agent": "x-bili-plugin", "conversation": "x-bili-plugin-conversation", "contextWindow": "x-bili-plugin-context-window", - "instructionsMutable": "x-bili-plugin-instructions-mutable" + "maxOutput": "x-bili-plugin-max-output", + "model": "x-bili-plugin-model", + "instructionsMutable": "x-bili-plugin-instructions-mutable", + "compression": "x-bili-plugin-compression" }, "toolEndpoint": "/__bili/plugin/tool", "statusEndpoint": "/__bili/plugin/status", @@ -53,7 +56,7 @@ Fetch once at plugin startup. } ``` -Register the tools natively with your agent, in whichever wire format your agent speaks — register exactly what `toolNames` lists. `absorb` appears only when the proxy's config enables it; `acp_rule` is advertised only when the proxy's config enables it (`compress.rules: true`, opt-in). Calling a tool that is known but disabled answers `200` with an explanatory `result` instead of an error (#1192). If `protocolVersion` is higher than you know, still register the tools — extra fields in schemas are ignored by agents. +Register the tools natively with your agent, in whichever wire format your agent speaks — register exactly what `toolNames` lists. `absorb` appears only when the proxy's config enables it; `acp_rule` is advertised only when the proxy's config enables it (`compress.rules: true`, opt-in). Calling a tool that is known but disabled answers `200` with an explanatory `result` instead of an error (#1192). If `protocolVersion` is higher than you know, still register the tools — extra fields in schemas are ignored by agents. `headers.compression` names an optional per-request override (#4855): when your host keeps the registered ACP tools out of the outgoing request (Hermes defers every plugin toolset behind `tool_search`), stamp `proxy` on that header alongside the identity headers — the proxy then keeps the session identity and model budget bound to your conversation but serves compression through its own wire channel (it injects the tools and executes compress server-side). Use it only when advertised; older proxies omit it, and the proxy honors it only from a request that already carries `headers.agent`. ### 2. Request headers