@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 +21 -0
- package/README.md +7 -0
- package/dist/hermes-acp-route-bridge/__pycache__/sitecustomize.cpython-314.pyc +0 -0
- package/dist/hermes-acp-route-bridge/sitecustomize.py +225 -0
- package/dist/hermes-acp-route-bridge.utils.d.ts +13 -0
- package/dist/hermes-acp-route-bridge.utils.js +59 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +32 -0
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 };
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
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
|
+
}
|