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
7 changes: 5 additions & 2 deletions PLUGIN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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

Expand Down
32 changes: 29 additions & 3 deletions hermes-plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@
"stop_event": threading.Event(),
"warned_direct": False,
"snapshot_warned": False,
# #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,
# #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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
# #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
_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 "
Expand Down Expand Up @@ -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
# #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"):
_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)
Expand Down
18 changes: 17 additions & 1 deletion src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
/** #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
* 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
Expand Down Expand Up @@ -253,6 +262,13 @@ export function pluginRequestAgentHeader(headers: Record<string, string | string
return headerValue(headers, PLUGIN_REQUEST_AGENT_HEADER);
}

/** #4855: the plugin asks the proxy to serve compression on this request (identity + budget stay
* plugin-bound). Gated on the agent marker so a plain client cannot flip the channel by name. */
export function pluginCompressionProxyHeader(headers: Record<string, string | string[] | undefined>): 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
Expand Down Expand Up @@ -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",
Expand Down
14 changes: 12 additions & 2 deletions src/server/handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
// #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.
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
Expand Down
3 changes: 2 additions & 1 deletion tests/golden/wire-contract/manifest-default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion tests/golden/wire-contract/manifest-optin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 50 additions & 1 deletion tests/hermes-native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -1108,6 +1134,10 @@ interface DriverOut {
stale_headers?: Record<string, string> | null;
no_tools_none?: boolean;
partial_tools_none?: boolean;
defer_none?: boolean;
defer_headers?: Record<string, string> | null;
partial_headers?: Record<string, string> | null;
visible_headers?: Record<string, string> | null;
anthropic_headers?: Record<string, string> | null;
register_elapsed?: number;
state_after_register?: string;
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading