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
ithrl/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Find agent context that preserves or changes a model decision."""
|
ithrl/__main__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Read and validate native coding-agent session evidence."""
|
|
2
|
+
|
|
3
|
+
from ithrl.adapters.contracts import (
|
|
4
|
+
NativeSession,
|
|
5
|
+
ParsingLimits,
|
|
6
|
+
SessionFormat,
|
|
7
|
+
SessionMetadata,
|
|
8
|
+
SourceFacts,
|
|
9
|
+
)
|
|
10
|
+
from ithrl.adapters.discovery import (
|
|
11
|
+
DiscoveredSession,
|
|
12
|
+
DiscoveryLimits,
|
|
13
|
+
DiscoveryResult,
|
|
14
|
+
HarnessName,
|
|
15
|
+
SessionSummary,
|
|
16
|
+
)
|
|
17
|
+
from ithrl.adapters.errors import AdapterError, AdapterErrorCode
|
|
18
|
+
from ithrl.adapters.registry import (
|
|
19
|
+
discover_sessions,
|
|
20
|
+
enumerate_decisions,
|
|
21
|
+
parse_session,
|
|
22
|
+
reopen_discovered_session,
|
|
23
|
+
resolve_session,
|
|
24
|
+
select_decision,
|
|
25
|
+
)
|
|
26
|
+
from ithrl.decisions import DecisionSummary, SelectedDecision
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AdapterError",
|
|
30
|
+
"AdapterErrorCode",
|
|
31
|
+
"DecisionSummary",
|
|
32
|
+
"DiscoveredSession",
|
|
33
|
+
"DiscoveryLimits",
|
|
34
|
+
"DiscoveryResult",
|
|
35
|
+
"HarnessName",
|
|
36
|
+
"NativeSession",
|
|
37
|
+
"ParsingLimits",
|
|
38
|
+
"SelectedDecision",
|
|
39
|
+
"SessionFormat",
|
|
40
|
+
"SessionMetadata",
|
|
41
|
+
"SessionSummary",
|
|
42
|
+
"SourceFacts",
|
|
43
|
+
"discover_sessions",
|
|
44
|
+
"enumerate_decisions",
|
|
45
|
+
"parse_session",
|
|
46
|
+
"reopen_discovered_session",
|
|
47
|
+
"resolve_session",
|
|
48
|
+
"select_decision",
|
|
49
|
+
]
|
ithrl/adapters/_jsonl.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Bounded reader for untrusted native JSONL evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ithrl.adapters.contracts import ParsingLimits, SourceFacts
|
|
14
|
+
from ithrl.adapters.errors import AdapterError, AdapterErrorCode
|
|
15
|
+
from ithrl.context import JsonObject, validate_json_limits
|
|
16
|
+
from ithrl.path_safety import UnsafePathError, validate_directory_chain
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class JsonlDocument:
|
|
21
|
+
raw: bytes
|
|
22
|
+
records: tuple[JsonObject, ...]
|
|
23
|
+
source: SourceFacts
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
type _SourceSignature = tuple[int, int, int, int, int]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _DuplicateKeyError(ValueError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def read_jsonl(path: str | os.PathLike[str], *, limits: ParsingLimits) -> JsonlDocument:
|
|
34
|
+
"""Read one regular JSONL file and reject links or concurrent source replacement."""
|
|
35
|
+
|
|
36
|
+
source_path = Path(path)
|
|
37
|
+
try:
|
|
38
|
+
validate_directory_chain(source_path.parent)
|
|
39
|
+
except UnsafePathError:
|
|
40
|
+
raise AdapterError(
|
|
41
|
+
AdapterErrorCode.UNSAFE_SOURCE,
|
|
42
|
+
"session source has an unsafe controlling directory",
|
|
43
|
+
) from None
|
|
44
|
+
initial = _lstat(source_path)
|
|
45
|
+
if stat.S_ISLNK(initial.st_mode) or not stat.S_ISREG(initial.st_mode):
|
|
46
|
+
raise AdapterError(AdapterErrorCode.UNSAFE_SOURCE, "session source must be a regular file")
|
|
47
|
+
if initial.st_size > limits.max_bytes:
|
|
48
|
+
raise AdapterError(
|
|
49
|
+
AdapterErrorCode.SOURCE_TOO_LARGE, "session source exceeds the byte limit"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
descriptor = _open_read_only(source_path)
|
|
53
|
+
try:
|
|
54
|
+
opened = os.fstat(descriptor)
|
|
55
|
+
if not stat.S_ISREG(opened.st_mode):
|
|
56
|
+
raise AdapterError(
|
|
57
|
+
AdapterErrorCode.SOURCE_CHANGED,
|
|
58
|
+
"session source changed while opening",
|
|
59
|
+
)
|
|
60
|
+
if _signature(initial) != _signature(opened):
|
|
61
|
+
raise AdapterError(
|
|
62
|
+
AdapterErrorCode.SOURCE_CHANGED, "session source changed while opening"
|
|
63
|
+
)
|
|
64
|
+
with os.fdopen(descriptor, "rb") as handle:
|
|
65
|
+
descriptor = -1
|
|
66
|
+
raw = handle.read(min(opened.st_size, limits.max_bytes) + 1)
|
|
67
|
+
finished = os.fstat(handle.fileno())
|
|
68
|
+
except OSError:
|
|
69
|
+
raise AdapterError(
|
|
70
|
+
AdapterErrorCode.SOURCE_UNAVAILABLE, "cannot read session source"
|
|
71
|
+
) from None
|
|
72
|
+
finally:
|
|
73
|
+
if descriptor >= 0:
|
|
74
|
+
os.close(descriptor)
|
|
75
|
+
|
|
76
|
+
if len(raw) > limits.max_bytes:
|
|
77
|
+
raise AdapterError(
|
|
78
|
+
AdapterErrorCode.SOURCE_TOO_LARGE,
|
|
79
|
+
"session source exceeds the byte limit",
|
|
80
|
+
bytes_read=len(raw),
|
|
81
|
+
)
|
|
82
|
+
try:
|
|
83
|
+
current = _lstat(source_path, changed=True)
|
|
84
|
+
except AdapterError as error:
|
|
85
|
+
error.bytes_read = len(raw)
|
|
86
|
+
raise
|
|
87
|
+
if _signature(opened) != _signature(finished) or _signature(opened) != _signature(current):
|
|
88
|
+
raise AdapterError(
|
|
89
|
+
AdapterErrorCode.SOURCE_CHANGED,
|
|
90
|
+
"session source changed while reading",
|
|
91
|
+
bytes_read=len(raw),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
return read_jsonl_bytes(raw, limits=limits)
|
|
96
|
+
except AdapterError as error:
|
|
97
|
+
error.bytes_read = len(raw)
|
|
98
|
+
raise
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def read_jsonl_bytes(raw: bytes, *, limits: ParsingLimits) -> JsonlDocument:
|
|
102
|
+
"""Parse one in-memory JSONL snapshot under the native evidence limits."""
|
|
103
|
+
|
|
104
|
+
if len(raw) > limits.max_bytes:
|
|
105
|
+
raise AdapterError(
|
|
106
|
+
AdapterErrorCode.SOURCE_TOO_LARGE, "session source exceeds the byte limit"
|
|
107
|
+
)
|
|
108
|
+
records = _parse_lines(raw, limits)
|
|
109
|
+
return JsonlDocument(
|
|
110
|
+
raw=raw,
|
|
111
|
+
records=records,
|
|
112
|
+
source=SourceFacts(sha256=hashlib.sha256(raw).hexdigest(), size_bytes=len(raw)),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _lstat(path: Path, *, changed: bool = False) -> os.stat_result:
|
|
117
|
+
try:
|
|
118
|
+
return path.lstat()
|
|
119
|
+
except OSError:
|
|
120
|
+
if changed:
|
|
121
|
+
raise AdapterError(
|
|
122
|
+
AdapterErrorCode.SOURCE_CHANGED, "session source changed while reading"
|
|
123
|
+
) from None
|
|
124
|
+
raise AdapterError(
|
|
125
|
+
AdapterErrorCode.SOURCE_UNAVAILABLE, "session source is unavailable"
|
|
126
|
+
) from None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _open_read_only(path: Path) -> int:
|
|
130
|
+
flags = os.O_RDONLY
|
|
131
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
132
|
+
flags |= os.O_CLOEXEC
|
|
133
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
134
|
+
flags |= os.O_NOFOLLOW
|
|
135
|
+
if hasattr(os, "O_NONBLOCK"):
|
|
136
|
+
flags |= os.O_NONBLOCK
|
|
137
|
+
try:
|
|
138
|
+
return os.open(path, flags)
|
|
139
|
+
except OSError:
|
|
140
|
+
raise AdapterError(
|
|
141
|
+
AdapterErrorCode.SOURCE_UNAVAILABLE, "cannot open session source"
|
|
142
|
+
) from None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _signature(metadata: os.stat_result) -> _SourceSignature:
|
|
146
|
+
return (
|
|
147
|
+
metadata.st_dev,
|
|
148
|
+
metadata.st_ino,
|
|
149
|
+
metadata.st_size,
|
|
150
|
+
metadata.st_mtime_ns,
|
|
151
|
+
metadata.st_ctime_ns,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _parse_lines(raw: bytes, limits: ParsingLimits) -> tuple[JsonObject, ...]:
|
|
156
|
+
records: list[JsonObject] = []
|
|
157
|
+
json_items = 0
|
|
158
|
+
start = 0
|
|
159
|
+
while start < len(raw):
|
|
160
|
+
end = raw.find(b"\n", start)
|
|
161
|
+
next_start = len(raw) if end < 0 else end + 1
|
|
162
|
+
end = len(raw) if end < 0 else end
|
|
163
|
+
line_number = len(records) + 1
|
|
164
|
+
if line_number > limits.max_records:
|
|
165
|
+
raise AdapterError(
|
|
166
|
+
AdapterErrorCode.TOO_MANY_RECORDS, "session source exceeds the record limit"
|
|
167
|
+
)
|
|
168
|
+
if end - start > limits.max_line_bytes:
|
|
169
|
+
raise AdapterError(
|
|
170
|
+
AdapterErrorCode.LINE_TOO_LARGE,
|
|
171
|
+
"session record exceeds the line limit",
|
|
172
|
+
line=line_number,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
line = raw[start:end].removesuffix(b"\r")
|
|
176
|
+
if not line:
|
|
177
|
+
raise AdapterError(
|
|
178
|
+
AdapterErrorCode.INVALID_JSONL,
|
|
179
|
+
"session source contains a blank record",
|
|
180
|
+
line=line_number,
|
|
181
|
+
)
|
|
182
|
+
record = _parse_record(line, line_number)
|
|
183
|
+
json_items += _count_json_items(record)
|
|
184
|
+
if json_items > limits.max_json_items:
|
|
185
|
+
raise AdapterError(
|
|
186
|
+
AdapterErrorCode.JSON_LIMIT_EXCEEDED,
|
|
187
|
+
"session source exceeds the JSON item limit",
|
|
188
|
+
line=line_number,
|
|
189
|
+
)
|
|
190
|
+
records.append(record)
|
|
191
|
+
start = next_start
|
|
192
|
+
if not records:
|
|
193
|
+
raise AdapterError(AdapterErrorCode.INVALID_JSONL, "session source contains no records")
|
|
194
|
+
return tuple(records)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _parse_record(raw: bytes, line_number: int) -> JsonObject:
|
|
198
|
+
try:
|
|
199
|
+
text = raw.decode("utf-8")
|
|
200
|
+
except UnicodeError:
|
|
201
|
+
raise AdapterError(
|
|
202
|
+
AdapterErrorCode.INVALID_UTF8,
|
|
203
|
+
"session source is not valid UTF-8",
|
|
204
|
+
line=line_number,
|
|
205
|
+
) from None
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
value: Any = json.loads(
|
|
209
|
+
text,
|
|
210
|
+
object_pairs_hook=_reject_duplicate_keys,
|
|
211
|
+
parse_constant=_reject_constant,
|
|
212
|
+
)
|
|
213
|
+
except _DuplicateKeyError:
|
|
214
|
+
raise AdapterError(
|
|
215
|
+
AdapterErrorCode.DUPLICATE_KEY,
|
|
216
|
+
"session record contains a duplicate object key",
|
|
217
|
+
line=line_number,
|
|
218
|
+
) from None
|
|
219
|
+
except (ValueError, RecursionError):
|
|
220
|
+
raise AdapterError(
|
|
221
|
+
AdapterErrorCode.INVALID_JSONL,
|
|
222
|
+
"session record is not valid JSON",
|
|
223
|
+
line=line_number,
|
|
224
|
+
) from None
|
|
225
|
+
if not isinstance(value, dict):
|
|
226
|
+
raise AdapterError(
|
|
227
|
+
AdapterErrorCode.INVALID_JSONL,
|
|
228
|
+
"session record must be a JSON object",
|
|
229
|
+
line=line_number,
|
|
230
|
+
)
|
|
231
|
+
try:
|
|
232
|
+
validate_json_limits(value)
|
|
233
|
+
except ValueError:
|
|
234
|
+
raise AdapterError(
|
|
235
|
+
AdapterErrorCode.JSON_LIMIT_EXCEEDED,
|
|
236
|
+
"session record exceeds JSON structure limits",
|
|
237
|
+
line=line_number,
|
|
238
|
+
) from None
|
|
239
|
+
return value
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _count_json_items(value: JsonObject) -> int:
|
|
243
|
+
items = 0
|
|
244
|
+
pending: list[object] = [value]
|
|
245
|
+
while pending:
|
|
246
|
+
current = pending.pop()
|
|
247
|
+
if isinstance(current, dict):
|
|
248
|
+
items += len(current)
|
|
249
|
+
pending.extend(current.values())
|
|
250
|
+
elif isinstance(current, list):
|
|
251
|
+
items += len(current)
|
|
252
|
+
pending.extend(current)
|
|
253
|
+
return items
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
257
|
+
value: dict[str, object] = {}
|
|
258
|
+
for key, item in pairs:
|
|
259
|
+
if key in value:
|
|
260
|
+
raise _DuplicateKeyError
|
|
261
|
+
value[key] = item
|
|
262
|
+
return value
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _reject_constant(_value: str) -> None:
|
|
266
|
+
raise ValueError
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Compile editable context graphs from parsed Codex CLI sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from ithrl.adapters.contracts import NativeSession
|
|
8
|
+
from ithrl.adapters.errors import AdapterError, AdapterErrorCode
|
|
9
|
+
from ithrl.context import (
|
|
10
|
+
MAX_CONTEXT_NODES,
|
|
11
|
+
ContextGraph,
|
|
12
|
+
ContextNode,
|
|
13
|
+
JsonObject,
|
|
14
|
+
JsonValue,
|
|
15
|
+
SourceRecordSelector,
|
|
16
|
+
content_digest,
|
|
17
|
+
stable_source_context_id,
|
|
18
|
+
)
|
|
19
|
+
from ithrl.decisions import DecisionSelector
|
|
20
|
+
|
|
21
|
+
_HARNESS = "codex"
|
|
22
|
+
_SOURCE = "source/rollout.jsonl"
|
|
23
|
+
_CALL_TYPES = frozenset({"custom_tool_call", "function_call"})
|
|
24
|
+
_OUTPUT_TYPES = frozenset({"custom_tool_call_output", "function_call_output"})
|
|
25
|
+
_STRUCTURAL_RECORD_TYPES = frozenset({"session_meta", "turn_context", "world_state"})
|
|
26
|
+
_OPAQUE_RECORD_TYPES = frozenset(
|
|
27
|
+
{
|
|
28
|
+
"inter_agent_communication_metadata",
|
|
29
|
+
"token_usage_record",
|
|
30
|
+
"world_state",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def compile_context(session: NativeSession, selector: DecisionSelector) -> ContextGraph:
|
|
36
|
+
"""Build context preceding one selector produced from the parsed session."""
|
|
37
|
+
|
|
38
|
+
records = session.records
|
|
39
|
+
_validate_selector(session, selector, records)
|
|
40
|
+
context_count = selector.context_before_line - 1
|
|
41
|
+
if context_count > MAX_CONTEXT_NODES:
|
|
42
|
+
raise AdapterError(
|
|
43
|
+
AdapterErrorCode.CONTEXT_TOO_LARGE,
|
|
44
|
+
"selected decision has too many preceding context records",
|
|
45
|
+
)
|
|
46
|
+
return ContextGraph(nodes=_context_nodes(records[:context_count]))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def source_selector(index: int, record: JsonObject) -> SourceRecordSelector:
|
|
50
|
+
"""Return the stable native selector for one Codex JSONL record."""
|
|
51
|
+
|
|
52
|
+
ordinal = record.get("ordinal")
|
|
53
|
+
if type(ordinal) is int:
|
|
54
|
+
return SourceRecordSelector(
|
|
55
|
+
line=index + 1,
|
|
56
|
+
identity_key="ordinal",
|
|
57
|
+
identity_value=ordinal,
|
|
58
|
+
)
|
|
59
|
+
return SourceRecordSelector(line=index + 1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def opaque_prefix(records: tuple[JsonObject, ...]) -> tuple[bool, ...]:
|
|
63
|
+
"""Track whether each source prefix contains frozen opaque context."""
|
|
64
|
+
|
|
65
|
+
prefixes = [False]
|
|
66
|
+
for record in records:
|
|
67
|
+
prefixes.append(prefixes[-1] or _is_opaque_record(record))
|
|
68
|
+
return tuple(prefixes)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _validate_selector(
|
|
72
|
+
session: NativeSession,
|
|
73
|
+
selector: DecisionSelector,
|
|
74
|
+
records: tuple[JsonObject, ...],
|
|
75
|
+
) -> None:
|
|
76
|
+
index = selector.record.line - 1
|
|
77
|
+
if (
|
|
78
|
+
selector.harness != _HARNESS
|
|
79
|
+
or selector.session_id != session.metadata.session_id
|
|
80
|
+
or selector.source != _SOURCE
|
|
81
|
+
or index >= session.record_count
|
|
82
|
+
):
|
|
83
|
+
raise AdapterError(
|
|
84
|
+
AdapterErrorCode.DECISION_NOT_FOUND,
|
|
85
|
+
"decision selector does not match the selected session",
|
|
86
|
+
)
|
|
87
|
+
record = records[index]
|
|
88
|
+
if selector.record != source_selector(
|
|
89
|
+
index, record
|
|
90
|
+
) or selector.record_digest != content_digest(record):
|
|
91
|
+
raise AdapterError(
|
|
92
|
+
AdapterErrorCode.DECISION_NOT_FOUND,
|
|
93
|
+
"decision selector does not match the selected session",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _context_nodes(records: tuple[JsonObject, ...]) -> tuple[ContextNode, ...]:
|
|
98
|
+
nodes: list[ContextNode] = []
|
|
99
|
+
calls: dict[str, str] = {}
|
|
100
|
+
for index, record in enumerate(records):
|
|
101
|
+
record_type = cast(str, record["type"])
|
|
102
|
+
payload = _object(record.get("payload"))
|
|
103
|
+
kind = _context_kind(record_type, payload)
|
|
104
|
+
digest = content_digest(record)
|
|
105
|
+
selector = source_selector(index, record)
|
|
106
|
+
node_id = stable_source_context_id(
|
|
107
|
+
source=_SOURCE,
|
|
108
|
+
selector=selector,
|
|
109
|
+
kind=kind,
|
|
110
|
+
digest=digest,
|
|
111
|
+
)
|
|
112
|
+
nodes.append(
|
|
113
|
+
ContextNode(
|
|
114
|
+
id=node_id,
|
|
115
|
+
kind=kind,
|
|
116
|
+
selector=selector,
|
|
117
|
+
mutable=_is_mutable(record_type, payload),
|
|
118
|
+
protected=_is_protected(record_type, payload),
|
|
119
|
+
depends_on=_dependencies(record_type, payload, calls),
|
|
120
|
+
source=_SOURCE,
|
|
121
|
+
digest=digest,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
_remember_call(record_type, payload, node_id, calls)
|
|
125
|
+
return tuple(nodes)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _context_kind(record_type: str, payload: JsonObject) -> str:
|
|
129
|
+
if record_type == "response_item" and (item_type := _string(payload.get("type"))):
|
|
130
|
+
return f"response_item.{item_type}"
|
|
131
|
+
if record_type == "event_msg" and (event_type := _string(payload.get("type"))):
|
|
132
|
+
return f"event_msg.{event_type}"
|
|
133
|
+
return record_type
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _dependencies(
|
|
137
|
+
record_type: str,
|
|
138
|
+
payload: JsonObject,
|
|
139
|
+
calls: dict[str, str],
|
|
140
|
+
) -> tuple[str, ...]:
|
|
141
|
+
item_type = _string(payload.get("type"))
|
|
142
|
+
call_id = _string(payload.get("call_id"))
|
|
143
|
+
if record_type == "response_item" and item_type in _OUTPUT_TYPES and call_id in calls:
|
|
144
|
+
return (calls[call_id],)
|
|
145
|
+
return ()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _remember_call(
|
|
149
|
+
record_type: str,
|
|
150
|
+
payload: JsonObject,
|
|
151
|
+
node_id: str,
|
|
152
|
+
calls: dict[str, str],
|
|
153
|
+
) -> None:
|
|
154
|
+
item_type = _string(payload.get("type"))
|
|
155
|
+
call_id = _string(payload.get("call_id"))
|
|
156
|
+
if record_type == "response_item" and item_type in _CALL_TYPES and call_id is not None:
|
|
157
|
+
calls[call_id] = node_id
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _is_mutable(record_type: str, payload: JsonObject) -> bool:
|
|
161
|
+
if record_type != "response_item":
|
|
162
|
+
return False
|
|
163
|
+
item_type = _string(payload.get("type"))
|
|
164
|
+
if item_type == "message":
|
|
165
|
+
return payload.get("role") in {"user", "assistant"}
|
|
166
|
+
return item_type in _CALL_TYPES or item_type in _OUTPUT_TYPES
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _is_protected(record_type: str, payload: JsonObject) -> bool:
|
|
170
|
+
if record_type in _STRUCTURAL_RECORD_TYPES:
|
|
171
|
+
return True
|
|
172
|
+
return record_type == "event_msg" and payload.get("type") in {
|
|
173
|
+
"task_started",
|
|
174
|
+
"task_complete",
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _is_opaque(kind: str) -> bool:
|
|
179
|
+
if kind in _OPAQUE_RECORD_TYPES:
|
|
180
|
+
return True
|
|
181
|
+
if kind.startswith("event_msg."):
|
|
182
|
+
return kind not in {"event_msg.task_started", "event_msg.task_complete"}
|
|
183
|
+
if kind.startswith("response_item."):
|
|
184
|
+
return kind.removeprefix("response_item.") not in {
|
|
185
|
+
"custom_tool_call",
|
|
186
|
+
"custom_tool_call_output",
|
|
187
|
+
"function_call",
|
|
188
|
+
"function_call_output",
|
|
189
|
+
"message",
|
|
190
|
+
}
|
|
191
|
+
return kind not in {"session_meta", "turn_context"}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _is_opaque_record(record: JsonObject) -> bool:
|
|
195
|
+
record_type = cast(str, record["type"])
|
|
196
|
+
return _is_opaque(_context_kind(record_type, _object(record.get("payload"))))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _object(value: JsonValue | None) -> JsonObject:
|
|
200
|
+
return value if isinstance(value, dict) else {}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _string(value: JsonValue | None) -> str | None:
|
|
204
|
+
return value if isinstance(value, str) and value else None
|