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.
- atif_analytics/__init__.py +9 -0
- atif_analytics/application/__init__.py +3 -0
- atif_analytics/application/analyze.py +158 -0
- atif_analytics/application/prompts.py +750 -0
- atif_analytics/application/use_cases/__init__.py +3 -0
- atif_analytics/application/use_cases/_shared.py +264 -0
- atif_analytics/application/use_cases/classify.py +430 -0
- atif_analytics/application/use_cases/conflicts.py +448 -0
- atif_analytics/application/use_cases/friction.py +634 -0
- atif_analytics/application/use_cases/perceived.py +451 -0
- atif_analytics/domain/__init__.py +3 -0
- atif_analytics/domain/authorship.py +166 -0
- atif_analytics/domain/config.py +30 -0
- atif_analytics/domain/costs.py +50 -0
- atif_analytics/domain/friction.py +117 -0
- atif_analytics/domain/layout.py +94 -0
- atif_analytics/domain/models.py +385 -0
- atif_analytics/domain/ports.py +100 -0
- atif_analytics/domain/transcript.py +359 -0
- atif_analytics/infrastructure/__init__.py +3 -0
- atif_analytics/infrastructure/corpus_reader.py +546 -0
- atif_analytics/infrastructure/parquet_cache.py +186 -0
- atif_analytics/infrastructure/settings.py +114 -0
- atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
- atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
- atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
- atif_analytics/py.typed +0 -0
- atif_cli/__init__.py +3 -0
- atif_cli/__main__.py +8 -0
- atif_cli/app.py +2459 -0
- atif_cli/converter_adapter.py +151 -0
- atif_cli/corpus.py +409 -0
- atif_cli/cron.py +311 -0
- atif_cli/duck_errors.py +97 -0
- atif_cli/embed_lake.py +101 -0
- atif_cli/errors.py +89 -0
- atif_cli/lake.py +373 -0
- atif_cli/lake_sessions.py +270 -0
- atif_cli/output.py +297 -0
- atif_cli/py.typed +0 -0
- atif_converter/__init__.py +3 -0
- atif_converter/application/__init__.py +3 -0
- atif_converter/application/convert_and_audit.py +294 -0
- atif_converter/application/convert_codex.py +210 -0
- atif_converter/domain/__init__.py +9 -0
- atif_converter/domain/agents.py +40 -0
- atif_converter/domain/atif/__init__.py +55 -0
- atif_converter/domain/atif/agent.py +37 -0
- atif_converter/domain/atif/content.py +161 -0
- atif_converter/domain/atif/final_metrics.py +45 -0
- atif_converter/domain/atif/metrics.py +49 -0
- atif_converter/domain/atif/observation.py +21 -0
- atif_converter/domain/atif/observation_result.py +47 -0
- atif_converter/domain/atif/step.py +144 -0
- atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
- atif_converter/domain/atif/tool_call.py +36 -0
- atif_converter/domain/atif/trajectory.py +211 -0
- atif_converter/domain/atif/trajectory_validator.py +299 -0
- atif_converter/domain/blobs.py +510 -0
- atif_converter/domain/claude_code_conversion.py +1077 -0
- atif_converter/domain/codex_conversion.py +1036 -0
- atif_converter/domain/codex_edges.py +192 -0
- atif_converter/domain/codex_enrichment.py +419 -0
- atif_converter/domain/codex_fidelity.py +124 -0
- atif_converter/domain/codex_nested_calls.py +165 -0
- atif_converter/domain/edges.py +113 -0
- atif_converter/domain/enrichment.py +412 -0
- atif_converter/domain/errors.py +60 -0
- atif_converter/domain/fidelity.py +171 -0
- atif_converter/domain/model_prices.json +8538 -0
- atif_converter/domain/pricing.py +790 -0
- atif_converter/domain/result_signals.py +774 -0
- atif_converter/domain/schema_version.py +90 -0
- atif_converter/domain/session_events.py +408 -0
- atif_converter/infrastructure/__init__.py +3 -0
- atif_converter/infrastructure/census.py +89 -0
- atif_converter/infrastructure/claude_code_converter.py +127 -0
- atif_converter/infrastructure/codex_adapter.py +132 -0
- atif_converter/infrastructure/codex_census.py +100 -0
- atif_converter/infrastructure/codex_converter.py +82 -0
- atif_converter/infrastructure/harbor_adapter.py +187 -0
- atif_converter/infrastructure/raw_records.py +426 -0
- atif_converter/infrastructure/source_archive.py +154 -0
- atif_converter/py.typed +0 -0
- atif_corpus/__init__.py +13 -0
- atif_corpus/application/__init__.py +7 -0
- atif_corpus/application/materialize.py +1826 -0
- atif_corpus/domain/__init__.py +9 -0
- atif_corpus/domain/agents.py +33 -0
- atif_corpus/domain/generation.py +34 -0
- atif_corpus/domain/layout.py +168 -0
- atif_corpus/domain/ports.py +175 -0
- atif_corpus/domain/session_id.py +68 -0
- atif_corpus/domain/sessions.py +228 -0
- atif_corpus/domain/slug.py +72 -0
- atif_corpus/domain/source_layout.py +143 -0
- atif_corpus/domain/watermark.py +76 -0
- atif_corpus/infrastructure/__init__.py +7 -0
- atif_corpus/infrastructure/atomic.py +266 -0
- atif_corpus/infrastructure/compress_artifacts.py +161 -0
- atif_corpus/infrastructure/fake_converter.py +174 -0
- atif_corpus/infrastructure/scanner.py +316 -0
- atif_corpus/infrastructure/settings.py +130 -0
- atif_corpus/infrastructure/source_archive.py +202 -0
- atif_corpus/py.typed +0 -0
- atif_duck/__init__.py +43 -0
- atif_duck/domain/__init__.py +32 -0
- atif_duck/domain/artifacts.py +66 -0
- atif_duck/domain/authorship.py +165 -0
- atif_duck/domain/catalog.py +626 -0
- atif_duck/domain/columnar.py +149 -0
- atif_duck/domain/embedding_guard.py +88 -0
- atif_duck/domain/examples.py +265 -0
- atif_duck/domain/lake.py +427 -0
- atif_duck/domain/lake_sessions.py +97 -0
- atif_duck/domain/raw_readers.py +140 -0
- atif_duck/domain/session_id.py +69 -0
- atif_duck/domain/sql_literal.py +50 -0
- atif_duck/infrastructure/__init__.py +34 -0
- atif_duck/infrastructure/analytics.py +469 -0
- atif_duck/infrastructure/authorship.py +139 -0
- atif_duck/infrastructure/columnar.py +641 -0
- atif_duck/infrastructure/lake.py +1383 -0
- atif_duck/infrastructure/lake_sessions.py +194 -0
- atif_duck/infrastructure/lake_settings.py +63 -0
- atif_duck/infrastructure/lake_steps.py +225 -0
- atif_duck/infrastructure/projections.py +221 -0
- atif_duck/infrastructure/registry.py +2194 -0
- atif_duck/infrastructure/stored_artifacts.py +115 -0
- atif_duck/py.typed +0 -0
- atif_embed/__init__.py +12 -0
- atif_embed/application/__init__.py +3 -0
- atif_embed/application/embed.py +293 -0
- atif_embed/application/prune.py +80 -0
- atif_embed/domain/__init__.py +3 -0
- atif_embed/domain/discovery.py +70 -0
- atif_embed/domain/embedding_guard.py +83 -0
- atif_embed/domain/errors.py +94 -0
- atif_embed/domain/ports.py +223 -0
- atif_embed/domain/sql_literal.py +49 -0
- atif_embed/domain/text_stamp.py +71 -0
- atif_embed/infrastructure/__init__.py +3 -0
- atif_embed/infrastructure/cohere_bedrock.py +433 -0
- atif_embed/infrastructure/corpus_text_rows.py +273 -0
- atif_embed/infrastructure/gemma_local.py +355 -0
- atif_embed/infrastructure/lake_text_rows.py +203 -0
- atif_embed/infrastructure/lance_store.py +440 -0
- atif_embed/infrastructure/providers.py +30 -0
- atif_embed/infrastructure/settings.py +154 -0
- atif_embed/py.typed +0 -0
- atif_models/__init__.py +14 -0
- atif_models/domain/__init__.py +9 -0
- atif_models/domain/ports.py +143 -0
- atif_models/domain/registry.py +151 -0
- atif_models/domain/schema.py +109 -0
- atif_models/infrastructure/__init__.py +8 -0
- atif_models/infrastructure/openai_bedrock.py +314 -0
- atif_models/infrastructure/settings.py +85 -0
- atif_models/py.typed +0 -0
- atif_sql-0.2.0.dist-info/METADATA +272 -0
- atif_sql-0.2.0.dist-info/RECORD +164 -0
- atif_sql-0.2.0.dist-info/WHEEL +4 -0
- atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|