@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.
- package/README.md +19 -0
- package/adapters/hermes_claps_adapter.py +539 -186
- package/index.js +23793 -25443
- package/neo-cli.js +7734 -0
- package/package.json +6 -3
|
@@ -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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
673
|
-
|
|
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__":
|