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.
Files changed (92) hide show
  1. roller/__init__.py +31 -0
  2. roller/__main__.py +6 -0
  3. roller/actf.py +431 -0
  4. roller/artifacts.py +124 -0
  5. roller/assets/deepseek/journal.mjs +44 -0
  6. roller/assets/deepseek/roller.patch.yml +15 -0
  7. roller/assets/deepseek/runner.py +149 -0
  8. roller/assets/hermes/runner.py +322 -0
  9. roller/assets/openclaw_capture.py +32 -0
  10. roller/assets/openclaw_drain.mjs +21 -0
  11. roller/assets/opencode/journal.mjs +42 -0
  12. roller/assets/opencode/runner.py +203 -0
  13. roller/assets/pi/runner.mjs +160 -0
  14. roller/assets/pi/runner.py +93 -0
  15. roller/benches/__init__.py +73 -0
  16. roller/benches/base.py +306 -0
  17. roller/benches/grading.py +553 -0
  18. roller/benches/pinchbench.py +976 -0
  19. roller/benches/qwenclaw/__init__.py +49 -0
  20. roller/benches/qwenclaw/adapter.py +167 -0
  21. roller/benches/qwenclaw/grader_runtime.py +395 -0
  22. roller/benches/qwenclaw/report.py +49 -0
  23. roller/benches/qwenclaw/task_parser.py +181 -0
  24. roller/benches/qwenclaw/workspace.py +202 -0
  25. roller/benches/report.py +166 -0
  26. roller/benches/skillsbench.py +708 -0
  27. roller/benches/transcripts.py +102 -0
  28. roller/benches/wildclaw.py +569 -0
  29. roller/bridge_capture.py +73 -0
  30. roller/bundles.py +545 -0
  31. roller/capture.py +385 -0
  32. roller/capture_protocol.py +301 -0
  33. roller/capture_runtime.py +48 -0
  34. roller/capture_states.py +37 -0
  35. roller/claude_capture.py +185 -0
  36. roller/claude_code_actf.py +594 -0
  37. roller/cli.py +1732 -0
  38. roller/codex_actf.py +584 -0
  39. roller/codex_capture.py +99 -0
  40. roller/codex_tools.py +137 -0
  41. roller/collector.py +818 -0
  42. roller/compaction_sft.py +672 -0
  43. roller/conversation.py +432 -0
  44. roller/deepseek_actf.py +135 -0
  45. roller/harness_registry.py +61 -0
  46. roller/harnesses/__init__.py +29 -0
  47. roller/harnesses/base.py +64 -0
  48. roller/harnesses/bridge.py +448 -0
  49. roller/harnesses/claude_code.py +839 -0
  50. roller/harnesses/codex.py +808 -0
  51. roller/harnesses/config_overrides.py +100 -0
  52. roller/harnesses/container.py +206 -0
  53. roller/harnesses/deepseek.py +104 -0
  54. roller/harnesses/docker.py +72 -0
  55. roller/harnesses/gateway_ports.py +155 -0
  56. roller/harnesses/hermes.py +119 -0
  57. roller/harnesses/openclaw.py +1605 -0
  58. roller/harnesses/opencode.py +193 -0
  59. roller/harnesses/pi.py +111 -0
  60. roller/hermes_actf.py +147 -0
  61. roller/image_build.py +206 -0
  62. roller/integrity.py +332 -0
  63. roller/model_protocols.py +201 -0
  64. roller/models.py +169 -0
  65. roller/openclaw_capture.py +140 -0
  66. roller/openclaw_delivery.py +211 -0
  67. roller/openclaw_usage.py +59 -0
  68. roller/opencode_actf.py +305 -0
  69. roller/opencode_capture.py +96 -0
  70. roller/pi_actf.py +172 -0
  71. roller/response_metadata.py +89 -0
  72. roller/run_lock.py +297 -0
  73. roller/sft.py +471 -0
  74. roller/storage.py +53 -0
  75. roller/tasks.py +689 -0
  76. roller/thinking.py +50 -0
  77. roller/thinking_capabilities.py +152 -0
  78. roller/trajectory_audit.py +386 -0
  79. roller/usage.py +168 -0
  80. roller/version.py +1 -0
  81. roller/viewer/__init__.py +3 -0
  82. roller/viewer/app.py +722 -0
  83. roller/viewer/static/app.js +752 -0
  84. roller/viewer/static/index.html +105 -0
  85. roller/viewer/static/style.css +213 -0
  86. roller/wire_trace.py +650 -0
  87. roller_agent-0.2.0.dist-info/METADATA +397 -0
  88. roller_agent-0.2.0.dist-info/RECORD +92 -0
  89. roller_agent-0.2.0.dist-info/WHEEL +5 -0
  90. roller_agent-0.2.0.dist-info/entry_points.txt +2 -0
  91. roller_agent-0.2.0.dist-info/licenses/LICENSE +201 -0
  92. 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
+ }