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
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
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
|