ithrl 0.1.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.
- ithrl/__init__.py +1 -0
- ithrl/__main__.py +6 -0
- ithrl/adapters/__init__.py +49 -0
- ithrl/adapters/_jsonl.py +266 -0
- ithrl/adapters/codex/__init__.py +5 -0
- ithrl/adapters/codex/context.py +204 -0
- ithrl/adapters/codex/decisions.py +224 -0
- ithrl/adapters/codex/discovery.py +60 -0
- ithrl/adapters/codex/parser.py +191 -0
- ithrl/adapters/contracts.py +106 -0
- ithrl/adapters/copilot/__init__.py +5 -0
- ithrl/adapters/copilot/context.py +182 -0
- ithrl/adapters/copilot/decisions.py +294 -0
- ithrl/adapters/copilot/discovery.py +28 -0
- ithrl/adapters/copilot/parser.py +283 -0
- ithrl/adapters/discovery.py +220 -0
- ithrl/adapters/errors.py +52 -0
- ithrl/adapters/registry.py +433 -0
- ithrl/behavior.py +138 -0
- ithrl/calibration.py +434 -0
- ithrl/case/__init__.py +33 -0
- ithrl/case/contracts.py +88 -0
- ithrl/case/evidence.py +169 -0
- ithrl/case/filesystem.py +340 -0
- ithrl/case/source.py +208 -0
- ithrl/case/source_context.py +119 -0
- ithrl/case/source_storage.py +234 -0
- ithrl/case/storage.py +377 -0
- ithrl/cli/__init__.py +968 -0
- ithrl/cli/extract.py +478 -0
- ithrl/cli/native.py +484 -0
- ithrl/cli/output.py +786 -0
- ithrl/cli/paths.py +24 -0
- ithrl/cli/sampled.py +485 -0
- ithrl/cli/sampled_output.py +472 -0
- ithrl/cli/sampled_reduction.py +1036 -0
- ithrl/cli/sampled_session.py +342 -0
- ithrl/cli/shrink.py +523 -0
- ithrl/context.py +465 -0
- ithrl/decisions.py +271 -0
- ithrl/examples/synthetic-trace.json +46 -0
- ithrl/execution/__init__.py +68 -0
- ithrl/execution/budget.py +146 -0
- ithrl/execution/contracts.py +426 -0
- ithrl/execution/preflight.py +346 -0
- ithrl/experiment.py +453 -0
- ithrl/extraction.py +206 -0
- ithrl/measurement.py +315 -0
- ithrl/observations.py +1148 -0
- ithrl/path_safety.py +100 -0
- ithrl/reduce/__init__.py +53 -0
- ithrl/reduce/contracts.py +508 -0
- ithrl/reduce/engine.py +901 -0
- ithrl/reduce/search.py +178 -0
- ithrl/results/__init__.py +49 -0
- ithrl/results/models.py +294 -0
- ithrl/results/storage.py +485 -0
- ithrl/results/summary.py +420 -0
- ithrl/results/validation.py +611 -0
- ithrl/run_state.py +1135 -0
- ithrl/runner/__init__.py +44 -0
- ithrl/runner/codex/__init__.py +17 -0
- ithrl/runner/codex/capability.py +708 -0
- ithrl/runner/codex/configuration.py +529 -0
- ithrl/runner/codex/isolation.py +396 -0
- ithrl/runner/codex/materialize.py +358 -0
- ithrl/runner/codex/policy.py +67 -0
- ithrl/runner/codex/protocol.py +649 -0
- ithrl/runner/codex/protocol_contracts.py +171 -0
- ithrl/runner/codex/runner.py +533 -0
- ithrl/runner/codex/runtime_configuration.py +264 -0
- ithrl/runner/codex/schema.py +527 -0
- ithrl/runner/codex/transcript.py +130 -0
- ithrl/runner/codex/worker.py +175 -0
- ithrl/runner/contracts.py +419 -0
- ithrl/runner/copilot/__init__.py +18 -0
- ithrl/runner/copilot/capability.py +825 -0
- ithrl/runner/copilot/configuration.py +201 -0
- ithrl/runner/copilot/isolation.py +374 -0
- ithrl/runner/copilot/materialize.py +484 -0
- ithrl/runner/copilot/policy.py +110 -0
- ithrl/runner/copilot/protocol/__init__.py +18 -0
- ithrl/runner/copilot/protocol/contracts.py +74 -0
- ithrl/runner/copilot/protocol/decoding.py +93 -0
- ithrl/runner/copilot/protocol/parser.py +418 -0
- ithrl/runner/copilot/protocol/state.py +130 -0
- ithrl/runner/copilot/runner.py +425 -0
- ithrl/runner/copilot/worker.py +351 -0
- ithrl/runner/evidence.py +345 -0
- ithrl/runner/fake.py +107 -0
- ithrl/runner/registry.py +90 -0
- ithrl/runner/runtime/__init__.py +50 -0
- ithrl/runner/runtime/contracts.py +157 -0
- ithrl/runner/runtime/output.py +184 -0
- ithrl/runner/runtime/process.py +954 -0
- ithrl/runner/runtime/protection.py +562 -0
- ithrl/runner/runtime/state.py +282 -0
- ithrl/runner/validation.py +376 -0
- ithrl/sampling.py +412 -0
- ithrl/statistics.py +276 -0
- ithrl/synthetic.py +511 -0
- ithrl/timestamps.py +26 -0
- ithrl/workflow.py +570 -0
- ithrl-0.1.0.dist-info/METADATA +118 -0
- ithrl-0.1.0.dist-info/RECORD +108 -0
- ithrl-0.1.0.dist-info/WHEEL +4 -0
- ithrl-0.1.0.dist-info/entry_points.txt +2 -0
- ithrl-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Enumerate completed decisions from parsed Codex CLI sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
from ithrl.adapters.codex.context import opaque_prefix, source_selector
|
|
9
|
+
from ithrl.adapters.contracts import NativeSession
|
|
10
|
+
from ithrl.adapters.errors import AdapterError, AdapterErrorCode
|
|
11
|
+
from ithrl.behavior import BehaviorObservation, FinalResponseTarget
|
|
12
|
+
from ithrl.context import JsonObject, JsonValue, content_digest
|
|
13
|
+
from ithrl.decisions import (
|
|
14
|
+
MAX_DECISIONS,
|
|
15
|
+
DecisionCaseFacts,
|
|
16
|
+
DecisionDraft,
|
|
17
|
+
DecisionLimitation,
|
|
18
|
+
DecisionSelector,
|
|
19
|
+
TargetCandidate,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_ADAPTER = "codex-session"
|
|
23
|
+
_ADAPTER_VERSION = "1"
|
|
24
|
+
_HARNESS = "codex"
|
|
25
|
+
_SOURCE = "source/rollout.jsonl"
|
|
26
|
+
_MAX_BEHAVIOR_BYTES = 64 * 1024
|
|
27
|
+
_OUTPUT_TYPES = frozenset({"custom_tool_call_output", "function_call_output"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class _DecisionRecord:
|
|
32
|
+
index: int
|
|
33
|
+
context_before_line: int
|
|
34
|
+
turn_id: str
|
|
35
|
+
kind: str
|
|
36
|
+
native_id: str
|
|
37
|
+
observation: BehaviorObservation
|
|
38
|
+
targets: tuple[TargetCandidate, ...]
|
|
39
|
+
model: str | None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def enumerate_decisions(session: NativeSession) -> tuple[DecisionDraft, ...]:
|
|
43
|
+
"""Return supported decisions whose enclosing Codex task completed."""
|
|
44
|
+
|
|
45
|
+
if session.metadata.format.harness != _HARNESS:
|
|
46
|
+
raise ValueError("native session is not a Codex CLI session")
|
|
47
|
+
|
|
48
|
+
records = session.records
|
|
49
|
+
decisions = _completed_decision_records(records)
|
|
50
|
+
opaque_context = opaque_prefix(records)
|
|
51
|
+
provider = _provider(records)
|
|
52
|
+
return tuple(
|
|
53
|
+
_build_draft(session, records, opaque_context, provider, decision) for decision in decisions
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _completed_decision_records(records: tuple[JsonObject, ...]) -> tuple[_DecisionRecord, ...]:
|
|
58
|
+
turn_models: dict[str, str] = {}
|
|
59
|
+
open_turns: dict[str, list[int]] = {}
|
|
60
|
+
decisions: list[_DecisionRecord] = []
|
|
61
|
+
latest_model_input_line = 0
|
|
62
|
+
|
|
63
|
+
for index, record in enumerate(records):
|
|
64
|
+
record_type = _string(record.get("type"))
|
|
65
|
+
payload = _object(record.get("payload"))
|
|
66
|
+
if record_type == "turn_context":
|
|
67
|
+
_remember_turn_model(payload, turn_models)
|
|
68
|
+
continue
|
|
69
|
+
if record_type == "response_item" and _is_model_input(payload):
|
|
70
|
+
latest_model_input_line = index + 2
|
|
71
|
+
continue
|
|
72
|
+
if record_type != "event_msg":
|
|
73
|
+
continue
|
|
74
|
+
event_type = _string(payload.get("type"))
|
|
75
|
+
turn_id = _string(payload.get("turn_id"))
|
|
76
|
+
if turn_id is None:
|
|
77
|
+
continue
|
|
78
|
+
if event_type == "task_started":
|
|
79
|
+
open_turns.setdefault(turn_id, []).append(index + 2)
|
|
80
|
+
continue
|
|
81
|
+
if event_type != "task_complete" or not (starts := open_turns.get(turn_id)):
|
|
82
|
+
continue
|
|
83
|
+
# Codex task IDs can overlap; a completion closes the latest unmatched start for that ID.
|
|
84
|
+
context_before_line = max(starts.pop(), latest_model_input_line)
|
|
85
|
+
if not starts:
|
|
86
|
+
del open_turns[turn_id]
|
|
87
|
+
decision = _final_response_record(
|
|
88
|
+
record,
|
|
89
|
+
index=index,
|
|
90
|
+
turn_id=turn_id,
|
|
91
|
+
context_before_line=context_before_line,
|
|
92
|
+
model=turn_models.get(turn_id),
|
|
93
|
+
)
|
|
94
|
+
if decision is None:
|
|
95
|
+
continue
|
|
96
|
+
if len(decisions) == MAX_DECISIONS:
|
|
97
|
+
raise AdapterError(
|
|
98
|
+
AdapterErrorCode.TOO_MANY_DECISIONS,
|
|
99
|
+
"session contains too many completed decisions",
|
|
100
|
+
)
|
|
101
|
+
decisions.append(decision)
|
|
102
|
+
|
|
103
|
+
return tuple(decisions)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _remember_turn_model(payload: JsonObject, models: dict[str, str]) -> None:
|
|
107
|
+
turn_id = _string(payload.get("turn_id"))
|
|
108
|
+
model = _bounded_string(payload.get("model"), 256)
|
|
109
|
+
if turn_id is not None and model is not None:
|
|
110
|
+
models[turn_id] = model
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _final_response_record(
|
|
114
|
+
record: JsonObject,
|
|
115
|
+
*,
|
|
116
|
+
index: int,
|
|
117
|
+
turn_id: str,
|
|
118
|
+
context_before_line: int,
|
|
119
|
+
model: str | None,
|
|
120
|
+
) -> _DecisionRecord | None:
|
|
121
|
+
payload = _object(record.get("payload"))
|
|
122
|
+
text = _bounded_behavior_text(payload.get("last_agent_message"))
|
|
123
|
+
if text is None:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
target = FinalResponseTarget(text_equals=text)
|
|
127
|
+
return _DecisionRecord(
|
|
128
|
+
index=index,
|
|
129
|
+
context_before_line=context_before_line,
|
|
130
|
+
turn_id=turn_id,
|
|
131
|
+
kind="codex-task-complete",
|
|
132
|
+
native_id=turn_id,
|
|
133
|
+
observation=BehaviorObservation(final_response=text),
|
|
134
|
+
targets=(_target_candidate(target),),
|
|
135
|
+
model=model,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _is_model_input(payload: JsonObject) -> bool:
|
|
140
|
+
item_type = _string(payload.get("type"))
|
|
141
|
+
return (
|
|
142
|
+
item_type == "message" and payload.get("role") in {"user", "developer", "system"}
|
|
143
|
+
) or item_type in _OUTPUT_TYPES
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _bounded_behavior_text(value: JsonValue | None) -> str | None:
|
|
147
|
+
if not isinstance(value, str) or not value:
|
|
148
|
+
return None
|
|
149
|
+
return value if len(value.encode("utf-8")) <= _MAX_BEHAVIOR_BYTES else None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _target_candidate(target: FinalResponseTarget) -> TargetCandidate:
|
|
153
|
+
return TargetCandidate(
|
|
154
|
+
ordinal=1,
|
|
155
|
+
target=target,
|
|
156
|
+
evidence_support="supported",
|
|
157
|
+
experimental_support="unvalidated",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _build_draft(
|
|
162
|
+
session: NativeSession,
|
|
163
|
+
records: tuple[JsonObject, ...],
|
|
164
|
+
opaque_prefix: tuple[bool, ...],
|
|
165
|
+
provider: str | None,
|
|
166
|
+
decision: _DecisionRecord,
|
|
167
|
+
) -> DecisionDraft:
|
|
168
|
+
record = records[decision.index]
|
|
169
|
+
limitations: list[DecisionLimitation] = ["experimental-support-unvalidated"]
|
|
170
|
+
if opaque_prefix[decision.context_before_line - 1]:
|
|
171
|
+
limitations.append("opaque-context-frozen")
|
|
172
|
+
return DecisionDraft(
|
|
173
|
+
case=DecisionCaseFacts(
|
|
174
|
+
adapter=_ADAPTER,
|
|
175
|
+
adapter_version=_ADAPTER_VERSION,
|
|
176
|
+
provider=provider,
|
|
177
|
+
model_requested=decision.model,
|
|
178
|
+
source_schema=session.metadata.format.name,
|
|
179
|
+
),
|
|
180
|
+
selector=_selector(session, record, decision),
|
|
181
|
+
observed_at=cast(str, record["timestamp"]),
|
|
182
|
+
observation=decision.observation,
|
|
183
|
+
targets=decision.targets,
|
|
184
|
+
limitations=tuple(limitations),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _selector(
|
|
189
|
+
session: NativeSession,
|
|
190
|
+
record: JsonObject,
|
|
191
|
+
decision: _DecisionRecord,
|
|
192
|
+
) -> DecisionSelector:
|
|
193
|
+
return DecisionSelector(
|
|
194
|
+
harness=_HARNESS,
|
|
195
|
+
session_id=session.metadata.session_id,
|
|
196
|
+
turn_id=decision.turn_id,
|
|
197
|
+
kind=decision.kind,
|
|
198
|
+
native_id=decision.native_id,
|
|
199
|
+
source=_SOURCE,
|
|
200
|
+
context_before_line=decision.context_before_line,
|
|
201
|
+
record=source_selector(decision.index, record),
|
|
202
|
+
record_digest=content_digest(record),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _provider(records: tuple[JsonObject, ...]) -> str | None:
|
|
207
|
+
if not records:
|
|
208
|
+
return None
|
|
209
|
+
first = records[0]
|
|
210
|
+
if first.get("type") != "session_meta":
|
|
211
|
+
return None
|
|
212
|
+
return _bounded_string(_object(first.get("payload")).get("model_provider"), 256)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _object(value: JsonValue | None) -> JsonObject:
|
|
216
|
+
return value if isinstance(value, dict) else {}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _string(value: JsonValue | None) -> str | None:
|
|
220
|
+
return value if isinstance(value, str) and value else None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _bounded_string(value: JsonValue | None, limit: int) -> str | None:
|
|
224
|
+
return value if isinstance(value, str) and 0 < len(value) <= limit else None
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Find Codex rollout sessions in an ordinary local store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ithrl.adapters.discovery import DiscoveryLimits, SourceScan, scan_store
|
|
9
|
+
|
|
10
|
+
_STORE_DEPTH = 3
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def discover_sources(
|
|
14
|
+
*,
|
|
15
|
+
root: Path | None = None,
|
|
16
|
+
limits: DiscoveryLimits | None = None,
|
|
17
|
+
session_id: str | None = None,
|
|
18
|
+
) -> SourceScan:
|
|
19
|
+
"""Return recent Codex rollout files from a local session store."""
|
|
20
|
+
|
|
21
|
+
selected_limits = limits or DiscoveryLimits()
|
|
22
|
+
return scan_store(
|
|
23
|
+
root if root is not None else _default_store(),
|
|
24
|
+
max_depth=_STORE_DEPTH,
|
|
25
|
+
accepts=lambda path: (
|
|
26
|
+
_is_rollout_path(path)
|
|
27
|
+
and (session_id is None or path.name.endswith(f"-{session_id}.jsonl"))
|
|
28
|
+
),
|
|
29
|
+
limits=selected_limits,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _default_store() -> Path:
|
|
34
|
+
codex_home = os.environ.get("CODEX_HOME")
|
|
35
|
+
if codex_home:
|
|
36
|
+
return Path(codex_home).expanduser() / "sessions"
|
|
37
|
+
return Path.home() / ".codex" / "sessions"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_rollout_path(path: Path) -> bool:
|
|
41
|
+
parts = path.parts
|
|
42
|
+
if len(parts) != 4:
|
|
43
|
+
return False
|
|
44
|
+
year, month, day, filename = parts
|
|
45
|
+
return all(
|
|
46
|
+
(
|
|
47
|
+
_is_ascii_number(year, width=4),
|
|
48
|
+
_is_ascii_number(month, width=2),
|
|
49
|
+
_is_ascii_number(day, width=2),
|
|
50
|
+
_is_rollout_filename(filename),
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_ascii_number(value: str, *, width: int) -> bool:
|
|
56
|
+
return len(value) == width and value.isascii() and value.isdigit()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _is_rollout_filename(value: str) -> bool:
|
|
60
|
+
return value.startswith("rollout-") and value != "rollout-.jsonl" and value.endswith(".jsonl")
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Validate Codex rollout JSONL that starts with `session_meta`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Never
|
|
8
|
+
|
|
9
|
+
from ithrl.adapters._jsonl import JsonlDocument
|
|
10
|
+
from ithrl.adapters.contracts import NativeSession, SessionFormat, SessionMetadata
|
|
11
|
+
from ithrl.adapters.errors import AdapterError, AdapterErrorCode
|
|
12
|
+
from ithrl.context import JsonObject
|
|
13
|
+
from ithrl.timestamps import validate_rfc3339_timestamp
|
|
14
|
+
|
|
15
|
+
_FORMAT_NAME = "codex-rollout-jsonl"
|
|
16
|
+
_CALL_OUTPUT_TYPES = {
|
|
17
|
+
"custom_tool_call": "custom_tool_call_output",
|
|
18
|
+
"function_call": "function_call_output",
|
|
19
|
+
}
|
|
20
|
+
_OUTPUT_TYPES = frozenset({"custom_tool_call_output", "function_call_output"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class _Record:
|
|
25
|
+
line: int
|
|
26
|
+
timestamp: datetime
|
|
27
|
+
type: str
|
|
28
|
+
payload: JsonObject
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _CodexSessionParser:
|
|
32
|
+
format_name = _FORMAT_NAME
|
|
33
|
+
|
|
34
|
+
def matches(self, document: JsonlDocument) -> bool:
|
|
35
|
+
"""Return whether the document has the observed Codex rollout signature."""
|
|
36
|
+
|
|
37
|
+
if not document.records:
|
|
38
|
+
return False
|
|
39
|
+
first = document.records[0]
|
|
40
|
+
payload = first.get("payload")
|
|
41
|
+
return (
|
|
42
|
+
first.get("type") == "session_meta"
|
|
43
|
+
and isinstance(payload, dict)
|
|
44
|
+
and "cli_version" in payload
|
|
45
|
+
and ("id" in payload or "session_id" in payload)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def parse(self, document: JsonlDocument) -> NativeSession:
|
|
49
|
+
"""Validate and preserve one supported Codex rollout document."""
|
|
50
|
+
|
|
51
|
+
if not self.matches(document):
|
|
52
|
+
raise AdapterError(
|
|
53
|
+
AdapterErrorCode.UNSUPPORTED_FORMAT,
|
|
54
|
+
"session source is not a Codex rollout",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
records = _validate_envelopes(document.records)
|
|
58
|
+
metadata = _session_metadata(records)
|
|
59
|
+
_validate_response_items(records)
|
|
60
|
+
return NativeSession(
|
|
61
|
+
metadata=metadata,
|
|
62
|
+
source=document.source,
|
|
63
|
+
native_bytes=document.raw,
|
|
64
|
+
records=document.records,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
PARSER = _CodexSessionParser()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _validate_envelopes(records: tuple[JsonObject, ...]) -> tuple[_Record, ...]:
|
|
72
|
+
validated: list[_Record] = []
|
|
73
|
+
for line, record in enumerate(records, start=1):
|
|
74
|
+
ordinal = record.get("ordinal")
|
|
75
|
+
if ordinal is not None and (type(ordinal) is not int or ordinal < 0):
|
|
76
|
+
_invalid("Codex record ordinal must be a non-negative integer when present", line)
|
|
77
|
+
|
|
78
|
+
timestamp_text = _required_string(record, "timestamp", line, "record timestamp")
|
|
79
|
+
timestamp = _parse_timestamp(timestamp_text, line, "record timestamp")
|
|
80
|
+
|
|
81
|
+
record_type = _required_string(record, "type", line, "record type")
|
|
82
|
+
payload = record.get("payload")
|
|
83
|
+
if not isinstance(payload, dict):
|
|
84
|
+
_invalid("Codex record payload must be an object", line)
|
|
85
|
+
|
|
86
|
+
validated.append(_Record(line=line, timestamp=timestamp, type=record_type, payload=payload))
|
|
87
|
+
return tuple(validated)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _session_metadata(records: tuple[_Record, ...]) -> SessionMetadata:
|
|
91
|
+
record = records[0]
|
|
92
|
+
if record.type != "session_meta":
|
|
93
|
+
_invalid("Codex session metadata must be the first record")
|
|
94
|
+
source_session_id = _required_string(
|
|
95
|
+
record.payload,
|
|
96
|
+
"id",
|
|
97
|
+
record.line,
|
|
98
|
+
"source session identifier",
|
|
99
|
+
)
|
|
100
|
+
_required_string(record.payload, "session_id", record.line, "session family identifier")
|
|
101
|
+
|
|
102
|
+
captured_at = _required_string(record.payload, "timestamp", record.line, "capture timestamp")
|
|
103
|
+
_parse_timestamp(captured_at, record.line, "capture timestamp")
|
|
104
|
+
|
|
105
|
+
producer_version = _required_string(
|
|
106
|
+
record.payload,
|
|
107
|
+
"cli_version",
|
|
108
|
+
record.line,
|
|
109
|
+
"producer version",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return SessionMetadata(
|
|
113
|
+
format=SessionFormat(
|
|
114
|
+
harness="codex",
|
|
115
|
+
name=_FORMAT_NAME,
|
|
116
|
+
producer_version=producer_version,
|
|
117
|
+
),
|
|
118
|
+
session_id=source_session_id,
|
|
119
|
+
captured_at=captured_at,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _validate_response_items(records: tuple[_Record, ...]) -> None:
|
|
124
|
+
item_ids: set[str] = set()
|
|
125
|
+
calls: dict[str, str] = {}
|
|
126
|
+
outputs: set[str] = set()
|
|
127
|
+
|
|
128
|
+
for record in records:
|
|
129
|
+
if record.type != "response_item":
|
|
130
|
+
continue
|
|
131
|
+
item_type = _required_string(record.payload, "type", record.line, "response item type")
|
|
132
|
+
item_id = _required_string(record.payload, "id", record.line, "response item identifier")
|
|
133
|
+
if item_id in item_ids:
|
|
134
|
+
_invalid("Codex response item identifiers must be unique", record.line)
|
|
135
|
+
item_ids.add(item_id)
|
|
136
|
+
|
|
137
|
+
_validate_tool_relationship(record, item_type, calls, outputs)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _validate_tool_relationship(
|
|
141
|
+
record: _Record,
|
|
142
|
+
item_type: str,
|
|
143
|
+
calls: dict[str, str],
|
|
144
|
+
outputs: set[str],
|
|
145
|
+
) -> None:
|
|
146
|
+
expected_output = _CALL_OUTPUT_TYPES.get(item_type)
|
|
147
|
+
if expected_output is not None:
|
|
148
|
+
call_id = _required_string(record.payload, "call_id", record.line, "tool call identifier")
|
|
149
|
+
if call_id in calls:
|
|
150
|
+
_invalid("Codex tool call identifiers must be unique", record.line)
|
|
151
|
+
calls[call_id] = expected_output
|
|
152
|
+
return
|
|
153
|
+
if item_type not in _OUTPUT_TYPES:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
call_id = _required_string(record.payload, "call_id", record.line, "tool call identifier")
|
|
157
|
+
if call_id not in calls:
|
|
158
|
+
_invalid("Codex tool output must follow its call", record.line)
|
|
159
|
+
if item_type != calls[call_id]:
|
|
160
|
+
_invalid("Codex tool output type must match its call type", record.line)
|
|
161
|
+
if call_id in outputs:
|
|
162
|
+
_invalid("Codex tool calls must have at most one output", record.line)
|
|
163
|
+
outputs.add(call_id)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _required_string(
|
|
167
|
+
value: JsonObject,
|
|
168
|
+
field: str,
|
|
169
|
+
line: int,
|
|
170
|
+
description: str,
|
|
171
|
+
) -> str:
|
|
172
|
+
item = value.get(field)
|
|
173
|
+
if not isinstance(item, str) or not item:
|
|
174
|
+
_invalid(f"Codex {description} must be a non-empty string", line)
|
|
175
|
+
return item
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _parse_timestamp(value: str, line: int, description: str) -> datetime:
|
|
179
|
+
try:
|
|
180
|
+
validate_rfc3339_timestamp(value, field=description)
|
|
181
|
+
return datetime.fromisoformat(f"{value[:-1]}+00:00" if value.endswith("Z") else value)
|
|
182
|
+
except ValueError:
|
|
183
|
+
raise AdapterError(
|
|
184
|
+
AdapterErrorCode.INVALID_SESSION,
|
|
185
|
+
f"Codex {description} must be a valid RFC 3339 timestamp",
|
|
186
|
+
line=line,
|
|
187
|
+
) from None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _invalid(message: str, line: int | None = None) -> Never:
|
|
191
|
+
raise AdapterError(AdapterErrorCode.INVALID_SESSION, message, line=line)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Harness-independent contracts for parsed native session evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import hashlib
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Annotated, Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
11
|
+
|
|
12
|
+
from ithrl.context import JsonObject
|
|
13
|
+
from ithrl.timestamps import validate_rfc3339_timestamp
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class ParsingLimits:
|
|
18
|
+
"""Resource limits applied before native session evidence is accepted."""
|
|
19
|
+
|
|
20
|
+
max_bytes: int = 64 * 1024 * 1024
|
|
21
|
+
max_line_bytes: int = 8 * 1024 * 1024
|
|
22
|
+
max_records: int = 250_000
|
|
23
|
+
max_json_items: int = 1_000_000
|
|
24
|
+
|
|
25
|
+
def __post_init__(self) -> None:
|
|
26
|
+
if min(self.max_bytes, self.max_line_bytes, self.max_records, self.max_json_items) < 1:
|
|
27
|
+
raise ValueError("parsing limits must be positive")
|
|
28
|
+
if self.max_line_bytes > self.max_bytes:
|
|
29
|
+
raise ValueError("max_line_bytes must not exceed max_bytes")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _ImmutableContract(BaseModel):
|
|
33
|
+
model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SessionFormat(_ImmutableContract):
|
|
37
|
+
"""Identity and producer metadata for one native saved-session format."""
|
|
38
|
+
|
|
39
|
+
schema_version: Literal[1] = 1
|
|
40
|
+
harness: Annotated[str, Field(min_length=1, max_length=64)]
|
|
41
|
+
name: Annotated[str, Field(min_length=1, max_length=128)]
|
|
42
|
+
producer_version: Annotated[str, Field(min_length=1, max_length=128)]
|
|
43
|
+
record_schema_version: int | str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SessionMetadata(_ImmutableContract):
|
|
47
|
+
"""Shared facts needed to identify parsed native evidence."""
|
|
48
|
+
|
|
49
|
+
schema_version: Literal[1] = 1
|
|
50
|
+
format: SessionFormat
|
|
51
|
+
session_id: Annotated[str, Field(min_length=1, max_length=1024)]
|
|
52
|
+
captured_at: Annotated[str, Field(min_length=1, max_length=64)]
|
|
53
|
+
|
|
54
|
+
@field_validator("captured_at")
|
|
55
|
+
@classmethod
|
|
56
|
+
def validate_captured_at(cls, value: str) -> str:
|
|
57
|
+
return validate_rfc3339_timestamp(value, field="captured_at")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SourceFacts(_ImmutableContract):
|
|
61
|
+
"""Content identity of the exact native source bytes that were parsed."""
|
|
62
|
+
|
|
63
|
+
schema_version: Literal[1] = 1
|
|
64
|
+
sha256: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")]
|
|
65
|
+
size_bytes: Annotated[int, Field(ge=1)]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class NativeSession:
|
|
69
|
+
"""Parsed metadata with an unchanged native JSONL snapshot."""
|
|
70
|
+
|
|
71
|
+
__slots__ = ("_native_bytes", "_records", "metadata", "source")
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
metadata: SessionMetadata,
|
|
77
|
+
source: SourceFacts,
|
|
78
|
+
native_bytes: bytes,
|
|
79
|
+
records: tuple[JsonObject, ...],
|
|
80
|
+
) -> None:
|
|
81
|
+
if not native_bytes or not records:
|
|
82
|
+
raise ValueError("native session evidence must not be empty")
|
|
83
|
+
if source.size_bytes != len(native_bytes):
|
|
84
|
+
raise ValueError("native session byte count does not match source facts")
|
|
85
|
+
if source.sha256 != hashlib.sha256(native_bytes).hexdigest():
|
|
86
|
+
raise ValueError("native session digest does not match source facts")
|
|
87
|
+
self.metadata = metadata
|
|
88
|
+
self.source = source
|
|
89
|
+
self._native_bytes = native_bytes
|
|
90
|
+
self._records = copy.deepcopy(records)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def native_bytes(self) -> bytes:
|
|
94
|
+
"""Return the exact bytes read from the native session file."""
|
|
95
|
+
|
|
96
|
+
return self._native_bytes
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def records(self) -> tuple[JsonObject, ...]:
|
|
100
|
+
"""Return defensive copies of native records in source order."""
|
|
101
|
+
|
|
102
|
+
return copy.deepcopy(self._records)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def record_count(self) -> int:
|
|
106
|
+
return len(self._records)
|