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/sft.py
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import json
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
from roller.codex_tools import tool_index
|
|
11
|
+
from roller.storage import atomic_write_json
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SFTConversionError(ValueError):
|
|
15
|
+
"""Raised when an ACTF trajectory cannot form a valid SFT conversation."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SFTUnsupportedContentError(SFTConversionError):
|
|
19
|
+
"""Raised when valid ACTF content cannot be represented without data loss."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Multimodal SFT contract: every image in a tool result becomes one
|
|
23
|
+
# ``<image>`` placeholder in the message text and one entry in the top-level
|
|
24
|
+
# ``images`` list. Scanning ``messages`` front to back, the Nth placeholder
|
|
25
|
+
# maps to ``images[N-1]``, so the placeholder count must equal the list length.
|
|
26
|
+
IMAGE_PLACEHOLDER = "<image>"
|
|
27
|
+
_IMAGE_MEDIA_TYPES = frozenset({"image/png", "image/jpeg", "image/webp", "image/gif"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _string(value: Any, field: str) -> str:
|
|
31
|
+
if not isinstance(value, str):
|
|
32
|
+
raise SFTConversionError(f"{field} must be a string")
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _normalize_tools(value: Any) -> list[dict[str, Any]]:
|
|
37
|
+
# ``None`` means the wire trace did not expose a tools schema. It is still
|
|
38
|
+
# safe to export trajectories that never called a tool; calls are checked
|
|
39
|
+
# against this empty list below.
|
|
40
|
+
if value is None:
|
|
41
|
+
return []
|
|
42
|
+
if not isinstance(value, list):
|
|
43
|
+
raise SFTConversionError("tools must be a list or null")
|
|
44
|
+
|
|
45
|
+
tools: list[dict[str, Any]] = []
|
|
46
|
+
names: set[str] = set()
|
|
47
|
+
for index, tool in enumerate(value):
|
|
48
|
+
if not isinstance(tool, dict) or tool.get("type") != "function":
|
|
49
|
+
raise SFTConversionError(f"tools[{index}] must be a function tool")
|
|
50
|
+
function = tool.get("function")
|
|
51
|
+
if function is None:
|
|
52
|
+
# The Responses API flattens function fields onto each tool item;
|
|
53
|
+
# Chat Completions nests them below ``function``. This legacy path
|
|
54
|
+
# uses the nested representation; captured Codex tools bypass it.
|
|
55
|
+
function = {key: deepcopy(item) for key, item in tool.items() if key != "type"}
|
|
56
|
+
elif not isinstance(function, dict):
|
|
57
|
+
raise SFTConversionError(f"tools[{index}].function must be an object")
|
|
58
|
+
name = function.get("name")
|
|
59
|
+
if not isinstance(name, str) or not name:
|
|
60
|
+
raise SFTConversionError(f"tools[{index}].function.name must be non-empty")
|
|
61
|
+
if name in names:
|
|
62
|
+
raise SFTConversionError(f"duplicate tool name: {name}")
|
|
63
|
+
names.add(name)
|
|
64
|
+
tools.append({"type": "function", "function": deepcopy(function)})
|
|
65
|
+
return tools
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _arguments_json(value: Any, field: str) -> str:
|
|
69
|
+
if isinstance(value, str):
|
|
70
|
+
try:
|
|
71
|
+
parsed = json.loads(value)
|
|
72
|
+
except json.JSONDecodeError as exc:
|
|
73
|
+
raise SFTConversionError(f"{field} must contain valid JSON") from exc
|
|
74
|
+
else:
|
|
75
|
+
parsed = value
|
|
76
|
+
if not isinstance(parsed, dict):
|
|
77
|
+
raise SFTConversionError(f"{field} must encode a JSON object")
|
|
78
|
+
try:
|
|
79
|
+
return json.dumps(parsed, ensure_ascii=False, separators=(",", ":"))
|
|
80
|
+
except (TypeError, ValueError) as exc:
|
|
81
|
+
raise SFTConversionError(f"{field} is not JSON-serializable") from exc
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _normalize_tool_call(call: Any, field: str) -> dict[str, Any]:
|
|
85
|
+
if not isinstance(call, dict):
|
|
86
|
+
raise SFTConversionError(f"{field} must be an object")
|
|
87
|
+
call_id = call.get("id")
|
|
88
|
+
if not isinstance(call_id, str) or not call_id:
|
|
89
|
+
raise SFTConversionError(f"{field}.id must be non-empty")
|
|
90
|
+
|
|
91
|
+
function = call.get("function")
|
|
92
|
+
if isinstance(function, dict):
|
|
93
|
+
name = function.get("name")
|
|
94
|
+
arguments = function.get("arguments", {})
|
|
95
|
+
else:
|
|
96
|
+
name = call.get("name")
|
|
97
|
+
arguments = call.get("arguments", {})
|
|
98
|
+
if not isinstance(name, str) or not name:
|
|
99
|
+
raise SFTConversionError(f"{field} tool name must be non-empty")
|
|
100
|
+
return {
|
|
101
|
+
"id": call_id,
|
|
102
|
+
"type": "function",
|
|
103
|
+
"function": {
|
|
104
|
+
"name": name,
|
|
105
|
+
"arguments": _arguments_json(arguments, f"{field}.arguments"),
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _responses_tool_call(call: Any, field: str) -> dict[str, Any]:
|
|
111
|
+
if isinstance(call, dict) and call.get("native_type") == "custom_tool_call":
|
|
112
|
+
call_id = call.get("id")
|
|
113
|
+
name = call.get("name")
|
|
114
|
+
value = call.get("input")
|
|
115
|
+
if (
|
|
116
|
+
not all(isinstance(x, str) and x for x in (call_id, name))
|
|
117
|
+
or not isinstance(value, str)
|
|
118
|
+
):
|
|
119
|
+
raise SFTConversionError(f"{field} has invalid custom tool input")
|
|
120
|
+
result = {
|
|
121
|
+
"id": call_id, "type": "custom",
|
|
122
|
+
"custom": {"name": name, "input": value},
|
|
123
|
+
}
|
|
124
|
+
else:
|
|
125
|
+
result = _normalize_tool_call(call, field)
|
|
126
|
+
if isinstance(call, dict) and "namespace" in call:
|
|
127
|
+
namespace = call["namespace"]
|
|
128
|
+
if not isinstance(namespace, str) or not namespace:
|
|
129
|
+
raise SFTConversionError(f"{field}.namespace must be a non-empty string")
|
|
130
|
+
result[result["type"]]["namespace"] = namespace
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _image_media_type(value: Any, field: str) -> str:
|
|
135
|
+
if not isinstance(value, str):
|
|
136
|
+
raise SFTConversionError(f"{field} must be a string")
|
|
137
|
+
media_type = value.strip().lower()
|
|
138
|
+
if media_type not in _IMAGE_MEDIA_TYPES:
|
|
139
|
+
supported = ", ".join(sorted(_IMAGE_MEDIA_TYPES))
|
|
140
|
+
raise SFTConversionError(
|
|
141
|
+
f"{field} must be one of {supported}; got {value!r}"
|
|
142
|
+
)
|
|
143
|
+
return media_type
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _image_data_uri(block: Any, field: str) -> str:
|
|
147
|
+
"""Normalize one ACTF image block to a canonical ``data:`` URI.
|
|
148
|
+
|
|
149
|
+
Accepts the base64 ``source`` form (Claude Code), the flat
|
|
150
|
+
``data`` + ``mimeType`` form (OpenClaw/Pi native), and an inline
|
|
151
|
+
``data:...;base64,...`` URI. The converter never reads files or networks;
|
|
152
|
+
blocks that only reference a path keep failing conversion.
|
|
153
|
+
"""
|
|
154
|
+
if not isinstance(block, dict):
|
|
155
|
+
raise SFTConversionError(f"{field} must be an object")
|
|
156
|
+
source = block.get("source")
|
|
157
|
+
if isinstance(source, dict):
|
|
158
|
+
if source.get("type") != "base64":
|
|
159
|
+
raise SFTConversionError(f"{field}.source.type must be 'base64'")
|
|
160
|
+
media_type = _image_media_type(
|
|
161
|
+
source.get("media_type"), f"{field}.source.media_type"
|
|
162
|
+
)
|
|
163
|
+
payload = source.get("data")
|
|
164
|
+
elif isinstance(block.get("data"), str):
|
|
165
|
+
payload = block["data"]
|
|
166
|
+
if payload.startswith("data:"):
|
|
167
|
+
header, separator, encoded = payload.partition(",")
|
|
168
|
+
if not separator or not header.endswith(";base64"):
|
|
169
|
+
raise SFTConversionError(
|
|
170
|
+
f"{field}.data must be a base64 data URI"
|
|
171
|
+
)
|
|
172
|
+
media_type = _image_media_type(header[5:-7], f"{field}.data media type")
|
|
173
|
+
payload = encoded
|
|
174
|
+
else:
|
|
175
|
+
media_type = _image_media_type(
|
|
176
|
+
block.get("mimeType", block.get("mime_type")), f"{field}.mimeType"
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
raise SFTConversionError(
|
|
180
|
+
f"{field} must carry base64 image bytes in source.data or data"
|
|
181
|
+
)
|
|
182
|
+
if not isinstance(payload, str) or not payload:
|
|
183
|
+
raise SFTConversionError(f"{field} image data must be non-empty base64")
|
|
184
|
+
try:
|
|
185
|
+
decoded = base64.b64decode(payload, validate=True)
|
|
186
|
+
except (ValueError, binascii.Error) as exc:
|
|
187
|
+
raise SFTConversionError(f"{field} image data is not valid base64") from exc
|
|
188
|
+
# Decoding accepts some redundant padding; never persist it verbatim.
|
|
189
|
+
canonical = base64.b64encode(decoded).decode("ascii")
|
|
190
|
+
return f"data:{media_type};base64,{canonical}"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _tool_result_content(value: Any, field: str, images: list[str]) -> str:
|
|
194
|
+
if isinstance(value, str):
|
|
195
|
+
return value
|
|
196
|
+
if not isinstance(value, list):
|
|
197
|
+
raise SFTConversionError(f"{field} must be a string or content-block list")
|
|
198
|
+
|
|
199
|
+
parts: list[str] = []
|
|
200
|
+
for index, block in enumerate(value):
|
|
201
|
+
block_field = f"{field}[{index}]"
|
|
202
|
+
if isinstance(block, str):
|
|
203
|
+
parts.append(block)
|
|
204
|
+
continue
|
|
205
|
+
if not isinstance(block, dict):
|
|
206
|
+
raise SFTUnsupportedContentError(
|
|
207
|
+
f"{block_field} is non-text; SFT export would lose data"
|
|
208
|
+
)
|
|
209
|
+
if block.get("type") == "text":
|
|
210
|
+
text = block.get("text")
|
|
211
|
+
if not isinstance(text, str):
|
|
212
|
+
raise SFTConversionError(f"{block_field}.text must be a string")
|
|
213
|
+
parts.append(text)
|
|
214
|
+
elif block.get("type") == "image":
|
|
215
|
+
# Append without deduplication: images encode the occurrence
|
|
216
|
+
# sequence, the Nth placeholder must keep mapping to images[N-1].
|
|
217
|
+
images.append(_image_data_uri(block, block_field))
|
|
218
|
+
parts.append(IMAGE_PLACEHOLDER)
|
|
219
|
+
else:
|
|
220
|
+
raise SFTUnsupportedContentError(
|
|
221
|
+
f"{block_field} is non-text; SFT export would lose data"
|
|
222
|
+
)
|
|
223
|
+
return "\n".join(parts)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _placeholder_count(message: Mapping[str, Any]) -> int:
|
|
227
|
+
# Tool-call arguments also enter training text. Count all message strings,
|
|
228
|
+
# not just content/reasoning_content, without escaping the reserved token.
|
|
229
|
+
return json.dumps(message, ensure_ascii=False).count(IMAGE_PLACEHOLDER)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def validate_sft_record(record: Any) -> None:
|
|
233
|
+
"""Check the top-level shape and the multimodal alignment contract.
|
|
234
|
+
|
|
235
|
+
Legacy text-only records keep exactly ``messages`` + ``tools`` and remain
|
|
236
|
+
the only records in run-level ``sft.jsonl``. Separate compaction segments
|
|
237
|
+
carry ``target_start_index``. Assistant messages at
|
|
238
|
+
or after that index are supervised targets; intervening user/tool messages
|
|
239
|
+
are context, and earlier assistant messages are preserved prompt history.
|
|
240
|
+
Records with
|
|
241
|
+
images add a non-empty ``images`` list of base64 data URIs whose length
|
|
242
|
+
equals the number of ``<image>`` placeholders in ``messages``.
|
|
243
|
+
"""
|
|
244
|
+
if not isinstance(record, Mapping):
|
|
245
|
+
raise ValueError("SFT record must be a JSON object")
|
|
246
|
+
keys = set(record)
|
|
247
|
+
target_start = record.get("target_start_index")
|
|
248
|
+
if "target_start_index" in keys:
|
|
249
|
+
if (not isinstance(target_start, int) or isinstance(target_start, bool)
|
|
250
|
+
or target_start < 1 or not isinstance(record.get("messages"), list)
|
|
251
|
+
or target_start >= len(record["messages"])):
|
|
252
|
+
raise ValueError("target_start_index must identify a target after the prompt")
|
|
253
|
+
target = record["messages"][target_start]
|
|
254
|
+
if not isinstance(target, Mapping) or target.get("role") != "assistant":
|
|
255
|
+
raise ValueError("target_start_index must point to an assistant target")
|
|
256
|
+
keys.remove("target_start_index")
|
|
257
|
+
if keys == {"messages", "tools"}:
|
|
258
|
+
return
|
|
259
|
+
if keys != {"messages", "tools", "images"}:
|
|
260
|
+
raise ValueError("SFT record may only contain messages, tools, and images")
|
|
261
|
+
images = record["images"]
|
|
262
|
+
if not isinstance(images, list) or not images:
|
|
263
|
+
raise ValueError("SFT images must be a non-empty list")
|
|
264
|
+
for index, uri in enumerate(images):
|
|
265
|
+
field = f"images[{index}]"
|
|
266
|
+
if not isinstance(uri, str) or not uri.startswith("data:"):
|
|
267
|
+
raise ValueError(f"{field} must be a base64 image data URI")
|
|
268
|
+
try:
|
|
269
|
+
canonical = _image_data_uri({"data": uri}, field)
|
|
270
|
+
except SFTConversionError as exc:
|
|
271
|
+
raise ValueError(f"{field} must be a base64 image data URI: {exc}") from exc
|
|
272
|
+
if canonical != uri:
|
|
273
|
+
raise ValueError(f"{field} must be a canonical base64 image data URI")
|
|
274
|
+
messages = record["messages"]
|
|
275
|
+
if not isinstance(messages, list):
|
|
276
|
+
raise ValueError("SFT messages must be a list")
|
|
277
|
+
placeholders = sum(
|
|
278
|
+
_placeholder_count(message) for message in messages if isinstance(message, Mapping)
|
|
279
|
+
)
|
|
280
|
+
if placeholders != len(images):
|
|
281
|
+
raise ValueError(
|
|
282
|
+
"SFT <image> placeholder count does not match the images length"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def actf_to_sft(trajectory: Mapping[str, Any]) -> dict[str, Any]:
|
|
287
|
+
"""Convert one ACTF trajectory to a minimal messages/tools SFT record."""
|
|
288
|
+
|
|
289
|
+
if trajectory.get("sft_unavailable_reason"):
|
|
290
|
+
raise SFTConversionError(str(trajectory["sft_unavailable_reason"]))
|
|
291
|
+
raw_steps = trajectory.get("steps")
|
|
292
|
+
for step in raw_steps if isinstance(raw_steps, list) else []:
|
|
293
|
+
extra = step.get("extra") if isinstance(step, Mapping) else None
|
|
294
|
+
if isinstance(extra, Mapping) and extra.get("is_sidechain"):
|
|
295
|
+
raise SFTConversionError("mixed subagent steps cannot be exported as one assistant")
|
|
296
|
+
if isinstance(extra, Mapping) and extra.get("model") == "<synthetic>":
|
|
297
|
+
raise SFTConversionError("synthetic assistant is not a model output")
|
|
298
|
+
|
|
299
|
+
system_prompt = trajectory.get("system_prompt", "")
|
|
300
|
+
if not isinstance(system_prompt, str):
|
|
301
|
+
raise SFTConversionError("system_prompt must be a string")
|
|
302
|
+
responses_tools = trajectory.get("tools_format") == "responses"
|
|
303
|
+
native_names: dict[tuple[str, str], str] = {}
|
|
304
|
+
if responses_tools:
|
|
305
|
+
capture = trajectory.get("tools_capture", {})
|
|
306
|
+
if not isinstance(capture, Mapping):
|
|
307
|
+
raise SFTConversionError("Codex tools_capture must be an object")
|
|
308
|
+
if capture.get("status") != "captured":
|
|
309
|
+
raise SFTConversionError(
|
|
310
|
+
capture.get("error") or
|
|
311
|
+
"Codex tools were not captured; schema inference is disabled"
|
|
312
|
+
)
|
|
313
|
+
tools = deepcopy(trajectory.get("tools"))
|
|
314
|
+
try:
|
|
315
|
+
native_names = tool_index(tools)
|
|
316
|
+
except ValueError as exc:
|
|
317
|
+
raise SFTConversionError(str(exc)) from exc
|
|
318
|
+
tool_names = set()
|
|
319
|
+
else:
|
|
320
|
+
tools = _normalize_tools(trajectory.get("tools"))
|
|
321
|
+
tool_names = {tool["function"]["name"] for tool in tools}
|
|
322
|
+
|
|
323
|
+
steps = trajectory.get("steps")
|
|
324
|
+
if not isinstance(steps, list):
|
|
325
|
+
raise SFTConversionError("steps must be a list")
|
|
326
|
+
|
|
327
|
+
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
|
|
328
|
+
seen_call_ids: set[str] = set()
|
|
329
|
+
user_count = 0
|
|
330
|
+
assistant_count = 0
|
|
331
|
+
images: list[str] = []
|
|
332
|
+
|
|
333
|
+
for step_index, step in enumerate(steps):
|
|
334
|
+
prefix = f"steps[{step_index}]"
|
|
335
|
+
if not isinstance(step, dict):
|
|
336
|
+
raise SFTConversionError(f"{prefix} must be an object")
|
|
337
|
+
|
|
338
|
+
user_content = _string(step.get("user_content", ""), f"{prefix}.user_content")
|
|
339
|
+
if user_content:
|
|
340
|
+
messages.append({"role": "user", "content": user_content})
|
|
341
|
+
user_count += 1
|
|
342
|
+
|
|
343
|
+
assistant = step.get("assistant_content")
|
|
344
|
+
if not isinstance(assistant, dict):
|
|
345
|
+
raise SFTConversionError(f"{prefix}.assistant_content must be an object")
|
|
346
|
+
reasoning = _string(
|
|
347
|
+
assistant.get("reasoning_content", ""),
|
|
348
|
+
f"{prefix}.assistant_content.reasoning_content",
|
|
349
|
+
)
|
|
350
|
+
content = _string(
|
|
351
|
+
assistant.get("content", ""), f"{prefix}.assistant_content.content"
|
|
352
|
+
)
|
|
353
|
+
raw_calls = assistant.get("tool_calls", [])
|
|
354
|
+
if not isinstance(raw_calls, list):
|
|
355
|
+
raise SFTConversionError(
|
|
356
|
+
f"{prefix}.assistant_content.tool_calls must be a list"
|
|
357
|
+
)
|
|
358
|
+
normalize_call = _responses_tool_call if responses_tools else _normalize_tool_call
|
|
359
|
+
calls = [
|
|
360
|
+
normalize_call(call, f"{prefix}.assistant_content.tool_calls[{index}]")
|
|
361
|
+
for index, call in enumerate(raw_calls)
|
|
362
|
+
]
|
|
363
|
+
if not reasoning and not content and not calls:
|
|
364
|
+
raise SFTConversionError(f"{prefix} contains an empty assistant message")
|
|
365
|
+
|
|
366
|
+
expected_ids: set[str] = set()
|
|
367
|
+
call_names: dict[str, str] = {}
|
|
368
|
+
for call in calls:
|
|
369
|
+
call_id = call["id"]
|
|
370
|
+
details = call[call["type"]]
|
|
371
|
+
name = details["name"]
|
|
372
|
+
if call_id in seen_call_ids:
|
|
373
|
+
raise SFTConversionError(f"duplicate tool call id: {call_id}")
|
|
374
|
+
if responses_tools:
|
|
375
|
+
key = (details.get("namespace", ""), name)
|
|
376
|
+
if key not in native_names and not key[0] and "." in name:
|
|
377
|
+
namespace, leaf = name.rsplit(".", 1)
|
|
378
|
+
key = (namespace, leaf)
|
|
379
|
+
if key not in native_names:
|
|
380
|
+
raise SFTConversionError(f"tool call references missing captured schema: {name}")
|
|
381
|
+
kind = native_names[key]
|
|
382
|
+
if (kind == "custom") != (call["type"] == "custom"):
|
|
383
|
+
raise SFTConversionError(f"tool call type differs from captured schema: {name}")
|
|
384
|
+
elif name not in tool_names:
|
|
385
|
+
raise SFTConversionError(f"tool call references missing schema: {name}")
|
|
386
|
+
seen_call_ids.add(call_id)
|
|
387
|
+
expected_ids.add(call_id)
|
|
388
|
+
call_names[call_id] = name
|
|
389
|
+
|
|
390
|
+
observations = step.get("observation", [])
|
|
391
|
+
if not isinstance(observations, list):
|
|
392
|
+
raise SFTConversionError(f"{prefix}.observation must be a list")
|
|
393
|
+
observed_ids: set[str] = set()
|
|
394
|
+
results_by_id: dict[str, tuple[dict[str, Any], str]] = {}
|
|
395
|
+
for observation_index, observation in enumerate(observations):
|
|
396
|
+
field = f"{prefix}.observation[{observation_index}]"
|
|
397
|
+
if not isinstance(observation, dict):
|
|
398
|
+
raise SFTConversionError(f"{field} must be an object")
|
|
399
|
+
call_id = observation.get("toolCallId", observation.get("tool_call_id"))
|
|
400
|
+
if not isinstance(call_id, str) or not call_id:
|
|
401
|
+
raise SFTConversionError(f"{field}.toolCallId must be non-empty")
|
|
402
|
+
if call_id in observed_ids:
|
|
403
|
+
raise SFTConversionError(f"duplicate tool result for call id: {call_id}")
|
|
404
|
+
if call_id not in expected_ids:
|
|
405
|
+
raise SFTConversionError(f"tool result has no matching call: {call_id}")
|
|
406
|
+
observed_name = observation.get("toolName")
|
|
407
|
+
if (
|
|
408
|
+
isinstance(observed_name, str)
|
|
409
|
+
and observed_name
|
|
410
|
+
and observed_name != call_names[call_id]
|
|
411
|
+
):
|
|
412
|
+
raise SFTConversionError(
|
|
413
|
+
f"tool result name {observed_name} does not match call {call_names[call_id]}"
|
|
414
|
+
)
|
|
415
|
+
observed_ids.add(call_id)
|
|
416
|
+
results_by_id[call_id] = (observation, field)
|
|
417
|
+
if observed_ids != expected_ids:
|
|
418
|
+
missing = sorted(expected_ids - observed_ids)
|
|
419
|
+
raise SFTConversionError(f"tool calls missing results: {', '.join(missing)}")
|
|
420
|
+
|
|
421
|
+
# Parallel tools can finish in any order. Canonicalize only the SFT
|
|
422
|
+
# output, matching by ID rather than completion order or tool name.
|
|
423
|
+
# Serialize media in this same order so positional image references
|
|
424
|
+
# remain aligned with the emitted <image> placeholders.
|
|
425
|
+
tool_messages: list[dict[str, Any]] = []
|
|
426
|
+
for call in calls:
|
|
427
|
+
observation, field = results_by_id[call["id"]]
|
|
428
|
+
tool_messages.append({
|
|
429
|
+
"role": "tool",
|
|
430
|
+
"tool_call_id": call["id"],
|
|
431
|
+
"content": _tool_result_content(
|
|
432
|
+
observation.get("content", []), f"{field}.content", images
|
|
433
|
+
),
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
messages.append(
|
|
437
|
+
{
|
|
438
|
+
"role": "assistant",
|
|
439
|
+
"reasoning_content": reasoning,
|
|
440
|
+
"content": content,
|
|
441
|
+
"tool_calls": calls,
|
|
442
|
+
}
|
|
443
|
+
)
|
|
444
|
+
messages.extend(tool_messages)
|
|
445
|
+
assistant_count += 1
|
|
446
|
+
|
|
447
|
+
if user_count == 0:
|
|
448
|
+
raise SFTConversionError("trajectory contains no user message")
|
|
449
|
+
if assistant_count == 0:
|
|
450
|
+
raise SFTConversionError("trajectory contains no assistant message")
|
|
451
|
+
if images:
|
|
452
|
+
# Every appended image contributed one placeholder, so any surplus
|
|
453
|
+
# occurrence is a literal <image> in source text and would silently
|
|
454
|
+
# misalign the positional images mapping.
|
|
455
|
+
placeholders = sum(_placeholder_count(message) for message in messages)
|
|
456
|
+
if placeholders != len(images):
|
|
457
|
+
raise SFTConversionError(
|
|
458
|
+
"text already contains a literal <image> placeholder; "
|
|
459
|
+
"multimodal alignment would be ambiguous"
|
|
460
|
+
)
|
|
461
|
+
return {"messages": messages, "tools": tools, "images": images}
|
|
462
|
+
return {"messages": messages, "tools": tools}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def write_sft(
|
|
466
|
+
path: Path,
|
|
467
|
+
trajectory: Mapping[str, Any],
|
|
468
|
+
) -> dict[str, Any]:
|
|
469
|
+
record = actf_to_sft(trajectory)
|
|
470
|
+
atomic_write_json(path, record)
|
|
471
|
+
return record
|
roller/storage.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Iterable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def safe_component(value: str, max_length: int = 80) -> str:
|
|
13
|
+
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._-") or "item"
|
|
14
|
+
if cleaned == value and len(cleaned) <= max_length:
|
|
15
|
+
return cleaned
|
|
16
|
+
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:8]
|
|
17
|
+
prefix = cleaned[: max_length - len(digest) - 1].rstrip("._-") or "item"
|
|
18
|
+
return f"{prefix}-{digest}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def atomic_write_json(path: Path, payload: Any) -> None:
|
|
22
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
24
|
+
try:
|
|
25
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
26
|
+
json.dump(payload, stream, ensure_ascii=False, indent=2)
|
|
27
|
+
stream.write("\n")
|
|
28
|
+
os.replace(temporary_name, path)
|
|
29
|
+
except BaseException:
|
|
30
|
+
try:
|
|
31
|
+
os.unlink(temporary_name)
|
|
32
|
+
except FileNotFoundError:
|
|
33
|
+
pass
|
|
34
|
+
raise
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def write_jsonl(path: Path, rows: Iterable[dict[str, Any]]) -> None:
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
40
|
+
try:
|
|
41
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
42
|
+
for row in rows:
|
|
43
|
+
stream.write(
|
|
44
|
+
json.dumps(row, ensure_ascii=False, separators=(",", ":"))
|
|
45
|
+
)
|
|
46
|
+
stream.write("\n")
|
|
47
|
+
os.replace(temporary_name, path)
|
|
48
|
+
except BaseException:
|
|
49
|
+
try:
|
|
50
|
+
os.unlink(temporary_name)
|
|
51
|
+
except FileNotFoundError:
|
|
52
|
+
pass
|
|
53
|
+
raise
|