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.
Files changed (108) hide show
  1. ithrl/__init__.py +1 -0
  2. ithrl/__main__.py +6 -0
  3. ithrl/adapters/__init__.py +49 -0
  4. ithrl/adapters/_jsonl.py +266 -0
  5. ithrl/adapters/codex/__init__.py +5 -0
  6. ithrl/adapters/codex/context.py +204 -0
  7. ithrl/adapters/codex/decisions.py +224 -0
  8. ithrl/adapters/codex/discovery.py +60 -0
  9. ithrl/adapters/codex/parser.py +191 -0
  10. ithrl/adapters/contracts.py +106 -0
  11. ithrl/adapters/copilot/__init__.py +5 -0
  12. ithrl/adapters/copilot/context.py +182 -0
  13. ithrl/adapters/copilot/decisions.py +294 -0
  14. ithrl/adapters/copilot/discovery.py +28 -0
  15. ithrl/adapters/copilot/parser.py +283 -0
  16. ithrl/adapters/discovery.py +220 -0
  17. ithrl/adapters/errors.py +52 -0
  18. ithrl/adapters/registry.py +433 -0
  19. ithrl/behavior.py +138 -0
  20. ithrl/calibration.py +434 -0
  21. ithrl/case/__init__.py +33 -0
  22. ithrl/case/contracts.py +88 -0
  23. ithrl/case/evidence.py +169 -0
  24. ithrl/case/filesystem.py +340 -0
  25. ithrl/case/source.py +208 -0
  26. ithrl/case/source_context.py +119 -0
  27. ithrl/case/source_storage.py +234 -0
  28. ithrl/case/storage.py +377 -0
  29. ithrl/cli/__init__.py +968 -0
  30. ithrl/cli/extract.py +478 -0
  31. ithrl/cli/native.py +484 -0
  32. ithrl/cli/output.py +786 -0
  33. ithrl/cli/paths.py +24 -0
  34. ithrl/cli/sampled.py +485 -0
  35. ithrl/cli/sampled_output.py +472 -0
  36. ithrl/cli/sampled_reduction.py +1036 -0
  37. ithrl/cli/sampled_session.py +342 -0
  38. ithrl/cli/shrink.py +523 -0
  39. ithrl/context.py +465 -0
  40. ithrl/decisions.py +271 -0
  41. ithrl/examples/synthetic-trace.json +46 -0
  42. ithrl/execution/__init__.py +68 -0
  43. ithrl/execution/budget.py +146 -0
  44. ithrl/execution/contracts.py +426 -0
  45. ithrl/execution/preflight.py +346 -0
  46. ithrl/experiment.py +453 -0
  47. ithrl/extraction.py +206 -0
  48. ithrl/measurement.py +315 -0
  49. ithrl/observations.py +1148 -0
  50. ithrl/path_safety.py +100 -0
  51. ithrl/reduce/__init__.py +53 -0
  52. ithrl/reduce/contracts.py +508 -0
  53. ithrl/reduce/engine.py +901 -0
  54. ithrl/reduce/search.py +178 -0
  55. ithrl/results/__init__.py +49 -0
  56. ithrl/results/models.py +294 -0
  57. ithrl/results/storage.py +485 -0
  58. ithrl/results/summary.py +420 -0
  59. ithrl/results/validation.py +611 -0
  60. ithrl/run_state.py +1135 -0
  61. ithrl/runner/__init__.py +44 -0
  62. ithrl/runner/codex/__init__.py +17 -0
  63. ithrl/runner/codex/capability.py +708 -0
  64. ithrl/runner/codex/configuration.py +529 -0
  65. ithrl/runner/codex/isolation.py +396 -0
  66. ithrl/runner/codex/materialize.py +358 -0
  67. ithrl/runner/codex/policy.py +67 -0
  68. ithrl/runner/codex/protocol.py +649 -0
  69. ithrl/runner/codex/protocol_contracts.py +171 -0
  70. ithrl/runner/codex/runner.py +533 -0
  71. ithrl/runner/codex/runtime_configuration.py +264 -0
  72. ithrl/runner/codex/schema.py +527 -0
  73. ithrl/runner/codex/transcript.py +130 -0
  74. ithrl/runner/codex/worker.py +175 -0
  75. ithrl/runner/contracts.py +419 -0
  76. ithrl/runner/copilot/__init__.py +18 -0
  77. ithrl/runner/copilot/capability.py +825 -0
  78. ithrl/runner/copilot/configuration.py +201 -0
  79. ithrl/runner/copilot/isolation.py +374 -0
  80. ithrl/runner/copilot/materialize.py +484 -0
  81. ithrl/runner/copilot/policy.py +110 -0
  82. ithrl/runner/copilot/protocol/__init__.py +18 -0
  83. ithrl/runner/copilot/protocol/contracts.py +74 -0
  84. ithrl/runner/copilot/protocol/decoding.py +93 -0
  85. ithrl/runner/copilot/protocol/parser.py +418 -0
  86. ithrl/runner/copilot/protocol/state.py +130 -0
  87. ithrl/runner/copilot/runner.py +425 -0
  88. ithrl/runner/copilot/worker.py +351 -0
  89. ithrl/runner/evidence.py +345 -0
  90. ithrl/runner/fake.py +107 -0
  91. ithrl/runner/registry.py +90 -0
  92. ithrl/runner/runtime/__init__.py +50 -0
  93. ithrl/runner/runtime/contracts.py +157 -0
  94. ithrl/runner/runtime/output.py +184 -0
  95. ithrl/runner/runtime/process.py +954 -0
  96. ithrl/runner/runtime/protection.py +562 -0
  97. ithrl/runner/runtime/state.py +282 -0
  98. ithrl/runner/validation.py +376 -0
  99. ithrl/sampling.py +412 -0
  100. ithrl/statistics.py +276 -0
  101. ithrl/synthetic.py +511 -0
  102. ithrl/timestamps.py +26 -0
  103. ithrl/workflow.py +570 -0
  104. ithrl-0.1.0.dist-info/METADATA +118 -0
  105. ithrl-0.1.0.dist-info/RECORD +108 -0
  106. ithrl-0.1.0.dist-info/WHEEL +4 -0
  107. ithrl-0.1.0.dist-info/entry_points.txt +2 -0
  108. 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,6 @@
1
+ """Run the ithrl command-line interface with ``python -m ithrl``."""
2
+
3
+ from ithrl.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -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
+ ]
@@ -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,5 @@
1
+ """Parse supported Codex CLI rollout sessions."""
2
+
3
+ from ithrl.adapters.codex.parser import PARSER
4
+
5
+ __all__ = ["PARSER"]
@@ -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