@triclaps/cli 0.0.2 → 0.0.4
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 +17 -5
- package/adapters/hermes_claps_adapter.py +697 -0
- package/index.js +5557 -811
- package/package.json +3 -2
- package/skills/preview-gateway/SKILL.md +10 -1
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CLAPS ↔ Hermes structured adapter.
|
|
4
|
+
|
|
5
|
+
Wraps Hermes as a Python library (no source modifications) and outputs
|
|
6
|
+
Codex-compatible NDJSON events to stdout for CLAPS stream consumption.
|
|
7
|
+
|
|
8
|
+
Events emitted (one JSON object per line):
|
|
9
|
+
{"type":"session_meta","payload":{"id":"..."}}
|
|
10
|
+
{"type":"item.completed","item":{"type":"reasoning","text":"..."}}
|
|
11
|
+
{"type":"item.started","item":{"type":"<tool>","id":"<id>","command":"..."}}
|
|
12
|
+
{"type":"item.completed","item":{"type":"<tool>","id":"<id>","status":"completed","aggregated_output":"..."}}
|
|
13
|
+
{"type":"item.completed","item":{"type":"agent_message"}}
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
python3 hermes_claps_adapter.py --prompt "..." [-o /tmp/output.txt] [--resume ID]
|
|
17
|
+
|
|
18
|
+
The final agent response is written to the file specified by -o (if provided).
|
|
19
|
+
All structured NDJSON events are emitted to stdout.
|
|
20
|
+
Hermes internal output is redirected to stderr.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import inspect
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
import sys
|
|
30
|
+
import threading
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Redirect stdout BEFORE importing any Hermes modules.
|
|
34
|
+
# Hermes prints banners, tool progress, and status messages to stdout/stderr
|
|
35
|
+
# during import and initialization. Capturing the real stdout early ensures
|
|
36
|
+
# the NDJSON event stream stays clean.
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
_ndjson_fd = os.dup(sys.stdout.fileno()) # duplicate the real stdout fd
|
|
39
|
+
_ndjson_out = os.fdopen(_ndjson_fd, "w", encoding="utf-8", closefd=True)
|
|
40
|
+
_emit_lock = threading.Lock()
|
|
41
|
+
|
|
42
|
+
# Point Python-level stdout to stderr so all Hermes prints go there.
|
|
43
|
+
sys.stdout = sys.stderr
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def env_flag_enabled(name: str) -> bool:
|
|
47
|
+
value = os.environ.get(name)
|
|
48
|
+
if value is None:
|
|
49
|
+
return False
|
|
50
|
+
return value.strip().lower() not in {"", "0", "false", "no", "off"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
DEBUG_ENABLED = env_flag_enabled("CLAPS_HERMES_DEBUG")
|
|
54
|
+
DEFAULT_DEBUG_BODY_BYTES = 64 * 1024
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_debug_body_limit() -> int:
|
|
58
|
+
raw_value = os.environ.get("CLAPS_HERMES_DEBUG_MAX_BODY_BYTES", "").strip()
|
|
59
|
+
if not raw_value:
|
|
60
|
+
return DEFAULT_DEBUG_BODY_BYTES
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
parsed = int(raw_value)
|
|
64
|
+
except ValueError:
|
|
65
|
+
return DEFAULT_DEBUG_BODY_BYTES
|
|
66
|
+
|
|
67
|
+
if parsed <= 0:
|
|
68
|
+
return DEFAULT_DEBUG_BODY_BYTES
|
|
69
|
+
|
|
70
|
+
return min(parsed, 5 * 1024 * 1024)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
DEBUG_BODY_LIMIT = resolve_debug_body_limit()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def emit(event: dict) -> None:
|
|
77
|
+
"""Write one NDJSON event to the real stdout (fd-level) and flush."""
|
|
78
|
+
line = json.dumps(event, ensure_ascii=False) + "\n"
|
|
79
|
+
with _emit_lock:
|
|
80
|
+
_ndjson_out.write(line)
|
|
81
|
+
_ndjson_out.flush()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def clip_debug_text(text: str) -> str:
|
|
85
|
+
if len(text) <= DEBUG_BODY_LIMIT:
|
|
86
|
+
return text
|
|
87
|
+
remaining = len(text) - DEBUG_BODY_LIMIT
|
|
88
|
+
return f"{text[:DEBUG_BODY_LIMIT]}... [truncated {remaining} chars]"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def normalize_debug_value(value: Any) -> Any:
|
|
92
|
+
if value is None:
|
|
93
|
+
return None
|
|
94
|
+
if isinstance(value, (bytes, bytearray)):
|
|
95
|
+
return clip_debug_text(value.decode("utf-8", errors="replace"))
|
|
96
|
+
if isinstance(value, str):
|
|
97
|
+
return clip_debug_text(value)
|
|
98
|
+
if isinstance(value, Path):
|
|
99
|
+
return str(value)
|
|
100
|
+
if isinstance(value, dict):
|
|
101
|
+
return {str(key): normalize_debug_value(inner) for key, inner in value.items()}
|
|
102
|
+
if isinstance(value, (list, tuple)):
|
|
103
|
+
return [normalize_debug_value(item) for item in value]
|
|
104
|
+
if isinstance(value, (bool, int, float)):
|
|
105
|
+
return value
|
|
106
|
+
return clip_debug_text(repr(value))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def debug_log(label: str, payload: dict[str, Any]) -> None:
|
|
110
|
+
if not DEBUG_ENABLED:
|
|
111
|
+
return
|
|
112
|
+
print(
|
|
113
|
+
f"[hermes-debug] {label}: {json.dumps(normalize_debug_value(payload), ensure_ascii=False)}",
|
|
114
|
+
file=sys.stderr,
|
|
115
|
+
flush=True,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def read_request_body_for_debug(request: Any) -> str | None:
|
|
120
|
+
try:
|
|
121
|
+
content = request.content
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
return f"<unavailable: {exc}>"
|
|
124
|
+
return normalize_debug_value(content)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def extract_response_body_for_debug(response: Any) -> str:
|
|
128
|
+
try:
|
|
129
|
+
content = response.read()
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
return f"<unavailable: {exc}>"
|
|
132
|
+
return normalize_debug_value(content)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def extract_async_response_body_for_debug(response: Any) -> str:
|
|
136
|
+
try:
|
|
137
|
+
content = await response.aread()
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
return f"<unavailable: {exc}>"
|
|
140
|
+
return normalize_debug_value(content)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def build_http_debug_payload(prefix: str, request: Any, *, stream: bool) -> dict[str, Any]:
|
|
144
|
+
return {
|
|
145
|
+
"kind": prefix,
|
|
146
|
+
"method": getattr(request, "method", None),
|
|
147
|
+
"url": str(getattr(request, "url", "")),
|
|
148
|
+
"headers": dict(getattr(request, "headers", {}) or {}),
|
|
149
|
+
"body": read_request_body_for_debug(request),
|
|
150
|
+
"stream": stream,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def is_likely_llm_url(url: str) -> bool:
|
|
155
|
+
normalized = url.strip()
|
|
156
|
+
if not normalized:
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
configured_prefixes = [
|
|
160
|
+
os.environ.get("HERMES_BASE_URL", "").strip(),
|
|
161
|
+
os.environ.get("OPENAI_BASE_URL", "").strip(),
|
|
162
|
+
os.environ.get("ANTHROPIC_BASE_URL", "").strip(),
|
|
163
|
+
]
|
|
164
|
+
for prefix in configured_prefixes:
|
|
165
|
+
if prefix and normalized.startswith(prefix.rstrip("/")):
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
return any(
|
|
169
|
+
marker in normalized
|
|
170
|
+
for marker in (
|
|
171
|
+
"/chat/completions",
|
|
172
|
+
"/responses",
|
|
173
|
+
"/messages",
|
|
174
|
+
"/v1/completions",
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def install_http_debug_logging() -> None:
|
|
180
|
+
if not DEBUG_ENABLED:
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
import httpx # type: ignore
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
debug_log("httpx_debug_unavailable", {"error": str(exc)})
|
|
187
|
+
else:
|
|
188
|
+
original_send = httpx.Client.send
|
|
189
|
+
if not getattr(original_send, "_claps_debug_wrapped", False):
|
|
190
|
+
def wrapped_send(self, request, *args, **kwargs):
|
|
191
|
+
stream = bool(kwargs.get("stream", False))
|
|
192
|
+
request_url = str(getattr(request, "url", ""))
|
|
193
|
+
if is_likely_llm_url(request_url):
|
|
194
|
+
debug_log("llm_request", build_http_debug_payload("httpx", request, stream=stream))
|
|
195
|
+
|
|
196
|
+
response = original_send(self, request, *args, **kwargs)
|
|
197
|
+
|
|
198
|
+
if is_likely_llm_url(request_url):
|
|
199
|
+
payload = {
|
|
200
|
+
"kind": "httpx",
|
|
201
|
+
"status_code": getattr(response, "status_code", None),
|
|
202
|
+
"url": str(getattr(response, "url", request_url)),
|
|
203
|
+
"headers": dict(getattr(response, "headers", {}) or {}),
|
|
204
|
+
"stream": stream,
|
|
205
|
+
}
|
|
206
|
+
if stream:
|
|
207
|
+
payload["body"] = "<streaming response body not eagerly captured>"
|
|
208
|
+
else:
|
|
209
|
+
payload["body"] = extract_response_body_for_debug(response)
|
|
210
|
+
debug_log("llm_response", payload)
|
|
211
|
+
|
|
212
|
+
return response
|
|
213
|
+
|
|
214
|
+
wrapped_send._claps_debug_wrapped = True # type: ignore[attr-defined]
|
|
215
|
+
httpx.Client.send = wrapped_send
|
|
216
|
+
|
|
217
|
+
original_async_send = httpx.AsyncClient.send
|
|
218
|
+
if not getattr(original_async_send, "_claps_debug_wrapped", False):
|
|
219
|
+
async def wrapped_async_send(self, request, *args, **kwargs):
|
|
220
|
+
stream = bool(kwargs.get("stream", False))
|
|
221
|
+
request_url = str(getattr(request, "url", ""))
|
|
222
|
+
if is_likely_llm_url(request_url):
|
|
223
|
+
debug_log("llm_request", build_http_debug_payload("httpx_async", request, stream=stream))
|
|
224
|
+
|
|
225
|
+
response = await original_async_send(self, request, *args, **kwargs)
|
|
226
|
+
|
|
227
|
+
if is_likely_llm_url(request_url):
|
|
228
|
+
payload = {
|
|
229
|
+
"kind": "httpx_async",
|
|
230
|
+
"status_code": getattr(response, "status_code", None),
|
|
231
|
+
"url": str(getattr(response, "url", request_url)),
|
|
232
|
+
"headers": dict(getattr(response, "headers", {}) or {}),
|
|
233
|
+
"stream": stream,
|
|
234
|
+
}
|
|
235
|
+
if stream:
|
|
236
|
+
payload["body"] = "<streaming response body not eagerly captured>"
|
|
237
|
+
else:
|
|
238
|
+
payload["body"] = await extract_async_response_body_for_debug(response)
|
|
239
|
+
debug_log("llm_response", payload)
|
|
240
|
+
|
|
241
|
+
return response
|
|
242
|
+
|
|
243
|
+
wrapped_async_send._claps_debug_wrapped = True # type: ignore[attr-defined]
|
|
244
|
+
httpx.AsyncClient.send = wrapped_async_send
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
import requests # type: ignore
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
debug_log("requests_debug_unavailable", {"error": str(exc)})
|
|
250
|
+
else:
|
|
251
|
+
original_request = requests.Session.request
|
|
252
|
+
if not getattr(original_request, "_claps_debug_wrapped", False):
|
|
253
|
+
def wrapped_request(self, method, url, **kwargs):
|
|
254
|
+
normalized_url = str(url)
|
|
255
|
+
if is_likely_llm_url(normalized_url):
|
|
256
|
+
debug_log(
|
|
257
|
+
"llm_request",
|
|
258
|
+
{
|
|
259
|
+
"kind": "requests",
|
|
260
|
+
"method": method,
|
|
261
|
+
"url": normalized_url,
|
|
262
|
+
"headers": dict(kwargs.get("headers") or {}),
|
|
263
|
+
"body": normalize_debug_value(kwargs.get("data") or kwargs.get("json")),
|
|
264
|
+
"stream": bool(kwargs.get("stream", False)),
|
|
265
|
+
},
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
response = original_request(self, method, url, **kwargs)
|
|
269
|
+
|
|
270
|
+
if is_likely_llm_url(normalized_url):
|
|
271
|
+
debug_log(
|
|
272
|
+
"llm_response",
|
|
273
|
+
{
|
|
274
|
+
"kind": "requests",
|
|
275
|
+
"status_code": getattr(response, "status_code", None),
|
|
276
|
+
"url": getattr(response, "url", normalized_url),
|
|
277
|
+
"headers": dict(getattr(response, "headers", {}) or {}),
|
|
278
|
+
"body": normalize_debug_value(getattr(response, "text", "")),
|
|
279
|
+
"stream": bool(kwargs.get("stream", False)),
|
|
280
|
+
},
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return response
|
|
284
|
+
|
|
285
|
+
wrapped_request._claps_debug_wrapped = True # type: ignore[attr-defined]
|
|
286
|
+
requests.Session.request = wrapped_request
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _looks_like_hermes_root(candidate: Path) -> bool:
|
|
290
|
+
return (candidate / "cli.py").is_file() and (candidate / "hermes_cli").is_dir()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def ensure_hermes_import_path() -> Path | None:
|
|
294
|
+
candidates: list[Path] = []
|
|
295
|
+
|
|
296
|
+
configured_root = os.environ.get("HERMES_PROJECT_ROOT")
|
|
297
|
+
if configured_root:
|
|
298
|
+
candidates.append(Path(configured_root).expanduser())
|
|
299
|
+
|
|
300
|
+
web_dist = os.environ.get("HERMES_WEB_DIST")
|
|
301
|
+
if web_dist:
|
|
302
|
+
current = Path(web_dist).expanduser().resolve()
|
|
303
|
+
candidates.extend(current.parents)
|
|
304
|
+
|
|
305
|
+
adapter_path = Path(__file__).resolve()
|
|
306
|
+
candidates.extend(adapter_path.parents)
|
|
307
|
+
candidates.extend(
|
|
308
|
+
[
|
|
309
|
+
Path.cwd(),
|
|
310
|
+
Path("/opt/hermes"),
|
|
311
|
+
]
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
seen: set[str] = set()
|
|
315
|
+
for candidate in candidates:
|
|
316
|
+
try:
|
|
317
|
+
resolved = candidate.resolve()
|
|
318
|
+
except OSError:
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
key = str(resolved)
|
|
322
|
+
if key in seen:
|
|
323
|
+
continue
|
|
324
|
+
seen.add(key)
|
|
325
|
+
|
|
326
|
+
if not _looks_like_hermes_root(resolved):
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
if key not in sys.path:
|
|
330
|
+
sys.path.insert(0, key)
|
|
331
|
+
return resolved
|
|
332
|
+
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def resolve_runtime_overrides_from_env() -> dict:
|
|
337
|
+
openai_api_key = os.environ.get("OPENAI_API_KEY")
|
|
338
|
+
openrouter_api_key = os.environ.get("OPENROUTER_API_KEY")
|
|
339
|
+
model = (
|
|
340
|
+
os.environ.get("HERMES_MODEL")
|
|
341
|
+
or os.environ.get("CLAPS_HERMES_MODEL")
|
|
342
|
+
or os.environ.get("OPENAI_MODEL")
|
|
343
|
+
or os.environ.get("ANTHROPIC_MODEL")
|
|
344
|
+
or None
|
|
345
|
+
)
|
|
346
|
+
base_url = (
|
|
347
|
+
os.environ.get("HERMES_BASE_URL")
|
|
348
|
+
or os.environ.get("OPENAI_BASE_URL")
|
|
349
|
+
or None
|
|
350
|
+
)
|
|
351
|
+
provider = (
|
|
352
|
+
os.environ.get("HERMES_INFERENCE_PROVIDER")
|
|
353
|
+
or None
|
|
354
|
+
)
|
|
355
|
+
if (
|
|
356
|
+
not base_url
|
|
357
|
+
and isinstance(openai_api_key, str)
|
|
358
|
+
and openai_api_key.strip()
|
|
359
|
+
and not (isinstance(openrouter_api_key, str) and openrouter_api_key.strip())
|
|
360
|
+
):
|
|
361
|
+
base_url = "https://api.openai.com/v1"
|
|
362
|
+
|
|
363
|
+
if (
|
|
364
|
+
not provider
|
|
365
|
+
and isinstance(openai_api_key, str)
|
|
366
|
+
and openai_api_key.strip()
|
|
367
|
+
and not (isinstance(openrouter_api_key, str) and openrouter_api_key.strip())
|
|
368
|
+
):
|
|
369
|
+
provider = "custom"
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
"model": model.strip() if isinstance(model, str) and model.strip() else None,
|
|
373
|
+
"base_url": base_url.strip() if isinstance(base_url, str) and base_url.strip() else None,
|
|
374
|
+
"provider": provider.strip() if isinstance(provider, str) and provider.strip() else None,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def resolve_toolsets_from_env() -> list[str] | None:
|
|
379
|
+
raw_value = (
|
|
380
|
+
os.environ.get("CLAPS_HERMES_TOOLSETS")
|
|
381
|
+
or os.environ.get("HERMES_TOOLSETS")
|
|
382
|
+
or None
|
|
383
|
+
)
|
|
384
|
+
if raw_value is None:
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
toolsets = [part.strip() for part in raw_value.split(",") if part.strip()]
|
|
388
|
+
return toolsets or None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def main() -> None:
|
|
392
|
+
parser = argparse.ArgumentParser(description="CLAPS Hermes structured adapter")
|
|
393
|
+
parser.add_argument("--prompt", required=True, help="User prompt")
|
|
394
|
+
parser.add_argument("-o", "--output", default=None, help="Output file for the final response")
|
|
395
|
+
parser.add_argument("--resume", default=None, help="Hermes session ID to resume")
|
|
396
|
+
parser.add_argument(
|
|
397
|
+
"--session-id",
|
|
398
|
+
default=None,
|
|
399
|
+
help="Deterministic session ID to use for a fresh session (ignored when --resume is set)",
|
|
400
|
+
)
|
|
401
|
+
parser.add_argument("--max-turns", type=int, default=None, help="Max tool iterations")
|
|
402
|
+
parser.add_argument("--skills", default=None, help="Comma-separated skill names to preload")
|
|
403
|
+
args = parser.parse_args()
|
|
404
|
+
|
|
405
|
+
debug_log(
|
|
406
|
+
"incoming_request",
|
|
407
|
+
{
|
|
408
|
+
"cwd": str(Path.cwd()),
|
|
409
|
+
"resume": args.resume,
|
|
410
|
+
"session_id": args.session_id,
|
|
411
|
+
"max_turns": args.max_turns,
|
|
412
|
+
"skills": args.skills,
|
|
413
|
+
"prompt": args.prompt,
|
|
414
|
+
"env": {
|
|
415
|
+
"HERMES_HOME": os.environ.get("HERMES_HOME"),
|
|
416
|
+
"HERMES_PROJECT_ROOT": os.environ.get("HERMES_PROJECT_ROOT"),
|
|
417
|
+
"HERMES_INFERENCE_PROVIDER": os.environ.get("HERMES_INFERENCE_PROVIDER"),
|
|
418
|
+
"HERMES_BASE_URL": os.environ.get("HERMES_BASE_URL"),
|
|
419
|
+
"OPENAI_BASE_URL": os.environ.get("OPENAI_BASE_URL"),
|
|
420
|
+
"OPENAI_MODEL": os.environ.get("OPENAI_MODEL"),
|
|
421
|
+
"ANTHROPIC_BASE_URL": os.environ.get("ANTHROPIC_BASE_URL"),
|
|
422
|
+
"ANTHROPIC_MODEL": os.environ.get("ANTHROPIC_MODEL"),
|
|
423
|
+
"CLAPS_HERMES_TOOLSETS": os.environ.get("CLAPS_HERMES_TOOLSETS"),
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
# ── Import Hermes modules ────────────────────────────────────────────
|
|
429
|
+
hermes_root = ensure_hermes_import_path()
|
|
430
|
+
try:
|
|
431
|
+
from cli import HermesCLI
|
|
432
|
+
except ImportError as exc:
|
|
433
|
+
root_hint = (
|
|
434
|
+
"Resolved roots searched, but no Hermes checkout was found. "
|
|
435
|
+
"Set HERMES_PROJECT_ROOT or PYTHONPATH to the Hermes source root."
|
|
436
|
+
if hermes_root is None
|
|
437
|
+
else f"Resolved Hermes root: {hermes_root}"
|
|
438
|
+
)
|
|
439
|
+
emit({"type": "error", "error": f"Cannot import Hermes modules: {exc}. {root_hint}"})
|
|
440
|
+
sys.exit(1)
|
|
441
|
+
|
|
442
|
+
# ── Create CLI instance (handles config loading, credential setup) ──
|
|
443
|
+
runtime_overrides = resolve_runtime_overrides_from_env()
|
|
444
|
+
configured_toolsets = resolve_toolsets_from_env()
|
|
445
|
+
install_http_debug_logging()
|
|
446
|
+
cli_init_kwargs = {
|
|
447
|
+
"resume": args.resume,
|
|
448
|
+
"pass_session_id": True,
|
|
449
|
+
}
|
|
450
|
+
if configured_toolsets is not None:
|
|
451
|
+
cli_init_kwargs["toolsets"] = configured_toolsets
|
|
452
|
+
if runtime_overrides["model"] is not None:
|
|
453
|
+
cli_init_kwargs["model"] = runtime_overrides["model"]
|
|
454
|
+
if runtime_overrides["base_url"] is not None:
|
|
455
|
+
cli_init_kwargs["base_url"] = runtime_overrides["base_url"]
|
|
456
|
+
if runtime_overrides["provider"] is not None:
|
|
457
|
+
cli_init_kwargs["provider"] = runtime_overrides["provider"]
|
|
458
|
+
|
|
459
|
+
debug_log(
|
|
460
|
+
"runtime_overrides",
|
|
461
|
+
{
|
|
462
|
+
"toolsets": configured_toolsets,
|
|
463
|
+
"runtime_overrides": runtime_overrides,
|
|
464
|
+
},
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
cli = HermesCLI(**cli_init_kwargs)
|
|
468
|
+
cli.tool_progress_mode = "off"
|
|
469
|
+
|
|
470
|
+
# ── Override session id for fresh sessions ───────────────────────────
|
|
471
|
+
# When CLAPS provides a deterministic --session-id, use it so that the
|
|
472
|
+
# same CLAPS conversation maps 1:1 onto a Hermes session. We only
|
|
473
|
+
# override on cold starts; resume paths already locate the session via
|
|
474
|
+
# --resume so the id must come from the persisted record.
|
|
475
|
+
#
|
|
476
|
+
# HermesCLI.__init__ auto-generates a timestamp-based session_id and may
|
|
477
|
+
# have already registered it with SessionDB before we get here, which
|
|
478
|
+
# leaves orphan session_<timestamp>_<hash>.json files behind on every
|
|
479
|
+
# cold start. Clean up the orphan id after overriding so each CLAPS
|
|
480
|
+
# conversation maps to exactly one hermes session file.
|
|
481
|
+
if args.session_id and not args.resume:
|
|
482
|
+
auto_session_id = cli.session_id
|
|
483
|
+
cli.session_id = args.session_id
|
|
484
|
+
if auto_session_id and auto_session_id != args.session_id:
|
|
485
|
+
session_db = getattr(cli, "_session_db", None)
|
|
486
|
+
if session_db is not None:
|
|
487
|
+
try:
|
|
488
|
+
session_db.delete_session(auto_session_id)
|
|
489
|
+
except Exception as exc:
|
|
490
|
+
print(
|
|
491
|
+
f"[adapter] failed to drop auto-generated session row {auto_session_id}: {exc}",
|
|
492
|
+
file=sys.stderr,
|
|
493
|
+
)
|
|
494
|
+
try:
|
|
495
|
+
hermes_home = os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes")
|
|
496
|
+
auto_file = Path(hermes_home) / "sessions" / f"session_{auto_session_id}.json"
|
|
497
|
+
if auto_file.exists():
|
|
498
|
+
auto_file.unlink()
|
|
499
|
+
except OSError as exc:
|
|
500
|
+
print(
|
|
501
|
+
f"[adapter] failed to remove auto-generated session file {auto_session_id}: {exc}",
|
|
502
|
+
file=sys.stderr,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
# ── Preload skills if requested ──────────────────────────────────────
|
|
506
|
+
if args.skills:
|
|
507
|
+
try:
|
|
508
|
+
from hermes_cli.skills_loader import build_preloaded_skills_prompt
|
|
509
|
+
skill_names = [s.strip() for s in args.skills.split(",") if s.strip()]
|
|
510
|
+
skills_prompt, _loaded, _missing = build_preloaded_skills_prompt(
|
|
511
|
+
skill_names, task_id=cli.session_id,
|
|
512
|
+
)
|
|
513
|
+
if skills_prompt:
|
|
514
|
+
cli.system_prompt = "\n\n".join(
|
|
515
|
+
part for part in (cli.system_prompt, skills_prompt) if part
|
|
516
|
+
).strip()
|
|
517
|
+
except Exception as exc:
|
|
518
|
+
print(f"[adapter] skill preload failed: {exc}", file=sys.stderr)
|
|
519
|
+
|
|
520
|
+
# ── Ensure credentials & initialize agent ────────────────────────────
|
|
521
|
+
if not cli._ensure_runtime_credentials():
|
|
522
|
+
emit({"type": "error", "error": "Hermes credential initialization failed"})
|
|
523
|
+
sys.exit(1)
|
|
524
|
+
|
|
525
|
+
turn_route = cli._resolve_turn_agent_config(args.prompt)
|
|
526
|
+
route_signature = turn_route.get("signature")
|
|
527
|
+
if route_signature != cli._active_agent_route_signature:
|
|
528
|
+
cli.agent = None
|
|
529
|
+
|
|
530
|
+
init_agent_kwargs = {
|
|
531
|
+
"model_override": turn_route.get("model"),
|
|
532
|
+
"runtime_override": turn_route.get("runtime"),
|
|
533
|
+
"request_overrides": turn_route.get("request_overrides"),
|
|
534
|
+
}
|
|
535
|
+
try:
|
|
536
|
+
init_agent_signature = inspect.signature(cli._init_agent)
|
|
537
|
+
except (TypeError, ValueError):
|
|
538
|
+
init_agent_signature = None
|
|
539
|
+
|
|
540
|
+
if init_agent_signature and "route_label" in init_agent_signature.parameters:
|
|
541
|
+
route_label = turn_route.get("label")
|
|
542
|
+
if route_label is not None:
|
|
543
|
+
init_agent_kwargs["route_label"] = route_label
|
|
544
|
+
|
|
545
|
+
if not cli._init_agent(**init_agent_kwargs):
|
|
546
|
+
emit({"type": "error", "error": "Hermes agent initialization failed"})
|
|
547
|
+
sys.exit(1)
|
|
548
|
+
|
|
549
|
+
debug_log(
|
|
550
|
+
"agent_initialized",
|
|
551
|
+
{
|
|
552
|
+
"session_id": cli.session_id,
|
|
553
|
+
"turn_route": turn_route,
|
|
554
|
+
"model": getattr(cli, "model", None),
|
|
555
|
+
"provider": getattr(cli, "provider", None),
|
|
556
|
+
"base_url": getattr(cli, "base_url", None),
|
|
557
|
+
},
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
agent = cli.agent
|
|
561
|
+
agent.quiet_mode = True
|
|
562
|
+
agent.suppress_status_output = True
|
|
563
|
+
|
|
564
|
+
if args.max_turns:
|
|
565
|
+
agent.max_iterations = args.max_turns
|
|
566
|
+
|
|
567
|
+
# ── Ensure thinking/reasoning tokens are requested ───────────────────
|
|
568
|
+
# Some providers (e.g. Gemini via LiteLLM) need an explicit thinking
|
|
569
|
+
# config in extra_body. Hermes only sends this for known providers
|
|
570
|
+
# (OpenRouter, Nous, GitHub), so custom/LiteLLM endpoints miss out.
|
|
571
|
+
# Inject it via request_overrides so reasoning_callback actually fires.
|
|
572
|
+
if not getattr(agent, "_supports_reasoning_extra_body", lambda: False)():
|
|
573
|
+
overrides = dict(getattr(agent, "request_overrides", None) or {})
|
|
574
|
+
existing_extra = overrides.get("extra_body", {})
|
|
575
|
+
if "reasoning" not in existing_extra and "thinking" not in existing_extra:
|
|
576
|
+
existing_extra["thinking"] = {"type": "enabled", "budget_tokens": 8192}
|
|
577
|
+
overrides["extra_body"] = existing_extra
|
|
578
|
+
agent.request_overrides = overrides
|
|
579
|
+
|
|
580
|
+
# ── Override callbacks with NDJSON emitters ──────────────────────────
|
|
581
|
+
# Disable streaming so reasoning_callback fires once with the complete
|
|
582
|
+
# reasoning text (instead of many deltas). Tool callbacks fire
|
|
583
|
+
# independently of the streaming path.
|
|
584
|
+
agent.stream_delta_callback = None
|
|
585
|
+
agent.tool_progress_callback = None
|
|
586
|
+
agent.tool_gen_callback = None
|
|
587
|
+
|
|
588
|
+
_last_reasoning_text = None
|
|
589
|
+
|
|
590
|
+
def on_reasoning(text: str) -> None:
|
|
591
|
+
nonlocal _last_reasoning_text
|
|
592
|
+
stripped = (text or "").strip()
|
|
593
|
+
if stripped and stripped != _last_reasoning_text:
|
|
594
|
+
_last_reasoning_text = stripped
|
|
595
|
+
emit({
|
|
596
|
+
"type": "item.completed",
|
|
597
|
+
"item": {"type": "reasoning", "text": stripped},
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
def on_tool_start(call_id: str, name: str, arguments) -> None:
|
|
601
|
+
cmd = ""
|
|
602
|
+
if isinstance(arguments, dict):
|
|
603
|
+
cmd = (
|
|
604
|
+
arguments.get("command", "")
|
|
605
|
+
or arguments.get("code", "")
|
|
606
|
+
or arguments.get("query", "")
|
|
607
|
+
or ""
|
|
608
|
+
)
|
|
609
|
+
emit({
|
|
610
|
+
"type": "item.started",
|
|
611
|
+
"item": {
|
|
612
|
+
"type": name,
|
|
613
|
+
"id": call_id or f"{name}-started",
|
|
614
|
+
"command": str(cmd)[:2000] if cmd else None,
|
|
615
|
+
},
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
def on_tool_complete(call_id: str, name: str, arguments, result: str) -> None:
|
|
619
|
+
emit({
|
|
620
|
+
"type": "item.completed",
|
|
621
|
+
"item": {
|
|
622
|
+
"type": name,
|
|
623
|
+
"id": call_id or f"{name}-completed",
|
|
624
|
+
"status": "completed",
|
|
625
|
+
"aggregated_output": (result or "")[:6000],
|
|
626
|
+
},
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
agent.reasoning_callback = on_reasoning
|
|
630
|
+
agent.tool_start_callback = on_tool_start
|
|
631
|
+
agent.tool_complete_callback = on_tool_complete
|
|
632
|
+
|
|
633
|
+
# ── Emit initial session meta ────────────────────────────────────────
|
|
634
|
+
emit({"type": "session_meta", "payload": {"id": cli.session_id}})
|
|
635
|
+
|
|
636
|
+
# ── Run conversation ─────────────────────────────────────────────────
|
|
637
|
+
failed = False
|
|
638
|
+
response = ""
|
|
639
|
+
result_error = ""
|
|
640
|
+
result_partial = False
|
|
641
|
+
result_completed = True
|
|
642
|
+
try:
|
|
643
|
+
result = agent.run_conversation(
|
|
644
|
+
user_message=args.prompt,
|
|
645
|
+
conversation_history=cli.conversation_history,
|
|
646
|
+
)
|
|
647
|
+
debug_log("run_conversation_result", {"result": result})
|
|
648
|
+
if isinstance(result, dict):
|
|
649
|
+
response = result.get("final_response", "") or ""
|
|
650
|
+
failed = result.get("failed", False)
|
|
651
|
+
result_partial = bool(result.get("partial", False))
|
|
652
|
+
if "completed" in result:
|
|
653
|
+
result_completed = bool(result.get("completed"))
|
|
654
|
+
raw_error = result.get("error")
|
|
655
|
+
if isinstance(raw_error, str):
|
|
656
|
+
result_error = raw_error.strip()
|
|
657
|
+
elif raw_error is not None:
|
|
658
|
+
result_error = str(raw_error).strip()
|
|
659
|
+
else:
|
|
660
|
+
response = str(result) if result else ""
|
|
661
|
+
except Exception as exc:
|
|
662
|
+
emit({"type": "error", "error": f"Hermes execution failed: {exc}"})
|
|
663
|
+
sys.exit(1)
|
|
664
|
+
|
|
665
|
+
# ── Emit final agent message event (consumed but filtered by CLAPS) ─
|
|
666
|
+
if response:
|
|
667
|
+
emit({
|
|
668
|
+
"type": "item.completed",
|
|
669
|
+
"item": {"type": "agent_message", "content": response},
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
should_fail = failed or (
|
|
673
|
+
not response.strip() and (result_partial or not result_completed or bool(result_error))
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
if should_fail:
|
|
677
|
+
emit({
|
|
678
|
+
"type": "error",
|
|
679
|
+
"error": result_error or "Hermes returned an incomplete result without a final response.",
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
# ── Emit closing session meta (session_id may update on resume) ──────
|
|
683
|
+
emit({"type": "session_meta", "payload": {"id": cli.session_id}})
|
|
684
|
+
|
|
685
|
+
# ── Write clean response to output file ──────────────────────────────
|
|
686
|
+
if args.output:
|
|
687
|
+
try:
|
|
688
|
+
with open(args.output, "w", encoding="utf-8") as f:
|
|
689
|
+
f.write(response)
|
|
690
|
+
except OSError as exc:
|
|
691
|
+
print(f"[adapter] failed to write output file: {exc}", file=sys.stderr)
|
|
692
|
+
|
|
693
|
+
sys.exit(1 if should_fail else 0)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
if __name__ == "__main__":
|
|
697
|
+
main()
|