@nextclaw/nextclaw-hermes-acp-bridge 0.1.1 → 0.1.3
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/README.md +30 -0
- package/dist/hermes-acp-route-bridge/__pycache__/nextclaw-hermes-acp-runtime-route.cpython-312.pyc +0 -0
- package/dist/hermes-acp-route-bridge/__pycache__/nextclaw-hermes-acp-runtime-route.cpython-314.pyc +0 -0
- package/dist/hermes-acp-route-bridge/__pycache__/nextclaw-hermes-acp-session-snapshot.cpython-312.pyc +0 -0
- package/dist/hermes-acp-route-bridge/__pycache__/nextclaw-hermes-acp-session-snapshot.cpython-314.pyc +0 -0
- package/dist/hermes-acp-route-bridge/__pycache__/nextclaw_hermes_acp_runtime_route.cpython-314.pyc +0 -0
- package/dist/hermes-acp-route-bridge/__pycache__/sitecustomize.cpython-312.pyc +0 -0
- package/dist/hermes-acp-route-bridge/__pycache__/sitecustomize.cpython-313.pyc +0 -0
- package/dist/hermes-acp-route-bridge/__pycache__/sitecustomize.cpython-314.pyc +0 -0
- package/dist/hermes-acp-route-bridge/nextclaw-hermes-acp-runtime-route.py +186 -0
- package/dist/hermes-acp-route-bridge/sitecustomize.py +160 -69
- package/dist/hermes-acp-route-bridge.utils.js +6 -3
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -5,3 +5,33 @@ This package contains Hermes-specific ACP bridge helpers.
|
|
|
5
5
|
It is intentionally separate from the generic
|
|
6
6
|
`@nextclaw/nextclaw-ncp-runtime-stdio-client` package so the stdio runtime stays
|
|
7
7
|
protocol-generic while Hermes keeps its own integration layer.
|
|
8
|
+
|
|
9
|
+
## Boundary
|
|
10
|
+
|
|
11
|
+
- This package exists to adapt Hermes into NextClaw. It is the primary place
|
|
12
|
+
for NextClaw-side bridge logic, launch-time injection, and compatibility
|
|
13
|
+
handling around Hermes ACP integration.
|
|
14
|
+
- Do not directly modify Hermes upstream source code when fixing NextClaw
|
|
15
|
+
integration issues. In this repo, Hermes should be treated as an upstream
|
|
16
|
+
dependency boundary.
|
|
17
|
+
- If Hermes behavior needs to be adjusted for NextClaw, prefer one of these
|
|
18
|
+
layers instead:
|
|
19
|
+
- bridge logic inside `packages/nextclaw-hermes-acp-bridge`
|
|
20
|
+
- NextClaw runtime adapter logic
|
|
21
|
+
- startup environment injection / wrapper behavior
|
|
22
|
+
- repo-local documentation and guardrails
|
|
23
|
+
|
|
24
|
+
## Execution Model
|
|
25
|
+
|
|
26
|
+
- Hermes ACP execution in NextClaw is request-scoped, not session-agent-scoped.
|
|
27
|
+
- The long-lived ACP session keeps conversation history, cwd, selected model
|
|
28
|
+
snapshot, lightweight session metadata, and the current execution agent.
|
|
29
|
+
- Each prompt treats `nextclaw_narp.providerRoute` as execution truth. When the
|
|
30
|
+
prompt route changes, NextClaw rebuilds the Hermes execution agent for that
|
|
31
|
+
request and replaces the session's current execution agent with the new one.
|
|
32
|
+
- `providerRoute` is the execution truth for Hermes ACP requests. NextClaw does
|
|
33
|
+
not rely on ACP `setSessionModel(modelId)` to perform real cross-provider
|
|
34
|
+
switching for Hermes.
|
|
35
|
+
- Request-scoped execution agents must rebuild Hermes's cached system prompt
|
|
36
|
+
from the current route. They must not inherit a previous model/provider
|
|
37
|
+
prompt cache across cross-provider switches.
|
package/dist/hermes-acp-route-bridge/__pycache__/nextclaw-hermes-acp-runtime-route.cpython-312.pyc
ADDED
|
Binary file
|
package/dist/hermes-acp-route-bridge/__pycache__/nextclaw-hermes-acp-runtime-route.cpython-314.pyc
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/hermes-acp-route-bridge/__pycache__/nextclaw_hermes_acp_runtime_route.cpython-314.pyc
ADDED
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
LOGGER = logging.getLogger("nextclaw.hermes_acp_route_bridge")
|
|
9
|
+
API_MODE_HEADER = "x-nextclaw-narp-api-mode"
|
|
10
|
+
NARP_PROMPT_META_KEY = "nextclaw_narp"
|
|
11
|
+
_SESSION_ROUTE_OVERRIDES: Dict[str, Dict[str, Any]] = {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def read_text_env(name: str) -> Optional[str]:
|
|
15
|
+
value = os.environ.get(name)
|
|
16
|
+
if not isinstance(value, str):
|
|
17
|
+
return None
|
|
18
|
+
trimmed = value.strip()
|
|
19
|
+
return trimmed or None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _read_headers_env() -> Dict[str, str]:
|
|
23
|
+
raw = read_text_env("NEXTCLAW_HEADERS_JSON")
|
|
24
|
+
if not raw:
|
|
25
|
+
return {}
|
|
26
|
+
try:
|
|
27
|
+
parsed = json.loads(raw)
|
|
28
|
+
except Exception:
|
|
29
|
+
LOGGER.debug("Failed to parse NEXTCLAW_HEADERS_JSON", exc_info=True)
|
|
30
|
+
return {}
|
|
31
|
+
if not isinstance(parsed, dict):
|
|
32
|
+
return {}
|
|
33
|
+
normalized: Dict[str, str] = {}
|
|
34
|
+
for key, value in parsed.items():
|
|
35
|
+
if isinstance(key, str) and isinstance(value, str) and key.strip() and value.strip():
|
|
36
|
+
normalized[key] = value
|
|
37
|
+
return normalized
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _normalize_route_headers(raw_headers: Any) -> Dict[str, str]:
|
|
41
|
+
if not isinstance(raw_headers, dict):
|
|
42
|
+
return {}
|
|
43
|
+
normalized: Dict[str, str] = {}
|
|
44
|
+
for key, value in raw_headers.items():
|
|
45
|
+
if isinstance(key, str) and isinstance(value, str):
|
|
46
|
+
trimmed_key = key.strip()
|
|
47
|
+
trimmed_value = value.strip()
|
|
48
|
+
if trimmed_key and trimmed_value:
|
|
49
|
+
normalized[trimmed_key] = trimmed_value
|
|
50
|
+
return normalized
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _normalize_route_payload(raw_route: Any) -> Optional[Dict[str, Any]]:
|
|
54
|
+
if not isinstance(raw_route, dict):
|
|
55
|
+
return None
|
|
56
|
+
model = raw_route.get("model")
|
|
57
|
+
api_base = raw_route.get("apiBase")
|
|
58
|
+
if api_base is None:
|
|
59
|
+
api_base = raw_route.get("api_base")
|
|
60
|
+
api_key = raw_route.get("apiKey")
|
|
61
|
+
if api_key is None:
|
|
62
|
+
api_key = raw_route.get("api_key")
|
|
63
|
+
api_mode = raw_route.get("apiMode")
|
|
64
|
+
if api_mode is None:
|
|
65
|
+
api_mode = raw_route.get("api_mode")
|
|
66
|
+
provider = raw_route.get("provider")
|
|
67
|
+
headers = (
|
|
68
|
+
_normalize_route_headers(raw_route.get("headers"))
|
|
69
|
+
or _normalize_route_headers(raw_route.get("extraHeaders"))
|
|
70
|
+
or _normalize_route_headers(raw_route.get("extra_headers"))
|
|
71
|
+
)
|
|
72
|
+
api_mode_from_headers = headers.pop(API_MODE_HEADER, None)
|
|
73
|
+
|
|
74
|
+
normalized_model = model.strip() if isinstance(model, str) else None
|
|
75
|
+
normalized_api_base = api_base.strip() if isinstance(api_base, str) else None
|
|
76
|
+
normalized_api_key = api_key.strip() if isinstance(api_key, str) else None
|
|
77
|
+
normalized_api_mode = api_mode.strip() if isinstance(api_mode, str) else None
|
|
78
|
+
normalized_provider = provider.strip() if isinstance(provider, str) else None
|
|
79
|
+
|
|
80
|
+
if not any(
|
|
81
|
+
[
|
|
82
|
+
normalized_model,
|
|
83
|
+
normalized_api_base,
|
|
84
|
+
normalized_api_key,
|
|
85
|
+
headers,
|
|
86
|
+
normalized_api_mode,
|
|
87
|
+
normalized_provider,
|
|
88
|
+
]
|
|
89
|
+
):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
"model": normalized_model,
|
|
94
|
+
"api_base": normalized_api_base,
|
|
95
|
+
"api_key": normalized_api_key or "",
|
|
96
|
+
"headers": headers,
|
|
97
|
+
"api_mode": normalized_api_mode or api_mode_from_headers or "chat_completions",
|
|
98
|
+
"provider": normalized_provider,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _merge_route_payloads(
|
|
103
|
+
base_route: Optional[Dict[str, Any]],
|
|
104
|
+
override_route: Optional[Dict[str, Any]],
|
|
105
|
+
) -> Optional[Dict[str, Any]]:
|
|
106
|
+
if base_route is None:
|
|
107
|
+
return dict(override_route) if override_route is not None else None
|
|
108
|
+
if override_route is None:
|
|
109
|
+
return dict(base_route)
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
"model": override_route.get("model") or base_route.get("model"),
|
|
113
|
+
"api_base": override_route.get("api_base") or base_route.get("api_base"),
|
|
114
|
+
"api_key": override_route.get("api_key")
|
|
115
|
+
if override_route.get("api_key") is not None
|
|
116
|
+
else base_route.get("api_key"),
|
|
117
|
+
"headers": dict(override_route.get("headers") or {}),
|
|
118
|
+
"api_mode": override_route.get("api_mode") or base_route.get("api_mode"),
|
|
119
|
+
"provider": override_route.get("provider") or base_route.get("provider"),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def read_nextclaw_route(
|
|
124
|
+
session_id: Optional[str] = None,
|
|
125
|
+
explicit_model: Optional[str] = None,
|
|
126
|
+
explicit_base_url: Optional[str] = None,
|
|
127
|
+
explicit_api_mode: Optional[str] = None,
|
|
128
|
+
explicit_provider: Optional[str] = None,
|
|
129
|
+
) -> Optional[Dict[str, Any]]:
|
|
130
|
+
model = explicit_model or read_text_env("NEXTCLAW_MODEL")
|
|
131
|
+
api_base = read_text_env("NEXTCLAW_API_BASE")
|
|
132
|
+
api_key = read_text_env("NEXTCLAW_API_KEY")
|
|
133
|
+
headers = _read_headers_env()
|
|
134
|
+
api_mode = headers.pop(API_MODE_HEADER, None) or "chat_completions"
|
|
135
|
+
route = _normalize_route_payload(
|
|
136
|
+
{
|
|
137
|
+
"model": model,
|
|
138
|
+
"api_base": api_base,
|
|
139
|
+
"api_key": api_key,
|
|
140
|
+
"headers": headers,
|
|
141
|
+
"api_mode": explicit_api_mode or api_mode,
|
|
142
|
+
"provider": explicit_provider,
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
if session_id:
|
|
146
|
+
route = _merge_route_payloads(route, _SESSION_ROUTE_OVERRIDES.get(session_id))
|
|
147
|
+
if route is None:
|
|
148
|
+
return None
|
|
149
|
+
if explicit_model:
|
|
150
|
+
route["model"] = explicit_model
|
|
151
|
+
if explicit_base_url:
|
|
152
|
+
route["api_base"] = explicit_base_url
|
|
153
|
+
if explicit_api_mode:
|
|
154
|
+
route["api_mode"] = explicit_api_mode
|
|
155
|
+
if explicit_provider:
|
|
156
|
+
route["provider"] = explicit_provider
|
|
157
|
+
return route
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def read_nextclaw_prompt_route(kwargs: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
161
|
+
nextclaw_meta = kwargs.get(NARP_PROMPT_META_KEY)
|
|
162
|
+
if not isinstance(nextclaw_meta, dict):
|
|
163
|
+
meta = kwargs.get("_meta")
|
|
164
|
+
if not isinstance(meta, dict):
|
|
165
|
+
return None
|
|
166
|
+
nextclaw_meta = meta.get(NARP_PROMPT_META_KEY)
|
|
167
|
+
if not isinstance(nextclaw_meta, dict):
|
|
168
|
+
return None
|
|
169
|
+
return _normalize_route_payload(nextclaw_meta.get("providerRoute"))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def remember_session_route(session_id: str, route: Dict[str, Any]) -> None:
|
|
173
|
+
_SESSION_ROUTE_OVERRIDES[session_id] = dict(route)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def read_session_route(session_id: str) -> Optional[Dict[str, Any]]:
|
|
177
|
+
route = _SESSION_ROUTE_OVERRIDES.get(session_id)
|
|
178
|
+
return dict(route) if route is not None else None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def pop_session_route(session_id: str) -> None:
|
|
182
|
+
_SESSION_ROUTE_OVERRIDES.pop(session_id, None)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def clear_session_routes() -> None:
|
|
186
|
+
_SESSION_ROUTE_OVERRIDES.clear()
|
|
@@ -7,57 +7,29 @@ NextClaw RuntimeRoute instead of resolving its own provider config first.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
import
|
|
10
|
+
import importlib.util
|
|
11
11
|
import logging
|
|
12
|
-
import
|
|
13
|
-
from typing import Any, Dict
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict
|
|
14
14
|
|
|
15
15
|
LOGGER = logging.getLogger("nextclaw.hermes_acp_route_bridge")
|
|
16
16
|
ROUTE_ENABLE_ENV = "NEXTCLAW_HERMES_ACP_ROUTE_BRIDGE"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
}
|
|
17
|
+
CREATE_EXECUTION_AGENT = None
|
|
18
|
+
|
|
19
|
+
_ROUTE_HELPER_SPEC = importlib.util.spec_from_file_location(
|
|
20
|
+
"nextclaw_hermes_acp_runtime_route",
|
|
21
|
+
Path(__file__).with_name("nextclaw-hermes-acp-runtime-route.py"),
|
|
22
|
+
)
|
|
23
|
+
if _ROUTE_HELPER_SPEC is None or _ROUTE_HELPER_SPEC.loader is None:
|
|
24
|
+
raise ImportError("Failed to load nextclaw Hermes ACP runtime route helper.")
|
|
25
|
+
_ROUTE_HELPER_MODULE = importlib.util.module_from_spec(_ROUTE_HELPER_SPEC)
|
|
26
|
+
_ROUTE_HELPER_SPEC.loader.exec_module(_ROUTE_HELPER_MODULE)
|
|
27
|
+
clear_session_routes = _ROUTE_HELPER_MODULE.clear_session_routes
|
|
28
|
+
pop_session_route = _ROUTE_HELPER_MODULE.pop_session_route
|
|
29
|
+
read_nextclaw_prompt_route = _ROUTE_HELPER_MODULE.read_nextclaw_prompt_route
|
|
30
|
+
read_nextclaw_route = _ROUTE_HELPER_MODULE.read_nextclaw_route
|
|
31
|
+
read_text_env = _ROUTE_HELPER_MODULE.read_text_env
|
|
32
|
+
remember_session_route = _ROUTE_HELPER_MODULE.remember_session_route
|
|
61
33
|
|
|
62
34
|
|
|
63
35
|
def _merge_openai_headers(agent: Any, headers: Dict[str, str]) -> None:
|
|
@@ -103,7 +75,7 @@ def _patch_acp_auth() -> None:
|
|
|
103
75
|
original_detect_provider = auth_module.detect_provider
|
|
104
76
|
|
|
105
77
|
def detect_provider():
|
|
106
|
-
route =
|
|
78
|
+
route = read_nextclaw_route()
|
|
107
79
|
if route is not None:
|
|
108
80
|
return "nextclaw"
|
|
109
81
|
return original_detect_provider()
|
|
@@ -116,6 +88,7 @@ def _patch_acp_auth() -> None:
|
|
|
116
88
|
|
|
117
89
|
|
|
118
90
|
def _patch_session_manager() -> None:
|
|
91
|
+
global CREATE_EXECUTION_AGENT
|
|
119
92
|
try:
|
|
120
93
|
from acp_adapter import session as session_module
|
|
121
94
|
except Exception:
|
|
@@ -124,6 +97,56 @@ def _patch_session_manager() -> None:
|
|
|
124
97
|
|
|
125
98
|
original_make_agent = session_module.SessionManager._make_agent
|
|
126
99
|
|
|
100
|
+
def create_execution_agent(
|
|
101
|
+
*,
|
|
102
|
+
session_id: str,
|
|
103
|
+
cwd: str,
|
|
104
|
+
model: str | None,
|
|
105
|
+
requested_provider: str | None,
|
|
106
|
+
base_url: str | None,
|
|
107
|
+
api_mode: str | None,
|
|
108
|
+
):
|
|
109
|
+
route = read_nextclaw_route(
|
|
110
|
+
session_id=session_id,
|
|
111
|
+
explicit_model=model,
|
|
112
|
+
explicit_base_url=base_url,
|
|
113
|
+
explicit_api_mode=api_mode,
|
|
114
|
+
explicit_provider=requested_provider,
|
|
115
|
+
)
|
|
116
|
+
if route is None:
|
|
117
|
+
raise RuntimeError(
|
|
118
|
+
"Missing NextClaw providerRoute for Hermes ACP request-scoped execution."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
from run_agent import AIAgent
|
|
122
|
+
|
|
123
|
+
kwargs = {
|
|
124
|
+
"platform": "acp",
|
|
125
|
+
"enabled_toolsets": ["hermes-acp"],
|
|
126
|
+
"quiet_mode": True,
|
|
127
|
+
"session_id": session_id,
|
|
128
|
+
"model": route["model"] or "",
|
|
129
|
+
"provider": route.get("provider") or requested_provider or "custom",
|
|
130
|
+
"api_mode": route["api_mode"],
|
|
131
|
+
"base_url": route["api_base"] or "",
|
|
132
|
+
"api_key": route["api_key"],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
session_module._register_task_cwd(session_id, cwd)
|
|
136
|
+
agent = AIAgent(**kwargs)
|
|
137
|
+
agent._print_fn = session_module._acp_stderr_print
|
|
138
|
+
_merge_openai_headers(agent, route["headers"])
|
|
139
|
+
_merge_anthropic_headers(agent, route["headers"])
|
|
140
|
+
LOGGER.info(
|
|
141
|
+
"Hermes ACP execution agent route resolved: session_id=%s model=%s provider=%s api_mode=%s base_url=%s",
|
|
142
|
+
session_id,
|
|
143
|
+
getattr(agent, "model", ""),
|
|
144
|
+
getattr(agent, "provider", ""),
|
|
145
|
+
getattr(agent, "api_mode", ""),
|
|
146
|
+
getattr(agent, "base_url", ""),
|
|
147
|
+
)
|
|
148
|
+
return agent
|
|
149
|
+
|
|
127
150
|
def bridged_make_agent(
|
|
128
151
|
self,
|
|
129
152
|
*,
|
|
@@ -134,7 +157,13 @@ def _patch_session_manager() -> None:
|
|
|
134
157
|
base_url: str | None = None,
|
|
135
158
|
api_mode: str | None = None,
|
|
136
159
|
):
|
|
137
|
-
route =
|
|
160
|
+
route = read_nextclaw_route(
|
|
161
|
+
session_id=session_id,
|
|
162
|
+
explicit_model=model,
|
|
163
|
+
explicit_base_url=base_url,
|
|
164
|
+
explicit_api_mode=api_mode,
|
|
165
|
+
explicit_provider=requested_provider,
|
|
166
|
+
)
|
|
138
167
|
if route is None:
|
|
139
168
|
return original_make_agent(
|
|
140
169
|
self,
|
|
@@ -149,28 +178,89 @@ def _patch_session_manager() -> None:
|
|
|
149
178
|
if self._agent_factory is not None:
|
|
150
179
|
return self._agent_factory()
|
|
151
180
|
|
|
152
|
-
|
|
181
|
+
return create_execution_agent(
|
|
182
|
+
session_id=session_id,
|
|
183
|
+
cwd=cwd,
|
|
184
|
+
model=(route or {}).get("model") or model,
|
|
185
|
+
requested_provider=(route or {}).get("provider") or requested_provider,
|
|
186
|
+
base_url=(route or {}).get("api_base") or base_url,
|
|
187
|
+
api_mode=(route or {}).get("api_mode") or api_mode,
|
|
188
|
+
)
|
|
153
189
|
|
|
154
|
-
|
|
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
|
-
}
|
|
190
|
+
session_module.SessionManager._make_agent = bridged_make_agent
|
|
165
191
|
|
|
166
|
-
|
|
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
|
|
192
|
+
original_remove_session = session_module.SessionManager.remove_session
|
|
172
193
|
|
|
173
|
-
|
|
194
|
+
def bridged_remove_session(self, session_id: str) -> bool:
|
|
195
|
+
pop_session_route(session_id)
|
|
196
|
+
return original_remove_session(self, session_id)
|
|
197
|
+
|
|
198
|
+
session_module.SessionManager.remove_session = bridged_remove_session
|
|
199
|
+
|
|
200
|
+
original_cleanup = session_module.SessionManager.cleanup
|
|
201
|
+
|
|
202
|
+
def bridged_cleanup(self) -> None:
|
|
203
|
+
clear_session_routes()
|
|
204
|
+
return original_cleanup(self)
|
|
205
|
+
|
|
206
|
+
session_module.SessionManager.cleanup = bridged_cleanup
|
|
207
|
+
CREATE_EXECUTION_AGENT = create_execution_agent
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _patch_prompt_execution() -> None:
|
|
211
|
+
try:
|
|
212
|
+
from acp_adapter import server as server_module
|
|
213
|
+
from acp_adapter import session as session_module
|
|
214
|
+
except Exception:
|
|
215
|
+
LOGGER.debug("Failed to import Hermes ACP server/session modules", exc_info=True)
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
original_prompt = getattr(server_module.HermesACPAgent, "prompt", None)
|
|
219
|
+
if not callable(original_prompt):
|
|
220
|
+
return
|
|
221
|
+
if getattr(original_prompt, "__nextclaw_request_scoped_agent_bridge__", False):
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
async def bridged_prompt(self, prompt, session_id: str, **kwargs):
|
|
225
|
+
if not callable(CREATE_EXECUTION_AGENT):
|
|
226
|
+
raise RuntimeError("NextClaw Hermes ACP request-scoped agent bridge is not ready.")
|
|
227
|
+
state = self.session_manager.get_session(session_id)
|
|
228
|
+
if state is None:
|
|
229
|
+
return await original_prompt(self, prompt, session_id, **kwargs)
|
|
230
|
+
|
|
231
|
+
prompt_route = read_nextclaw_prompt_route(kwargs)
|
|
232
|
+
if prompt_route is not None:
|
|
233
|
+
remember_session_route(session_id, prompt_route)
|
|
234
|
+
if prompt_route.get("model"):
|
|
235
|
+
state.model = prompt_route["model"]
|
|
236
|
+
|
|
237
|
+
previous_agent = getattr(state, "agent", None)
|
|
238
|
+
provider_hint = None
|
|
239
|
+
base_url_hint = None
|
|
240
|
+
api_mode_hint = None
|
|
241
|
+
try:
|
|
242
|
+
state.agent = CREATE_EXECUTION_AGENT(
|
|
243
|
+
session_id=session_id,
|
|
244
|
+
cwd=state.cwd,
|
|
245
|
+
model=state.model or getattr(previous_agent, "model", None),
|
|
246
|
+
requested_provider=provider_hint,
|
|
247
|
+
base_url=base_url_hint,
|
|
248
|
+
api_mode=api_mode_hint,
|
|
249
|
+
)
|
|
250
|
+
session_module._register_task_cwd(session_id, state.cwd)
|
|
251
|
+
except Exception as exc:
|
|
252
|
+
LOGGER.exception(
|
|
253
|
+
"Failed to create prompt-routed Hermes execution agent for session %s",
|
|
254
|
+
session_id,
|
|
255
|
+
)
|
|
256
|
+
raise RuntimeError(
|
|
257
|
+
"NextClaw Hermes ACP failed to create the prompt-routed execution agent."
|
|
258
|
+
) from exc
|
|
259
|
+
|
|
260
|
+
return await original_prompt(self, prompt, session_id, **kwargs)
|
|
261
|
+
|
|
262
|
+
bridged_prompt.__nextclaw_request_scoped_agent_bridge__ = True
|
|
263
|
+
server_module.HermesACPAgent.prompt = bridged_prompt
|
|
174
264
|
|
|
175
265
|
|
|
176
266
|
def _patch_acp_reasoning_mapping() -> None:
|
|
@@ -215,10 +305,11 @@ def _patch_acp_reasoning_mapping() -> None:
|
|
|
215
305
|
|
|
216
306
|
|
|
217
307
|
def _activate() -> None:
|
|
218
|
-
if
|
|
308
|
+
if read_text_env(ROUTE_ENABLE_ENV) != "1":
|
|
219
309
|
return
|
|
220
310
|
_patch_acp_auth()
|
|
221
311
|
_patch_session_manager()
|
|
312
|
+
_patch_prompt_execution()
|
|
222
313
|
_patch_acp_reasoning_mapping()
|
|
223
314
|
|
|
224
315
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { basename, delimiter } from "node:path";
|
|
2
|
+
import { basename, delimiter, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
//#region src/hermes-acp-route-bridge.utils.ts
|
|
5
5
|
const HERMES_ACP_BRIDGE_ENABLE_ENV = "NEXTCLAW_HERMES_ACP_ROUTE_BRIDGE";
|
|
@@ -34,8 +34,11 @@ function isHermesAcpRuntimeConfig(config) {
|
|
|
34
34
|
return false;
|
|
35
35
|
}
|
|
36
36
|
function resolveHermesAcpBridgeDir() {
|
|
37
|
-
const
|
|
38
|
-
|
|
37
|
+
const packageDir = fileURLToPath(new URL("../", import.meta.url));
|
|
38
|
+
const sourceBridgeDir = resolve(packageDir, "src/hermes-acp-route-bridge");
|
|
39
|
+
if (existsSync(sourceBridgeDir)) return sourceBridgeDir;
|
|
40
|
+
const distBridgeDir = resolve(packageDir, "dist/hermes-acp-route-bridge");
|
|
41
|
+
return existsSync(distBridgeDir) ? distBridgeDir : null;
|
|
39
42
|
}
|
|
40
43
|
function mergePathList(existingValue, injectedPath) {
|
|
41
44
|
const parts = (existingValue ?? "").split(delimiter).map((entry) => entry.trim()).filter(Boolean);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nextclaw/nextclaw-hermes-acp-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Hermes-specific ACP bridge helpers used by NextClaw runtime integrations.",
|
|
6
6
|
"type": "module",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"README.md"
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@nextclaw/ncp": "0.5.
|
|
19
|
+
"@nextclaw/ncp": "0.5.4"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "^20.17.6",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
27
|
"build": "tsdown src/index.ts --dts --clean --target es2022 --no-fixedExtension --unbundle && node ./scripts/copy-hermes-acp-route-bridge.mjs",
|
|
28
|
+
"check:bridge-sync": "node ./scripts/check-hermes-acp-route-bridge-sync.mjs",
|
|
28
29
|
"lint": "eslint .",
|
|
29
30
|
"tsc": "tsc -p tsconfig.json",
|
|
30
31
|
"test": "vitest run"
|