@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.
@@ -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()