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
roller/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """Roller: batch collection of agentic trajectories."""
2
+
3
+ from roller.version import __version__
4
+ from roller.bundles import BundlePolicy, TaskBundleStore
5
+ from roller.collector import CollectionConfig, Collector
6
+ from roller.conversation import ConversationManager
7
+ from roller.models import (
8
+ ContextFile,
9
+ HarnessResult,
10
+ InputFile,
11
+ RunRequest,
12
+ Task,
13
+ TaskBundle,
14
+ UserSimulatorSpec,
15
+ )
16
+
17
+
18
+ __all__ = [
19
+ "BundlePolicy",
20
+ "CollectionConfig",
21
+ "ContextFile",
22
+ "Collector",
23
+ "ConversationManager",
24
+ "HarnessResult",
25
+ "InputFile",
26
+ "RunRequest",
27
+ "Task",
28
+ "TaskBundle",
29
+ "TaskBundleStore",
30
+ "UserSimulatorSpec",
31
+ ]
roller/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from roller.cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
6
+
roller/actf.py ADDED
@@ -0,0 +1,431 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from copy import deepcopy
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any, Iterable
8
+
9
+ from roller.openclaw_delivery import OpenClawDeliveryTracker
10
+ from roller.openclaw_usage import OpenClawUsage
11
+
12
+ OPENCLAW_ANNOUNCEMENT_SFT_REASON = (
13
+ "OpenClaw announcement input lacks native response binding; use captured per-call contexts"
14
+ )
15
+
16
+
17
+ def _event_time(value: Any) -> datetime | None:
18
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
19
+ try:
20
+ return datetime.fromtimestamp(float(value) / 1000, tz=timezone.utc)
21
+ except (OverflowError, OSError, ValueError):
22
+ return None
23
+ if not isinstance(value, str) or not value.strip():
24
+ return None
25
+ text = value.strip()
26
+ if text.endswith("Z"):
27
+ text = f"{text[:-1]}+00:00"
28
+ try:
29
+ parsed = datetime.fromisoformat(text)
30
+ except ValueError:
31
+ return None
32
+ if parsed.tzinfo is None:
33
+ parsed = parsed.replace(tzinfo=timezone.utc)
34
+ return parsed.astimezone(timezone.utc)
35
+
36
+
37
+ def _timestamp(value: Any) -> str | None:
38
+ parsed = _event_time(value)
39
+ return parsed.strftime("%Y-%m-%d %H:%M:%S.%f") if parsed is not None else None
40
+
41
+
42
+ def _text(value: Any) -> str:
43
+ if isinstance(value, str):
44
+ return value
45
+ if isinstance(value, list):
46
+ parts: list[str] = []
47
+ for item in value:
48
+ if isinstance(item, str):
49
+ parts.append(item)
50
+ elif isinstance(item, dict) and isinstance(item.get("text"), str):
51
+ parts.append(item["text"])
52
+ return "\n".join(part for part in parts if part)
53
+ if isinstance(value, dict) and isinstance(value.get("text"), str):
54
+ return value["text"]
55
+ return "" if value is None else str(value)
56
+
57
+
58
+ def _tool_call(block: dict[str, Any]) -> dict[str, Any]:
59
+ arguments = block.get("arguments", {})
60
+ if not isinstance(arguments, dict):
61
+ arguments = {"value": arguments}
62
+ return {
63
+ "type": "toolCall",
64
+ "id": str(block.get("id", "")),
65
+ "name": str(block.get("name", "")),
66
+ "arguments": deepcopy(arguments),
67
+ }
68
+
69
+
70
+ def _assistant_parts(content: Any) -> tuple[str, str, list[dict[str, Any]]]:
71
+ if isinstance(content, str):
72
+ return content, "", []
73
+ if not isinstance(content, list):
74
+ return "", "", []
75
+ visible: list[str] = []
76
+ reasoning: list[str] = []
77
+ calls: list[dict[str, Any]] = []
78
+ for block in content:
79
+ if isinstance(block, str):
80
+ visible.append(block)
81
+ continue
82
+ if not isinstance(block, dict):
83
+ continue
84
+ block_type = block.get("type")
85
+ if block_type == "text" and isinstance(block.get("text"), str):
86
+ visible.append(block["text"])
87
+ elif block_type in {"thinking", "reasoning"}:
88
+ value = block.get("thinking", block.get("text", block.get("reasoning", "")))
89
+ if isinstance(value, str):
90
+ reasoning.append(value)
91
+ elif block_type in {"toolCall", "tool_call"}:
92
+ calls.append(_tool_call(block))
93
+ return "\n".join(visible), "\n".join(reasoning), calls
94
+
95
+
96
+ def _observation(
97
+ message: dict[str, Any], tool_call_namespace: str | None = None
98
+ ) -> dict[str, Any]:
99
+ content = message.get("content", [])
100
+ if not isinstance(content, list):
101
+ content = [{"type": "text", "text": _text(content)}]
102
+ row: dict[str, Any] = {
103
+ "role": "toolResult",
104
+ "toolCallId": _tool_call_id(message.get("toolCallId", ""), tool_call_namespace),
105
+ "toolName": str(message.get("toolName", "")),
106
+ "content": deepcopy(content),
107
+ "isError": bool(message.get("isError", False)),
108
+ }
109
+ if message.get("timestamp") is not None:
110
+ row["timestamp"] = message["timestamp"]
111
+ if "details" in message:
112
+ row["details"] = deepcopy(message["details"])
113
+ return row
114
+
115
+
116
+ def _tool_call_id(value: Any, namespace: str | None) -> str:
117
+ identifier = str(value)
118
+ return f"{namespace}:{identifier}" if namespace else identifier
119
+
120
+
121
+ def _message_event(raw: Any) -> dict[str, Any] | None:
122
+ if not isinstance(raw, dict) or raw.get("type") != "message":
123
+ return None
124
+ message = raw.get("message")
125
+ return message if isinstance(message, dict) else None
126
+
127
+
128
+ def _read_events(path: Path) -> list[dict[str, Any]]:
129
+ events: list[dict[str, Any]] = []
130
+ with path.open("r", encoding="utf-8", errors="replace") as stream:
131
+ for line in stream:
132
+ try:
133
+ row = json.loads(line)
134
+ except json.JSONDecodeError:
135
+ continue
136
+ if isinstance(row, dict):
137
+ events.append(row)
138
+ return events
139
+
140
+
141
+ def _cache_trace_system_prompts(path: Path) -> list[str]:
142
+ """Extract the assembled system prompt of every LLM call from an OpenClaw cache-trace file.
143
+
144
+ Prefers per-call ``stream:context.system`` entries; falls back to the
145
+ session-level ``session:loaded.system`` snapshot (openclaw emits the
146
+ assembled prompt there once per session).
147
+ """
148
+ per_call: list[str] = []
149
+ session_level: list[str] = []
150
+ with path.open("r", encoding="utf-8", errors="replace") as stream:
151
+ for line in stream:
152
+ try:
153
+ row = json.loads(line)
154
+ except json.JSONDecodeError:
155
+ continue
156
+ if not isinstance(row, dict):
157
+ continue
158
+ system = row.get("system")
159
+ if not isinstance(system, str):
160
+ continue
161
+ stage = row.get("stage")
162
+ if stage == "stream:context":
163
+ per_call.append(system)
164
+ elif stage == "session:loaded":
165
+ session_level.append(system)
166
+ return per_call if per_call else session_level
167
+
168
+
169
+ def _provider_message_ids(message: dict[str, Any]) -> set[str]:
170
+ ids: set[str] = set()
171
+ content = message.get("content", [])
172
+ for block in content if isinstance(content, list) else []:
173
+ if not isinstance(block, dict):
174
+ continue
175
+ signature = block.get("textSignature") or block.get("thinkingSignature")
176
+ if isinstance(signature, str):
177
+ try:
178
+ value = json.loads(signature)
179
+ except (ValueError, TypeError):
180
+ continue
181
+ if isinstance(value, dict) and isinstance(value.get("id"), str):
182
+ ids.add(value["id"])
183
+ return ids
184
+
185
+
186
+ def _announcement_inputs(rows: list[dict[str, Any]]) -> tuple[dict[str, str], bool]:
187
+ """Bind actual announcement input to the new native provider response IDs."""
188
+ runs: dict[str, list[dict[str, Any]]] = {}
189
+ unresolved = False
190
+ for row in rows:
191
+ run = row.get("runId", "")
192
+ if not isinstance(run, str) or not run.startswith("announce:v1:"):
193
+ continue
194
+ session_key = row.get("sessionKey", "")
195
+ if not isinstance(session_key, str):
196
+ unresolved = True
197
+ continue
198
+ if ":subagent:" not in session_key:
199
+ runs.setdefault(run, []).append(row)
200
+ inputs: dict[str, str] = {}
201
+ for events in runs.values():
202
+ context = next((e for e in events if e.get("stage") == "stream:context"), None)
203
+ after = next((e for e in reversed(events) if e.get("stage") == "session:after"), None)
204
+ if context is None or after is None:
205
+ unresolved = True
206
+ continue
207
+ before = context.get("messages", [])
208
+ old_ids = set().union(*(_provider_message_ids(m) for m in before if m.get("role") == "assistant"))
209
+ new = next((m for m in after.get("messages", []) if m.get("role") == "assistant" and _provider_message_ids(m) - old_ids), None)
210
+ last_assistant = max((i for i, m in enumerate(before) if m.get("role") == "assistant"), default=-1)
211
+ texts = [_text(m.get("content")) for m in before[last_assistant + 1:] if m.get("role") == "user"]
212
+ if new is None or not texts:
213
+ unresolved = True
214
+ continue
215
+ for mid in _provider_message_ids(new) - old_ids:
216
+ inputs[mid] = "\n\n".join(texts)
217
+ return inputs, unresolved
218
+
219
+
220
+ def openclaw_events_to_actf(
221
+ events: Iterable[dict[str, Any]],
222
+ user_messages: Iterable[str] | None = None,
223
+ system_prompts: Iterable[str] | None = None,
224
+ tools: list[Any] | None = None,
225
+ keep_thinking: bool = True,
226
+ notification_inputs: dict[str, str] | None = None,
227
+ ) -> dict[str, Any]:
228
+ """Project raw OpenClaw session events into the ACTF_v1.0 step shape.
229
+
230
+ The system prompt and tools schema are stored once at the top level (they
231
+ are session-level constants for a rollout); steps never repeat them.
232
+ ``keep_thinking=False`` blanks ``reasoning_content`` in every step.
233
+ """
234
+
235
+ rows = list(events)
236
+ multiple_sessions = sum(row.get("type") == "session" for row in rows) > 1
237
+ session_index = 0
238
+ delivery_tracker = OpenClawDeliveryTracker()
239
+ system_prompt_list = list(system_prompts) if system_prompts is not None else []
240
+ system_prompt = system_prompt_list[0] if system_prompt_list else ""
241
+ first_time = next(
242
+ (_timestamp(row.get("timestamp")) for row in rows if _timestamp(row.get("timestamp"))),
243
+ None,
244
+ )
245
+ pending_user = ""
246
+ pending_user_time: str | None = None
247
+ expected_users = tuple(user_messages) if user_messages is not None else ()
248
+ user_index = 0
249
+ retry_after_empty_error = False
250
+ steps: list[dict[str, Any]] = []
251
+ current: dict[str, Any] | None = None
252
+ last_message_was_dropped_empty_attempt = False
253
+
254
+ def finish_current(fallback: str | None = None) -> None:
255
+ nonlocal current
256
+ if current is None:
257
+ return
258
+ if not current.get("finished_at"):
259
+ current["finished_at"] = fallback or current["started_at"]
260
+ steps.append(current)
261
+ current = None
262
+
263
+ for row in rows:
264
+ if row.get("type") == "session":
265
+ session_index += 1
266
+ delivery = delivery_tracker.observe(row) if isinstance(row, dict) else None
267
+ message = _message_event(row)
268
+ if message is None:
269
+ continue
270
+ last_message_was_dropped_empty_attempt = False
271
+ role = message.get("role")
272
+ message_time = _timestamp(row.get("timestamp") or message.get("timestamp"))
273
+ if role == "user":
274
+ value = (
275
+ expected_users[user_index]
276
+ if user_index < len(expected_users)
277
+ else _text(message.get("content"))
278
+ )
279
+ if retry_after_empty_error and pending_user and value == pending_user:
280
+ # OpenClaw records its internal provider retry as the same user
281
+ # message again. Keep that transport detail in the raw trace,
282
+ # but do not turn it into duplicate training context.
283
+ retry_after_empty_error = False
284
+ continue
285
+ retry_after_empty_error = False
286
+ finish_current(message_time)
287
+ user_index += 1
288
+ pending_user = f"{pending_user}\n\n{value}" if pending_user and value else value
289
+ pending_user_time = pending_user_time or message_time
290
+ continue
291
+ if role == "assistant":
292
+ if delivery is not None and delivery.skip_message:
293
+ # Same-content CLI delivery duplicate of the provider reply;
294
+ # skipping it before finish_current keeps the real step's
295
+ # finished_at derived from genuine conversation events only.
296
+ continue
297
+ content, reasoning, tool_calls = _assistant_parts(message.get("content"))
298
+ stop_reason = message.get("stopReason", message.get("stop_reason", ""))
299
+ tool_call_namespace = (
300
+ f"session-{session_index}" if multiple_sessions else None
301
+ )
302
+ for tool_call in tool_calls:
303
+ tool_call["id"] = _tool_call_id(
304
+ tool_call.get("id", ""), tool_call_namespace
305
+ )
306
+ if not content.strip() and not reasoning.strip() and not tool_calls:
307
+ # OpenClaw can emit an empty assistant event while finalizing a
308
+ # tool turn (and also before retrying a provider error). Keep the
309
+ # event in the native trace for auditability, but do not turn it
310
+ # into an invalid empty ACTF/SFT assistant message.
311
+ retry_after_empty_error = bool(message.get("errorMessage"))
312
+ last_message_was_dropped_empty_attempt = stop_reason in {
313
+ "aborted",
314
+ "error",
315
+ "length",
316
+ }
317
+ continue
318
+ retry_after_empty_error = False
319
+ finish_current(message_time)
320
+ notifications = {notification_inputs[mid] for mid in _provider_message_ids(message)
321
+ if notification_inputs and mid in notification_inputs}
322
+ if len(notifications) == 1:
323
+ pending_user = notifications.pop()
324
+ pending_user_time = message_time
325
+ # Malformed counters are sanitized by OpenClawUsage so they can
326
+ # neither crash (NaN/Infinity int() conversion) nor contaminate
327
+ # step metrics with negative values. skip_usage stays the only
328
+ # deduplication decision; a retained gap-fill reply keeps its
329
+ # text without duplicating the recorded step token metrics.
330
+ parsed = (
331
+ None
332
+ if delivery is not None and delivery.skip_usage
333
+ else OpenClawUsage.parse(message.get("usage"))
334
+ )
335
+ started_at = (
336
+ first_time or pending_user_time or message_time
337
+ if not steps
338
+ else pending_user_time or steps[-1]["finished_at"]
339
+ )
340
+ finished_at = None if tool_calls else message_time or started_at
341
+ current = {
342
+ "step_id": len(steps) + 1,
343
+ "assistant_content": {
344
+ "content": content,
345
+ "reasoning_content": reasoning if keep_thinking else "",
346
+ "tool_calls": deepcopy(tool_calls),
347
+ },
348
+ "metric": {
349
+ "prompt_tokens_len": int(parsed.counts[0]) if parsed else 0,
350
+ "completion_tokens_len": int(parsed.counts[1]) if parsed else 0,
351
+ "llm_infer_ms": None,
352
+ "env_action_ms": None,
353
+ "stop_reason": stop_reason,
354
+ },
355
+ "user_content": pending_user,
356
+ "tools": deepcopy(tool_calls),
357
+ "observation": [],
358
+ "started_at": started_at or "",
359
+ "finished_at": finished_at,
360
+ }
361
+ pending_user = ""
362
+ pending_user_time = None
363
+ continue
364
+ if role == "toolResult" and current is not None:
365
+ tool_call_namespace = (
366
+ f"session-{session_index}" if multiple_sessions else None
367
+ )
368
+ current["observation"].append(
369
+ _observation(message, tool_call_namespace)
370
+ )
371
+ current["finished_at"] = message_time or current.get("finished_at")
372
+
373
+ last_time = next(
374
+ (_timestamp(row.get("timestamp")) for row in reversed(rows) if _timestamp(row.get("timestamp"))),
375
+ None,
376
+ )
377
+ finish_current(last_time)
378
+ started_at = steps[0]["started_at"] if steps else first_time or ""
379
+ finished_at = (
380
+ last_time
381
+ if last_message_was_dropped_empty_attempt and last_time
382
+ else steps[-1]["finished_at"]
383
+ if steps
384
+ else last_time or started_at
385
+ )
386
+ return {
387
+ "schema_version": "ACTF_v1.0",
388
+ "system_prompt": system_prompt,
389
+ "tools": deepcopy(tools) if tools is not None else None,
390
+ "steps": steps,
391
+ "started_at": started_at,
392
+ "finished_at": finished_at,
393
+ }
394
+
395
+
396
+ def convert_openclaw_trace(
397
+ source: Path,
398
+ destination: Path,
399
+ *,
400
+ user_messages: Iterable[str] | None = None,
401
+ cache_trace: Path | None = None,
402
+ tools: list[Any] | None = None,
403
+ keep_thinking: bool = True,
404
+ ) -> dict[str, Any]:
405
+ system_prompts = _cache_trace_system_prompts(cache_trace) if cache_trace is not None else []
406
+ notification_inputs, unresolved = _announcement_inputs(_read_events(cache_trace)) if cache_trace is not None else ({}, False)
407
+ native_events = _read_events(source)
408
+ native_ids = set().union(*(
409
+ _provider_message_ids(message)
410
+ for event in native_events
411
+ if isinstance(message := event.get("message"), dict)
412
+ ))
413
+ matched_inputs = {text for mid, text in notification_inputs.items() if mid in native_ids}
414
+ unresolved = unresolved or bool(set(notification_inputs.values()) - matched_inputs)
415
+ trajectory = openclaw_events_to_actf(
416
+ native_events,
417
+ user_messages=user_messages,
418
+ system_prompts=system_prompts,
419
+ tools=tools,
420
+ keep_thinking=keep_thinking,
421
+ notification_inputs=notification_inputs,
422
+ )
423
+ if unresolved:
424
+ trajectory["sft_unavailable_reason"] = OPENCLAW_ANNOUNCEMENT_SFT_REASON
425
+ destination.parent.mkdir(parents=True, exist_ok=True)
426
+ temporary = destination.with_name(f".{destination.name}.tmp")
427
+ temporary.write_text(
428
+ json.dumps(trajectory, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
429
+ )
430
+ temporary.replace(destination)
431
+ return trajectory
roller/artifacts.py ADDED
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import mimetypes
5
+ import os
6
+ import shutil
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ IGNORED_OUTPUT_DIRECTORIES = frozenset({"node_modules"})
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ArtifactCollectionResult:
16
+ passed: bool
17
+ results_path: Path | None
18
+ file_count: int
19
+ directory_count: int
20
+ files: tuple[dict[str, Any], ...]
21
+ errors: tuple[dict[str, Any], ...]
22
+
23
+ def as_dict(self) -> dict[str, Any]:
24
+ return {
25
+ "passed": self.passed,
26
+ "path": str(self.results_path) if self.results_path is not None else None,
27
+ "file_count": self.file_count,
28
+ "directory_count": self.directory_count,
29
+ "files": list(self.files),
30
+ "errors": list(self.errors),
31
+ }
32
+
33
+
34
+ def _sha256(path: Path) -> str:
35
+ digest = hashlib.sha256()
36
+ with path.open("rb") as stream:
37
+ while chunk := stream.read(1024 * 1024):
38
+ digest.update(chunk)
39
+ return digest.hexdigest()
40
+
41
+
42
+ def _walk_outputs(
43
+ root: Path,
44
+ ) -> tuple[dict[str, Path], set[str], list[dict[str, Any]]]:
45
+ files: dict[str, Path] = {}
46
+ directories: set[str] = set()
47
+ errors: list[dict[str, Any]] = []
48
+ if not root.exists():
49
+ return files, directories, errors
50
+ for current, dir_names, file_names in os.walk(root, followlinks=False):
51
+ current_path = Path(current)
52
+ retained_dirs: list[str] = []
53
+ for name in sorted(dir_names):
54
+ if name in IGNORED_OUTPUT_DIRECTORIES:
55
+ continue
56
+ directory = current_path / name
57
+ relative = directory.relative_to(root).as_posix()
58
+ if directory.is_symlink():
59
+ errors.append({"type": "unsafe_symlink", "path": relative})
60
+ else:
61
+ retained_dirs.append(name)
62
+ directories.add(relative)
63
+ dir_names[:] = retained_dirs
64
+ for name in sorted(file_names):
65
+ path = current_path / name
66
+ relative = path.relative_to(root).as_posix()
67
+ if path.is_symlink():
68
+ errors.append({"type": "unsafe_symlink", "path": relative})
69
+ elif not path.is_file():
70
+ errors.append({"type": "non_regular_file", "path": relative})
71
+ else:
72
+ files[relative] = path
73
+ return files, directories, errors
74
+
75
+
76
+ def collect_output_directory(
77
+ staging_root: Path,
78
+ sample_dir: Path,
79
+ ) -> ArtifactCollectionResult:
80
+ """Safely copy every regular file from the task output directory to ``results/``."""
81
+
82
+ results_root = sample_dir / "results"
83
+ if results_root.exists():
84
+ shutil.rmtree(results_root)
85
+ staging_root.mkdir(parents=True, exist_ok=True)
86
+
87
+ known_files, directories, errors = _walk_outputs(staging_root)
88
+ if not known_files and not directories:
89
+ return ArtifactCollectionResult(
90
+ passed=not errors,
91
+ results_path=None,
92
+ file_count=0,
93
+ directory_count=0,
94
+ files=(),
95
+ errors=tuple(errors),
96
+ )
97
+
98
+ results_root.mkdir(parents=True, exist_ok=True)
99
+ for directory in sorted(directories):
100
+ (results_root / directory).mkdir(parents=True, exist_ok=True)
101
+
102
+ file_rows: list[dict[str, Any]] = []
103
+ for relative, source in sorted(known_files.items()):
104
+ destination = results_root / relative
105
+ destination.parent.mkdir(parents=True, exist_ok=True)
106
+ shutil.copy2(source, destination)
107
+ mime_type, _ = mimetypes.guess_type(relative)
108
+ file_rows.append(
109
+ {
110
+ "path": relative,
111
+ "sha256": _sha256(source),
112
+ "size": source.stat().st_size,
113
+ "mime_type": mime_type or "application/octet-stream",
114
+ }
115
+ )
116
+
117
+ return ArtifactCollectionResult(
118
+ passed=not errors,
119
+ results_path=results_root,
120
+ file_count=len(file_rows),
121
+ directory_count=len(directories),
122
+ files=tuple(file_rows),
123
+ errors=tuple(errors),
124
+ )
@@ -0,0 +1,44 @@
1
+ // Observe the native adapter's serialized fetch body; never record headers.
2
+ import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
3
+
4
+ export const name = 'roller-request-journal';
5
+ export const inject = ['subprocess'];
6
+ export function apply(ctx) {
7
+ const original = globalThis.fetch;
8
+ let turn, count = 0;
9
+ const journal = process.env.ROLLER_DEEPSEEK_JOURNAL;
10
+ // dsh scrubs credential-shaped ambient names. Restore only environment
11
+ // entries the Roller task or --pass-env explicitly requested, using the
12
+ // native subprocess service's deliberate env override seam.
13
+ const service = ctx.subprocess;
14
+ const spawnTerminal = service.spawnTerminal;
15
+ service.spawnTerminal = function (spec) {
16
+ const config = JSON.parse(readFileSync('/roller/deepseek-state/turn.json', 'utf8'));
17
+ const env = Object.fromEntries(config.tool_env_names.filter(n => process.env[n] !== undefined)
18
+ .map(n => [n, process.env[n]]));
19
+ return spawnTerminal.call(service, {...spec, env: {...env, ...spec.env}});
20
+ };
21
+ globalThis.fetch = async function (input, init) {
22
+ const url = typeof input === 'string' ? input : input.url ?? String(input);
23
+ if (new URL(url).pathname.endsWith('/chat/completions')) {
24
+ const config = JSON.parse(readFileSync('/roller/deepseek-state/turn.json', 'utf8'));
25
+ if (turn !== config.turn) { turn = config.turn; count = 0; }
26
+ if (++count > config.max_steps) {
27
+ const error = `Roller DeepSeek max steps exceeded (${config.max_steps})`;
28
+ appendFileSync(journal, JSON.stringify({type: 'runner_error', turn, error}) + '\n');
29
+ writeFileSync('/roller/deepseek-state/limit.json', JSON.stringify({turn, error}));
30
+ throw new Error(error);
31
+ }
32
+ if (typeof init?.body !== 'string') throw new Error('DeepSeek request body is not serialized JSON');
33
+ const body = JSON.parse(init.body);
34
+ appendFileSync(journal, JSON.stringify({type: 'request', turn, body}) + '\n');
35
+ if (config.capture_calls) {
36
+ const headers = new Headers(init.headers ?? (typeof input === 'object' ? input.headers : undefined));
37
+ headers.set('x-roller-native-session-id', config.session_id);
38
+ init = {...init, headers};
39
+ }
40
+ }
41
+ return original.call(globalThis, input, init);
42
+ };
43
+ ctx.on('dispose', () => { globalThis.fetch = original; service.spawnTerminal = spawnTerminal; });
44
+ }
@@ -0,0 +1,15 @@
1
+ # Fixed additions to the official sdk-minimal profile. The native agent loop,
2
+ # persistent bash tool and session persistence are left under dsh's ownership.
3
+ - id: session-log-deepseek
4
+ disabled: true
5
+ - id: plugin-package-inventory-deepseek
6
+ disabled: true
7
+ - id: llm-deepseek
8
+ config:
9
+ models: !!js "[{id: process.env.ROLLER_DEEPSEEK_MODEL, contextWindow: Number(process.env.DSH_CONTEXT_WINDOW), maxTokens: Number(process.env.ROLLER_DEEPSEEK_OUTPUT_TOKENS)}]"
10
+ retryPolicy:
11
+ mode: normal
12
+ maxRetries: 0
13
+ - insert:
14
+ - id: roller-request-journal
15
+ name: /roller/deepseek_journal.mjs