@nextclaw/nextclaw-hermes-acp-bridge 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NextClaw contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # NextClaw Hermes ACP Bridge
2
+
3
+ This package contains Hermes-specific ACP bridge helpers.
4
+
5
+ It is intentionally separate from the generic
6
+ `@nextclaw/nextclaw-ncp-runtime-stdio-client` package so the stdio runtime stays
7
+ protocol-generic while Hermes keeps its own integration layer.
@@ -0,0 +1,225 @@
1
+ """NextClaw bridge for Hermes ACP RuntimeRoute passthrough.
2
+
3
+ This module is auto-imported by Python when PYTHONPATH points at this
4
+ directory. It patches Hermes ACP so `hermes acp` consumes the active
5
+ NextClaw RuntimeRoute instead of resolving its own provider config first.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+ from typing import Any, Dict, Optional
14
+
15
+ LOGGER = logging.getLogger("nextclaw.hermes_acp_route_bridge")
16
+ ROUTE_ENABLE_ENV = "NEXTCLAW_HERMES_ACP_ROUTE_BRIDGE"
17
+ API_MODE_HEADER = "x-nextclaw-narp-api-mode"
18
+
19
+
20
+ def _read_text_env(name: str) -> Optional[str]:
21
+ value = os.environ.get(name)
22
+ if not isinstance(value, str):
23
+ return None
24
+ trimmed = value.strip()
25
+ return trimmed or None
26
+
27
+
28
+ def _read_headers_env() -> Dict[str, str]:
29
+ raw = _read_text_env("NEXTCLAW_HEADERS_JSON")
30
+ if not raw:
31
+ return {}
32
+ try:
33
+ parsed = json.loads(raw)
34
+ except Exception:
35
+ LOGGER.debug("Failed to parse NEXTCLAW_HEADERS_JSON", exc_info=True)
36
+ return {}
37
+ if not isinstance(parsed, dict):
38
+ return {}
39
+ normalized: Dict[str, str] = {}
40
+ for key, value in parsed.items():
41
+ if isinstance(key, str) and isinstance(value, str) and key.strip() and value.strip():
42
+ normalized[key] = value
43
+ return normalized
44
+
45
+
46
+ def _read_nextclaw_route(explicit_model: Optional[str] = None) -> Optional[Dict[str, Any]]:
47
+ model = explicit_model or _read_text_env("NEXTCLAW_MODEL")
48
+ api_base = _read_text_env("NEXTCLAW_API_BASE")
49
+ api_key = _read_text_env("NEXTCLAW_API_KEY")
50
+ headers = _read_headers_env()
51
+ api_mode = headers.pop(API_MODE_HEADER, None) or "chat_completions"
52
+ if not any([model, api_base, api_key, headers]):
53
+ return None
54
+ return {
55
+ "model": model,
56
+ "api_base": api_base,
57
+ "api_key": api_key or "",
58
+ "headers": headers,
59
+ "api_mode": api_mode,
60
+ }
61
+
62
+
63
+ def _merge_openai_headers(agent: Any, headers: Dict[str, str]) -> None:
64
+ if not headers:
65
+ return
66
+ client_kwargs = getattr(agent, "_client_kwargs", None)
67
+ if not isinstance(client_kwargs, dict):
68
+ return
69
+ merged = dict(client_kwargs.get("default_headers") or {})
70
+ merged.update(headers)
71
+ client_kwargs["default_headers"] = merged
72
+ replace_client = getattr(agent, "_replace_primary_openai_client", None)
73
+ if callable(replace_client):
74
+ replace_client(reason="nextclaw_runtime_route_bridge")
75
+
76
+
77
+ def _merge_anthropic_headers(agent: Any, headers: Dict[str, str]) -> None:
78
+ if not headers:
79
+ return
80
+ client = getattr(agent, "_anthropic_client", None)
81
+ if client is None:
82
+ return
83
+ merged = dict(getattr(client, "_default_headers", {}) or {})
84
+ merged.update(headers)
85
+ if hasattr(client, "_default_headers"):
86
+ client._default_headers = merged
87
+ inner_client = getattr(client, "_client", None)
88
+ if inner_client is not None:
89
+ if hasattr(inner_client, "_default_headers"):
90
+ inner_client._default_headers = merged
91
+ transport_headers = getattr(inner_client, "headers", None)
92
+ if hasattr(transport_headers, "update"):
93
+ transport_headers.update(merged)
94
+
95
+
96
+ def _patch_acp_auth() -> None:
97
+ try:
98
+ from acp_adapter import auth as auth_module
99
+ except Exception:
100
+ LOGGER.debug("Failed to import acp_adapter.auth", exc_info=True)
101
+ return
102
+
103
+ original_detect_provider = auth_module.detect_provider
104
+
105
+ def detect_provider():
106
+ route = _read_nextclaw_route()
107
+ if route is not None:
108
+ return "nextclaw"
109
+ return original_detect_provider()
110
+
111
+ def has_provider():
112
+ return detect_provider() is not None
113
+
114
+ auth_module.detect_provider = detect_provider
115
+ auth_module.has_provider = has_provider
116
+
117
+
118
+ def _patch_session_manager() -> None:
119
+ try:
120
+ from acp_adapter import session as session_module
121
+ except Exception:
122
+ LOGGER.debug("Failed to import acp_adapter.session", exc_info=True)
123
+ return
124
+
125
+ original_make_agent = session_module.SessionManager._make_agent
126
+
127
+ def bridged_make_agent(
128
+ self,
129
+ *,
130
+ session_id: str,
131
+ cwd: str,
132
+ model: str | None = None,
133
+ requested_provider: str | None = None,
134
+ base_url: str | None = None,
135
+ api_mode: str | None = None,
136
+ ):
137
+ route = _read_nextclaw_route(explicit_model=model)
138
+ if route is None:
139
+ return original_make_agent(
140
+ self,
141
+ session_id=session_id,
142
+ cwd=cwd,
143
+ model=model,
144
+ requested_provider=requested_provider,
145
+ base_url=base_url,
146
+ api_mode=api_mode,
147
+ )
148
+
149
+ if self._agent_factory is not None:
150
+ return self._agent_factory()
151
+
152
+ from run_agent import AIAgent
153
+
154
+ kwargs = {
155
+ "platform": "acp",
156
+ "enabled_toolsets": ["hermes-acp"],
157
+ "quiet_mode": True,
158
+ "session_id": session_id,
159
+ "model": route["model"] or "",
160
+ "provider": requested_provider or "custom",
161
+ "api_mode": api_mode or route["api_mode"],
162
+ "base_url": base_url or route["api_base"] or "",
163
+ "api_key": route["api_key"],
164
+ }
165
+
166
+ session_module._register_task_cwd(session_id, cwd)
167
+ agent = AIAgent(**kwargs)
168
+ agent._print_fn = session_module._acp_stderr_print
169
+ _merge_openai_headers(agent, route["headers"])
170
+ _merge_anthropic_headers(agent, route["headers"])
171
+ return agent
172
+
173
+ session_module.SessionManager._make_agent = bridged_make_agent
174
+
175
+
176
+ def _patch_acp_reasoning_mapping() -> None:
177
+ try:
178
+ from run_agent import AIAgent
179
+ except Exception:
180
+ LOGGER.debug("Failed to import run_agent.AIAgent", exc_info=True)
181
+ return
182
+
183
+ original_run_conversation = getattr(AIAgent, "run_conversation", None)
184
+ if not callable(original_run_conversation):
185
+ return
186
+ if getattr(original_run_conversation, "__nextclaw_reasoning_bridge__", False):
187
+ return
188
+
189
+ def bridged_run_conversation(self, *args, **kwargs):
190
+ original_thinking = getattr(self, "thinking_callback", None)
191
+ original_reasoning = getattr(self, "reasoning_callback", None)
192
+ remapped = (
193
+ getattr(self, "platform", None) == "acp"
194
+ and callable(original_thinking)
195
+ and original_reasoning is None
196
+ )
197
+
198
+ if remapped:
199
+ # Hermes ACP currently wires its transient spinner/status callback
200
+ # into ACP thought events. For ACP sessions, remap that callback to
201
+ # the real reasoning channel so downstream clients receive model
202
+ # reasoning instead of strings like "(⌐■_■) computing...".
203
+ self.reasoning_callback = original_thinking
204
+ self.thinking_callback = None
205
+
206
+ try:
207
+ return original_run_conversation(self, *args, **kwargs)
208
+ finally:
209
+ if remapped:
210
+ self.thinking_callback = original_thinking
211
+ self.reasoning_callback = original_reasoning
212
+
213
+ bridged_run_conversation.__nextclaw_reasoning_bridge__ = True
214
+ AIAgent.run_conversation = bridged_run_conversation
215
+
216
+
217
+ def _activate() -> None:
218
+ if _read_text_env(ROUTE_ENABLE_ENV) != "1":
219
+ return
220
+ _patch_acp_auth()
221
+ _patch_session_manager()
222
+ _patch_acp_reasoning_mapping()
223
+
224
+
225
+ _activate()
@@ -0,0 +1,13 @@
1
+ //#region src/hermes-acp-route-bridge.utils.d.ts
2
+ type HermesAcpLaunchTarget = {
3
+ command: string;
4
+ args: string[];
5
+ };
6
+ type HermesAcpBridgeLaunchEnvParams = HermesAcpLaunchTarget & {
7
+ baseEnv?: Record<string, string | undefined>;
8
+ useProbeRoute?: boolean;
9
+ };
10
+ declare function isHermesAcpRuntimeConfig(config: HermesAcpLaunchTarget): boolean;
11
+ declare function buildHermesAcpBridgeLaunchEnv(params: HermesAcpBridgeLaunchEnvParams): Record<string, string>;
12
+ //#endregion
13
+ export { buildHermesAcpBridgeLaunchEnv, isHermesAcpRuntimeConfig };
@@ -0,0 +1,59 @@
1
+ import { existsSync } from "node:fs";
2
+ import { basename, delimiter } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ //#region src/hermes-acp-route-bridge.utils.ts
5
+ const HERMES_ACP_BRIDGE_ENABLE_ENV = "NEXTCLAW_HERMES_ACP_ROUTE_BRIDGE";
6
+ const HERMES_ACP_BRIDGE_DIR_ENV = "NEXTCLAW_HERMES_ACP_ROUTE_BRIDGE_DIR";
7
+ const HERMES_ACP_PROBE_ROUTE_ENV = {
8
+ NEXTCLAW_MODEL: "nextclaw-hermes-acp-probe",
9
+ NEXTCLAW_API_BASE: "http://127.0.0.1:1/v1",
10
+ NEXTCLAW_API_KEY: "nextclaw-hermes-acp-probe-key",
11
+ NEXTCLAW_HEADERS_JSON: JSON.stringify({ "x-nextclaw-narp-api-mode": "chat_completions" })
12
+ };
13
+ function normalizeCommandBasename(command) {
14
+ return basename(command).trim().toLowerCase();
15
+ }
16
+ function normalizeStringEnv(source) {
17
+ const output = {};
18
+ for (const [key, value] of Object.entries(source)) if (typeof value === "string") output[key] = value;
19
+ return output;
20
+ }
21
+ function isPythonCommand(command) {
22
+ const normalized = normalizeCommandBasename(command);
23
+ return normalized === "python" || normalized === "python3" || normalized.startsWith("python3.");
24
+ }
25
+ function targetsHermesAcpModule(args) {
26
+ for (let index = 0; index < args.length - 1; index += 1) if (args[index] === "-m" && args[index + 1]?.trim().toLowerCase() === "acp_adapter.entry") return true;
27
+ return false;
28
+ }
29
+ function isHermesAcpRuntimeConfig(config) {
30
+ const command = normalizeCommandBasename(config.command);
31
+ if (command === "hermes-acp") return true;
32
+ if (command === "hermes") return config.args[0]?.trim().toLowerCase() === "acp";
33
+ if (isPythonCommand(config.command)) return targetsHermesAcpModule(config.args);
34
+ return false;
35
+ }
36
+ function resolveHermesAcpBridgeDir() {
37
+ const bridgeDir = fileURLToPath(new URL("./hermes-acp-route-bridge", import.meta.url));
38
+ return existsSync(bridgeDir) ? bridgeDir : null;
39
+ }
40
+ function mergePathList(existingValue, injectedPath) {
41
+ const parts = (existingValue ?? "").split(delimiter).map((entry) => entry.trim()).filter(Boolean);
42
+ if (!parts.includes(injectedPath)) parts.unshift(injectedPath);
43
+ return parts.join(delimiter);
44
+ }
45
+ function buildHermesAcpBridgeLaunchEnv(params) {
46
+ const env = normalizeStringEnv(params.baseEnv ?? process.env);
47
+ if (!isHermesAcpRuntimeConfig(params)) return env;
48
+ const bridgeDir = resolveHermesAcpBridgeDir();
49
+ if (!bridgeDir) return env;
50
+ return {
51
+ ...env,
52
+ ...params.useProbeRoute ? HERMES_ACP_PROBE_ROUTE_ENV : {},
53
+ [HERMES_ACP_BRIDGE_ENABLE_ENV]: "1",
54
+ [HERMES_ACP_BRIDGE_DIR_ENV]: bridgeDir,
55
+ PYTHONPATH: mergePathList(env.PYTHONPATH, bridgeDir)
56
+ };
57
+ }
58
+ //#endregion
59
+ export { buildHermesAcpBridgeLaunchEnv, isHermesAcpRuntimeConfig };
@@ -0,0 +1,2 @@
1
+ import { buildHermesAcpBridgeLaunchEnv, isHermesAcpRuntimeConfig } from "./hermes-acp-route-bridge.utils.js";
2
+ export { buildHermesAcpBridgeLaunchEnv, isHermesAcpRuntimeConfig };
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import { buildHermesAcpBridgeLaunchEnv, isHermesAcpRuntimeConfig } from "./hermes-acp-route-bridge.utils.js";
2
+ export { buildHermesAcpBridgeLaunchEnv, isHermesAcpRuntimeConfig };
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@nextclaw/nextclaw-hermes-acp-bridge",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "description": "Hermes-specific ACP bridge helpers used by NextClaw runtime integrations.",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "development": "./src/index.ts",
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "dependencies": {
19
+ "@nextclaw/ncp": "0.5.2"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^20.17.6",
23
+ "typescript": "^5.6.3",
24
+ "vitest": "^4.1.2"
25
+ },
26
+ "scripts": {
27
+ "build": "tsdown src/index.ts --dts --clean --target es2022 --no-fixedExtension --unbundle && node ./scripts/copy-hermes-acp-route-bridge.mjs",
28
+ "lint": "eslint .",
29
+ "tsc": "tsc -p tsconfig.json",
30
+ "test": "vitest run"
31
+ }
32
+ }