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,182 @@
|
|
|
1
|
+
"""Compile editable context graphs from parsed GitHub Copilot 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 = "copilot"
|
|
22
|
+
_SOURCE = "source/events.jsonl"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def compile_context(session: NativeSession, selector: DecisionSelector) -> ContextGraph:
|
|
26
|
+
"""Build context preceding one selector produced from the parsed session."""
|
|
27
|
+
|
|
28
|
+
records = session.records
|
|
29
|
+
_validate_selector(session, selector, records)
|
|
30
|
+
context_count = selector.context_before_line - 1
|
|
31
|
+
if context_count > MAX_CONTEXT_NODES:
|
|
32
|
+
raise AdapterError(
|
|
33
|
+
AdapterErrorCode.CONTEXT_TOO_LARGE,
|
|
34
|
+
"selected decision has too many preceding context records",
|
|
35
|
+
)
|
|
36
|
+
return ContextGraph(nodes=_context_nodes(records[:context_count]))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def source_selector(index: int, record: JsonObject) -> SourceRecordSelector:
|
|
40
|
+
"""Return the stable native selector for one Copilot JSONL record."""
|
|
41
|
+
|
|
42
|
+
return SourceRecordSelector(
|
|
43
|
+
line=index + 1,
|
|
44
|
+
identity_key="id",
|
|
45
|
+
identity_value=cast(str, record["id"]),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def opaque_prefix(records: tuple[JsonObject, ...]) -> tuple[bool, ...]:
|
|
50
|
+
"""Track whether each source prefix contains frozen opaque context."""
|
|
51
|
+
|
|
52
|
+
prefixes = [False]
|
|
53
|
+
for record in records:
|
|
54
|
+
record_type = cast(str, record["type"])
|
|
55
|
+
prefixes.append(prefixes[-1] or _is_opaque(record_type))
|
|
56
|
+
return tuple(prefixes)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _validate_selector(
|
|
60
|
+
session: NativeSession,
|
|
61
|
+
selector: DecisionSelector,
|
|
62
|
+
records: tuple[JsonObject, ...],
|
|
63
|
+
) -> None:
|
|
64
|
+
index = selector.record.line - 1
|
|
65
|
+
if (
|
|
66
|
+
selector.harness != _HARNESS
|
|
67
|
+
or selector.session_id != session.metadata.session_id
|
|
68
|
+
or selector.source != _SOURCE
|
|
69
|
+
or index >= session.record_count
|
|
70
|
+
):
|
|
71
|
+
raise AdapterError(
|
|
72
|
+
AdapterErrorCode.DECISION_NOT_FOUND,
|
|
73
|
+
"decision selector does not match the selected session",
|
|
74
|
+
)
|
|
75
|
+
record = records[index]
|
|
76
|
+
if selector.record != source_selector(
|
|
77
|
+
index, record
|
|
78
|
+
) or selector.record_digest != content_digest(record):
|
|
79
|
+
raise AdapterError(
|
|
80
|
+
AdapterErrorCode.DECISION_NOT_FOUND,
|
|
81
|
+
"decision selector does not match the selected session",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _context_nodes(records: tuple[JsonObject, ...]) -> tuple[ContextNode, ...]:
|
|
86
|
+
nodes: list[ContextNode] = []
|
|
87
|
+
tool_requests: dict[str, str] = {}
|
|
88
|
+
tool_starts: dict[str, str] = {}
|
|
89
|
+
|
|
90
|
+
for index, record in enumerate(records):
|
|
91
|
+
record_type = cast(str, record["type"])
|
|
92
|
+
digest = content_digest(record)
|
|
93
|
+
selector = source_selector(index, record)
|
|
94
|
+
node_id = stable_source_context_id(
|
|
95
|
+
source=_SOURCE,
|
|
96
|
+
selector=selector,
|
|
97
|
+
kind=record_type,
|
|
98
|
+
digest=digest,
|
|
99
|
+
)
|
|
100
|
+
nodes.append(
|
|
101
|
+
ContextNode(
|
|
102
|
+
id=node_id,
|
|
103
|
+
kind=record_type,
|
|
104
|
+
selector=selector,
|
|
105
|
+
mutable=_is_mutable(record_type),
|
|
106
|
+
protected=_is_protected(record_type),
|
|
107
|
+
depends_on=_dependencies(record_type, record, tool_requests, tool_starts),
|
|
108
|
+
source=_SOURCE,
|
|
109
|
+
digest=digest,
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
_remember_tool_relationship(record_type, record, node_id, tool_requests, tool_starts)
|
|
113
|
+
|
|
114
|
+
return tuple(nodes)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _dependencies(
|
|
118
|
+
record_type: str,
|
|
119
|
+
record: JsonObject,
|
|
120
|
+
requests: dict[str, str],
|
|
121
|
+
starts: dict[str, str],
|
|
122
|
+
) -> tuple[str, ...]:
|
|
123
|
+
tool_call_id = _string(_object(record.get("data")).get("toolCallId"))
|
|
124
|
+
if tool_call_id is None:
|
|
125
|
+
return ()
|
|
126
|
+
if record_type == "tool.execution_start" and tool_call_id in requests:
|
|
127
|
+
return (requests[tool_call_id],)
|
|
128
|
+
if record_type == "tool.execution_complete" and tool_call_id in starts:
|
|
129
|
+
return (starts[tool_call_id],)
|
|
130
|
+
return ()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _remember_tool_relationship(
|
|
134
|
+
record_type: str,
|
|
135
|
+
record: JsonObject,
|
|
136
|
+
node_id: str,
|
|
137
|
+
requests: dict[str, str],
|
|
138
|
+
starts: dict[str, str],
|
|
139
|
+
) -> None:
|
|
140
|
+
data = _object(record.get("data"))
|
|
141
|
+
if record_type == "assistant.message":
|
|
142
|
+
tool_requests = data.get("toolRequests")
|
|
143
|
+
if not isinstance(tool_requests, list):
|
|
144
|
+
return
|
|
145
|
+
for request in tool_requests:
|
|
146
|
+
if isinstance(request, dict) and (tool_call_id := _string(request.get("toolCallId"))):
|
|
147
|
+
requests[tool_call_id] = node_id
|
|
148
|
+
elif record_type == "tool.execution_start":
|
|
149
|
+
if tool_call_id := _string(data.get("toolCallId")):
|
|
150
|
+
starts[tool_call_id] = node_id
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _is_mutable(record_type: str) -> bool:
|
|
154
|
+
return record_type in {
|
|
155
|
+
"assistant.message",
|
|
156
|
+
"model.message",
|
|
157
|
+
"skill.invoked",
|
|
158
|
+
"tool.execution_complete",
|
|
159
|
+
"tool.execution_start",
|
|
160
|
+
"user.message",
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _is_protected(record_type: str) -> bool:
|
|
165
|
+
return record_type in {
|
|
166
|
+
"assistant.turn_end",
|
|
167
|
+
"assistant.turn_start",
|
|
168
|
+
"session.start",
|
|
169
|
+
"session.task_complete",
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _is_opaque(record_type: str) -> bool:
|
|
174
|
+
return not _is_mutable(record_type) and not _is_protected(record_type)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _object(value: JsonValue | None) -> JsonObject:
|
|
178
|
+
return value if isinstance(value, dict) else {}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _string(value: JsonValue | None) -> str | None:
|
|
182
|
+
return value if isinstance(value, str) and value else None
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Enumerate completed decisions from parsed GitHub Copilot CLI sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from ithrl.adapters.contracts import NativeSession
|
|
10
|
+
from ithrl.adapters.copilot.context import opaque_prefix, source_selector
|
|
11
|
+
from ithrl.adapters.errors import AdapterError, AdapterErrorCode
|
|
12
|
+
from ithrl.behavior import (
|
|
13
|
+
BehaviorObservation,
|
|
14
|
+
FinalResponseTarget,
|
|
15
|
+
ShellAction,
|
|
16
|
+
ShellActionTarget,
|
|
17
|
+
)
|
|
18
|
+
from ithrl.context import JsonObject, JsonValue, content_digest
|
|
19
|
+
from ithrl.decisions import (
|
|
20
|
+
MAX_DECISIONS,
|
|
21
|
+
DecisionCaseFacts,
|
|
22
|
+
DecisionDraft,
|
|
23
|
+
DecisionLimitation,
|
|
24
|
+
DecisionSelector,
|
|
25
|
+
TargetCandidate,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_ADAPTER = "copilot-session"
|
|
29
|
+
_ADAPTER_VERSION = "1"
|
|
30
|
+
_HARNESS = "copilot"
|
|
31
|
+
_SOURCE = "source/events.jsonl"
|
|
32
|
+
_SHELL_TOOL_NAMES = frozenset({"bash", "shell"})
|
|
33
|
+
_MAX_BEHAVIOR_BYTES = 64 * 1024
|
|
34
|
+
_MAX_SHELL_ACTIONS = 32
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class _DecisionRecord:
|
|
39
|
+
index: int
|
|
40
|
+
context_before_line: int
|
|
41
|
+
turn_id: str
|
|
42
|
+
kind: str
|
|
43
|
+
native_id: str
|
|
44
|
+
observation: BehaviorObservation
|
|
45
|
+
targets: tuple[TargetCandidate, ...]
|
|
46
|
+
model: str | None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class _TurnInterval:
|
|
51
|
+
turn_id: str
|
|
52
|
+
start: int
|
|
53
|
+
stop: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def enumerate_decisions(session: NativeSession) -> tuple[DecisionDraft, ...]:
|
|
57
|
+
"""Return supported decisions whose enclosing assistant turn completed."""
|
|
58
|
+
|
|
59
|
+
if session.metadata.format.harness != _HARNESS:
|
|
60
|
+
raise ValueError("native session is not a GitHub Copilot CLI session")
|
|
61
|
+
|
|
62
|
+
records = session.records
|
|
63
|
+
completed = _completed_decision_records(records)
|
|
64
|
+
opaque_context = opaque_prefix(records)
|
|
65
|
+
return tuple(_build_draft(session, records, opaque_context, decision) for decision in completed)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _completed_decision_records(
|
|
69
|
+
records: tuple[JsonObject, ...],
|
|
70
|
+
) -> tuple[_DecisionRecord, ...]:
|
|
71
|
+
completed_turns = _completed_turns(records)
|
|
72
|
+
starts = {turn.start: turn for turn in completed_turns}
|
|
73
|
+
stops = {turn.stop: turn for turn in completed_turns}
|
|
74
|
+
active_turns: dict[str, list[_TurnInterval]] = {}
|
|
75
|
+
decisions: list[_DecisionRecord] = []
|
|
76
|
+
for index, record in enumerate(records):
|
|
77
|
+
if turn := starts.get(index):
|
|
78
|
+
active_turns.setdefault(turn.turn_id, []).append(turn)
|
|
79
|
+
decision = _decision_from_record(index, record, active_turns)
|
|
80
|
+
if decision is not None:
|
|
81
|
+
if len(decisions) == MAX_DECISIONS:
|
|
82
|
+
raise AdapterError(
|
|
83
|
+
AdapterErrorCode.TOO_MANY_DECISIONS,
|
|
84
|
+
"session contains too many completed decisions",
|
|
85
|
+
)
|
|
86
|
+
decisions.append(decision)
|
|
87
|
+
_close_turn(index, stops, active_turns)
|
|
88
|
+
return tuple(decisions)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _decision_from_record(
|
|
92
|
+
index: int,
|
|
93
|
+
record: JsonObject,
|
|
94
|
+
active_turns: dict[str, list[_TurnInterval]],
|
|
95
|
+
) -> _DecisionRecord | None:
|
|
96
|
+
if record.get("type") != "assistant.message":
|
|
97
|
+
return None
|
|
98
|
+
data = _object(record.get("data"))
|
|
99
|
+
turn_id = _bounded_string(data.get("turnId"), 1024)
|
|
100
|
+
turns = active_turns.get(turn_id) if turn_id is not None else None
|
|
101
|
+
turn = turns[-1] if turns else None
|
|
102
|
+
if turn is None:
|
|
103
|
+
return None
|
|
104
|
+
return _assistant_message_decision(index, turn.start + 1, data)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _assistant_message_decision(
|
|
108
|
+
index: int,
|
|
109
|
+
context_before_line: int,
|
|
110
|
+
data: JsonObject,
|
|
111
|
+
) -> _DecisionRecord | None:
|
|
112
|
+
turn_id = _bounded_string(data.get("turnId"), 1024)
|
|
113
|
+
native_id = _bounded_string(data.get("messageId"), 1024)
|
|
114
|
+
if turn_id is None or native_id is None:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
actions = _shell_actions(data.get("toolRequests"))
|
|
118
|
+
final_response = _final_response(data)
|
|
119
|
+
if not actions and final_response is None:
|
|
120
|
+
return None
|
|
121
|
+
targets = (
|
|
122
|
+
(_target_candidate(FinalResponseTarget(text_equals=final_response), ordinal=1),)
|
|
123
|
+
if final_response is not None
|
|
124
|
+
else _shell_targets(actions)
|
|
125
|
+
)
|
|
126
|
+
return _DecisionRecord(
|
|
127
|
+
index=index,
|
|
128
|
+
context_before_line=context_before_line,
|
|
129
|
+
turn_id=turn_id,
|
|
130
|
+
kind="copilot-assistant-message",
|
|
131
|
+
native_id=native_id,
|
|
132
|
+
observation=BehaviorObservation(
|
|
133
|
+
final_response=final_response,
|
|
134
|
+
shell_actions=actions,
|
|
135
|
+
),
|
|
136
|
+
targets=targets,
|
|
137
|
+
model=_bounded_string(data.get("model"), 256),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _final_response(data: JsonObject) -> str | None:
|
|
142
|
+
tool_requests = data.get("toolRequests")
|
|
143
|
+
if (
|
|
144
|
+
data.get("phase") != "final_answer"
|
|
145
|
+
or data.get("parentToolCallId") is not None
|
|
146
|
+
or not isinstance(tool_requests, list)
|
|
147
|
+
or tool_requests
|
|
148
|
+
):
|
|
149
|
+
return None
|
|
150
|
+
return _behavior_text(data.get("content"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _shell_targets(actions: tuple[ShellAction, ...]) -> tuple[TargetCandidate, ...]:
|
|
154
|
+
counts = Counter((action.tool_name, action.command) for action in actions)
|
|
155
|
+
return tuple(
|
|
156
|
+
_target_candidate(
|
|
157
|
+
ShellActionTarget(
|
|
158
|
+
tool_name_equals=tool_name,
|
|
159
|
+
command_equals=command,
|
|
160
|
+
count=count,
|
|
161
|
+
),
|
|
162
|
+
ordinal=ordinal,
|
|
163
|
+
)
|
|
164
|
+
for ordinal, ((tool_name, command), count) in enumerate(counts.items(), start=1)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _close_turn(
|
|
169
|
+
index: int,
|
|
170
|
+
stops: dict[int, _TurnInterval],
|
|
171
|
+
active_turns: dict[str, list[_TurnInterval]],
|
|
172
|
+
) -> None:
|
|
173
|
+
turn = stops.get(index)
|
|
174
|
+
if turn is None:
|
|
175
|
+
return
|
|
176
|
+
turns = active_turns[turn.turn_id]
|
|
177
|
+
turns.pop()
|
|
178
|
+
if not turns:
|
|
179
|
+
del active_turns[turn.turn_id]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _completed_turns(records: tuple[JsonObject, ...]) -> tuple[_TurnInterval, ...]:
|
|
183
|
+
open_turns: dict[str, list[int]] = {}
|
|
184
|
+
completed: list[_TurnInterval] = []
|
|
185
|
+
for index, record in enumerate(records):
|
|
186
|
+
record_type = record.get("type")
|
|
187
|
+
if record_type not in {"assistant.turn_start", "assistant.turn_end"}:
|
|
188
|
+
continue
|
|
189
|
+
turn_id = _bounded_string(_object(record.get("data")).get("turnId"), 1024)
|
|
190
|
+
if turn_id is None:
|
|
191
|
+
continue
|
|
192
|
+
if record_type == "assistant.turn_start":
|
|
193
|
+
open_turns.setdefault(turn_id, []).append(index)
|
|
194
|
+
elif starts := open_turns.get(turn_id):
|
|
195
|
+
completed.append(_TurnInterval(turn_id=turn_id, start=starts.pop(), stop=index))
|
|
196
|
+
if not starts:
|
|
197
|
+
del open_turns[turn_id]
|
|
198
|
+
return tuple(completed)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _shell_actions(value: JsonValue | None) -> tuple[ShellAction, ...]:
|
|
202
|
+
if not isinstance(value, list):
|
|
203
|
+
return ()
|
|
204
|
+
|
|
205
|
+
actions: list[ShellAction] = []
|
|
206
|
+
total_bytes = 0
|
|
207
|
+
for request in value:
|
|
208
|
+
if not isinstance(request, dict):
|
|
209
|
+
continue
|
|
210
|
+
name = _bounded_string(request.get("name"), 256)
|
|
211
|
+
arguments = _object(request.get("arguments"))
|
|
212
|
+
command = _behavior_text(arguments.get("command"))
|
|
213
|
+
if name is None or name not in _SHELL_TOOL_NAMES or command is None:
|
|
214
|
+
continue
|
|
215
|
+
total_bytes += len(command.encode("utf-8"))
|
|
216
|
+
if total_bytes > _MAX_BEHAVIOR_BYTES or len(actions) == _MAX_SHELL_ACTIONS:
|
|
217
|
+
return ()
|
|
218
|
+
actions.append(ShellAction(tool_name=name, command=command))
|
|
219
|
+
return tuple(actions)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _target_candidate(
|
|
223
|
+
target: FinalResponseTarget | ShellActionTarget,
|
|
224
|
+
*,
|
|
225
|
+
ordinal: int,
|
|
226
|
+
) -> TargetCandidate:
|
|
227
|
+
return TargetCandidate(
|
|
228
|
+
ordinal=ordinal,
|
|
229
|
+
target=target,
|
|
230
|
+
evidence_support="supported",
|
|
231
|
+
experimental_support="unvalidated",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _build_draft(
|
|
236
|
+
session: NativeSession,
|
|
237
|
+
records: tuple[JsonObject, ...],
|
|
238
|
+
opaque_prefix: tuple[bool, ...],
|
|
239
|
+
decision: _DecisionRecord,
|
|
240
|
+
) -> DecisionDraft:
|
|
241
|
+
record = records[decision.index]
|
|
242
|
+
selector = _selector(session, record, decision)
|
|
243
|
+
return DecisionDraft(
|
|
244
|
+
case=DecisionCaseFacts(
|
|
245
|
+
adapter=_ADAPTER,
|
|
246
|
+
adapter_version=_ADAPTER_VERSION,
|
|
247
|
+
model_requested=decision.model,
|
|
248
|
+
source_schema=session.metadata.format.name,
|
|
249
|
+
),
|
|
250
|
+
selector=selector,
|
|
251
|
+
observed_at=cast(str, record["timestamp"]),
|
|
252
|
+
observation=decision.observation,
|
|
253
|
+
targets=decision.targets,
|
|
254
|
+
limitations=_limitations(opaque_prefix[decision.context_before_line - 1]),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _limitations(has_opaque_context: bool) -> tuple[DecisionLimitation, ...]:
|
|
259
|
+
limitations: list[DecisionLimitation] = ["experimental-support-unvalidated"]
|
|
260
|
+
if has_opaque_context:
|
|
261
|
+
limitations.append("opaque-context-frozen")
|
|
262
|
+
return tuple(limitations)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _selector(
|
|
266
|
+
session: NativeSession,
|
|
267
|
+
record: JsonObject,
|
|
268
|
+
decision: _DecisionRecord,
|
|
269
|
+
) -> DecisionSelector:
|
|
270
|
+
return DecisionSelector(
|
|
271
|
+
harness=_HARNESS,
|
|
272
|
+
session_id=session.metadata.session_id,
|
|
273
|
+
turn_id=decision.turn_id,
|
|
274
|
+
kind=decision.kind,
|
|
275
|
+
native_id=decision.native_id,
|
|
276
|
+
source=_SOURCE,
|
|
277
|
+
context_before_line=decision.context_before_line,
|
|
278
|
+
record=source_selector(decision.index, record),
|
|
279
|
+
record_digest=content_digest(record),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _object(value: JsonValue | None) -> JsonObject:
|
|
284
|
+
return value if isinstance(value, dict) else {}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _bounded_string(value: JsonValue | None, limit: int) -> str | None:
|
|
288
|
+
return value if isinstance(value, str) and 0 < len(value) <= limit else None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _behavior_text(value: JsonValue | None) -> str | None:
|
|
292
|
+
if not isinstance(value, str) or not value:
|
|
293
|
+
return None
|
|
294
|
+
return value if len(value.encode("utf-8")) <= _MAX_BEHAVIOR_BYTES else None
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Discover ordinary GitHub Copilot CLI session event logs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ithrl.adapters.discovery import DiscoveryLimits, SourceScan, scan_store
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def discover_sources(
|
|
11
|
+
*,
|
|
12
|
+
root: Path | None = None,
|
|
13
|
+
limits: DiscoveryLimits | None = None,
|
|
14
|
+
session_id: str | None = None,
|
|
15
|
+
) -> SourceScan:
|
|
16
|
+
"""Return event logs from the ordinary Copilot CLI session store."""
|
|
17
|
+
|
|
18
|
+
store = root if root is not None else Path.home() / ".copilot" / "session-state"
|
|
19
|
+
return scan_store(
|
|
20
|
+
store,
|
|
21
|
+
max_depth=1,
|
|
22
|
+
accepts=lambda path: (
|
|
23
|
+
len(path.parts) == 2
|
|
24
|
+
and path.name == "events.jsonl"
|
|
25
|
+
and (session_id is None or path.parent.name == session_id)
|
|
26
|
+
),
|
|
27
|
+
limits=limits or DiscoveryLimits(),
|
|
28
|
+
)
|