@triclaps/cli 0.0.5 → 0.0.7

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.
@@ -11,9 +11,11 @@ Events emitted (one JSON object per line):
11
11
  {"type":"item.started","item":{"type":"<tool>","id":"<id>","command":"..."}}
12
12
  {"type":"item.completed","item":{"type":"<tool>","id":"<id>","status":"completed","aggregated_output":"..."}}
13
13
  {"type":"item.completed","item":{"type":"agent_message"}}
14
+ {"type":"request.completed","success":true,"response":"...","session_id":"..."}
14
15
 
15
16
  Usage:
16
- python3 hermes_claps_adapter.py --prompt "..." [-o /tmp/output.txt] [--resume ID]
17
+ python3 hermes_claps_adapter.py --prompt "..." [--yolo] [-o /tmp/output.txt] [--resume ID]
18
+ python3 hermes_claps_adapter.py --server [--yolo] [--resume ID]
17
19
 
18
20
  The final agent response is written to the file specified by -o (if provided).
19
21
  All structured NDJSON events are emitted to stdout.
@@ -25,9 +27,13 @@ import inspect
25
27
  import json
26
28
  import os
27
29
  from pathlib import Path
30
+ import time
28
31
  from typing import Any
29
32
  import sys
30
33
  import threading
34
+ import urllib.error
35
+ import urllib.parse
36
+ import urllib.request
31
37
 
32
38
  # ---------------------------------------------------------------------------
33
39
  # Redirect stdout BEFORE importing any Hermes modules.
@@ -71,16 +77,36 @@ def resolve_debug_body_limit() -> int:
71
77
 
72
78
 
73
79
  DEBUG_BODY_LIMIT = resolve_debug_body_limit()
80
+ DEFAULT_CLARIFY_TIMEOUT_RESPONSE = (
81
+ "The user did not provide a response within the time limit. "
82
+ "Use your best judgement to make the choice and proceed."
83
+ )
84
+ CURRENT_REQUEST_CONTEXT: dict[str, str | None] = {
85
+ "request_id": None,
86
+ "ticket_id": None,
87
+ }
74
88
 
75
89
 
76
- def emit(event: dict) -> None:
90
+ def emit(event: dict, *, request_id: str | None = None) -> None:
77
91
  """Write one NDJSON event to the real stdout (fd-level) and flush."""
78
- line = json.dumps(event, ensure_ascii=False) + "\n"
92
+ payload = dict(event)
93
+ if request_id:
94
+ payload["request_id"] = request_id
95
+ line = json.dumps(payload, ensure_ascii=False) + "\n"
79
96
  with _emit_lock:
80
97
  _ndjson_out.write(line)
81
98
  _ndjson_out.flush()
82
99
 
83
100
 
101
+ def set_current_request_context(
102
+ *,
103
+ request_id: str | None,
104
+ ticket_id: str | None,
105
+ ) -> None:
106
+ CURRENT_REQUEST_CONTEXT["request_id"] = request_id
107
+ CURRENT_REQUEST_CONTEXT["ticket_id"] = ticket_id
108
+
109
+
84
110
  def clip_debug_text(text: str) -> str:
85
111
  if len(text) <= DEBUG_BODY_LIMIT:
86
112
  return text
@@ -88,6 +114,72 @@ def clip_debug_text(text: str) -> str:
88
114
  return f"{text[:DEBUG_BODY_LIMIT]}... [truncated {remaining} chars]"
89
115
 
90
116
 
117
+ def sanitize_topic_title(title: str | None) -> str | None:
118
+ if not isinstance(title, str):
119
+ return None
120
+
121
+ cleaned = " ".join(title.replace("[", " ").replace("]", " ").split()).strip()
122
+ lowered = cleaned.lower()
123
+
124
+ if (
125
+ not cleaned
126
+ or lowered == "{sanitized_title}"
127
+ or "sanitized_title" in lowered
128
+ or (cleaned.startswith("{") and cleaned.endswith("}"))
129
+ ):
130
+ return None
131
+
132
+ return cleaned or None
133
+
134
+
135
+ def response_already_sets_topic_title(response: str) -> bool:
136
+ return "[[TOPIC_TITLE:" in (response or "")
137
+
138
+
139
+ def resolve_topic_title_from_session(cli, *, wait_timeout_sec: float = 2.0) -> str | None:
140
+ session_db = getattr(cli, "_session_db", None)
141
+ session_id = getattr(cli, "session_id", None)
142
+ conversation_history = getattr(cli, "conversation_history", None) or []
143
+
144
+ if session_db is None or not session_id:
145
+ return None
146
+
147
+ user_message_count = sum(
148
+ 1
149
+ for item in conversation_history
150
+ if isinstance(item, dict) and item.get("role") == "user"
151
+ )
152
+ should_wait_for_background_title = user_message_count <= 2
153
+ deadline = time.monotonic() + (
154
+ max(wait_timeout_sec, 0.0) if should_wait_for_background_title else 0.0
155
+ )
156
+
157
+ while True:
158
+ try:
159
+ current_title = sanitize_topic_title(session_db.get_session_title(session_id))
160
+ except Exception:
161
+ return None
162
+
163
+ if current_title:
164
+ return current_title
165
+
166
+ if time.monotonic() >= deadline:
167
+ return None
168
+
169
+ time.sleep(0.05)
170
+
171
+
172
+ def append_topic_title_control_line(response: str, topic_title: str | None) -> str:
173
+ sanitized_title = sanitize_topic_title(topic_title)
174
+ if not sanitized_title or response_already_sets_topic_title(response):
175
+ return response
176
+
177
+ if not response.strip():
178
+ return f"[[TOPIC_TITLE:{sanitized_title}]]"
179
+
180
+ return f"{response.rstrip()}\n[[TOPIC_TITLE:{sanitized_title}]]"
181
+
182
+
91
183
  def normalize_debug_value(value: Any) -> Any:
92
184
  if value is None:
93
185
  return None
@@ -388,9 +480,421 @@ def resolve_toolsets_from_env() -> list[str] | None:
388
480
  return toolsets or None
389
481
 
390
482
 
483
+ def prepare_agent_for_request(cli, prompt: str, max_turns: int | None):
484
+ if not cli._ensure_runtime_credentials():
485
+ raise RuntimeError("Hermes credential initialization failed")
486
+
487
+ turn_route = cli._resolve_turn_agent_config(prompt)
488
+ route_signature = turn_route.get("signature")
489
+ if route_signature != cli._active_agent_route_signature:
490
+ cli.agent = None
491
+
492
+ init_agent_kwargs = {
493
+ "model_override": turn_route.get("model"),
494
+ "runtime_override": turn_route.get("runtime"),
495
+ "request_overrides": turn_route.get("request_overrides"),
496
+ }
497
+ try:
498
+ init_agent_signature = inspect.signature(cli._init_agent)
499
+ except (TypeError, ValueError):
500
+ init_agent_signature = None
501
+
502
+ if init_agent_signature and "route_label" in init_agent_signature.parameters:
503
+ route_label = turn_route.get("label")
504
+ if route_label is not None:
505
+ init_agent_kwargs["route_label"] = route_label
506
+
507
+ if not cli._init_agent(**init_agent_kwargs):
508
+ raise RuntimeError("Hermes agent initialization failed")
509
+
510
+ debug_log(
511
+ "agent_initialized",
512
+ {
513
+ "session_id": cli.session_id,
514
+ "turn_route": turn_route,
515
+ "model": getattr(cli, "model", None),
516
+ "provider": getattr(cli, "provider", None),
517
+ "base_url": getattr(cli, "base_url", None),
518
+ },
519
+ )
520
+
521
+ agent = cli.agent
522
+ agent.quiet_mode = True
523
+ agent.suppress_status_output = True
524
+
525
+ if max_turns:
526
+ agent.max_iterations = max_turns
527
+
528
+ if not getattr(agent, "_supports_reasoning_extra_body", lambda: False)():
529
+ overrides = dict(getattr(agent, "request_overrides", None) or {})
530
+ existing_extra = overrides.get("extra_body", {})
531
+ if "reasoning" not in existing_extra and "thinking" not in existing_extra:
532
+ existing_extra["thinking"] = {"type": "enabled", "budget_tokens": 8192}
533
+ overrides["extra_body"] = existing_extra
534
+ agent.request_overrides = overrides
535
+
536
+ return agent
537
+
538
+
539
+ def install_ndjson_callbacks(agent, *, request_id: str | None):
540
+ agent.stream_delta_callback = None
541
+ agent.tool_progress_callback = lambda *args, **kwargs: None
542
+ agent.tool_gen_callback = None
543
+
544
+ def on_reasoning(text: str) -> None:
545
+ stripped = (text or "").strip()
546
+ if not stripped:
547
+ return
548
+ emit(
549
+ {
550
+ "type": "item.completed",
551
+ "item": {"type": "reasoning", "text": stripped},
552
+ },
553
+ request_id=request_id,
554
+ )
555
+
556
+ def on_tool_start(call_id: str, name: str, arguments) -> None:
557
+ cmd = ""
558
+ if isinstance(arguments, dict):
559
+ cmd = (
560
+ arguments.get("command", "")
561
+ or arguments.get("code", "")
562
+ or arguments.get("query", "")
563
+ or ""
564
+ )
565
+ emit(
566
+ {
567
+ "type": "item.started",
568
+ "item": {
569
+ "type": name,
570
+ "id": call_id or f"{name}-started",
571
+ "command": str(cmd)[:2000] if cmd else None,
572
+ },
573
+ },
574
+ request_id=request_id,
575
+ )
576
+
577
+ def on_tool_complete(call_id: str, name: str, arguments, result: str) -> None:
578
+ emit(
579
+ {
580
+ "type": "item.completed",
581
+ "item": {
582
+ "type": name,
583
+ "id": call_id or f"{name}-completed",
584
+ "status": "completed",
585
+ "aggregated_output": (result or "")[:6000],
586
+ },
587
+ },
588
+ request_id=request_id,
589
+ )
590
+
591
+ def on_clarify(question: str, choices=None) -> str:
592
+ api_base_url = os.environ.get("CLAPS_API_BASE_URL", "").strip().rstrip("/")
593
+ agent_token = os.environ.get("CLAPS_AGENT_TOKEN", "").strip()
594
+ ticket_id = (
595
+ CURRENT_REQUEST_CONTEXT.get("ticket_id")
596
+ or os.environ.get("CLAPS_CONVERSATION_TICKET_ID", "")
597
+ ).strip()
598
+ timeout_raw = os.environ.get("CLAPS_CLARIFY_TIMEOUT_SEC", "").strip()
599
+
600
+ try:
601
+ timeout_sec = int(timeout_raw) if timeout_raw else 120
602
+ except ValueError:
603
+ timeout_sec = 120
604
+
605
+ timeout_sec = max(1, min(timeout_sec, 300))
606
+
607
+ if not api_base_url or not agent_token or not ticket_id:
608
+ debug_log(
609
+ "clarify_unconfigured",
610
+ {
611
+ "api_base_url": api_base_url,
612
+ "has_agent_token": bool(agent_token),
613
+ "ticket_id": ticket_id,
614
+ },
615
+ )
616
+ return DEFAULT_CLARIFY_TIMEOUT_RESPONSE
617
+
618
+ payload = {
619
+ "question": str(question or "").strip(),
620
+ "choices": choices,
621
+ "timeoutSec": timeout_sec,
622
+ }
623
+ debug_log(
624
+ "clarify_request_started",
625
+ {
626
+ "api_base_url": api_base_url,
627
+ "ticket_id": ticket_id,
628
+ "timeout_sec": timeout_sec,
629
+ "question": payload["question"],
630
+ "choices": payload["choices"],
631
+ },
632
+ )
633
+ request_url = (
634
+ f"{api_base_url}/api/conversation-tickets/"
635
+ f"{urllib.parse.quote(ticket_id)}/clarify-requests"
636
+ )
637
+ request = urllib.request.Request(
638
+ request_url,
639
+ data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
640
+ headers={
641
+ "authorization": f"Bearer {agent_token}",
642
+ "content-type": "application/json",
643
+ "accept": "application/json",
644
+ },
645
+ method="POST",
646
+ )
647
+
648
+ try:
649
+ with urllib.request.urlopen(request, timeout=timeout_sec + 15) as response:
650
+ body = response.read().decode("utf-8", errors="replace")
651
+ except urllib.error.HTTPError as exc:
652
+ body = exc.read().decode("utf-8", errors="replace")
653
+ print(
654
+ f"[adapter] clarify request failed: {exc.code} {exc.reason} {body}",
655
+ file=sys.stderr,
656
+ flush=True,
657
+ )
658
+ return DEFAULT_CLARIFY_TIMEOUT_RESPONSE
659
+ except Exception as exc:
660
+ print(f"[adapter] clarify request failed: {exc}", file=sys.stderr, flush=True)
661
+ return DEFAULT_CLARIFY_TIMEOUT_RESPONSE
662
+
663
+ try:
664
+ parsed = json.loads(body)
665
+ except json.JSONDecodeError:
666
+ print(
667
+ f"[adapter] clarify response was not valid JSON: {body}",
668
+ file=sys.stderr,
669
+ flush=True,
670
+ )
671
+ return DEFAULT_CLARIFY_TIMEOUT_RESPONSE
672
+
673
+ debug_log(
674
+ "clarify_request_resolved",
675
+ {
676
+ "ticket_id": ticket_id,
677
+ "response": parsed,
678
+ },
679
+ )
680
+ answer = parsed.get("answer")
681
+ if isinstance(answer, str) and answer.strip():
682
+ return answer.strip()
683
+
684
+ return DEFAULT_CLARIFY_TIMEOUT_RESPONSE
685
+
686
+ agent.reasoning_callback = on_reasoning
687
+ agent.tool_start_callback = on_tool_start
688
+ agent.tool_complete_callback = on_tool_complete
689
+ agent.clarify_callback = on_clarify
690
+
691
+
692
+ def run_request(
693
+ cli,
694
+ *,
695
+ prompt: str,
696
+ output_path: str | None = None,
697
+ max_turns: int | None = None,
698
+ request_id: str | None = None,
699
+ ticket_id: str | None = None,
700
+ ) -> dict[str, Any]:
701
+ set_current_request_context(request_id=request_id, ticket_id=ticket_id)
702
+ emit({"type": "session_meta", "payload": {"id": cli.session_id}}, request_id=request_id)
703
+
704
+ try:
705
+ try:
706
+ agent = prepare_agent_for_request(cli, prompt, max_turns)
707
+ install_ndjson_callbacks(agent, request_id=request_id)
708
+ except Exception as exc:
709
+ failure_message = f"Hermes execution failed: {exc}"
710
+ emit({"type": "error", "error": failure_message}, request_id=request_id)
711
+ emit({"type": "session_meta", "payload": {"id": cli.session_id}}, request_id=request_id)
712
+ return {
713
+ "success": False,
714
+ "response": "",
715
+ "error": failure_message,
716
+ "session_id": cli.session_id,
717
+ "exit_code": 1,
718
+ }
719
+
720
+ failed = False
721
+ response = ""
722
+ result_error = ""
723
+ result_partial = False
724
+ result_completed = True
725
+
726
+ try:
727
+ result = agent.run_conversation(
728
+ user_message=prompt,
729
+ conversation_history=cli.conversation_history,
730
+ )
731
+ debug_log("run_conversation_result", {"result": result})
732
+ if isinstance(result, dict):
733
+ response = result.get("final_response", "") or ""
734
+ failed = result.get("failed", False)
735
+ result_partial = bool(result.get("partial", False))
736
+ if "completed" in result:
737
+ result_completed = bool(result.get("completed"))
738
+ raw_error = result.get("error")
739
+ if isinstance(raw_error, str):
740
+ result_error = raw_error.strip()
741
+ elif raw_error is not None:
742
+ result_error = str(raw_error).strip()
743
+ else:
744
+ response = str(result) if result else ""
745
+ except Exception as exc:
746
+ failure_message = f"Hermes execution failed: {exc}"
747
+ emit({"type": "error", "error": failure_message}, request_id=request_id)
748
+ emit({"type": "session_meta", "payload": {"id": cli.session_id}}, request_id=request_id)
749
+ return {
750
+ "success": False,
751
+ "response": "",
752
+ "error": failure_message,
753
+ "session_id": cli.session_id,
754
+ "exit_code": 1,
755
+ }
756
+
757
+ if (
758
+ response
759
+ and not failed
760
+ and not result_partial
761
+ and result_completed
762
+ and not response_already_sets_topic_title(response)
763
+ ):
764
+ response = append_topic_title_control_line(
765
+ response,
766
+ resolve_topic_title_from_session(cli),
767
+ )
768
+
769
+ if response:
770
+ emit(
771
+ {
772
+ "type": "item.completed",
773
+ "item": {"type": "agent_message", "content": response},
774
+ },
775
+ request_id=request_id,
776
+ )
777
+
778
+ should_fail = failed or (
779
+ not response.strip() and (result_partial or not result_completed or bool(result_error))
780
+ )
781
+ failure_message = (
782
+ result_error or "Hermes returned an incomplete result without a final response."
783
+ )
784
+
785
+ if should_fail:
786
+ emit({"type": "error", "error": failure_message}, request_id=request_id)
787
+
788
+ emit({"type": "session_meta", "payload": {"id": cli.session_id}}, request_id=request_id)
789
+
790
+ if output_path:
791
+ try:
792
+ with open(output_path, "w", encoding="utf-8") as f:
793
+ f.write(response)
794
+ except OSError as exc:
795
+ print(f"[adapter] failed to write output file: {exc}", file=sys.stderr)
796
+
797
+ return {
798
+ "success": not should_fail,
799
+ "response": response,
800
+ "error": failure_message if should_fail else None,
801
+ "session_id": cli.session_id,
802
+ "exit_code": 0 if not should_fail else 1,
803
+ }
804
+ finally:
805
+ set_current_request_context(request_id=None, ticket_id=None)
806
+
807
+
808
+ def serve_requests(cli, *, default_max_turns: int | None = None) -> None:
809
+ for raw_line in sys.stdin:
810
+ payload_text = raw_line.strip()
811
+
812
+ if not payload_text:
813
+ continue
814
+
815
+ try:
816
+ payload = json.loads(payload_text)
817
+ except json.JSONDecodeError as exc:
818
+ print(f"[adapter] invalid server request JSON: {exc}", file=sys.stderr, flush=True)
819
+ continue
820
+
821
+ if not isinstance(payload, dict):
822
+ print("[adapter] invalid server request payload", file=sys.stderr, flush=True)
823
+ continue
824
+
825
+ if payload.get("type") == "shutdown":
826
+ break
827
+
828
+ request_id = payload.get("request_id")
829
+ if not isinstance(request_id, str) or not request_id.strip():
830
+ request_id = f"server_request_{time.time_ns()}"
831
+
832
+ prompt = payload.get("prompt")
833
+ if not isinstance(prompt, str) or not prompt.strip():
834
+ emit(
835
+ {
836
+ "type": "request.completed",
837
+ "success": False,
838
+ "response": "",
839
+ "error": "Server request is missing a prompt.",
840
+ "session_id": getattr(cli, "session_id", None),
841
+ "exit_code": 1,
842
+ },
843
+ request_id=request_id,
844
+ )
845
+ continue
846
+
847
+ max_turns = (
848
+ payload.get("max_turns")
849
+ if isinstance(payload.get("max_turns"), int)
850
+ else default_max_turns
851
+ )
852
+ ticket_id = payload.get("ticket_id") if isinstance(payload.get("ticket_id"), str) else None
853
+ try:
854
+ result = run_request(
855
+ cli,
856
+ prompt=prompt,
857
+ output_path=None,
858
+ max_turns=max_turns,
859
+ request_id=request_id,
860
+ ticket_id=ticket_id,
861
+ )
862
+ except Exception as exc:
863
+ failure_message = f"Hermes execution failed: {exc}"
864
+ emit({"type": "error", "error": failure_message}, request_id=request_id)
865
+ emit(
866
+ {"type": "session_meta", "payload": {"id": getattr(cli, "session_id", None)}},
867
+ request_id=request_id,
868
+ )
869
+ emit(
870
+ {
871
+ "type": "request.completed",
872
+ "success": False,
873
+ "response": "",
874
+ "error": failure_message,
875
+ "session_id": getattr(cli, "session_id", None),
876
+ "exit_code": 1,
877
+ },
878
+ request_id=request_id,
879
+ )
880
+ continue
881
+
882
+ emit(
883
+ {
884
+ "type": "request.completed",
885
+ "success": result["success"],
886
+ "response": result["response"],
887
+ "error": result["error"],
888
+ "session_id": result["session_id"],
889
+ "exit_code": result["exit_code"],
890
+ },
891
+ request_id=request_id,
892
+ )
893
+
894
+
391
895
  def main() -> None:
392
896
  parser = argparse.ArgumentParser(description="CLAPS Hermes structured adapter")
393
- parser.add_argument("--prompt", required=True, help="User prompt")
897
+ parser.add_argument("--prompt", default=None, help="User prompt")
394
898
  parser.add_argument("-o", "--output", default=None, help="Output file for the final response")
395
899
  parser.add_argument("--resume", default=None, help="Hermes session ID to resume")
396
900
  parser.add_argument(
@@ -400,8 +904,21 @@ def main() -> None:
400
904
  )
401
905
  parser.add_argument("--max-turns", type=int, default=None, help="Max tool iterations")
402
906
  parser.add_argument("--skills", default=None, help="Comma-separated skill names to preload")
907
+ parser.add_argument(
908
+ "--yolo",
909
+ action="store_true",
910
+ help="Enable Hermes YOLO mode so dangerous command approvals are auto-bypassed.",
911
+ )
912
+ parser.add_argument(
913
+ "--server",
914
+ action="store_true",
915
+ help="Keep Hermes initialized and serve multiple requests from stdin.",
916
+ )
403
917
  args = parser.parse_args()
404
918
 
919
+ if not args.server and (not isinstance(args.prompt, str) or not args.prompt.strip()):
920
+ parser.error("--prompt is required unless --server is used")
921
+
405
922
  debug_log(
406
923
  "incoming_request",
407
924
  {
@@ -410,9 +927,12 @@ def main() -> None:
410
927
  "session_id": args.session_id,
411
928
  "max_turns": args.max_turns,
412
929
  "skills": args.skills,
930
+ "yolo": args.yolo,
413
931
  "prompt": args.prompt,
932
+ "server": args.server,
414
933
  "env": {
415
934
  "HERMES_HOME": os.environ.get("HERMES_HOME"),
935
+ "HERMES_YOLO_MODE": os.environ.get("HERMES_YOLO_MODE"),
416
936
  "HERMES_PROJECT_ROOT": os.environ.get("HERMES_PROJECT_ROOT"),
417
937
  "HERMES_INFERENCE_PROVIDER": os.environ.get("HERMES_INFERENCE_PROVIDER"),
418
938
  "HERMES_BASE_URL": os.environ.get("HERMES_BASE_URL"),
@@ -425,7 +945,9 @@ def main() -> None:
425
945
  },
426
946
  )
427
947
 
428
- # ── Import Hermes modules ────────────────────────────────────────────
948
+ if args.yolo:
949
+ os.environ["HERMES_YOLO_MODE"] = "1"
950
+
429
951
  hermes_root = ensure_hermes_import_path()
430
952
  try:
431
953
  from cli import HermesCLI
@@ -439,7 +961,6 @@ def main() -> None:
439
961
  emit({"type": "error", "error": f"Cannot import Hermes modules: {exc}. {root_hint}"})
440
962
  sys.exit(1)
441
963
 
442
- # ── Create CLI instance (handles config loading, credential setup) ──
443
964
  runtime_overrides = resolve_runtime_overrides_from_env()
444
965
  configured_toolsets = resolve_toolsets_from_env()
445
966
  install_http_debug_logging()
@@ -467,17 +988,6 @@ def main() -> None:
467
988
  cli = HermesCLI(**cli_init_kwargs)
468
989
  cli.tool_progress_mode = "off"
469
990
 
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
991
  if args.session_id and not args.resume:
482
992
  auto_session_id = cli.session_id
483
993
  cli.session_id = args.session_id
@@ -502,10 +1012,10 @@ def main() -> None:
502
1012
  file=sys.stderr,
503
1013
  )
504
1014
 
505
- # ── Preload skills if requested ──────────────────────────────────────
506
1015
  if args.skills:
507
1016
  try:
508
1017
  from hermes_cli.skills_loader import build_preloaded_skills_prompt
1018
+
509
1019
  skill_names = [s.strip() for s in args.skills.split(",") if s.strip()]
510
1020
  skills_prompt, _loaded, _missing = build_preloaded_skills_prompt(
511
1021
  skill_names, task_id=cli.session_id,
@@ -517,180 +1027,23 @@ def main() -> None:
517
1027
  except Exception as exc:
518
1028
  print(f"[adapter] skill preload failed: {exc}", file=sys.stderr)
519
1029
 
520
- # ── Ensure credentials & initialize agent ────────────────────────────
521
1030
  if not cli._ensure_runtime_credentials():
522
1031
  emit({"type": "error", "error": "Hermes credential initialization failed"})
523
1032
  sys.exit(1)
524
1033
 
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
- })
1034
+ if args.server:
1035
+ serve_requests(cli, default_max_turns=args.max_turns)
1036
+ return
671
1037
 
672
- should_fail = failed or (
673
- not response.strip() and (result_partial or not result_completed or bool(result_error))
1038
+ result = run_request(
1039
+ cli,
1040
+ prompt=args.prompt.strip(),
1041
+ output_path=args.output,
1042
+ max_turns=args.max_turns,
1043
+ request_id=None,
1044
+ ticket_id=None,
674
1045
  )
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)
1046
+ sys.exit(0 if result["success"] else 1)
694
1047
 
695
1048
 
696
1049
  if __name__ == "__main__":