@nextclaw/nextclaw-ncp-runtime-stdio-client 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 +9 -0
- package/dist/hermes-acp-route-bridge/__pycache__/sitecustomize.cpython-312.pyc +0 -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.js +61 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/stdio-runtime-config.utils.d.ts +36 -0
- package/dist/stdio-runtime-config.utils.js +102 -0
- package/dist/stdio-runtime-probe.utils.d.ts +6 -0
- package/dist/stdio-runtime-probe.utils.js +69 -0
- package/dist/stdio-runtime.service.d.ts +18 -0
- package/dist/stdio-runtime.service.js +603 -0
- package/package.json +33 -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,9 @@
|
|
|
1
|
+
# @nextclaw/nextclaw-ncp-runtime-stdio-client
|
|
2
|
+
|
|
3
|
+
Generic `NcpAgentRuntime` package for external agent connectors that speak ACP over stdio.
|
|
4
|
+
|
|
5
|
+
It is intentionally generic:
|
|
6
|
+
|
|
7
|
+
- no Hermes-specific protocol logic
|
|
8
|
+
- no runtime type registration logic
|
|
9
|
+
- no UI config concerns
|
|
@@ -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,61 @@
|
|
|
1
|
+
import { buildRuntimeRouteBridgeEnv } from "./stdio-runtime-config.utils.js";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { basename, delimiter } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
//#region src/hermes-acp-route-bridge.utils.ts
|
|
6
|
+
const HERMES_ACP_BRIDGE_ENABLE_ENV = "NEXTCLAW_HERMES_ACP_ROUTE_BRIDGE";
|
|
7
|
+
const HERMES_ACP_BRIDGE_DIR_ENV = "NEXTCLAW_HERMES_ACP_ROUTE_BRIDGE_DIR";
|
|
8
|
+
const HERMES_ACP_PROBE_ROUTE = {
|
|
9
|
+
model: "nextclaw-hermes-acp-probe",
|
|
10
|
+
apiBase: "http://127.0.0.1:1/v1",
|
|
11
|
+
apiKey: "nextclaw-hermes-acp-probe-key",
|
|
12
|
+
headers: { "x-nextclaw-narp-api-mode": "chat_completions" }
|
|
13
|
+
};
|
|
14
|
+
function normalizeCommandBasename(command) {
|
|
15
|
+
return basename(command).trim().toLowerCase();
|
|
16
|
+
}
|
|
17
|
+
function isPythonCommand(command) {
|
|
18
|
+
const normalized = normalizeCommandBasename(command);
|
|
19
|
+
return normalized === "python" || normalized === "python3" || normalized.startsWith("python3.");
|
|
20
|
+
}
|
|
21
|
+
function targetsHermesAcpModule(args) {
|
|
22
|
+
for (let index = 0; index < args.length - 1; index += 1) if (args[index] === "-m" && args[index + 1]?.trim().toLowerCase() === "acp_adapter.entry") return true;
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
function isHermesAcpRuntimeConfig(config) {
|
|
26
|
+
const command = normalizeCommandBasename(config.command);
|
|
27
|
+
if (command === "hermes-acp") return true;
|
|
28
|
+
if (command === "hermes") return config.args[0]?.trim().toLowerCase() === "acp";
|
|
29
|
+
if (isPythonCommand(config.command)) return targetsHermesAcpModule(config.args);
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
function resolveHermesAcpBridgeDir() {
|
|
33
|
+
const bridgeDir = fileURLToPath(new URL("./hermes-acp-route-bridge", import.meta.url));
|
|
34
|
+
return existsSync(bridgeDir) ? bridgeDir : null;
|
|
35
|
+
}
|
|
36
|
+
function mergePathList(existingValue, injectedPath) {
|
|
37
|
+
const parts = (existingValue ?? "").split(delimiter).map((entry) => entry.trim()).filter(Boolean);
|
|
38
|
+
if (!parts.includes(injectedPath)) parts.unshift(injectedPath);
|
|
39
|
+
return parts.join(delimiter);
|
|
40
|
+
}
|
|
41
|
+
function buildStdioLaunchEnv(params) {
|
|
42
|
+
const providerRoute = params.providerRoute ?? (params.useProbeRoute && isHermesAcpRuntimeConfig(params.config) ? HERMES_ACP_PROBE_ROUTE : void 0);
|
|
43
|
+
const baseEnv = {};
|
|
44
|
+
for (const [key, value] of Object.entries(params.baseEnv ?? process.env)) if (typeof value === "string") baseEnv[key] = value;
|
|
45
|
+
const env = {
|
|
46
|
+
...baseEnv,
|
|
47
|
+
...params.config.env ?? {},
|
|
48
|
+
...buildRuntimeRouteBridgeEnv({ providerRoute })
|
|
49
|
+
};
|
|
50
|
+
if (!isHermesAcpRuntimeConfig(params.config)) return env;
|
|
51
|
+
const bridgeDir = resolveHermesAcpBridgeDir();
|
|
52
|
+
if (!bridgeDir) return env;
|
|
53
|
+
return {
|
|
54
|
+
...env,
|
|
55
|
+
[HERMES_ACP_BRIDGE_ENABLE_ENV]: "1",
|
|
56
|
+
[HERMES_ACP_BRIDGE_DIR_ENV]: bridgeDir,
|
|
57
|
+
PYTHONPATH: mergePathList(env.PYTHONPATH, bridgeDir)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
export { buildStdioLaunchEnv };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { NARP_STDIO_PROMPT_META_KEY, NarpStdioPromptMeta, StdioRuntimeConfigResolver, StdioRuntimeEnv, StdioRuntimeProcessScope, StdioRuntimeResolvedConfig, StdioRuntimeWireDialect, buildRuntimeRouteBridgeEnv } from "./stdio-runtime-config.utils.js";
|
|
2
|
+
import { StdioRuntimeNcpAgentRuntime, StdioRuntimeNcpAgentRuntimeConfig } from "./stdio-runtime.service.js";
|
|
3
|
+
import { probeStdioRuntime } from "./stdio-runtime-probe.utils.js";
|
|
4
|
+
export { NARP_STDIO_PROMPT_META_KEY, type NarpStdioPromptMeta, StdioRuntimeConfigResolver, type StdioRuntimeEnv, StdioRuntimeNcpAgentRuntime, type StdioRuntimeNcpAgentRuntimeConfig, type StdioRuntimeProcessScope, type StdioRuntimeResolvedConfig, type StdioRuntimeWireDialect, buildRuntimeRouteBridgeEnv, probeStdioRuntime };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { NARP_STDIO_PROMPT_META_KEY, StdioRuntimeConfigResolver, buildRuntimeRouteBridgeEnv } from "./stdio-runtime-config.utils.js";
|
|
2
|
+
import { StdioRuntimeNcpAgentRuntime } from "./stdio-runtime.service.js";
|
|
3
|
+
import { probeStdioRuntime } from "./stdio-runtime-probe.utils.js";
|
|
4
|
+
export { NARP_STDIO_PROMPT_META_KEY, StdioRuntimeConfigResolver, StdioRuntimeNcpAgentRuntime, buildRuntimeRouteBridgeEnv, probeStdioRuntime };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { NcpProviderRuntimeRoute, OpenAITool } from "@nextclaw/ncp";
|
|
2
|
+
|
|
3
|
+
//#region src/stdio-runtime-config.utils.d.ts
|
|
4
|
+
declare const NARP_STDIO_PROMPT_META_KEY = "nextclaw_narp";
|
|
5
|
+
type StdioRuntimeEnv = Record<string, string>;
|
|
6
|
+
type StdioRuntimeWireDialect = "acp";
|
|
7
|
+
type StdioRuntimeProcessScope = "per-session";
|
|
8
|
+
type NarpStdioPromptMeta = {
|
|
9
|
+
correlationId?: string;
|
|
10
|
+
providerRoute?: NcpProviderRuntimeRoute;
|
|
11
|
+
sessionMetadata?: Record<string, unknown>;
|
|
12
|
+
tools?: ReadonlyArray<OpenAITool>;
|
|
13
|
+
};
|
|
14
|
+
type StdioRuntimeResolvedConfig = {
|
|
15
|
+
wireDialect: StdioRuntimeWireDialect;
|
|
16
|
+
processScope: StdioRuntimeProcessScope;
|
|
17
|
+
command: string;
|
|
18
|
+
args: string[];
|
|
19
|
+
cwd?: string;
|
|
20
|
+
env?: StdioRuntimeEnv;
|
|
21
|
+
startupTimeoutMs: number;
|
|
22
|
+
probeTimeoutMs: number;
|
|
23
|
+
requestTimeoutMs: number;
|
|
24
|
+
};
|
|
25
|
+
declare class StdioRuntimeConfigResolver {
|
|
26
|
+
private readonly source;
|
|
27
|
+
constructor(source?: Record<string, unknown>);
|
|
28
|
+
resolve: () => StdioRuntimeResolvedConfig;
|
|
29
|
+
private resolveWireDialect;
|
|
30
|
+
private resolveProcessScope;
|
|
31
|
+
}
|
|
32
|
+
declare function buildRuntimeRouteBridgeEnv(params: {
|
|
33
|
+
providerRoute?: NcpProviderRuntimeRoute;
|
|
34
|
+
}): Record<string, string>;
|
|
35
|
+
//#endregion
|
|
36
|
+
export { NARP_STDIO_PROMPT_META_KEY, NarpStdioPromptMeta, StdioRuntimeConfigResolver, StdioRuntimeEnv, StdioRuntimeProcessScope, StdioRuntimeResolvedConfig, StdioRuntimeWireDialect, buildRuntimeRouteBridgeEnv };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
//#region src/stdio-runtime-config.utils.ts
|
|
2
|
+
const NARP_STDIO_PROMPT_META_KEY = "nextclaw_narp";
|
|
3
|
+
const DEFAULT_STARTUP_TIMEOUT_MS = 1e4;
|
|
4
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 3e3;
|
|
5
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
|
|
6
|
+
const DEFAULT_RUNTIME_ROUTE_BRIDGE_FIELDS = {
|
|
7
|
+
model: "NEXTCLAW_MODEL",
|
|
8
|
+
apiBase: "NEXTCLAW_API_BASE",
|
|
9
|
+
apiKey: "NEXTCLAW_API_KEY",
|
|
10
|
+
headers: "NEXTCLAW_HEADERS_JSON"
|
|
11
|
+
};
|
|
12
|
+
function readString(value) {
|
|
13
|
+
if (typeof value !== "string") return;
|
|
14
|
+
const trimmed = value.trim();
|
|
15
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
16
|
+
}
|
|
17
|
+
function readPositiveInteger(value) {
|
|
18
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : NaN;
|
|
19
|
+
if (!Number.isFinite(parsed)) return;
|
|
20
|
+
const normalized = Math.trunc(parsed);
|
|
21
|
+
return normalized > 0 ? normalized : void 0;
|
|
22
|
+
}
|
|
23
|
+
function readStringArray(value) {
|
|
24
|
+
if (!Array.isArray(value)) return;
|
|
25
|
+
const output = [];
|
|
26
|
+
for (const entry of value) {
|
|
27
|
+
const normalized = readString(entry);
|
|
28
|
+
if (!normalized) continue;
|
|
29
|
+
output.push(normalized);
|
|
30
|
+
}
|
|
31
|
+
return output.length > 0 ? output : void 0;
|
|
32
|
+
}
|
|
33
|
+
function readStringRecord(value) {
|
|
34
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
35
|
+
const output = {};
|
|
36
|
+
for (const [entryKey, entryValue] of Object.entries(value)) {
|
|
37
|
+
const normalizedValue = readString(entryValue);
|
|
38
|
+
if (!normalizedValue) continue;
|
|
39
|
+
output[entryKey] = normalizedValue;
|
|
40
|
+
}
|
|
41
|
+
return Object.keys(output).length > 0 ? output : void 0;
|
|
42
|
+
}
|
|
43
|
+
var StdioRuntimeConfigResolver = class {
|
|
44
|
+
constructor(source = {}) {
|
|
45
|
+
this.source = source;
|
|
46
|
+
}
|
|
47
|
+
resolve = () => {
|
|
48
|
+
const wireDialect = this.resolveWireDialect();
|
|
49
|
+
const processScope = this.resolveProcessScope();
|
|
50
|
+
const command = readString(this.source.command) ?? readString(process.env.NEXTCLAW_NARP_STDIO_COMMAND);
|
|
51
|
+
if (!command) throw new Error("[narp-stdio] missing stdio command");
|
|
52
|
+
return {
|
|
53
|
+
wireDialect,
|
|
54
|
+
processScope,
|
|
55
|
+
command,
|
|
56
|
+
args: readStringArray(this.source.args) ?? readStringArray(parseJsonArray(process.env.NEXTCLAW_NARP_STDIO_ARGS)) ?? [],
|
|
57
|
+
...readString(this.source.cwd) ?? readString(process.env.NEXTCLAW_NARP_STDIO_CWD) ? { cwd: readString(this.source.cwd) ?? readString(process.env.NEXTCLAW_NARP_STDIO_CWD) } : {},
|
|
58
|
+
...readStringRecord(this.source.env) ?? readStringRecord(parseJsonObject(process.env.NEXTCLAW_NARP_STDIO_ENV)) ? { env: readStringRecord(this.source.env) ?? readStringRecord(parseJsonObject(process.env.NEXTCLAW_NARP_STDIO_ENV)) } : {},
|
|
59
|
+
startupTimeoutMs: readPositiveInteger(this.source.startupTimeoutMs) ?? readPositiveInteger(process.env.NEXTCLAW_NARP_STDIO_STARTUP_TIMEOUT_MS) ?? DEFAULT_STARTUP_TIMEOUT_MS,
|
|
60
|
+
probeTimeoutMs: readPositiveInteger(this.source.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS,
|
|
61
|
+
requestTimeoutMs: readPositiveInteger(this.source.requestTimeoutMs) ?? DEFAULT_REQUEST_TIMEOUT_MS
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
resolveWireDialect = () => {
|
|
65
|
+
const wireDialect = readString(this.source.wireDialect) ?? "acp";
|
|
66
|
+
if (wireDialect !== "acp") throw new Error(`[narp-stdio] unsupported wireDialect "${wireDialect}"`);
|
|
67
|
+
return "acp";
|
|
68
|
+
};
|
|
69
|
+
resolveProcessScope = () => {
|
|
70
|
+
const processScope = readString(this.source.processScope) ?? "per-session";
|
|
71
|
+
if (processScope !== "per-session") throw new Error(`[narp-stdio] unsupported processScope "${processScope}"`);
|
|
72
|
+
return "per-session";
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
function buildRuntimeRouteBridgeEnv(params) {
|
|
76
|
+
const { providerRoute } = params;
|
|
77
|
+
if (!providerRoute) return {};
|
|
78
|
+
return {
|
|
79
|
+
[DEFAULT_RUNTIME_ROUTE_BRIDGE_FIELDS.model]: providerRoute.model,
|
|
80
|
+
[DEFAULT_RUNTIME_ROUTE_BRIDGE_FIELDS.apiBase]: providerRoute.apiBase ?? "",
|
|
81
|
+
[DEFAULT_RUNTIME_ROUTE_BRIDGE_FIELDS.apiKey]: providerRoute.apiKey ?? "",
|
|
82
|
+
[DEFAULT_RUNTIME_ROUTE_BRIDGE_FIELDS.headers]: JSON.stringify(providerRoute.headers ?? {})
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function parseJsonArray(value) {
|
|
86
|
+
if (typeof value !== "string" || !value.trim()) return;
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(value);
|
|
89
|
+
} catch {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function parseJsonObject(value) {
|
|
94
|
+
if (typeof value !== "string" || !value.trim()) return;
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(value);
|
|
97
|
+
} catch {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
export { NARP_STDIO_PROMPT_META_KEY, StdioRuntimeConfigResolver, buildRuntimeRouteBridgeEnv, readString };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { buildStdioLaunchEnv } from "./hermes-acp-route-bridge.utils.js";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { Readable, Writable } from "node:stream";
|
|
4
|
+
import * as acp from "@agentclientprotocol/sdk";
|
|
5
|
+
//#region src/stdio-runtime-probe.utils.ts
|
|
6
|
+
const createProbeClientBridge = () => ({
|
|
7
|
+
sessionUpdate: async () => void 0,
|
|
8
|
+
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
|
9
|
+
readTextFile: async () => ({ content: "" }),
|
|
10
|
+
writeTextFile: async () => ({})
|
|
11
|
+
});
|
|
12
|
+
async function probeStdioRuntime(config) {
|
|
13
|
+
const child = spawn(config.command, config.args, {
|
|
14
|
+
cwd: config.cwd,
|
|
15
|
+
env: buildStdioLaunchEnv({
|
|
16
|
+
config,
|
|
17
|
+
useProbeRoute: true
|
|
18
|
+
}),
|
|
19
|
+
stdio: [
|
|
20
|
+
"pipe",
|
|
21
|
+
"pipe",
|
|
22
|
+
"pipe"
|
|
23
|
+
]
|
|
24
|
+
});
|
|
25
|
+
const spawnErrorPromise = new Promise((_, reject) => {
|
|
26
|
+
child.once("error", (error) => {
|
|
27
|
+
const cwdSuffix = config.cwd ? ` (cwd: ${config.cwd})` : "";
|
|
28
|
+
reject(/* @__PURE__ */ new Error(`[narp-stdio] failed to start stdio runtime command "${config.command}"${cwdSuffix}: ${error.message}`));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
let stderr = "";
|
|
32
|
+
child.stderr.setEncoding("utf8");
|
|
33
|
+
child.stderr.on("data", (chunk) => {
|
|
34
|
+
stderr = `${stderr}${chunk}`.slice(-4e3);
|
|
35
|
+
});
|
|
36
|
+
const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
|
|
37
|
+
const connection = new acp.ClientSideConnection(() => createProbeClientBridge(), stream);
|
|
38
|
+
try {
|
|
39
|
+
const session = await Promise.race([(async () => {
|
|
40
|
+
await withTimeout(connection.initialize({
|
|
41
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
42
|
+
clientCapabilities: {}
|
|
43
|
+
}), config.probeTimeoutMs, `[narp-stdio] probe timed out initializing stdio runtime`);
|
|
44
|
+
return withTimeout(connection.newSession({
|
|
45
|
+
cwd: config.cwd ?? process.cwd(),
|
|
46
|
+
mcpServers: []
|
|
47
|
+
}), config.probeTimeoutMs, `[narp-stdio] probe timed out creating remote session`);
|
|
48
|
+
})(), spawnErrorPromise]);
|
|
49
|
+
await connection.cancel({ sessionId: session.sessionId }).catch(() => void 0);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
const message = error instanceof Error ? error.message : "[narp-stdio] stdio runtime probe failed";
|
|
52
|
+
throw new Error(`${message}. stderr=${stderr}`.trim());
|
|
53
|
+
} finally {
|
|
54
|
+
child.kill("SIGTERM");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function withTimeout(promise, timeoutMs, message) {
|
|
58
|
+
let timeoutHandle;
|
|
59
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
60
|
+
timeoutHandle = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
64
|
+
} finally {
|
|
65
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
export { probeStdioRuntime };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { StdioRuntimeResolvedConfig } from "./stdio-runtime-config.utils.js";
|
|
2
|
+
import { NcpAgentConversationStateManager, NcpAgentRunInput, NcpAgentRunOptions, NcpAgentRuntime, NcpEndpointEvent, NcpProviderRuntimeRoute, OpenAITool } from "@nextclaw/ncp";
|
|
3
|
+
|
|
4
|
+
//#region src/stdio-runtime.service.d.ts
|
|
5
|
+
type StdioRuntimeNcpAgentRuntimeConfig = StdioRuntimeResolvedConfig & {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
stateManager?: NcpAgentConversationStateManager;
|
|
8
|
+
resolveTools?: (input: NcpAgentRunInput) => ReadonlyArray<OpenAITool> | undefined;
|
|
9
|
+
resolveProviderRoute?: (input: NcpAgentRunInput) => NcpProviderRuntimeRoute | undefined;
|
|
10
|
+
};
|
|
11
|
+
declare class StdioRuntimeNcpAgentRuntime implements NcpAgentRuntime {
|
|
12
|
+
private readonly config;
|
|
13
|
+
private readonly session;
|
|
14
|
+
constructor(config: StdioRuntimeNcpAgentRuntimeConfig);
|
|
15
|
+
run: (this: StdioRuntimeNcpAgentRuntime, input: NcpAgentRunInput, options?: NcpAgentRunOptions) => AsyncGenerator<NcpEndpointEvent>;
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
export { StdioRuntimeNcpAgentRuntime, StdioRuntimeNcpAgentRuntimeConfig };
|
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import { NARP_STDIO_PROMPT_META_KEY, readString } from "./stdio-runtime-config.utils.js";
|
|
2
|
+
import { buildStdioLaunchEnv } from "./hermes-acp-route-bridge.utils.js";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { Readable, Writable } from "node:stream";
|
|
6
|
+
import * as acp from "@agentclientprotocol/sdk";
|
|
7
|
+
import { NcpEventType } from "@nextclaw/ncp";
|
|
8
|
+
//#region src/stdio-runtime.service.ts
|
|
9
|
+
var UpdateBuffer = class {
|
|
10
|
+
updates = [];
|
|
11
|
+
waiters = /* @__PURE__ */ new Set();
|
|
12
|
+
push = (update) => {
|
|
13
|
+
this.updates.push(update);
|
|
14
|
+
this.flush();
|
|
15
|
+
};
|
|
16
|
+
shift = () => this.updates.shift();
|
|
17
|
+
hasItems = () => this.updates.length > 0;
|
|
18
|
+
waitForChange = async () => {
|
|
19
|
+
if (this.updates.length > 0) return;
|
|
20
|
+
await new Promise((resolve) => {
|
|
21
|
+
this.waiters.add(resolve);
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
notify = () => {
|
|
25
|
+
this.flush();
|
|
26
|
+
};
|
|
27
|
+
flush = () => {
|
|
28
|
+
if (this.waiters.size === 0) return;
|
|
29
|
+
const waiters = [...this.waiters];
|
|
30
|
+
this.waiters.clear();
|
|
31
|
+
for (const waiter of waiters) waiter();
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
var PromptUpdateCollector = class {
|
|
35
|
+
parts = [];
|
|
36
|
+
toolIndex = /* @__PURE__ */ new Map();
|
|
37
|
+
apply = (event) => {
|
|
38
|
+
switch (event.type) {
|
|
39
|
+
case NcpEventType.MessageTextDelta:
|
|
40
|
+
this.appendPart("text", event.payload.delta);
|
|
41
|
+
return;
|
|
42
|
+
case NcpEventType.MessageReasoningDelta:
|
|
43
|
+
this.appendPart("reasoning", event.payload.delta);
|
|
44
|
+
return;
|
|
45
|
+
case NcpEventType.MessageToolCallStart:
|
|
46
|
+
this.upsertTool({
|
|
47
|
+
toolCallId: event.payload.toolCallId,
|
|
48
|
+
toolName: event.payload.toolName,
|
|
49
|
+
args: "",
|
|
50
|
+
state: "partial-call"
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
case NcpEventType.MessageToolCallArgs:
|
|
54
|
+
this.upsertTool({
|
|
55
|
+
toolCallId: event.payload.toolCallId,
|
|
56
|
+
toolName: this.readToolName(event.payload.toolCallId),
|
|
57
|
+
args: event.payload.args,
|
|
58
|
+
state: "partial-call"
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
case NcpEventType.MessageToolCallArgsDelta:
|
|
62
|
+
this.upsertTool({
|
|
63
|
+
toolCallId: event.payload.toolCallId,
|
|
64
|
+
toolName: this.readToolName(event.payload.toolCallId),
|
|
65
|
+
args: `${this.readToolArgs(event.payload.toolCallId)}${event.payload.delta}`,
|
|
66
|
+
state: "partial-call"
|
|
67
|
+
});
|
|
68
|
+
return;
|
|
69
|
+
case NcpEventType.MessageToolCallEnd:
|
|
70
|
+
this.upsertTool({
|
|
71
|
+
toolCallId: event.payload.toolCallId,
|
|
72
|
+
toolName: this.readToolName(event.payload.toolCallId),
|
|
73
|
+
args: this.readToolArgs(event.payload.toolCallId),
|
|
74
|
+
state: "call"
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
default: return;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
hasParts = () => this.parts.length > 0;
|
|
81
|
+
buildParts = () => structuredClone(this.parts);
|
|
82
|
+
appendPart = (type, text) => {
|
|
83
|
+
if (!text) return;
|
|
84
|
+
const last = this.parts.at(-1);
|
|
85
|
+
if (last?.type === type) {
|
|
86
|
+
last.text = `${last.text}${text}`;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
this.parts.push({
|
|
90
|
+
type,
|
|
91
|
+
text
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
upsertTool = (nextTool) => {
|
|
95
|
+
const existingIndex = this.toolIndex.get(nextTool.toolCallId);
|
|
96
|
+
if (typeof existingIndex === "number") {
|
|
97
|
+
const existing = this.parts[existingIndex];
|
|
98
|
+
if (existing?.type !== "tool-invocation") return;
|
|
99
|
+
this.parts[existingIndex] = {
|
|
100
|
+
...existing,
|
|
101
|
+
...nextTool
|
|
102
|
+
};
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.toolIndex.set(nextTool.toolCallId, this.parts.length);
|
|
106
|
+
this.parts.push({
|
|
107
|
+
type: "tool-invocation",
|
|
108
|
+
...nextTool
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
readToolArgs = (toolCallId) => {
|
|
112
|
+
return this.findTool(toolCallId)?.args ?? "";
|
|
113
|
+
};
|
|
114
|
+
readToolName = (toolCallId) => {
|
|
115
|
+
return this.findTool(toolCallId)?.toolName ?? "unknown";
|
|
116
|
+
};
|
|
117
|
+
findTool = (toolCallId) => {
|
|
118
|
+
const index = this.toolIndex.get(toolCallId);
|
|
119
|
+
if (typeof index !== "number") return null;
|
|
120
|
+
const part = this.parts[index];
|
|
121
|
+
return part?.type === "tool-invocation" ? part : null;
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
var StdioRuntimeClientBridge = class {
|
|
125
|
+
activeSessionId = null;
|
|
126
|
+
updateHandler = null;
|
|
127
|
+
attach = (params) => {
|
|
128
|
+
this.activeSessionId = params.sessionId;
|
|
129
|
+
this.updateHandler = params.onUpdate;
|
|
130
|
+
return () => {
|
|
131
|
+
if (this.activeSessionId !== params.sessionId) return;
|
|
132
|
+
this.activeSessionId = null;
|
|
133
|
+
this.updateHandler = null;
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
sessionUpdate = async (params) => {
|
|
137
|
+
if (params.sessionId !== this.activeSessionId) return;
|
|
138
|
+
this.updateHandler?.(params.update);
|
|
139
|
+
};
|
|
140
|
+
requestPermission = async () => ({ outcome: { outcome: "cancelled" } });
|
|
141
|
+
readTextFile = async () => ({ content: "" });
|
|
142
|
+
writeTextFile = async () => ({});
|
|
143
|
+
};
|
|
144
|
+
var StdioRuntimeSession = class {
|
|
145
|
+
child = null;
|
|
146
|
+
connection = null;
|
|
147
|
+
promptInFlight = false;
|
|
148
|
+
remoteSessionId = null;
|
|
149
|
+
clientBridge = new StdioRuntimeClientBridge();
|
|
150
|
+
stderr = "";
|
|
151
|
+
pendingProviderRoute;
|
|
152
|
+
constructor(config, sessionId) {
|
|
153
|
+
this.config = config;
|
|
154
|
+
this.sessionId = sessionId;
|
|
155
|
+
}
|
|
156
|
+
ensureStarted = async (params) => {
|
|
157
|
+
this.pendingProviderRoute = params?.providerRoute;
|
|
158
|
+
if (this.connection && this.remoteSessionId) return;
|
|
159
|
+
const env = buildStdioLaunchEnv({
|
|
160
|
+
config: this.config,
|
|
161
|
+
providerRoute: this.pendingProviderRoute
|
|
162
|
+
});
|
|
163
|
+
this.child = spawn(this.config.command, this.config.args, {
|
|
164
|
+
cwd: this.config.cwd,
|
|
165
|
+
env,
|
|
166
|
+
stdio: [
|
|
167
|
+
"pipe",
|
|
168
|
+
"pipe",
|
|
169
|
+
"pipe"
|
|
170
|
+
]
|
|
171
|
+
});
|
|
172
|
+
const spawnErrorPromise = new Promise((_, reject) => {
|
|
173
|
+
this.child?.once("error", (error) => {
|
|
174
|
+
reject(new Error(buildSpawnFailureMessage({
|
|
175
|
+
command: this.config.command,
|
|
176
|
+
cwd: this.config.cwd,
|
|
177
|
+
error
|
|
178
|
+
})));
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
this.child.stderr.setEncoding("utf8");
|
|
182
|
+
this.child.stderr.on("data", (chunk) => {
|
|
183
|
+
this.stderr = `${this.stderr}${chunk}`.slice(-4e3);
|
|
184
|
+
});
|
|
185
|
+
const stream = acp.ndJsonStream(Writable.toWeb(this.child.stdin), Readable.toWeb(this.child.stdout));
|
|
186
|
+
this.connection = new acp.ClientSideConnection(() => this.clientBridge, stream);
|
|
187
|
+
this.remoteSessionId = (await Promise.race([(async () => {
|
|
188
|
+
await withTimeout(this.connection?.initialize({
|
|
189
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
190
|
+
clientCapabilities: {}
|
|
191
|
+
}) ?? Promise.reject(/* @__PURE__ */ new Error("[narp-stdio] stdio runtime connection not started")), this.config.startupTimeoutMs, `[narp-stdio] timed out initializing stdio runtime for session ${this.sessionId}`);
|
|
192
|
+
return withTimeout(this.connection?.newSession({
|
|
193
|
+
cwd: this.config.cwd ?? process.cwd(),
|
|
194
|
+
mcpServers: []
|
|
195
|
+
}) ?? Promise.reject(/* @__PURE__ */ new Error("[narp-stdio] stdio runtime connection not started")), this.config.startupTimeoutMs, `[narp-stdio] timed out creating remote session for ${this.sessionId}`);
|
|
196
|
+
})(), spawnErrorPromise])).sessionId;
|
|
197
|
+
};
|
|
198
|
+
runPrompt = async (params) => {
|
|
199
|
+
const { meta, modelId, onUpdate, signal, text } = params;
|
|
200
|
+
if (!this.connection || !this.remoteSessionId) throw new Error("[narp-stdio] stdio runtime connection not started");
|
|
201
|
+
if (this.promptInFlight) throw new Error("[narp-stdio] concurrent prompt is not supported for one stdio session");
|
|
202
|
+
this.promptInFlight = true;
|
|
203
|
+
const detach = this.clientBridge.attach({
|
|
204
|
+
sessionId: this.remoteSessionId,
|
|
205
|
+
onUpdate
|
|
206
|
+
});
|
|
207
|
+
const releaseAbort = this.bindAbortSignal(signal);
|
|
208
|
+
try {
|
|
209
|
+
if (modelId) try {
|
|
210
|
+
await this.connection.unstable_setSessionModel({
|
|
211
|
+
sessionId: this.remoteSessionId,
|
|
212
|
+
modelId
|
|
213
|
+
});
|
|
214
|
+
} catch {}
|
|
215
|
+
return await withTimeout(this.connection.prompt({
|
|
216
|
+
sessionId: this.remoteSessionId,
|
|
217
|
+
prompt: [{
|
|
218
|
+
type: "text",
|
|
219
|
+
text
|
|
220
|
+
}],
|
|
221
|
+
_meta: { [NARP_STDIO_PROMPT_META_KEY]: meta }
|
|
222
|
+
}), this.config.requestTimeoutMs, `[narp-stdio] prompt timed out for session ${this.sessionId}`);
|
|
223
|
+
} finally {
|
|
224
|
+
releaseAbort();
|
|
225
|
+
detach();
|
|
226
|
+
this.promptInFlight = false;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
cancel = async () => {
|
|
230
|
+
if (!this.connection || !this.remoteSessionId) return;
|
|
231
|
+
try {
|
|
232
|
+
await this.connection.cancel({ sessionId: this.remoteSessionId });
|
|
233
|
+
} catch {}
|
|
234
|
+
};
|
|
235
|
+
bindAbortSignal = (signal) => {
|
|
236
|
+
if (!signal) return () => void 0;
|
|
237
|
+
const onAbort = () => {
|
|
238
|
+
this.cancel();
|
|
239
|
+
};
|
|
240
|
+
if (signal.aborted) {
|
|
241
|
+
onAbort();
|
|
242
|
+
return () => void 0;
|
|
243
|
+
}
|
|
244
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
245
|
+
return () => {
|
|
246
|
+
signal.removeEventListener("abort", onAbort);
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
readStderr = () => this.stderr;
|
|
250
|
+
};
|
|
251
|
+
function buildSpawnFailureMessage(params) {
|
|
252
|
+
const cwdSuffix = params.cwd ? ` (cwd: ${params.cwd})` : "";
|
|
253
|
+
return `[narp-stdio] failed to start stdio runtime command "${params.command}"${cwdSuffix}: ${params.error.message}`;
|
|
254
|
+
}
|
|
255
|
+
var StdioRuntimeRunController = class {
|
|
256
|
+
buffer = new UpdateBuffer();
|
|
257
|
+
collector = new PromptUpdateCollector();
|
|
258
|
+
toolStates = /* @__PURE__ */ new Map();
|
|
259
|
+
textStarted = false;
|
|
260
|
+
reasoningStarted = false;
|
|
261
|
+
resolvedTools;
|
|
262
|
+
resolvedProviderRoute;
|
|
263
|
+
constructor(session, input, stateManager, resolveTools, resolveProviderRoute) {
|
|
264
|
+
this.session = session;
|
|
265
|
+
this.input = input;
|
|
266
|
+
this.stateManager = stateManager;
|
|
267
|
+
this.resolveTools = resolveTools;
|
|
268
|
+
this.resolveProviderRoute = resolveProviderRoute;
|
|
269
|
+
this.resolvedTools = resolveTools?.(input) ?? [];
|
|
270
|
+
this.resolvedProviderRoute = resolveProviderRoute?.(input);
|
|
271
|
+
}
|
|
272
|
+
execute = async function* (options) {
|
|
273
|
+
const requestMessage = this.input.messages.at(-1);
|
|
274
|
+
if (!requestMessage) throw new Error("[narp-stdio] runtime.run requires at least one input message");
|
|
275
|
+
const assistantMessageId = createAssistantMessageId(requestMessage.id);
|
|
276
|
+
const promptPromise = this.session.runPrompt({
|
|
277
|
+
text: extractPromptText(requestMessage),
|
|
278
|
+
meta: {
|
|
279
|
+
...this.input.correlationId ? { correlationId: this.input.correlationId } : {},
|
|
280
|
+
...this.resolvedProviderRoute ? { providerRoute: this.resolvedProviderRoute } : {},
|
|
281
|
+
...this.input.metadata ? { sessionMetadata: this.input.metadata } : {},
|
|
282
|
+
...this.resolvedTools.length > 0 ? { tools: this.resolvedTools } : {}
|
|
283
|
+
},
|
|
284
|
+
modelId: resolveModelId({
|
|
285
|
+
providerRoute: this.resolvedProviderRoute,
|
|
286
|
+
metadata: this.input.metadata
|
|
287
|
+
}),
|
|
288
|
+
signal: options?.signal,
|
|
289
|
+
onUpdate: (update) => this.buffer.push(update)
|
|
290
|
+
});
|
|
291
|
+
let promptSettled = false;
|
|
292
|
+
let promptError = null;
|
|
293
|
+
promptPromise.then(() => {
|
|
294
|
+
promptSettled = true;
|
|
295
|
+
this.buffer.notify();
|
|
296
|
+
}).catch((error) => {
|
|
297
|
+
promptSettled = true;
|
|
298
|
+
promptError = error;
|
|
299
|
+
this.buffer.notify();
|
|
300
|
+
});
|
|
301
|
+
yield* this.emitEvent({
|
|
302
|
+
type: NcpEventType.MessageAccepted,
|
|
303
|
+
payload: {
|
|
304
|
+
messageId: assistantMessageId,
|
|
305
|
+
...this.input.correlationId ? { correlationId: this.input.correlationId } : {}
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
yield* this.emitEvent({
|
|
309
|
+
type: NcpEventType.RunStarted,
|
|
310
|
+
payload: {
|
|
311
|
+
sessionId: this.input.sessionId,
|
|
312
|
+
messageId: assistantMessageId,
|
|
313
|
+
runId: `narp-stdio:${this.input.sessionId}:${Date.now()}`
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
try {
|
|
317
|
+
while (!promptSettled || this.buffer.hasItems()) {
|
|
318
|
+
const update = this.buffer.shift();
|
|
319
|
+
if (!update) {
|
|
320
|
+
await this.buffer.waitForChange();
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
for (const event of this.translateUpdate(update, assistantMessageId)) yield* this.emitEvent(event);
|
|
324
|
+
}
|
|
325
|
+
if (promptError) throw promptError;
|
|
326
|
+
if (options?.signal?.aborted) throw createNcpError("abort-error", "ACP prompt cancelled");
|
|
327
|
+
for (const terminalEvent of this.createTerminalEvents(assistantMessageId)) yield* this.emitEvent(terminalEvent);
|
|
328
|
+
if (!this.collector.hasParts()) throw new Error(`[narp-stdio] ACP prompt completed without any assistant content for session ${this.input.sessionId}. stderr=${this.session.readStderr()}`);
|
|
329
|
+
yield* this.emitEvent({
|
|
330
|
+
type: NcpEventType.MessageCompleted,
|
|
331
|
+
payload: {
|
|
332
|
+
sessionId: this.input.sessionId,
|
|
333
|
+
correlationId: this.input.correlationId,
|
|
334
|
+
message: {
|
|
335
|
+
id: assistantMessageId,
|
|
336
|
+
sessionId: this.input.sessionId,
|
|
337
|
+
role: "assistant",
|
|
338
|
+
status: "final",
|
|
339
|
+
parts: this.collector.buildParts(),
|
|
340
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
yield* this.emitEvent({
|
|
345
|
+
type: NcpEventType.RunFinished,
|
|
346
|
+
payload: {
|
|
347
|
+
sessionId: this.input.sessionId,
|
|
348
|
+
messageId: assistantMessageId,
|
|
349
|
+
runId: `narp-stdio:${this.input.sessionId}`
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
} catch (error) {
|
|
353
|
+
const ncpError = normalizeRuntimeError(error);
|
|
354
|
+
yield* this.emitEvent({
|
|
355
|
+
type: NcpEventType.MessageFailed,
|
|
356
|
+
payload: {
|
|
357
|
+
sessionId: this.input.sessionId,
|
|
358
|
+
messageId: assistantMessageId,
|
|
359
|
+
correlationId: this.input.correlationId,
|
|
360
|
+
error: ncpError
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
yield* this.emitEvent({
|
|
364
|
+
type: NcpEventType.RunError,
|
|
365
|
+
payload: {
|
|
366
|
+
sessionId: this.input.sessionId,
|
|
367
|
+
messageId: assistantMessageId,
|
|
368
|
+
runId: `narp-stdio:${this.input.sessionId}`,
|
|
369
|
+
error: ncpError.message
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
emitEvent = async function* (event) {
|
|
375
|
+
this.collector.apply(event);
|
|
376
|
+
await this.stateManager?.dispatch(event);
|
|
377
|
+
yield event;
|
|
378
|
+
};
|
|
379
|
+
translateUpdate = (update, messageId) => {
|
|
380
|
+
switch (update.sessionUpdate) {
|
|
381
|
+
case "agent_message_chunk": return this.emitTextDelta(update.content, messageId);
|
|
382
|
+
case "agent_thought_chunk": return this.emitReasoningDelta(update.content, messageId);
|
|
383
|
+
case "tool_call": return this.emitToolCallStart(update, messageId);
|
|
384
|
+
case "tool_call_update": return this.emitToolCallUpdate(update);
|
|
385
|
+
default: return [];
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
emitTextDelta = (content, messageId) => {
|
|
389
|
+
if (content.type !== "text" || !content.text) return [];
|
|
390
|
+
const events = [];
|
|
391
|
+
if (!this.textStarted) {
|
|
392
|
+
this.textStarted = true;
|
|
393
|
+
events.push({
|
|
394
|
+
type: NcpEventType.MessageTextStart,
|
|
395
|
+
payload: {
|
|
396
|
+
sessionId: this.input.sessionId,
|
|
397
|
+
messageId
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
events.push({
|
|
402
|
+
type: NcpEventType.MessageTextDelta,
|
|
403
|
+
payload: {
|
|
404
|
+
sessionId: this.input.sessionId,
|
|
405
|
+
messageId,
|
|
406
|
+
delta: content.text
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
return events;
|
|
410
|
+
};
|
|
411
|
+
emitReasoningDelta = (content, messageId) => {
|
|
412
|
+
if (content.type !== "text" || !content.text) return [];
|
|
413
|
+
const events = [];
|
|
414
|
+
if (!this.reasoningStarted) {
|
|
415
|
+
this.reasoningStarted = true;
|
|
416
|
+
events.push({
|
|
417
|
+
type: NcpEventType.MessageReasoningStart,
|
|
418
|
+
payload: {
|
|
419
|
+
sessionId: this.input.sessionId,
|
|
420
|
+
messageId
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
events.push({
|
|
425
|
+
type: NcpEventType.MessageReasoningDelta,
|
|
426
|
+
payload: {
|
|
427
|
+
sessionId: this.input.sessionId,
|
|
428
|
+
messageId,
|
|
429
|
+
delta: content.text
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
return events;
|
|
433
|
+
};
|
|
434
|
+
emitToolCallStart = (update, messageId) => {
|
|
435
|
+
const toolName = resolveToolName(update);
|
|
436
|
+
const args = serializeToolArgs(update.rawInput);
|
|
437
|
+
this.toolStates.set(update.toolCallId, {
|
|
438
|
+
toolName,
|
|
439
|
+
args,
|
|
440
|
+
completed: false
|
|
441
|
+
});
|
|
442
|
+
return [{
|
|
443
|
+
type: NcpEventType.MessageToolCallStart,
|
|
444
|
+
payload: {
|
|
445
|
+
sessionId: this.input.sessionId,
|
|
446
|
+
messageId,
|
|
447
|
+
toolCallId: update.toolCallId,
|
|
448
|
+
toolName
|
|
449
|
+
}
|
|
450
|
+
}, {
|
|
451
|
+
type: NcpEventType.MessageToolCallArgs,
|
|
452
|
+
payload: {
|
|
453
|
+
sessionId: this.input.sessionId,
|
|
454
|
+
toolCallId: update.toolCallId,
|
|
455
|
+
args
|
|
456
|
+
}
|
|
457
|
+
}];
|
|
458
|
+
};
|
|
459
|
+
emitToolCallUpdate = (update) => {
|
|
460
|
+
const existing = this.toolStates.get(update.toolCallId);
|
|
461
|
+
if (!existing) return [];
|
|
462
|
+
const nextArgs = serializeToolArgs(update.rawInput);
|
|
463
|
+
const argsChanged = typeof update.rawInput !== "undefined" && nextArgs !== existing.args;
|
|
464
|
+
const events = [];
|
|
465
|
+
if (argsChanged) {
|
|
466
|
+
existing.args = nextArgs;
|
|
467
|
+
events.push({
|
|
468
|
+
type: NcpEventType.MessageToolCallArgs,
|
|
469
|
+
payload: {
|
|
470
|
+
sessionId: this.input.sessionId,
|
|
471
|
+
toolCallId: update.toolCallId,
|
|
472
|
+
args: nextArgs
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
if (update.status === "completed" || update.status === "failed") {
|
|
477
|
+
if (!existing.completed) {
|
|
478
|
+
existing.completed = true;
|
|
479
|
+
events.push({
|
|
480
|
+
type: NcpEventType.MessageToolCallEnd,
|
|
481
|
+
payload: {
|
|
482
|
+
sessionId: this.input.sessionId,
|
|
483
|
+
toolCallId: update.toolCallId
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
if (typeof update.rawOutput !== "undefined") events.push({
|
|
488
|
+
type: NcpEventType.MessageToolCallResult,
|
|
489
|
+
payload: {
|
|
490
|
+
sessionId: this.input.sessionId,
|
|
491
|
+
toolCallId: update.toolCallId,
|
|
492
|
+
content: update.rawOutput
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
return events;
|
|
497
|
+
};
|
|
498
|
+
createTerminalEvents = (messageId) => {
|
|
499
|
+
const events = [];
|
|
500
|
+
if (this.textStarted) {
|
|
501
|
+
events.push({
|
|
502
|
+
type: NcpEventType.MessageTextEnd,
|
|
503
|
+
payload: {
|
|
504
|
+
sessionId: this.input.sessionId,
|
|
505
|
+
messageId
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
this.textStarted = false;
|
|
509
|
+
}
|
|
510
|
+
if (this.reasoningStarted) {
|
|
511
|
+
events.push({
|
|
512
|
+
type: NcpEventType.MessageReasoningEnd,
|
|
513
|
+
payload: {
|
|
514
|
+
sessionId: this.input.sessionId,
|
|
515
|
+
messageId
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
this.reasoningStarted = false;
|
|
519
|
+
}
|
|
520
|
+
for (const [toolCallId, state] of this.toolStates.entries()) {
|
|
521
|
+
if (state.completed) continue;
|
|
522
|
+
events.push({
|
|
523
|
+
type: NcpEventType.MessageToolCallEnd,
|
|
524
|
+
payload: {
|
|
525
|
+
sessionId: this.input.sessionId,
|
|
526
|
+
toolCallId
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
state.completed = true;
|
|
530
|
+
}
|
|
531
|
+
return events;
|
|
532
|
+
};
|
|
533
|
+
};
|
|
534
|
+
var StdioRuntimeNcpAgentRuntime = class {
|
|
535
|
+
session;
|
|
536
|
+
constructor(config) {
|
|
537
|
+
this.config = config;
|
|
538
|
+
this.session = new StdioRuntimeSession(config, config.sessionId);
|
|
539
|
+
}
|
|
540
|
+
run = async function* (input, options) {
|
|
541
|
+
await this.session.ensureStarted({ providerRoute: this.config.resolveProviderRoute?.(input) });
|
|
542
|
+
yield* new StdioRuntimeRunController(this.session, input, this.config.stateManager, this.config.resolveTools, this.config.resolveProviderRoute).execute(options);
|
|
543
|
+
};
|
|
544
|
+
};
|
|
545
|
+
function extractPromptText(message) {
|
|
546
|
+
const text = (message.parts ?? []).map((part) => {
|
|
547
|
+
if (part.type === "text" || part.type === "reasoning" || part.type === "rich-text") return part.text ?? "";
|
|
548
|
+
return "";
|
|
549
|
+
}).join("\n").trim();
|
|
550
|
+
return text.length > 0 ? text : "[empty message]";
|
|
551
|
+
}
|
|
552
|
+
function resolveModelId(params) {
|
|
553
|
+
const { metadata, providerRoute } = params;
|
|
554
|
+
return providerRoute?.model ?? readString(metadata?.preferred_model) ?? readString(metadata?.preferredModel) ?? readString(metadata?.model);
|
|
555
|
+
}
|
|
556
|
+
function resolveToolName(update) {
|
|
557
|
+
if (typeof update.rawInput === "object" && update.rawInput && !Array.isArray(update.rawInput)) {
|
|
558
|
+
const candidate = readString(update.rawInput.toolName) ?? readString(update.rawInput.tool);
|
|
559
|
+
if (candidate) return candidate;
|
|
560
|
+
}
|
|
561
|
+
return readString(update.title) ?? "unknown";
|
|
562
|
+
}
|
|
563
|
+
function createAssistantMessageId(requestMessageId) {
|
|
564
|
+
return `assistant:${requestMessageId.trim() || "request"}:${randomUUID()}`;
|
|
565
|
+
}
|
|
566
|
+
function serializeToolArgs(value) {
|
|
567
|
+
if (typeof value === "string") return value;
|
|
568
|
+
if (typeof value === "undefined") return "{}";
|
|
569
|
+
return JSON.stringify(value);
|
|
570
|
+
}
|
|
571
|
+
function normalizeRuntimeError(error) {
|
|
572
|
+
if (isNcpError(error)) return error;
|
|
573
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
574
|
+
const lowered = message.toLowerCase();
|
|
575
|
+
return {
|
|
576
|
+
code: lowered.includes("abort") || lowered.includes("cancel") ? "abort-error" : "runtime-error",
|
|
577
|
+
message
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
function createNcpError(code, message) {
|
|
581
|
+
return {
|
|
582
|
+
code,
|
|
583
|
+
message
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
function isNcpError(value) {
|
|
587
|
+
if (!value || typeof value !== "object") return false;
|
|
588
|
+
return typeof value.code === "string" && typeof value.message === "string";
|
|
589
|
+
}
|
|
590
|
+
async function withTimeout(promise, timeoutMs, message) {
|
|
591
|
+
let timeoutHandle = null;
|
|
592
|
+
try {
|
|
593
|
+
return await Promise.race([promise, new Promise((_, reject) => {
|
|
594
|
+
timeoutHandle = setTimeout(() => {
|
|
595
|
+
reject(new Error(message));
|
|
596
|
+
}, timeoutMs);
|
|
597
|
+
})]);
|
|
598
|
+
} finally {
|
|
599
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
//#endregion
|
|
603
|
+
export { StdioRuntimeNcpAgentRuntime };
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nextclaw/nextclaw-ncp-runtime-stdio-client",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Optional NCP runtime adapter backed by a protocol-aware stdio agent command.",
|
|
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
|
+
"@agentclientprotocol/sdk": "^0.19.0",
|
|
20
|
+
"@nextclaw/ncp": "0.5.2"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^20.17.6",
|
|
24
|
+
"typescript": "^5.6.3",
|
|
25
|
+
"vitest": "^4.1.2"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsdown src/index.ts --dts --clean --target es2022 --no-fixedExtension --unbundle && node ./scripts/copy-hermes-acp-route-bridge.mjs",
|
|
29
|
+
"lint": "eslint .",
|
|
30
|
+
"tsc": "tsc -p tsconfig.json",
|
|
31
|
+
"test": "vitest run"
|
|
32
|
+
}
|
|
33
|
+
}
|