atif-sql 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 (164) hide show
  1. atif_analytics/__init__.py +9 -0
  2. atif_analytics/application/__init__.py +3 -0
  3. atif_analytics/application/analyze.py +158 -0
  4. atif_analytics/application/prompts.py +750 -0
  5. atif_analytics/application/use_cases/__init__.py +3 -0
  6. atif_analytics/application/use_cases/_shared.py +264 -0
  7. atif_analytics/application/use_cases/classify.py +430 -0
  8. atif_analytics/application/use_cases/conflicts.py +448 -0
  9. atif_analytics/application/use_cases/friction.py +634 -0
  10. atif_analytics/application/use_cases/perceived.py +451 -0
  11. atif_analytics/domain/__init__.py +3 -0
  12. atif_analytics/domain/authorship.py +166 -0
  13. atif_analytics/domain/config.py +30 -0
  14. atif_analytics/domain/costs.py +50 -0
  15. atif_analytics/domain/friction.py +117 -0
  16. atif_analytics/domain/layout.py +94 -0
  17. atif_analytics/domain/models.py +385 -0
  18. atif_analytics/domain/ports.py +100 -0
  19. atif_analytics/domain/transcript.py +359 -0
  20. atif_analytics/infrastructure/__init__.py +3 -0
  21. atif_analytics/infrastructure/corpus_reader.py +546 -0
  22. atif_analytics/infrastructure/parquet_cache.py +186 -0
  23. atif_analytics/infrastructure/settings.py +114 -0
  24. atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
  25. atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
  26. atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
  27. atif_analytics/py.typed +0 -0
  28. atif_cli/__init__.py +3 -0
  29. atif_cli/__main__.py +8 -0
  30. atif_cli/app.py +2459 -0
  31. atif_cli/converter_adapter.py +151 -0
  32. atif_cli/corpus.py +409 -0
  33. atif_cli/cron.py +311 -0
  34. atif_cli/duck_errors.py +97 -0
  35. atif_cli/embed_lake.py +101 -0
  36. atif_cli/errors.py +89 -0
  37. atif_cli/lake.py +373 -0
  38. atif_cli/lake_sessions.py +270 -0
  39. atif_cli/output.py +297 -0
  40. atif_cli/py.typed +0 -0
  41. atif_converter/__init__.py +3 -0
  42. atif_converter/application/__init__.py +3 -0
  43. atif_converter/application/convert_and_audit.py +294 -0
  44. atif_converter/application/convert_codex.py +210 -0
  45. atif_converter/domain/__init__.py +9 -0
  46. atif_converter/domain/agents.py +40 -0
  47. atif_converter/domain/atif/__init__.py +55 -0
  48. atif_converter/domain/atif/agent.py +37 -0
  49. atif_converter/domain/atif/content.py +161 -0
  50. atif_converter/domain/atif/final_metrics.py +45 -0
  51. atif_converter/domain/atif/metrics.py +49 -0
  52. atif_converter/domain/atif/observation.py +21 -0
  53. atif_converter/domain/atif/observation_result.py +47 -0
  54. atif_converter/domain/atif/step.py +144 -0
  55. atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
  56. atif_converter/domain/atif/tool_call.py +36 -0
  57. atif_converter/domain/atif/trajectory.py +211 -0
  58. atif_converter/domain/atif/trajectory_validator.py +299 -0
  59. atif_converter/domain/blobs.py +510 -0
  60. atif_converter/domain/claude_code_conversion.py +1077 -0
  61. atif_converter/domain/codex_conversion.py +1036 -0
  62. atif_converter/domain/codex_edges.py +192 -0
  63. atif_converter/domain/codex_enrichment.py +419 -0
  64. atif_converter/domain/codex_fidelity.py +124 -0
  65. atif_converter/domain/codex_nested_calls.py +165 -0
  66. atif_converter/domain/edges.py +113 -0
  67. atif_converter/domain/enrichment.py +412 -0
  68. atif_converter/domain/errors.py +60 -0
  69. atif_converter/domain/fidelity.py +171 -0
  70. atif_converter/domain/model_prices.json +8538 -0
  71. atif_converter/domain/pricing.py +790 -0
  72. atif_converter/domain/result_signals.py +774 -0
  73. atif_converter/domain/schema_version.py +90 -0
  74. atif_converter/domain/session_events.py +408 -0
  75. atif_converter/infrastructure/__init__.py +3 -0
  76. atif_converter/infrastructure/census.py +89 -0
  77. atif_converter/infrastructure/claude_code_converter.py +127 -0
  78. atif_converter/infrastructure/codex_adapter.py +132 -0
  79. atif_converter/infrastructure/codex_census.py +100 -0
  80. atif_converter/infrastructure/codex_converter.py +82 -0
  81. atif_converter/infrastructure/harbor_adapter.py +187 -0
  82. atif_converter/infrastructure/raw_records.py +426 -0
  83. atif_converter/infrastructure/source_archive.py +154 -0
  84. atif_converter/py.typed +0 -0
  85. atif_corpus/__init__.py +13 -0
  86. atif_corpus/application/__init__.py +7 -0
  87. atif_corpus/application/materialize.py +1826 -0
  88. atif_corpus/domain/__init__.py +9 -0
  89. atif_corpus/domain/agents.py +33 -0
  90. atif_corpus/domain/generation.py +34 -0
  91. atif_corpus/domain/layout.py +168 -0
  92. atif_corpus/domain/ports.py +175 -0
  93. atif_corpus/domain/session_id.py +68 -0
  94. atif_corpus/domain/sessions.py +228 -0
  95. atif_corpus/domain/slug.py +72 -0
  96. atif_corpus/domain/source_layout.py +143 -0
  97. atif_corpus/domain/watermark.py +76 -0
  98. atif_corpus/infrastructure/__init__.py +7 -0
  99. atif_corpus/infrastructure/atomic.py +266 -0
  100. atif_corpus/infrastructure/compress_artifacts.py +161 -0
  101. atif_corpus/infrastructure/fake_converter.py +174 -0
  102. atif_corpus/infrastructure/scanner.py +316 -0
  103. atif_corpus/infrastructure/settings.py +130 -0
  104. atif_corpus/infrastructure/source_archive.py +202 -0
  105. atif_corpus/py.typed +0 -0
  106. atif_duck/__init__.py +43 -0
  107. atif_duck/domain/__init__.py +32 -0
  108. atif_duck/domain/artifacts.py +66 -0
  109. atif_duck/domain/authorship.py +165 -0
  110. atif_duck/domain/catalog.py +626 -0
  111. atif_duck/domain/columnar.py +149 -0
  112. atif_duck/domain/embedding_guard.py +88 -0
  113. atif_duck/domain/examples.py +265 -0
  114. atif_duck/domain/lake.py +427 -0
  115. atif_duck/domain/lake_sessions.py +97 -0
  116. atif_duck/domain/raw_readers.py +140 -0
  117. atif_duck/domain/session_id.py +69 -0
  118. atif_duck/domain/sql_literal.py +50 -0
  119. atif_duck/infrastructure/__init__.py +34 -0
  120. atif_duck/infrastructure/analytics.py +469 -0
  121. atif_duck/infrastructure/authorship.py +139 -0
  122. atif_duck/infrastructure/columnar.py +641 -0
  123. atif_duck/infrastructure/lake.py +1383 -0
  124. atif_duck/infrastructure/lake_sessions.py +194 -0
  125. atif_duck/infrastructure/lake_settings.py +63 -0
  126. atif_duck/infrastructure/lake_steps.py +225 -0
  127. atif_duck/infrastructure/projections.py +221 -0
  128. atif_duck/infrastructure/registry.py +2194 -0
  129. atif_duck/infrastructure/stored_artifacts.py +115 -0
  130. atif_duck/py.typed +0 -0
  131. atif_embed/__init__.py +12 -0
  132. atif_embed/application/__init__.py +3 -0
  133. atif_embed/application/embed.py +293 -0
  134. atif_embed/application/prune.py +80 -0
  135. atif_embed/domain/__init__.py +3 -0
  136. atif_embed/domain/discovery.py +70 -0
  137. atif_embed/domain/embedding_guard.py +83 -0
  138. atif_embed/domain/errors.py +94 -0
  139. atif_embed/domain/ports.py +223 -0
  140. atif_embed/domain/sql_literal.py +49 -0
  141. atif_embed/domain/text_stamp.py +71 -0
  142. atif_embed/infrastructure/__init__.py +3 -0
  143. atif_embed/infrastructure/cohere_bedrock.py +433 -0
  144. atif_embed/infrastructure/corpus_text_rows.py +273 -0
  145. atif_embed/infrastructure/gemma_local.py +355 -0
  146. atif_embed/infrastructure/lake_text_rows.py +203 -0
  147. atif_embed/infrastructure/lance_store.py +440 -0
  148. atif_embed/infrastructure/providers.py +30 -0
  149. atif_embed/infrastructure/settings.py +154 -0
  150. atif_embed/py.typed +0 -0
  151. atif_models/__init__.py +14 -0
  152. atif_models/domain/__init__.py +9 -0
  153. atif_models/domain/ports.py +143 -0
  154. atif_models/domain/registry.py +151 -0
  155. atif_models/domain/schema.py +109 -0
  156. atif_models/infrastructure/__init__.py +8 -0
  157. atif_models/infrastructure/openai_bedrock.py +314 -0
  158. atif_models/infrastructure/settings.py +85 -0
  159. atif_models/py.typed +0 -0
  160. atif_sql-0.2.0.dist-info/METADATA +272 -0
  161. atif_sql-0.2.0.dist-info/RECORD +164 -0
  162. atif_sql-0.2.0.dist-info/WHEEL +4 -0
  163. atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
  164. atif_sql-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,774 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Typed tool-outcome, attachment and subagent fields, written into the trajectory.
4
+
5
+ A second post-conversion pass beside :mod:`atif_converter.domain.enrichment`
6
+ and :mod:`atif_converter.domain.codex_enrichment`: pure functions over the
7
+ trajectory dict and the raw records, no harbor import, no I/O. Everything it
8
+ adds lands under ``extra``, which ATIF leaves free-form, apart from a Codex
9
+ script's nested tool calls, each a ``ToolCall`` with its result on the step
10
+ that already holds the script's call, which is the shape the validator asks
11
+ for. The trajectory still validates, and the harbor port itself, which this
12
+ pass runs after, stays at parity with its oracle.
13
+
14
+ What it writes
15
+ --------------
16
+ On every ``observation.results[]`` entry, under ``extra``:
17
+
18
+ ``is_error``
19
+ Claude Code: the ``is_error`` flag of the ``tool_result`` block, the flag
20
+ the harness sent the model. The Messages API defaults it to false, so an
21
+ absent flag is ``false``. Codex: ``true`` when the call failed (a
22
+ ``failed`` status on its item, a non-zero exit code, an MCP ``isError``),
23
+ ``false`` when it completed cleanly, absent when the rollout says neither.
24
+ A code-mode ``exec`` script is ``true`` when its output says ``Script
25
+ failed`` or a command, MCP call or patch it ran failed, and absent while
26
+ it's still running.
27
+ ``exit_code``
28
+ The process exit code, only where the transcript states one. Claude Code
29
+ states it for a failed ``Bash`` call alone, as the fixed ``Exit code N``
30
+ first line of the result; a successful call states nothing (and a call
31
+ with a ``returnCodeInterpretation``, such as grep's "No matches found",
32
+ is not an error yet exited non-zero), so it stays absent rather than being
33
+ guessed as 0. Codex states it structurally on the ``CommandExecution``
34
+ item, and in the ``Process exited with code N`` / ``Exit code: N`` header
35
+ of an output that has no item. An ``exec`` script's commands complete
36
+ items of their own, with no link back to the script, so they're matched
37
+ to it by position (between its call and its output, in the same turn); its
38
+ exit code is the first non-zero one they report, else 0, and absent when
39
+ it ran no command.
40
+ ``interrupted``
41
+ Claude Code's ``toolUseResult.interrupted`` (``Bash`` reports it).
42
+ ``images``
43
+ The attachments :mod:`atif_converter.domain.blobs` lifted out of this
44
+ result, as ``BlobRef.to_json()`` entries (``image/*`` only).
45
+
46
+ Nested tool calls
47
+ -----------------
48
+ A code-mode ``exec`` script's nested MCP calls and commands
49
+ (:data:`~atif_converter.domain.codex_nested_calls.NESTED_CALL_ITEM_TYPES`
50
+ items, attributed to the script by position as ``exit_code`` above says) are
51
+ added to the script's step as tool calls of their own, right after the
52
+ ``exec`` call, each with ``extra.nested_in`` naming it and one result carrying
53
+ its own ``is_error`` / ``exit_code`` (and ``images``). A script whose
54
+ attribution is ambiguous adds none, an item whose id is already a call id is
55
+ never added twice, and the ``exec`` result keeps the outcome folded from its
56
+ items. :mod:`atif_converter.domain.codex_nested_calls` says what each field
57
+ holds.
58
+
59
+ On steps, under ``extra``:
60
+
61
+ ``agent_id``
62
+ The subagent a sidechain step belongs to. The port sets it on assistant
63
+ steps; this pass fills it on the remaining steps (the subagent's prompt,
64
+ an orphan tool result) from the ``agentId`` of the records the step came
65
+ from, when those records name exactly one.
66
+ ``images``
67
+ On a user step: attachments pasted into the message itself.
68
+
69
+ On the trajectory, under ``extra``:
70
+
71
+ ``subagents``
72
+ One entry per subagent the session spawned, from its ``agent-*.meta.json``
73
+ sidecar when there is one (type, description, the ``toolUseId`` of the
74
+ spawning call, spawn depth, parent agent) and otherwise from the spawning
75
+ call's own result (``toolUseResult.agentId``), which is the structured
76
+ link the result text used to be regexed for. ``link_source`` says which.
77
+ """
78
+
79
+ from __future__ import annotations
80
+
81
+ import json
82
+ import re
83
+ from dataclasses import dataclass, field
84
+ from typing import TYPE_CHECKING, Any
85
+
86
+ from atif_converter.domain.codex_nested_calls import (
87
+ NESTED_CALL_ITEM_TYPES,
88
+ nested_result_content,
89
+ nested_tool_call,
90
+ )
91
+
92
+ if TYPE_CHECKING:
93
+ from collections.abc import Iterable, Mapping, Sequence
94
+
95
+ from atif_converter.domain.blobs import BlobIndex, BlobRef
96
+
97
+ #: Claude Code's first line on a failed Bash call.
98
+ _CLAUDE_EXIT_RE = re.compile(r"\A(?:Error: )?Exit code (-?\d+)\b")
99
+
100
+ #: Codex's output headers that state an exit code, within the first lines.
101
+ _CODEX_EXIT_RE = re.compile(r"^(?:Process exited with code|Exit code:) (-?\d+)\b", re.MULTILINE)
102
+
103
+ #: How much of a Codex output the header search reads. The header sits in the
104
+ #: first few lines; bounding the search keeps an exit code printed deep inside
105
+ #: the command's own output from being taken for the process's.
106
+ _CODEX_HEADER_CHARS = 512
107
+
108
+ #: The first line of an ``exec`` script's output: how the script itself ended.
109
+ _CODEX_SCRIPT_RE = re.compile(r"\AScript (completed|failed|running)\b")
110
+
111
+ #: Codex's code-mode tool: one JavaScript script that calls other tools.
112
+ _EXEC_TOOL = "exec"
113
+
114
+ #: Where a Codex call record names its turn.
115
+ _PASSTHROUGH_KEY = "internal_chat_message_metadata_passthrough"
116
+
117
+ #: Response items that open a tool call, and the ones that answer it.
118
+ _CODEX_CALL_TYPES: frozenset[str] = frozenset({"function_call", "custom_tool_call"})
119
+ _CODEX_OUTPUT_TYPES: frozenset[str] = frozenset({"function_call_output", "custom_tool_call_output"})
120
+
121
+ #: Completed items whose only outcome signal is a ``status``.
122
+ _STATUS_ITEM_TYPES: frozenset[str] = frozenset({"FileChange", "CollabAgentToolCall"})
123
+
124
+ #: Claude Code tools whose failed result leads with ``Exit code N``.
125
+ _EXIT_CODE_TOOLS: frozenset[str] = frozenset({"Bash"})
126
+
127
+ #: Sidecar keys copied into a ``subagents`` entry, under snake_case names.
128
+ _SIDECAR_FIELDS: tuple[tuple[str, str], ...] = (
129
+ ("agentType", "agent_type"),
130
+ ("description", "description"),
131
+ ("toolUseId", "parent_tool_call_id"),
132
+ ("spawnDepth", "spawn_depth"),
133
+ ("parentAgentId", "parent_agent_id"),
134
+ )
135
+
136
+
137
+ def _extra(holder: dict[str, Any]) -> dict[str, Any]:
138
+ extra = holder.get("extra")
139
+ if not isinstance(extra, dict):
140
+ extra = {}
141
+ holder["extra"] = extra
142
+ return extra
143
+
144
+
145
+ def _results(steps: Iterable[Any]) -> Iterable[dict[str, Any]]:
146
+ for step in steps:
147
+ if not isinstance(step, dict):
148
+ continue
149
+ observation = step.get("observation")
150
+ results = observation.get("results") if isinstance(observation, dict) else None
151
+ if not isinstance(results, list):
152
+ continue
153
+ for result in results:
154
+ if isinstance(result, dict):
155
+ yield result
156
+
157
+
158
+ def _image_json(refs: Sequence[BlobRef]) -> list[dict[str, Any]]:
159
+ return [ref.to_json() for ref in refs if ref.is_image]
160
+
161
+
162
+ def _parse_int(text: str) -> int | None:
163
+ try:
164
+ return int(text)
165
+ except ValueError:
166
+ return None
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Claude Code
171
+ # ---------------------------------------------------------------------------
172
+
173
+
174
+ @dataclass(frozen=True, slots=True)
175
+ class _ClaudeResult:
176
+ """The raw facts about one tool result, as its record carried them."""
177
+
178
+ is_error: bool
179
+ first_text: str | None
180
+ tool_use_result: Any
181
+ agent_id: str | None
182
+
183
+
184
+ def _first_text(content: Any) -> str | None:
185
+ if isinstance(content, str):
186
+ return content
187
+ if isinstance(content, list):
188
+ for item in content:
189
+ text = item.get("text") if isinstance(item, dict) else None
190
+ if isinstance(text, str):
191
+ return text
192
+ return None
193
+
194
+
195
+ def _index_claude_records(
196
+ records: Iterable[Any],
197
+ ) -> tuple[dict[str, str], dict[str, _ClaudeResult], dict[str, str]]:
198
+ """``(tool name by call id, result facts by call id, agentId by record uuid)``.
199
+
200
+ The first occurrence of a call id wins, as harbor's own dedup keeps it.
201
+ """
202
+ tool_names: dict[str, str] = {}
203
+ results: dict[str, _ClaudeResult] = {}
204
+ agent_by_uuid: dict[str, str] = {}
205
+ for record in records:
206
+ if not isinstance(record, dict):
207
+ continue
208
+ agent_id = record.get("agentId")
209
+ agent_id = agent_id if isinstance(agent_id, str) and agent_id else None
210
+ uuid = record.get("uuid")
211
+ if agent_id and isinstance(uuid, str) and uuid:
212
+ agent_by_uuid.setdefault(uuid, agent_id)
213
+ message = record.get("message")
214
+ content = message.get("content") if isinstance(message, dict) else None
215
+ if not isinstance(content, list):
216
+ continue
217
+ for block in content:
218
+ if not isinstance(block, dict):
219
+ continue
220
+ call_id = block.get("id") if block.get("type") == "tool_use" else None
221
+ if isinstance(call_id, str) and call_id and isinstance(block.get("name"), str):
222
+ tool_names.setdefault(call_id, block["name"])
223
+ if block.get("type") != "tool_result":
224
+ continue
225
+ result_id = block.get("tool_use_id")
226
+ if not isinstance(result_id, str) or not result_id or result_id in results:
227
+ continue
228
+ results[result_id] = _ClaudeResult(
229
+ is_error=block.get("is_error") is True,
230
+ first_text=_first_text(block.get("content")),
231
+ tool_use_result=record.get("toolUseResult"),
232
+ agent_id=agent_id,
233
+ )
234
+ return tool_names, results, agent_by_uuid
235
+
236
+
237
+ def _claude_exit_code(tool_name: str | None, facts: _ClaudeResult) -> int | None:
238
+ if _str(tool_name) not in _EXIT_CODE_TOOLS or not facts.is_error:
239
+ return None
240
+ for text in (facts.first_text, facts.tool_use_result):
241
+ if isinstance(text, str):
242
+ match = _CLAUDE_EXIT_RE.match(text)
243
+ if match:
244
+ return _parse_int(match.group(1))
245
+ return None
246
+
247
+
248
+ def _annotate_claude_results(
249
+ steps: Sequence[Any],
250
+ tool_names: Mapping[str, str],
251
+ results: Mapping[str, _ClaudeResult],
252
+ blob_index: BlobIndex,
253
+ ) -> None:
254
+ for result in _results(steps):
255
+ call_id = result.get("source_call_id")
256
+ if not isinstance(call_id, str):
257
+ continue
258
+ facts = results.get(call_id)
259
+ images = _image_json(blob_index.by_tool_call_id.get(call_id, ()))
260
+ if facts is None and not images:
261
+ continue
262
+ extra = _extra(result)
263
+ if facts is not None:
264
+ extra["is_error"] = facts.is_error
265
+ exit_code = _claude_exit_code(tool_names.get(call_id), facts)
266
+ if exit_code is not None:
267
+ extra["exit_code"] = exit_code
268
+ tool_use_result = facts.tool_use_result
269
+ if isinstance(tool_use_result, dict) and isinstance(
270
+ tool_use_result.get("interrupted"), bool
271
+ ):
272
+ extra["interrupted"] = tool_use_result["interrupted"]
273
+ if images:
274
+ extra["images"] = images
275
+
276
+
277
+ def _annotate_steps(
278
+ steps: Sequence[Any],
279
+ agent_by_uuid: Mapping[str, str],
280
+ blob_index: BlobIndex,
281
+ ) -> None:
282
+ """Fill ``agent_id`` on steps the port left without one; attach user-message images."""
283
+ for step in steps:
284
+ if not isinstance(step, dict):
285
+ continue
286
+ extra = step.get("extra")
287
+ uuids = extra.get("source_uuids") if isinstance(extra, dict) else None
288
+ if not isinstance(uuids, list):
289
+ continue
290
+ if isinstance(extra, dict) and not extra.get("agent_id"):
291
+ agents = {agent_by_uuid[uuid] for uuid in uuids if uuid in agent_by_uuid}
292
+ if len(agents) == 1:
293
+ extra["agent_id"] = agents.pop()
294
+ if step.get("source") == "user":
295
+ refs: list[BlobRef] = []
296
+ for uuid in uuids:
297
+ refs.extend(blob_index.by_record_key.get(uuid, ()))
298
+ images = _image_json(refs)
299
+ if images:
300
+ _extra(step)["images"] = images
301
+
302
+
303
+ def _subagent_entries(
304
+ sidecars: Mapping[str, Any],
305
+ tool_names: Mapping[str, str],
306
+ results: Mapping[str, _ClaudeResult],
307
+ agent_ids_seen: Iterable[str],
308
+ ) -> list[dict[str, Any]]:
309
+ """One ``trajectory.extra.subagents`` entry per subagent, sorted by ``agent_id``.
310
+
311
+ ``sidecars`` maps an agent id to its parsed ``agent-<id>.meta.json``. A
312
+ spawning call's result that names the agent (``toolUseResult.agentId``)
313
+ supplies the link when the sidecar has none, and its ``description`` /
314
+ ``agentType`` fill the gaps a sidecar leaves.
315
+ """
316
+ from_results: dict[str, tuple[str, dict[str, Any]]] = {}
317
+ for call_id, facts in results.items():
318
+ tool_use_result = facts.tool_use_result
319
+ if not isinstance(tool_use_result, dict):
320
+ continue
321
+ agent_id = tool_use_result.get("agentId")
322
+ if (
323
+ isinstance(agent_id, str)
324
+ and agent_id
325
+ and _str(tool_names.get(call_id)) in {"Task", "Agent"}
326
+ ):
327
+ from_results.setdefault(agent_id, (call_id, tool_use_result))
328
+
329
+ entries: list[dict[str, Any]] = []
330
+ for agent_id in sorted({*sidecars, *from_results, *agent_ids_seen}):
331
+ entry: dict[str, Any] = {"agent_id": agent_id}
332
+ sidecar = sidecars.get(agent_id)
333
+ if isinstance(sidecar, dict):
334
+ for source_key, target_key in _SIDECAR_FIELDS:
335
+ value = sidecar.get(source_key)
336
+ if value is not None and value != "":
337
+ entry[target_key] = value
338
+ link = from_results.get(agent_id)
339
+ if "parent_tool_call_id" in entry:
340
+ entry["link_source"] = "meta"
341
+ elif link is not None:
342
+ entry["parent_tool_call_id"] = link[0]
343
+ entry["link_source"] = "tool_result"
344
+ if link is not None:
345
+ for source_key, target_key in (
346
+ ("agentType", "agent_type"),
347
+ ("description", "description"),
348
+ ):
349
+ value = link[1].get(source_key)
350
+ if target_key not in entry and isinstance(value, str) and value:
351
+ entry[target_key] = value
352
+ entries.append(entry)
353
+ return entries
354
+
355
+
356
+ def annotate_claude_code_trajectory(
357
+ trajectory: dict[str, Any],
358
+ records: Sequence[Any],
359
+ blob_index: BlobIndex,
360
+ sidecars: Mapping[str, Any] | None = None,
361
+ ) -> dict[str, Any]:
362
+ """Write the typed fields (module docstring) into ``trajectory`` in place; return it.
363
+
364
+ ``records`` are every parsed record of the session (main and side files),
365
+ already rewritten by :func:`~atif_converter.domain.blobs.extract_claude_code_blobs`.
366
+ ``sidecars`` maps agent id -> parsed ``agent-<id>.meta.json``. Run it AFTER
367
+ :func:`~atif_converter.domain.enrichment.enrich_trajectory`: the step-level
368
+ fields follow ``source_uuids``.
369
+ """
370
+ steps = trajectory.get("steps")
371
+ step_list: list[Any] = steps if isinstance(steps, list) else []
372
+ tool_names, results, agent_by_uuid = _index_claude_records(records)
373
+ _annotate_claude_results(step_list, tool_names, results, blob_index)
374
+ _annotate_steps(step_list, agent_by_uuid, blob_index)
375
+ subagents = _subagent_entries(sidecars or {}, tool_names, results, set(agent_by_uuid.values()))
376
+ if subagents:
377
+ _extra(trajectory)["subagents"] = subagents
378
+ return trajectory
379
+
380
+
381
+ # ---------------------------------------------------------------------------
382
+ # Codex
383
+ # ---------------------------------------------------------------------------
384
+
385
+
386
+ @dataclass(frozen=True, slots=True)
387
+ class _CodexOutcome:
388
+ is_error: bool | None
389
+ exit_code: int | None
390
+
391
+
392
+ def _int_or_none(value: Any) -> int | None:
393
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
394
+
395
+
396
+ def _command_outcome(item: dict[str, Any]) -> _CodexOutcome:
397
+ status = item.get("status")
398
+ exit_code = _int_or_none(item.get("exit_code"))
399
+ is_error: bool | None = None
400
+ if status == "failed" or (exit_code is not None and exit_code != 0):
401
+ is_error = True
402
+ elif status == "completed" or exit_code == 0:
403
+ is_error = False
404
+ return _CodexOutcome(is_error=is_error, exit_code=exit_code)
405
+
406
+
407
+ def _mcp_outcome(item: dict[str, Any]) -> _CodexOutcome | None:
408
+ status = item.get("status")
409
+ result = item.get("result")
410
+ if status == "failed" or (
411
+ isinstance(result, dict) and (result.get("isError") is True or "Err" in result)
412
+ ):
413
+ return _CodexOutcome(is_error=True, exit_code=None)
414
+ if status == "completed":
415
+ return _CodexOutcome(is_error=False, exit_code=None)
416
+ return None
417
+
418
+
419
+ def _status_outcome(item: dict[str, Any]) -> _CodexOutcome | None:
420
+ """A ``FileChange`` / ``CollabAgentToolCall`` item's ``status``, the only signal it carries."""
421
+ status = item.get("status")
422
+ if status == "failed":
423
+ return _CodexOutcome(is_error=True, exit_code=None)
424
+ if status == "completed":
425
+ return _CodexOutcome(is_error=False, exit_code=None)
426
+ return None
427
+
428
+
429
+ def _item_outcome(item: dict[str, Any]) -> _CodexOutcome | None:
430
+ """What a completed tool item says about the call it belongs to."""
431
+ item_type = item.get("type")
432
+ if item_type == "CommandExecution":
433
+ return _command_outcome(item)
434
+ if item_type == "McpToolCall":
435
+ return _mcp_outcome(item)
436
+ if _str(item_type) in _STATUS_ITEM_TYPES:
437
+ return _status_outcome(item)
438
+ return None
439
+
440
+
441
+ def _combine(primary: _CodexOutcome, fallback: _CodexOutcome) -> _CodexOutcome:
442
+ """Two statements about one call: any failure wins, ``primary``'s exit code first."""
443
+ if primary.is_error is True or fallback.is_error is True:
444
+ is_error: bool | None = True
445
+ else:
446
+ is_error = primary.is_error if primary.is_error is not None else fallback.is_error
447
+ exit_code = primary.exit_code if primary.exit_code is not None else fallback.exit_code
448
+ return _CodexOutcome(is_error=is_error, exit_code=exit_code)
449
+
450
+
451
+ @dataclass(slots=True)
452
+ class _ExecWindow:
453
+ """One ``exec`` script call, from its call record to its output record.
454
+
455
+ The items a script's nested tool calls complete carry ids of their own and
456
+ no link to the script, so they're attributed by position: an item that
457
+ completes while exactly one script of its turn is open belongs to it. An
458
+ item that could belong to more than one open script makes each of them
459
+ ``ambiguous``, and an ambiguous script claims nothing its items said.
460
+ ``calls`` keeps the ``item_completed`` payloads of the MCP calls and
461
+ commands attributed to it, in completion order, for
462
+ :func:`_emit_nested_calls`.
463
+ """
464
+
465
+ turn_id: str | None
466
+ nested: list[_CodexOutcome] = field(default_factory=list)
467
+ calls: list[dict[str, Any]] = field(default_factory=list)
468
+ ambiguous: bool = False
469
+
470
+
471
+ def _exec_outcome(output: Any, window: _ExecWindow | None) -> _CodexOutcome | None:
472
+ """An ``exec`` script's outcome: its header, then the items its nested calls completed.
473
+
474
+ ``Script failed`` is an error. ``Script completed`` is an error when a
475
+ nested command, MCP call or patch failed. ``Script running`` (the script
476
+ yielded) states nothing yet. The exit code describes the processes the
477
+ script ran: the first non-zero one, else 0 when every one exited 0, absent
478
+ when it ran none.
479
+ """
480
+ text = _output_text(output)
481
+ match = _CODEX_SCRIPT_RE.match(text) if text is not None else None
482
+ if match is None or match.group(1) == "running":
483
+ return None
484
+ failed = match.group(1) == "failed"
485
+ if window is None or window.ambiguous:
486
+ return _CodexOutcome(is_error=True, exit_code=None) if failed else None
487
+ codes = [outcome.exit_code for outcome in window.nested if outcome.exit_code is not None]
488
+ exit_code = next((code for code in codes if code != 0), 0 if codes else None)
489
+ is_error = failed or any(outcome.is_error is True for outcome in window.nested)
490
+ return _CodexOutcome(is_error=is_error, exit_code=exit_code)
491
+
492
+
493
+ def _turn_id(holder: Any) -> str | None:
494
+ value = holder.get("turn_id") if isinstance(holder, dict) else None
495
+ return value if isinstance(value, str) and value else None
496
+
497
+
498
+ def _legacy_metadata_outcome(output: str) -> _CodexOutcome | None:
499
+ """``{"output": ..., "metadata": {"exit_code": N}}``, the older exec output shape."""
500
+ try:
501
+ parsed = json.loads(output)
502
+ except json.JSONDecodeError:
503
+ return None
504
+ metadata = parsed.get("metadata") if isinstance(parsed, dict) else None
505
+ code = _int_or_none(metadata.get("exit_code")) if isinstance(metadata, dict) else None
506
+ return None if code is None else _CodexOutcome(is_error=code != 0, exit_code=code)
507
+
508
+
509
+ def _output_text(output: Any) -> str | None:
510
+ if isinstance(output, str):
511
+ return output
512
+ if isinstance(output, list):
513
+ for item in output:
514
+ text = item.get("text") if isinstance(item, dict) else None
515
+ if isinstance(text, str):
516
+ return text
517
+ return None
518
+
519
+
520
+ def _output_outcome(output: Any) -> _CodexOutcome | None:
521
+ """The exit code a tool output's own header or legacy metadata states."""
522
+ if isinstance(output, str) and output.startswith("{"):
523
+ return _legacy_metadata_outcome(output)
524
+ text = _output_text(output)
525
+ match = _CODEX_EXIT_RE.search(text[:_CODEX_HEADER_CHARS]) if text is not None else None
526
+ exit_code = _parse_int(match.group(1)) if match is not None else None
527
+ return (
528
+ None if exit_code is None else _CodexOutcome(is_error=exit_code != 0, exit_code=exit_code)
529
+ )
530
+
531
+
532
+ class _CodexIndex:
533
+ """The outcome per call id, built in one pass over a rollout's records."""
534
+
535
+ def __init__(self) -> None:
536
+ self.items: dict[str, _CodexOutcome] = {}
537
+ self.outputs: dict[str, _CodexOutcome] = {}
538
+ self.call_ids: set[str] = set()
539
+ self.exec_calls: dict[str, _ExecWindow] = {}
540
+ self.open_exec: dict[str, _ExecWindow] = {}
541
+
542
+ def call(self, payload: dict[str, Any]) -> None:
543
+ call_id = payload.get("call_id")
544
+ if not isinstance(call_id, str) or not call_id:
545
+ return
546
+ self.call_ids.add(call_id)
547
+ if payload.get("type") == "custom_tool_call" and payload.get("name") == _EXEC_TOOL:
548
+ window = _ExecWindow(turn_id=_turn_id(payload.get(_PASSTHROUGH_KEY)))
549
+ self.exec_calls.setdefault(call_id, window)
550
+ self.open_exec[call_id] = window
551
+
552
+ def item(self, payload: dict[str, Any]) -> None:
553
+ item = payload.get("item")
554
+ if not isinstance(item, dict) or not isinstance(item.get("id"), str):
555
+ return
556
+ outcome = _item_outcome(item)
557
+ if outcome is None:
558
+ return
559
+ item_id = item["id"]
560
+ known = self.items.get(item_id)
561
+ self.items[item_id] = outcome if known is None else _combine(known, outcome)
562
+ if item_id in self.call_ids:
563
+ return
564
+ turn_id = _turn_id(payload)
565
+ candidates = [
566
+ window
567
+ for window in self.open_exec.values()
568
+ if turn_id is None or window.turn_id is None or window.turn_id == turn_id
569
+ ]
570
+ if len(candidates) == 1:
571
+ candidates[0].nested.append(outcome)
572
+ if item.get("type") in NESTED_CALL_ITEM_TYPES:
573
+ candidates[0].calls.append(payload)
574
+ else:
575
+ for window in candidates:
576
+ window.ambiguous = True
577
+
578
+ def output(self, payload: dict[str, Any]) -> None:
579
+ call_id = payload.get("call_id")
580
+ if not isinstance(call_id, str) or not call_id:
581
+ return
582
+ self.open_exec.pop(call_id, None)
583
+ if call_id in self.exec_calls:
584
+ outcome = _exec_outcome(payload.get("output"), self.exec_calls[call_id])
585
+ else:
586
+ outcome = _output_outcome(payload.get("output"))
587
+ if outcome is not None:
588
+ self.outputs.setdefault(call_id, outcome)
589
+
590
+ def outcomes(self) -> dict[str, _CodexOutcome]:
591
+ """A structured item outranks a parsed header for the same call's exit code."""
592
+ merged = dict(self.outputs)
593
+ for call_id, outcome in self.items.items():
594
+ known = merged.get(call_id)
595
+ merged[call_id] = outcome if known is None else _combine(outcome, known)
596
+ return merged
597
+
598
+
599
+ def _index_codex_records(records: Iterable[Any]) -> _CodexIndex:
600
+ index = _CodexIndex()
601
+ for record in records:
602
+ if not isinstance(record, dict):
603
+ continue
604
+ payload = record.get("payload")
605
+ if not isinstance(payload, dict):
606
+ continue
607
+ payload_type = payload.get("type")
608
+ record_type = record.get("type")
609
+ if record_type == "event_msg" and payload_type == "item_completed":
610
+ index.item(payload)
611
+ elif record_type == "response_item" and _str(payload_type) in _CODEX_CALL_TYPES:
612
+ index.call(payload)
613
+ elif record_type == "response_item" and _str(payload_type) in _CODEX_OUTPUT_TYPES:
614
+ index.output(payload)
615
+ return index
616
+
617
+
618
+ def _write_outcome(
619
+ result: dict[str, Any], outcome: _CodexOutcome | None, images: list[dict[str, Any]]
620
+ ) -> None:
621
+ if outcome is None and not images:
622
+ return
623
+ extra = _extra(result)
624
+ if outcome is not None and outcome.is_error is not None:
625
+ extra["is_error"] = outcome.is_error
626
+ if outcome is not None and outcome.exit_code is not None:
627
+ extra["exit_code"] = outcome.exit_code
628
+ if images:
629
+ extra["images"] = images
630
+
631
+
632
+ def _trajectory_call_ids(steps: Iterable[Any]) -> set[str]:
633
+ ids: set[str] = set()
634
+ for step in steps:
635
+ calls = step.get("tool_calls") if isinstance(step, dict) else None
636
+ for call in calls if isinstance(calls, list) else ():
637
+ call_id = call.get("tool_call_id") if isinstance(call, dict) else None
638
+ if isinstance(call_id, str):
639
+ ids.add(call_id)
640
+ return ids
641
+
642
+
643
+ def _nested_pairs(
644
+ window: _ExecWindow,
645
+ exec_call_id: str,
646
+ taken: set[str],
647
+ outcomes: Mapping[str, _CodexOutcome],
648
+ blob_index: BlobIndex,
649
+ ) -> list[tuple[dict[str, Any], dict[str, Any]]]:
650
+ """The ``(ToolCall, ObservationResult)`` JSON pairs one script's nested items become.
651
+
652
+ An item id already taken (a top-level call's id, or an item emitted
653
+ before) is skipped, so no call appears twice.
654
+ """
655
+ pairs: list[tuple[dict[str, Any], dict[str, Any]]] = []
656
+ for payload in window.calls:
657
+ item = payload["item"]
658
+ item_id = item["id"]
659
+ if item_id in taken:
660
+ continue
661
+ taken.add(item_id)
662
+ result: dict[str, Any] = {"source_call_id": item_id}
663
+ content = nested_result_content(item)
664
+ if content is not None:
665
+ result["content"] = content
666
+ _write_outcome(
667
+ result,
668
+ outcomes.get(item_id),
669
+ _image_json(blob_index.by_tool_call_id.get(item_id, ())),
670
+ )
671
+ pairs.append((nested_tool_call(payload, exec_call_id), result))
672
+ return pairs
673
+
674
+
675
+ def _insert_after(results: list[Any], exec_call_id: str, nested: list[dict[str, Any]]) -> None:
676
+ """Put ``nested`` right after the ``exec`` call's own result, or last when it has none."""
677
+ for position, result in enumerate(results):
678
+ if isinstance(result, dict) and result.get("source_call_id") == exec_call_id:
679
+ results[position + 1 : position + 1] = nested
680
+ return
681
+ results.extend(nested)
682
+
683
+
684
+ def _emit_nested_calls(
685
+ steps: Sequence[Any],
686
+ index: _CodexIndex,
687
+ outcomes: Mapping[str, _CodexOutcome],
688
+ blob_index: BlobIndex,
689
+ ) -> int:
690
+ """Add each script's nested MCP calls and commands to the step of its ``exec`` call.
691
+
692
+ A script whose window is ``ambiguous`` emits nothing, as it claims no
693
+ outcome. Each nested call follows its ``exec`` call in ``tool_calls`` and
694
+ its result follows the script's result, in completion order. Returns how
695
+ many calls were added.
696
+ """
697
+ taken = _trajectory_call_ids(steps) | index.call_ids
698
+ emitted = 0
699
+ for step in steps:
700
+ calls = step.get("tool_calls") if isinstance(step, dict) else None
701
+ if not isinstance(calls, list):
702
+ continue
703
+ new_calls: list[Any] = []
704
+ nested_results: list[tuple[str, list[dict[str, Any]]]] = []
705
+ for call in calls:
706
+ new_calls.append(call)
707
+ call_id = call.get("tool_call_id") if isinstance(call, dict) else None
708
+ window = index.exec_calls.get(call_id) if isinstance(call_id, str) else None
709
+ if call_id is None or window is None or window.ambiguous:
710
+ continue
711
+ pairs = _nested_pairs(window, call_id, taken, outcomes, blob_index)
712
+ new_calls.extend(tool_call for tool_call, _result in pairs)
713
+ if pairs:
714
+ nested_results.append((call_id, [result for _call, result in pairs]))
715
+ if not nested_results:
716
+ continue
717
+ step["tool_calls"] = new_calls
718
+ observation = step.get("observation")
719
+ if not isinstance(observation, dict) or not isinstance(observation.get("results"), list):
720
+ observation = {"results": []}
721
+ step["observation"] = observation
722
+ for exec_call_id, results in nested_results:
723
+ _insert_after(observation["results"], exec_call_id, results)
724
+ emitted += len(results)
725
+ return emitted
726
+
727
+
728
+ def annotate_codex_trajectory(
729
+ trajectory: dict[str, Any],
730
+ records: Sequence[Any],
731
+ blob_index: BlobIndex,
732
+ ) -> dict[str, Any]:
733
+ """The Codex half: ``is_error`` / ``exit_code`` / ``images`` per result, user-step images.
734
+
735
+ Also emits each code-mode script's nested MCP calls and commands as tool
736
+ calls of their own (module docstring, and
737
+ :mod:`atif_converter.domain.codex_nested_calls`).
738
+
739
+ ``records`` are the rollout's parsed records, already rewritten by
740
+ :func:`~atif_converter.domain.blobs.extract_codex_blobs` with the same
741
+ record keys the enrichment pass writes into ``source_uuids``.
742
+ """
743
+ steps = trajectory.get("steps")
744
+ step_list: list[Any] = steps if isinstance(steps, list) else []
745
+ index = _index_codex_records(records)
746
+ outcomes = index.outcomes()
747
+ for result in _results(step_list):
748
+ call_id = result.get("source_call_id")
749
+ if not isinstance(call_id, str):
750
+ continue
751
+ _write_outcome(
752
+ result,
753
+ outcomes.get(call_id),
754
+ _image_json(blob_index.by_tool_call_id.get(call_id, ())),
755
+ )
756
+ _emit_nested_calls(step_list, index, outcomes, blob_index)
757
+ _annotate_steps(step_list, {}, blob_index)
758
+ return trajectory
759
+
760
+
761
+ def _str(value: object) -> str | None:
762
+ """``value`` when it is a string, else ``None``: a set-membership key that cannot raise.
763
+
764
+ A transcript field can hold any JSON value, and an object or a list is unhashable, so
765
+ testing one against a set raised ``TypeError`` past the converter (found by the fuzzer).
766
+ A non-string value never matched one of these string sets, so no result changes.
767
+ """
768
+ return value if isinstance(value, str) else None
769
+
770
+
771
+ __all__ = [
772
+ "annotate_claude_code_trajectory",
773
+ "annotate_codex_trajectory",
774
+ ]