roller-agent 0.2.0__py3-none-any.whl
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.
- roller/__init__.py +31 -0
- roller/__main__.py +6 -0
- roller/actf.py +431 -0
- roller/artifacts.py +124 -0
- roller/assets/deepseek/journal.mjs +44 -0
- roller/assets/deepseek/roller.patch.yml +15 -0
- roller/assets/deepseek/runner.py +149 -0
- roller/assets/hermes/runner.py +322 -0
- roller/assets/openclaw_capture.py +32 -0
- roller/assets/openclaw_drain.mjs +21 -0
- roller/assets/opencode/journal.mjs +42 -0
- roller/assets/opencode/runner.py +203 -0
- roller/assets/pi/runner.mjs +160 -0
- roller/assets/pi/runner.py +93 -0
- roller/benches/__init__.py +73 -0
- roller/benches/base.py +306 -0
- roller/benches/grading.py +553 -0
- roller/benches/pinchbench.py +976 -0
- roller/benches/qwenclaw/__init__.py +49 -0
- roller/benches/qwenclaw/adapter.py +167 -0
- roller/benches/qwenclaw/grader_runtime.py +395 -0
- roller/benches/qwenclaw/report.py +49 -0
- roller/benches/qwenclaw/task_parser.py +181 -0
- roller/benches/qwenclaw/workspace.py +202 -0
- roller/benches/report.py +166 -0
- roller/benches/skillsbench.py +708 -0
- roller/benches/transcripts.py +102 -0
- roller/benches/wildclaw.py +569 -0
- roller/bridge_capture.py +73 -0
- roller/bundles.py +545 -0
- roller/capture.py +385 -0
- roller/capture_protocol.py +301 -0
- roller/capture_runtime.py +48 -0
- roller/capture_states.py +37 -0
- roller/claude_capture.py +185 -0
- roller/claude_code_actf.py +594 -0
- roller/cli.py +1732 -0
- roller/codex_actf.py +584 -0
- roller/codex_capture.py +99 -0
- roller/codex_tools.py +137 -0
- roller/collector.py +818 -0
- roller/compaction_sft.py +672 -0
- roller/conversation.py +432 -0
- roller/deepseek_actf.py +135 -0
- roller/harness_registry.py +61 -0
- roller/harnesses/__init__.py +29 -0
- roller/harnesses/base.py +64 -0
- roller/harnesses/bridge.py +448 -0
- roller/harnesses/claude_code.py +839 -0
- roller/harnesses/codex.py +808 -0
- roller/harnesses/config_overrides.py +100 -0
- roller/harnesses/container.py +206 -0
- roller/harnesses/deepseek.py +104 -0
- roller/harnesses/docker.py +72 -0
- roller/harnesses/gateway_ports.py +155 -0
- roller/harnesses/hermes.py +119 -0
- roller/harnesses/openclaw.py +1605 -0
- roller/harnesses/opencode.py +193 -0
- roller/harnesses/pi.py +111 -0
- roller/hermes_actf.py +147 -0
- roller/image_build.py +206 -0
- roller/integrity.py +332 -0
- roller/model_protocols.py +201 -0
- roller/models.py +169 -0
- roller/openclaw_capture.py +140 -0
- roller/openclaw_delivery.py +211 -0
- roller/openclaw_usage.py +59 -0
- roller/opencode_actf.py +305 -0
- roller/opencode_capture.py +96 -0
- roller/pi_actf.py +172 -0
- roller/response_metadata.py +89 -0
- roller/run_lock.py +297 -0
- roller/sft.py +471 -0
- roller/storage.py +53 -0
- roller/tasks.py +689 -0
- roller/thinking.py +50 -0
- roller/thinking_capabilities.py +152 -0
- roller/trajectory_audit.py +386 -0
- roller/usage.py +168 -0
- roller/version.py +1 -0
- roller/viewer/__init__.py +3 -0
- roller/viewer/app.py +722 -0
- roller/viewer/static/app.js +752 -0
- roller/viewer/static/index.html +105 -0
- roller/viewer/static/style.css +213 -0
- roller/wire_trace.py +650 -0
- roller_agent-0.2.0.dist-info/METADATA +397 -0
- roller_agent-0.2.0.dist-info/RECORD +92 -0
- roller_agent-0.2.0.dist-info/WHEEL +5 -0
- roller_agent-0.2.0.dist-info/entry_points.txt +2 -0
- roller_agent-0.2.0.dist-info/licenses/LICENSE +201 -0
- roller_agent-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Container-only bridge to the pinned official DeepSeek Harness SDK.
|
|
2
|
+
|
|
3
|
+
A private Unix socket keeps one SDK/runtime process (and its Bash PTY) alive
|
|
4
|
+
across Roller turns. The host owns the hard deadline and container cleanup.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
from importlib.metadata import version
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import socket
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
VERSION = "0.1.5rc1"
|
|
19
|
+
STATE = Path("/roller/deepseek-state")
|
|
20
|
+
SOCKET = str(STATE / "runner.sock")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check():
|
|
24
|
+
from deepseek_harness_runtime import bundled_runtime_path
|
|
25
|
+
for package in ("deepseek-harness-sdk", "deepseek-harness-runtime-bin"):
|
|
26
|
+
installed = version(package)
|
|
27
|
+
if installed != VERSION:
|
|
28
|
+
raise RuntimeError(f"{package} version mismatch: expected {VERSION}, got {installed}")
|
|
29
|
+
if not os.access(bundled_runtime_path(), os.X_OK):
|
|
30
|
+
raise RuntimeError("DeepSeek native runtime is not executable")
|
|
31
|
+
return VERSION
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def redact(value):
|
|
35
|
+
text = json.dumps(value, ensure_ascii=False)
|
|
36
|
+
key = os.environ.get("DEEPSEEK_API_KEY")
|
|
37
|
+
return text.replace(key, "[REDACTED]") if key else text
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def journal(value):
|
|
41
|
+
with (STATE / "trace.jsonl").open("a", encoding="utf-8") as stream:
|
|
42
|
+
stream.write(redact(value) + "\n")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def write_result(value):
|
|
46
|
+
temp = STATE / "result.json.tmp"
|
|
47
|
+
temp.write_text(redact(value), encoding="utf-8")
|
|
48
|
+
temp.replace(STATE / "result.json")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def serve(config):
|
|
52
|
+
from deepseek_harness import DeepSeekHarness
|
|
53
|
+
check()
|
|
54
|
+
os.environ["DSH_SYSTEM_PROMPT"] = config["system_prompt"]
|
|
55
|
+
os.environ["DSH_CONTEXT_WINDOW"] = str(config["context_length"])
|
|
56
|
+
os.environ["ROLLER_DEEPSEEK_MODEL"] = config["model"]
|
|
57
|
+
os.environ["ROLLER_DEEPSEEK_OUTPUT_TOKENS"] = str(config["output_tokens"])
|
|
58
|
+
journal({"type": "metadata", "schema": "roller.deepseek-events/v1", "version": VERSION,
|
|
59
|
+
"profile": "sdk-minimal", "session_id": config["session_id"], "model": config["model"]})
|
|
60
|
+
# Keep initialization errors reviewable even when there is no native turn.
|
|
61
|
+
with DeepSeekHarness(
|
|
62
|
+
model=config["model"], provider="deepseek-official", cwd=os.getcwd(),
|
|
63
|
+
profile="sdk-minimal", dsh_home="/roller/deepseek-home",
|
|
64
|
+
patches=(str(STATE / "roller.patch.yml"),),
|
|
65
|
+
reasoning_effort=config["thinking"], max_tokens=config["output_tokens"],
|
|
66
|
+
initialize_timeout_seconds=min(60, config["timeout_seconds"]),
|
|
67
|
+
) as harness:
|
|
68
|
+
session = harness.start_session(config["session_id"])
|
|
69
|
+
with socket.socket(socket.AF_UNIX) as server:
|
|
70
|
+
server.bind(SOCKET)
|
|
71
|
+
os.chmod(SOCKET, 0o600)
|
|
72
|
+
server.listen(1)
|
|
73
|
+
while True:
|
|
74
|
+
connection, _ = server.accept()
|
|
75
|
+
with connection, connection.makefile("r", encoding="utf-8") as stream:
|
|
76
|
+
payload = json.loads(stream.readline())
|
|
77
|
+
try:
|
|
78
|
+
result = session.run(payload["message"], on_notification=lambda n: journal(
|
|
79
|
+
{"type": "notification", "method": n.method, "payload": n.payload}))
|
|
80
|
+
limit_path = STATE / "limit.json"
|
|
81
|
+
if limit_path.exists():
|
|
82
|
+
limit = json.loads(limit_path.read_text())
|
|
83
|
+
if limit["turn"] == payload["turn"]:
|
|
84
|
+
raise RuntimeError(limit["error"])
|
|
85
|
+
if result.finish_reason != "completed" or not result.final_response.strip():
|
|
86
|
+
reason = next((e["data"]["reason"] for e in reversed(result.events)
|
|
87
|
+
if e.get("type") == "turn/end"), result.finish_reason)
|
|
88
|
+
raise RuntimeError(f"DeepSeek turn did not complete: {json.dumps(reason, ensure_ascii=False)}")
|
|
89
|
+
value = {"ok": True, "content": result.final_response}
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
value = {"ok": False, "error": str(exc)}
|
|
92
|
+
journal({"type": "runner_error", "error": str(exc)})
|
|
93
|
+
connection.sendall((redact(value) + "\n").encode())
|
|
94
|
+
if not value["ok"]:
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def send(config):
|
|
99
|
+
process = None
|
|
100
|
+
if not Path(SOCKET).exists():
|
|
101
|
+
with (STATE / "runtime.log").open("a") as log:
|
|
102
|
+
process = subprocess.Popen([sys.executable, __file__, "--serve", str(STATE / "turn.json")],
|
|
103
|
+
stdin=subprocess.DEVNULL, stdout=log, stderr=log,
|
|
104
|
+
start_new_session=True)
|
|
105
|
+
deadline = time.monotonic() + config["timeout_seconds"]
|
|
106
|
+
with socket.socket(socket.AF_UNIX) as client:
|
|
107
|
+
while True:
|
|
108
|
+
if process is not None and process.poll() is not None:
|
|
109
|
+
raise RuntimeError("DeepSeek runtime startup failed; see the native journal and rollout log")
|
|
110
|
+
try:
|
|
111
|
+
client.connect(SOCKET)
|
|
112
|
+
break
|
|
113
|
+
except (FileNotFoundError, ConnectionRefusedError):
|
|
114
|
+
if time.monotonic() >= deadline:
|
|
115
|
+
raise TimeoutError("DeepSeek runtime startup timed out")
|
|
116
|
+
time.sleep(0.05)
|
|
117
|
+
client.settimeout(max(0.001, deadline - time.monotonic()))
|
|
118
|
+
client.sendall((json.dumps(config) + "\n").encode())
|
|
119
|
+
with client.makefile("r", encoding="utf-8") as stream:
|
|
120
|
+
value = json.loads(stream.readline())
|
|
121
|
+
write_result(value)
|
|
122
|
+
return 0 if value["ok"] else 1
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main():
|
|
126
|
+
parser = argparse.ArgumentParser()
|
|
127
|
+
parser.add_argument("--check", action="store_true")
|
|
128
|
+
parser.add_argument("--config", type=Path)
|
|
129
|
+
parser.add_argument("--serve", type=Path)
|
|
130
|
+
args = parser.parse_args()
|
|
131
|
+
try:
|
|
132
|
+
if args.check:
|
|
133
|
+
print(check())
|
|
134
|
+
return 0
|
|
135
|
+
config = json.loads((args.serve or args.config).read_text())
|
|
136
|
+
if args.serve:
|
|
137
|
+
serve(config)
|
|
138
|
+
return 0
|
|
139
|
+
return send(config)
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
if not args.check:
|
|
142
|
+
journal({"type": "runner_error", "error": str(exc)})
|
|
143
|
+
write_result({"ok": False, "error": str(exc), "timeout": isinstance(exc, TimeoutError)})
|
|
144
|
+
print(redact({"error": str(exc)}), file=sys.stderr)
|
|
145
|
+
return 1
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Container-side bridge for the pinned Hermes AIAgent (no Roller dependency).
|
|
2
|
+
|
|
3
|
+
Each turn restores native conversation history in the same disposable container.
|
|
4
|
+
The append-only journal lives separately from Hermes' compressible history.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import threading
|
|
15
|
+
from contextvars import ContextVar
|
|
16
|
+
|
|
17
|
+
DELEGATE_CALL = ContextVar("roller_delegate_call", default=None)
|
|
18
|
+
|
|
19
|
+
REVISION = "d15ed4445207dda418b984e8bda0f68f48b8c6f3"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def plain(value):
|
|
23
|
+
if hasattr(value, "model_dump"):
|
|
24
|
+
return value.model_dump(mode="json", exclude_none=True)
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def openai_compatible_tool_media(messages):
|
|
29
|
+
"""Move tool-result images to a following user message.
|
|
30
|
+
|
|
31
|
+
Several OpenAI-compatible gateways accept image_url only on user messages.
|
|
32
|
+
Keep Hermes' native tool result in the journal, but use this equivalent wire
|
|
33
|
+
shape for the next model request.
|
|
34
|
+
"""
|
|
35
|
+
rewritten = []
|
|
36
|
+
pending_images = []
|
|
37
|
+
|
|
38
|
+
def flush_images():
|
|
39
|
+
if not pending_images:
|
|
40
|
+
return
|
|
41
|
+
rewritten.append({
|
|
42
|
+
"role": "user",
|
|
43
|
+
"content": [
|
|
44
|
+
{"type": "text", "text": "Images returned by the preceding tool results:"},
|
|
45
|
+
*pending_images,
|
|
46
|
+
],
|
|
47
|
+
})
|
|
48
|
+
pending_images.clear()
|
|
49
|
+
|
|
50
|
+
for message in messages:
|
|
51
|
+
copied = deepcopy(message)
|
|
52
|
+
if copied.get("role") == "tool":
|
|
53
|
+
content = copied.get("content")
|
|
54
|
+
if isinstance(content, list):
|
|
55
|
+
text_parts = []
|
|
56
|
+
for block in content:
|
|
57
|
+
if isinstance(block, dict) and block.get("type") == "image_url":
|
|
58
|
+
pending_images.append(deepcopy(block))
|
|
59
|
+
elif isinstance(block, dict) and block.get("type") == "text" and isinstance(block.get("text"), str):
|
|
60
|
+
text_parts.append(block["text"])
|
|
61
|
+
elif isinstance(block, str):
|
|
62
|
+
text_parts.append(block)
|
|
63
|
+
if pending_images:
|
|
64
|
+
copied["content"] = "\n".join(text_parts) or "Image attached in the following user message."
|
|
65
|
+
rewritten.append(copied)
|
|
66
|
+
continue
|
|
67
|
+
flush_images()
|
|
68
|
+
rewritten.append(copied)
|
|
69
|
+
flush_images()
|
|
70
|
+
return rewritten
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Journal:
|
|
74
|
+
def __init__(self, path):
|
|
75
|
+
self.path = Path(path)
|
|
76
|
+
self.lock = threading.RLock()
|
|
77
|
+
self.tools_seen = set()
|
|
78
|
+
if self.path.exists():
|
|
79
|
+
# JSONL is LF-delimited; preserve Unicode line separators inside
|
|
80
|
+
# JSON string values.
|
|
81
|
+
for line in self.path.read_text().split("\n"):
|
|
82
|
+
if not line.strip():
|
|
83
|
+
continue
|
|
84
|
+
event = json.loads(line)
|
|
85
|
+
message = event.get("message", {})
|
|
86
|
+
if message.get("role") == "tool":
|
|
87
|
+
self.tools_seen.add(message["tool_call_id"])
|
|
88
|
+
|
|
89
|
+
def write(self, kind, **fields):
|
|
90
|
+
with self.lock, self.path.open("a", encoding="utf-8") as stream:
|
|
91
|
+
stream.write(json.dumps({"type": kind, "timestamp": datetime.now(timezone.utc).isoformat(), **fields}, ensure_ascii=False) + "\n")
|
|
92
|
+
stream.flush()
|
|
93
|
+
|
|
94
|
+
def observe_tools(self, messages):
|
|
95
|
+
# Native flushes occur after tools and BEFORE history compression. IDs
|
|
96
|
+
# survive deep copies and prevent repeats across flushes and user turns.
|
|
97
|
+
with self.lock:
|
|
98
|
+
for message in messages:
|
|
99
|
+
call_id = message.get("tool_call_id")
|
|
100
|
+
if message.get("role") != "tool" or call_id in self.tools_seen:
|
|
101
|
+
continue
|
|
102
|
+
self.write("message", message={k: v for k, v in message.items() if not k.startswith("_")})
|
|
103
|
+
self.tools_seen.add(call_id)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def request_audit_parameters(kwargs):
|
|
107
|
+
"""Retain only the SDK parameters needed to audit the actual request."""
|
|
108
|
+
controls = {}
|
|
109
|
+
for key in ("reasoning_effort", "enable_thinking"):
|
|
110
|
+
if key in kwargs:
|
|
111
|
+
controls[key] = kwargs[key]
|
|
112
|
+
for key, fields in (
|
|
113
|
+
("reasoning", ("effort", "enabled")),
|
|
114
|
+
("output_config", ("effort",)),
|
|
115
|
+
("thinking", ("type",)),
|
|
116
|
+
("chat_template_kwargs", ("enable_thinking",)),
|
|
117
|
+
):
|
|
118
|
+
value = kwargs.get(key)
|
|
119
|
+
if isinstance(value, dict):
|
|
120
|
+
selected = {field: value[field] for field in fields if field in value}
|
|
121
|
+
if selected:
|
|
122
|
+
controls[key] = selected
|
|
123
|
+
extra_body = kwargs.get("extra_body")
|
|
124
|
+
if isinstance(extra_body, dict):
|
|
125
|
+
selected = request_audit_parameters(extra_body)
|
|
126
|
+
if selected:
|
|
127
|
+
controls["extra_body"] = selected
|
|
128
|
+
return controls
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def journaled_agent(base_class, journal, *, capture=False, root_session_id=None):
|
|
132
|
+
class RecordedAgent(base_class):
|
|
133
|
+
def __init__(self, *args, **kwargs):
|
|
134
|
+
super().__init__(*args, **kwargs)
|
|
135
|
+
self._roller_journal = journal
|
|
136
|
+
if capture:
|
|
137
|
+
if self.session_id != root_session_id:
|
|
138
|
+
child_path = journal.path.parent / "capture-native" / (self.session_id + ".jsonl")
|
|
139
|
+
child_path.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
self._roller_journal = Journal(child_path)
|
|
141
|
+
owner = DELEGATE_CALL.get()
|
|
142
|
+
self._roller_journal.write("participant", session_id=self.session_id,
|
|
143
|
+
parent_session_id=getattr(self, "_parent_session_id", None),
|
|
144
|
+
tool_call_id=owner[1] if owner and owner[0] == getattr(self, "_parent_session_id", None) else None)
|
|
145
|
+
callback = self.tool_start_callback
|
|
146
|
+
def tool_started(call_id, name, arguments):
|
|
147
|
+
DELEGATE_CALL.set((self.session_id, call_id) if name == "delegate_task" else None)
|
|
148
|
+
if callback:
|
|
149
|
+
callback(call_id, name, arguments)
|
|
150
|
+
self.tool_start_callback = tool_started
|
|
151
|
+
self._disable_streaming = True
|
|
152
|
+
|
|
153
|
+
def _interruptible_api_call(self, api_kwargs):
|
|
154
|
+
journal = self._roller_journal
|
|
155
|
+
native_messages = api_kwargs.get("messages", [])
|
|
156
|
+
journal.observe_tools(native_messages)
|
|
157
|
+
outgoing = dict(api_kwargs)
|
|
158
|
+
outgoing["messages"] = openai_compatible_tool_media(native_messages)
|
|
159
|
+
if capture:
|
|
160
|
+
outgoing["extra_headers"] = {**outgoing.get("extra_headers", {}),
|
|
161
|
+
"x-roller-native-session-id": self.session_id}
|
|
162
|
+
# Capture untruncated assembled prompts and actual schemas, without
|
|
163
|
+
# HTTP authorization headers or the SDK client configuration.
|
|
164
|
+
journal.write(
|
|
165
|
+
"request", messages=outgoing["messages"],
|
|
166
|
+
tools=outgoing.get("tools", []), model=outgoing.get("model"),
|
|
167
|
+
**request_audit_parameters(outgoing),
|
|
168
|
+
)
|
|
169
|
+
response = super()._interruptible_api_call(outgoing)
|
|
170
|
+
data = plain(response)
|
|
171
|
+
journal.write("response", response=data)
|
|
172
|
+
choices = data.get("choices", [])
|
|
173
|
+
if len(choices) != 1:
|
|
174
|
+
raise RuntimeError("Hermes bridge requires exactly one completion choice")
|
|
175
|
+
choice = choices[0]
|
|
176
|
+
journal.write("message", message=choice["message"], usage=data.get("usage") or {}, finish_reason=choice.get("finish_reason"))
|
|
177
|
+
return response
|
|
178
|
+
|
|
179
|
+
def _flush_messages_to_session_db(self, messages, conversation_history=None):
|
|
180
|
+
self._roller_journal.observe_tools(messages)
|
|
181
|
+
return super()._flush_messages_to_session_db(messages, conversation_history)
|
|
182
|
+
|
|
183
|
+
def run_conversation(self, *args, **kwargs):
|
|
184
|
+
try:
|
|
185
|
+
result = super().run_conversation(*args, **kwargs)
|
|
186
|
+
self._roller_journal.observe_tools(result.get("messages", []))
|
|
187
|
+
if capture and self.session_id != root_session_id:
|
|
188
|
+
self._roller_journal.write("child_end", completed=bool(result.get("completed")
|
|
189
|
+
and not result.get("failed") and not result.get("error")))
|
|
190
|
+
return result
|
|
191
|
+
except BaseException:
|
|
192
|
+
if capture and self.session_id != root_session_id:
|
|
193
|
+
self._roller_journal.write("child_end", completed=False)
|
|
194
|
+
raise
|
|
195
|
+
|
|
196
|
+
return RecordedAgent
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def run_turn(config, state_dir):
|
|
200
|
+
# Provider discovery imports optional adapters even for a custom endpoint.
|
|
201
|
+
# Their lazy dependency installers must never run during a rollout.
|
|
202
|
+
home = Path(os.environ["HERMES_HOME"])
|
|
203
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
settings = {
|
|
205
|
+
"security": {"allow_lazy_installs": False},
|
|
206
|
+
"model": {"supports_vision": bool(config.get("vision", True))},
|
|
207
|
+
"agent": {
|
|
208
|
+
"image_input_mode": "native" if config.get("vision", True) else "text"
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
if config.get("vision", True):
|
|
212
|
+
# The vision tool's availability probe runs before the first model
|
|
213
|
+
# request establishes Hermes' runtime-main provider. Point the probe at
|
|
214
|
+
# the same disposable custom endpoint; the native fast path then keeps
|
|
215
|
+
# image bytes in the main model conversation instead of describing them
|
|
216
|
+
# through an unrelated auxiliary service.
|
|
217
|
+
settings["auxiliary"] = {
|
|
218
|
+
"vision": {
|
|
219
|
+
"provider": "openai",
|
|
220
|
+
"model": config["model"],
|
|
221
|
+
"base_url": os.environ["OPENAI_BASE_URL"],
|
|
222
|
+
"api_key": os.environ["OPENAI_API_KEY"],
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if config.get("context_length") is not None:
|
|
226
|
+
settings["model"]["context_length"] = config["context_length"]
|
|
227
|
+
(home / "config.yaml").write_text(json.dumps(settings), encoding="utf-8")
|
|
228
|
+
from run_agent import AIAgent
|
|
229
|
+
|
|
230
|
+
state_dir = Path(state_dir)
|
|
231
|
+
journal = Journal(state_dir / "trace.jsonl")
|
|
232
|
+
capture = bool(config.get("capture_calls"))
|
|
233
|
+
if capture:
|
|
234
|
+
(state_dir / "capture-native").mkdir(exist_ok=True)
|
|
235
|
+
# Native Hermes falls back to inline delegation for finite sessions.
|
|
236
|
+
os.environ["HERMES_SINGLE_QUERY_SESSION"] = "1"
|
|
237
|
+
recorded = journaled_agent(AIAgent, journal, capture=True, root_session_id=config["session_id"])
|
|
238
|
+
import run_agent
|
|
239
|
+
run_agent.AIAgent = recorded
|
|
240
|
+
else:
|
|
241
|
+
recorded = journaled_agent(AIAgent, journal)
|
|
242
|
+
history_path = state_dir / "history.json"
|
|
243
|
+
history = json.loads(history_path.read_text()) if history_path.exists() else []
|
|
244
|
+
journal.write("message", message={"role": "user", "content": config["message"]})
|
|
245
|
+
effort = config.get("thinking")
|
|
246
|
+
reasoning = None if effort is None else ({"enabled": False} if effort == "off" else {"enabled": True, "effort": effort})
|
|
247
|
+
# Keep Hermes' capability-neutral custom transport for compatibility with
|
|
248
|
+
# arbitrary OpenAI-compatible models. The explicit model/image settings
|
|
249
|
+
# above opt only the vision tool into its native image path.
|
|
250
|
+
provider = "custom"
|
|
251
|
+
from agent.auxiliary_client import reset_runtime_main, set_runtime_main
|
|
252
|
+
runtime_token = set_runtime_main(
|
|
253
|
+
provider,
|
|
254
|
+
config["model"],
|
|
255
|
+
base_url=os.environ["OPENAI_BASE_URL"],
|
|
256
|
+
api_key=os.environ["OPENAI_API_KEY"],
|
|
257
|
+
api_mode="chat_completions",
|
|
258
|
+
)
|
|
259
|
+
try:
|
|
260
|
+
agent = recorded(
|
|
261
|
+
model=config["model"], provider=provider, api_mode="chat_completions",
|
|
262
|
+
base_url=os.environ["OPENAI_BASE_URL"], api_key=os.environ["OPENAI_API_KEY"],
|
|
263
|
+
max_iterations=config["max_iterations"],
|
|
264
|
+
enabled_toolsets=["terminal", "file", *(["vision"] if config.get("vision", True) else []),
|
|
265
|
+
*(["delegation"] if capture else [])],
|
|
266
|
+
quiet_mode=True, save_trajectories=False, session_id=config["session_id"],
|
|
267
|
+
ephemeral_system_prompt=config["system_prompt"],
|
|
268
|
+
skip_context_files=True, load_soul_identity=False, skip_memory=True,
|
|
269
|
+
skip_background_review=True, checkpoints_enabled=False,
|
|
270
|
+
reasoning_config=reasoning, run_budget_seconds=config["timeout_seconds"],
|
|
271
|
+
)
|
|
272
|
+
# This bridge captures the Chat Completions response atomically. Hermes'
|
|
273
|
+
# streaming path is a separate private API and is deliberately disabled.
|
|
274
|
+
agent._disable_streaming = True
|
|
275
|
+
result = agent.run_conversation(
|
|
276
|
+
config["message"], conversation_history=history, task_id=config["session_id"]
|
|
277
|
+
)
|
|
278
|
+
finally:
|
|
279
|
+
reset_runtime_main(runtime_token)
|
|
280
|
+
journal.observe_tools(result.get("messages", []))
|
|
281
|
+
temporary = history_path.with_suffix(".tmp")
|
|
282
|
+
temporary.write_text(json.dumps(result.get("messages", []), ensure_ascii=False), encoding="utf-8")
|
|
283
|
+
temporary.replace(history_path)
|
|
284
|
+
reply = result.get("final_response")
|
|
285
|
+
if (not result.get("completed") or result.get("failed") or result.get("interrupted")
|
|
286
|
+
or result.get("error") or not isinstance(reply, str) or not reply.strip()):
|
|
287
|
+
raise RuntimeError("Hermes turn did not complete: " + str(result.get("error") or result.get("failure_reason") or reply or "empty response")[:2000])
|
|
288
|
+
return {"ok": True, "content": reply, "api_calls": result.get("api_calls", 0)}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def main():
|
|
292
|
+
parser = argparse.ArgumentParser()
|
|
293
|
+
parser.add_argument("--check", action="store_true")
|
|
294
|
+
parser.add_argument("--config")
|
|
295
|
+
parser.add_argument("--state-dir", default="/roller/hermes-state")
|
|
296
|
+
args = parser.parse_args()
|
|
297
|
+
actual = Path("/opt/hermes/roller-revision").read_text().strip()
|
|
298
|
+
if actual != REVISION:
|
|
299
|
+
raise RuntimeError(f"Hermes revision mismatch: expected {REVISION}, got {actual}")
|
|
300
|
+
if args.check:
|
|
301
|
+
from run_agent import AIAgent
|
|
302
|
+
assert callable(AIAgent._interruptible_api_call)
|
|
303
|
+
assert callable(AIAgent._flush_messages_to_session_db)
|
|
304
|
+
print("Hermes " + REVISION)
|
|
305
|
+
return
|
|
306
|
+
state = Path(args.state_dir)
|
|
307
|
+
state.mkdir(parents=True, exist_ok=True)
|
|
308
|
+
destination = state / "result.json"
|
|
309
|
+
destination.unlink(missing_ok=True)
|
|
310
|
+
try:
|
|
311
|
+
result = run_turn(json.loads(Path(args.config).read_text()), state)
|
|
312
|
+
except Exception as exc:
|
|
313
|
+
result = {"ok": False, "error": str(exc)}
|
|
314
|
+
temporary = destination.with_suffix(".tmp")
|
|
315
|
+
temporary.write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8")
|
|
316
|
+
temporary.replace(destination)
|
|
317
|
+
if not result["ok"]:
|
|
318
|
+
raise SystemExit(1)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
if __name__ == "__main__":
|
|
322
|
+
main()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Instrument the pinned native cache wrapper inside a disposable container.
|
|
2
|
+
|
|
3
|
+
The wrapper's base.sessionId belongs to this exact stream invocation, so
|
|
4
|
+
parallel agents never share a mutable 'current agent' identity.
|
|
5
|
+
"""
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
needle = "return streamFn(model, context, options);\n\t\t};\n\t\treturn wrapped;"
|
|
9
|
+
replacement = ('rollerCaptureAppend("/tmp/roller-capture-stream.jsonl", JSON.stringify({...base, '
|
|
10
|
+
'stage: "stream:context", messages: context.messages}) + "\\n"); '
|
|
11
|
+
'return streamFn(model, context, {...options, headers: {...options?.headers, '
|
|
12
|
+
'"x-roller-native-session-id": base.sessionId, "x-roller-native-run-id": base.runId}});\n\t\t};\n\t\treturn wrapped;')
|
|
13
|
+
matched = [p for p in Path("/app/dist").glob("selection-*.js") if needle in p.read_text()]
|
|
14
|
+
if len(matched) != 1 or matched[0].read_text().count(needle) != 1:
|
|
15
|
+
raise RuntimeError("Pinned OpenClaw cache stream capture hook is unavailable")
|
|
16
|
+
path = matched[0]
|
|
17
|
+
path.write_text('import {appendFileSync as rollerCaptureAppend} from "node:fs";\n' + path.read_text().replace(needle, replacement))
|
|
18
|
+
|
|
19
|
+
# Observe the native occurrence-aware ID resolver rather than reproducing its
|
|
20
|
+
# strict/strict9/provider rules in Roller.
|
|
21
|
+
needle = "const next = duplicateToolCallIdStyle ==="
|
|
22
|
+
files = [p for p in Path("/app/dist").glob("tool-call-id-*.js") if needle in p.read_text()]
|
|
23
|
+
if len(files) != 1:
|
|
24
|
+
raise RuntimeError("Pinned OpenClaw tool ID capture hook unavailable")
|
|
25
|
+
path = files[0]
|
|
26
|
+
source = path.read_text()
|
|
27
|
+
marker = "\n\t\tconst pending = pendingByRawId.get(id);"
|
|
28
|
+
start = source.index(needle)
|
|
29
|
+
offset = source.index(marker, start)
|
|
30
|
+
source = source[:offset] + ('\n\t\trollerCaptureAppend("/tmp/roller-tool-id-aliases.jsonl", '
|
|
31
|
+
'JSON.stringify({original_id: id, context_id: next}) + "\\n");') + source[offset:]
|
|
32
|
+
path.write_text('import {appendFileSync as rollerCaptureAppend} from "node:fs";\n' + source)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Use the pinned native registry's pending-descendant semantics. Completion
|
|
2
|
+
// includes announcement cleanup, not merely the child's final model response.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
const files = fs.readdirSync("/app/dist").filter(n => /^subagent-registry-state-.*\.js$/.test(n));
|
|
6
|
+
if (files.length !== 1) throw new Error("Pinned OpenClaw registry capture hook unavailable");
|
|
7
|
+
const registry = await import(path.join("/app/dist", files[0]));
|
|
8
|
+
const sid = process.env.ROLLER_CAPTURE_ROOT_SESSION;
|
|
9
|
+
const deadline = Date.now() + Number(process.env.ROLLER_CAPTURE_TIMEOUT_MS);
|
|
10
|
+
while (true) {
|
|
11
|
+
const cache = fs.existsSync("/tmp/roller-cache-trace.jsonl")
|
|
12
|
+
? fs.readFileSync("/tmp/roller-cache-trace.jsonl", "utf8").split("\n").filter(Boolean).map(JSON.parse) : [];
|
|
13
|
+
const key = cache.find(r => r.sessionId === sid)?.sessionKey;
|
|
14
|
+
const runs = new Map();
|
|
15
|
+
registry.i({runs, mergeOnly: false});
|
|
16
|
+
fs.writeFileSync("/tmp/roller-subagent-runs.json", JSON.stringify([...runs.values()]));
|
|
17
|
+
if (!key) throw new Error("Cannot confirm OpenClaw root session for subagent drain");
|
|
18
|
+
if (registry._(runs, key) === 0) break;
|
|
19
|
+
if (Date.now() >= deadline) throw new Error("Timed out draining OpenClaw subagents and announcements");
|
|
20
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
21
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
// A local plugin, loaded before provider construction. Capture the serialized
|
|
4
|
+
// provider request without copying credentials or modifying the HTTP exchange.
|
|
5
|
+
export default async function RollerJournal() {
|
|
6
|
+
const path = process.env.ROLLER_OPENCODE_JOURNAL;
|
|
7
|
+
if (!path) throw new Error("Roller journal path is missing");
|
|
8
|
+
const turn = JSON.parse(readFileSync("/roller/opencode-state/turn.json", "utf8"));
|
|
9
|
+
const write = (value) => appendFileSync(path, JSON.stringify({
|
|
10
|
+
timestamp: new Date().toISOString(), ...value,
|
|
11
|
+
}) + "\n");
|
|
12
|
+
return {
|
|
13
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
14
|
+
// Retain OpenCode's native instructions and append the task contract.
|
|
15
|
+
output.system.push(turn.system_prompt);
|
|
16
|
+
},
|
|
17
|
+
"chat.headers": async (input, output) => {
|
|
18
|
+
if (turn.capture_calls) {
|
|
19
|
+
// A local correlation field, stripped by Roller's proxy before it
|
|
20
|
+
// reaches the provider. Each request carries its own session identity.
|
|
21
|
+
output.headers["x-roller-native-session-id"] = input.sessionID;
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
config: async (config) => {
|
|
25
|
+
const provider = config.provider?.roller;
|
|
26
|
+
if (!provider) throw new Error("Roller provider is missing");
|
|
27
|
+
provider.options.fetch = async (input, init) => {
|
|
28
|
+
const body = typeof init?.body === "string" ? JSON.parse(init.body) : null;
|
|
29
|
+
const sessionID = new Headers(init?.headers).get("x-roller-native-session-id");
|
|
30
|
+
if (body && Array.isArray(body.messages)) write({ type: "request", body,
|
|
31
|
+
...(sessionID ? { native_session_id: sessionID } : {}) });
|
|
32
|
+
return fetch(input, init);
|
|
33
|
+
};
|
|
34
|
+
write({ type: "bridge_ready", version: "1.18.30" });
|
|
35
|
+
},
|
|
36
|
+
event: ({ event }) => {
|
|
37
|
+
if (["message.updated", "message.part.updated", "session.created", "session.updated", "session.error"].includes(event.type)) {
|
|
38
|
+
write({ type: "event", event });
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|