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