custos-code 0.0.1__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.
- custos_code/__init__.py +6 -0
- custos_code/adapters/__init__.py +194 -0
- custos_code/adapters/claude_code.py +266 -0
- custos_code/adapters/codex.py +437 -0
- custos_code/adapters/copilot.py +158 -0
- custos_code/adapters/devin.py +172 -0
- custos_code/adapters/machine.py +379 -0
- custos_code/adapters/otel.py +210 -0
- custos_code/adapters/state.py +164 -0
- custos_code/claims.py +319 -0
- custos_code/cli.py +789 -0
- custos_code/compress.py +113 -0
- custos_code/cost.py +216 -0
- custos_code/demo_fixtures/__init__.py +1 -0
- custos_code/demo_fixtures/ok_tests_0.jsonl +8 -0
- custos_code/demo_fixtures/trap_echo_0.jsonl +4 -0
- custos_code/demo_fixtures/trap_ghost_0.jsonl +4 -0
- custos_code/demo_fixtures/trap_piped_0.jsonl +4 -0
- custos_code/feedback.py +93 -0
- custos_code/hooks.py +648 -0
- custos_code/judge.py +338 -0
- custos_code/ledger.py +93 -0
- custos_code/models.py +129 -0
- custos_code/parsers.py +408 -0
- custos_code/report.py +317 -0
- custos_code/rerun.py +424 -0
- custos_code/review.py +381 -0
- custos_code/rules.py +464 -0
- custos_code/scope.py +471 -0
- custos_code/verdicts.py +296 -0
- custos_code-0.0.1.dist-info/METADATA +138 -0
- custos_code-0.0.1.dist-info/RECORD +35 -0
- custos_code-0.0.1.dist-info/WHEEL +4 -0
- custos_code-0.0.1.dist-info/entry_points.txt +2 -0
- custos_code-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""Codex rollout JSONL -> ledger (class F; see docs/ADAPTERS.md §3).
|
|
2
|
+
|
|
3
|
+
File: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<session_id>.jsonl (CLI and Codex Desktop).
|
|
4
|
+
Observed on a real rollout on this machine:
|
|
5
|
+
session_meta{id,cwd,originator,cli_version} -> Session (key codex:<id>)
|
|
6
|
+
turn_context{turn_id,cwd,sandbox_policy} -> turn boundary
|
|
7
|
+
function_call{name=exec_command,arguments{cmd,workdir},call_id} -> CALL tool=Bash
|
|
8
|
+
exec_command_end{call_id,command[argv],cwd,exit_code,aggregated_output,duration,status,parsed_cmd}
|
|
9
|
+
-> RESULT with argv, exit_code, full output
|
|
10
|
+
function_call_output{call_id,output "... Process exited with code N ... Original token count: T"}
|
|
11
|
+
-> flags.truncated only
|
|
12
|
+
custom_tool_call{name=apply_patch,input "*** Begin Patch"} -> CALL tool=Edit, paths from Add/Update/Delete File
|
|
13
|
+
patch_apply_end{call_id,success,changes{path:{type,content}}} -> RESULT per path
|
|
14
|
+
task_complete{turn_id,last_agent_message} -> TEXT (the report for the turn)
|
|
15
|
+
turn_aborted / compacted / thread_rolled_back -> integrity flags
|
|
16
|
+
|
|
17
|
+
Owner: Ananya.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import glob
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from ..ledger import MAX_OUTPUT_BYTES, chain, redact
|
|
31
|
+
from ..models import EventFlags, EventKind, LedgerEvent, Session
|
|
32
|
+
from ..parsers import is_piped
|
|
33
|
+
|
|
34
|
+
_PATH_RE = re.compile(r"(?<![\w-])((?:\.{0,2}/)?[\w.-]+(?:/[\w.-]+)+\.[A-Za-z0-9]{1,8})")
|
|
35
|
+
_PATCH_PATH_RE = re.compile(r"^\*\*\* (Add|Update|Delete) File: (.+)$", re.MULTILINE)
|
|
36
|
+
_EXIT_RE = re.compile(r"Process exited with code (-?\d+)")
|
|
37
|
+
_TOKEN_COUNT_RE = re.compile(r"Original token count: (\d+)")
|
|
38
|
+
|
|
39
|
+
# payload.type values that carry no evidence and no report
|
|
40
|
+
_IGNORED = frozenset(
|
|
41
|
+
{"token_count", "agent_reasoning", "agent_reasoning_delta", "agent_message_delta"}
|
|
42
|
+
)
|
|
43
|
+
# payload.type values meaning the record before this point may be summarized or discarded
|
|
44
|
+
_INTEGRITY = frozenset({"compacted", "thread_rolled_back"})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _ts(rec: dict[str, Any], payload: dict[str, Any], fallback: datetime) -> datetime:
|
|
48
|
+
for raw in (rec.get("timestamp"), payload.get("timestamp")):
|
|
49
|
+
if isinstance(raw, str):
|
|
50
|
+
try:
|
|
51
|
+
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
52
|
+
except ValueError:
|
|
53
|
+
continue
|
|
54
|
+
return fallback
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _abs_paths(paths: list[str], cwd: str | None) -> list[str]:
|
|
58
|
+
seen: set[str] = set()
|
|
59
|
+
out: list[str] = []
|
|
60
|
+
for p in paths:
|
|
61
|
+
q = p if os.path.isabs(p) or not cwd else os.path.normpath(os.path.join(cwd, p))
|
|
62
|
+
if q not in seen:
|
|
63
|
+
seen.add(q)
|
|
64
|
+
out.append(q)
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _command_text(value: object) -> str:
|
|
69
|
+
"""exec_command carries either a shell string or an argv list; the ledger stores one string."""
|
|
70
|
+
if isinstance(value, str):
|
|
71
|
+
return value
|
|
72
|
+
if isinstance(value, list):
|
|
73
|
+
return " ".join(str(part) for part in value)
|
|
74
|
+
return ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _arguments(raw: object) -> dict[str, Any]:
|
|
78
|
+
if isinstance(raw, dict):
|
|
79
|
+
return dict(raw)
|
|
80
|
+
if isinstance(raw, str):
|
|
81
|
+
try:
|
|
82
|
+
parsed = json.loads(raw)
|
|
83
|
+
except json.JSONDecodeError:
|
|
84
|
+
return {}
|
|
85
|
+
if isinstance(parsed, dict):
|
|
86
|
+
return parsed
|
|
87
|
+
return {}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _str(value: object) -> str:
|
|
91
|
+
return value if isinstance(value, str) else ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _obj(value: object) -> dict[str, Any]:
|
|
95
|
+
return dict(value) if isinstance(value, dict) else {}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _duration_ms(value: object) -> int | None:
|
|
99
|
+
if isinstance(value, dict):
|
|
100
|
+
secs, nanos = value.get("secs"), value.get("nanos")
|
|
101
|
+
if isinstance(secs, int | float) or isinstance(nanos, int | float):
|
|
102
|
+
s = float(secs) if isinstance(secs, int | float) else 0.0
|
|
103
|
+
n = float(nanos) if isinstance(nanos, int | float) else 0.0
|
|
104
|
+
return int(s * 1000 + n / 1_000_000)
|
|
105
|
+
if isinstance(value, int | float):
|
|
106
|
+
return int(float(value) * 1000)
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _patch_paths(patch: str) -> list[str]:
|
|
111
|
+
return [m.group(2).strip() for m in _PATCH_PATH_RE.finditer(patch)]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _Builder:
|
|
115
|
+
"""Accumulates ledger events while walking one rollout file."""
|
|
116
|
+
|
|
117
|
+
def __init__(self) -> None:
|
|
118
|
+
self.events: list[LedgerEvent] = []
|
|
119
|
+
self.seq = 0
|
|
120
|
+
self.session_id = ""
|
|
121
|
+
self.cwd: str | None = None
|
|
122
|
+
self.agent = "codex"
|
|
123
|
+
self.model: str | None = None
|
|
124
|
+
self.started: datetime | None = None
|
|
125
|
+
self.ended: datetime = datetime.fromtimestamp(0)
|
|
126
|
+
self.report: str | None = None
|
|
127
|
+
self.calls: dict[str, tuple[str, dict[str, Any]]] = {}
|
|
128
|
+
self.max_output_tokens: dict[str, int] = {}
|
|
129
|
+
self.results: dict[str, int] = {} # call_id -> index into self.events
|
|
130
|
+
self.integrity_events = 0
|
|
131
|
+
|
|
132
|
+
def add(self, **fields: Any) -> LedgerEvent:
|
|
133
|
+
event = LedgerEvent(seq=self.seq, session_id=self.session_id, **fields)
|
|
134
|
+
self.events.append(event)
|
|
135
|
+
self.seq += 1
|
|
136
|
+
return event
|
|
137
|
+
|
|
138
|
+
def add_result(self, call_id: str | None, **fields: Any) -> LedgerEvent:
|
|
139
|
+
event = self.add(kind=EventKind.RESULT, **fields)
|
|
140
|
+
if call_id:
|
|
141
|
+
self.results[call_id] = len(self.events) - 1
|
|
142
|
+
return event
|
|
143
|
+
|
|
144
|
+
def result_for(self, call_id: object) -> LedgerEvent | None:
|
|
145
|
+
if isinstance(call_id, str) and call_id in self.results:
|
|
146
|
+
return self.events[self.results[call_id]]
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def store_output(self, event: LedgerEvent, text: str) -> None:
|
|
150
|
+
full = redact(text)
|
|
151
|
+
raw = full.encode()
|
|
152
|
+
if len(raw) > MAX_OUTPUT_BYTES:
|
|
153
|
+
event.flags.truncated = True
|
|
154
|
+
event.output = raw[:MAX_OUTPUT_BYTES].decode(errors="ignore")
|
|
155
|
+
event.output_hash = hashlib.sha256(raw).hexdigest()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _on_session_meta(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
|
|
159
|
+
b.session_id = str(p.get("id") or b.session_id)
|
|
160
|
+
if isinstance(p.get("cwd"), str):
|
|
161
|
+
b.cwd = p["cwd"]
|
|
162
|
+
if isinstance(p.get("originator"), str) and p["originator"]:
|
|
163
|
+
b.agent = p["originator"]
|
|
164
|
+
if isinstance(p.get("model"), str):
|
|
165
|
+
b.model = p["model"]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _on_turn_context(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
|
|
169
|
+
cwd = p["cwd"] if isinstance(p.get("cwd"), str) else b.cwd
|
|
170
|
+
b.add(
|
|
171
|
+
kind=EventKind.META,
|
|
172
|
+
ts=ts,
|
|
173
|
+
tool=None,
|
|
174
|
+
cwd=cwd,
|
|
175
|
+
input=redact(
|
|
176
|
+
{
|
|
177
|
+
"turn_id": str(p.get("turn_id", "")),
|
|
178
|
+
"sandbox_policy": json.dumps(p.get("sandbox_policy"), sort_keys=True)
|
|
179
|
+
if p.get("sandbox_policy") is not None
|
|
180
|
+
else "",
|
|
181
|
+
"approval_policy": str(p.get("approval_policy", "")),
|
|
182
|
+
}
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _on_function_call(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
|
|
188
|
+
name = str(p.get("name", ""))
|
|
189
|
+
args = _arguments(p.get("arguments"))
|
|
190
|
+
call_id = str(p.get("call_id", ""))
|
|
191
|
+
if name in ("exec_command", "shell", "local_shell"):
|
|
192
|
+
command = _command_text(args.get("cmd") or args.get("command"))
|
|
193
|
+
cwd = args["workdir"] if isinstance(args.get("workdir"), str) else b.cwd
|
|
194
|
+
inp: dict[str, Any] = {"command": command}
|
|
195
|
+
if isinstance(args.get("workdir"), str):
|
|
196
|
+
inp["workdir"] = args["workdir"]
|
|
197
|
+
tool = "Bash"
|
|
198
|
+
paths = _abs_paths([m.group(1) for m in _PATH_RE.finditer(command)], cwd)
|
|
199
|
+
else:
|
|
200
|
+
cwd = b.cwd
|
|
201
|
+
inp = args
|
|
202
|
+
tool = name or "unknown"
|
|
203
|
+
paths = _abs_paths(
|
|
204
|
+
[v for k, v in args.items() if k in ("path", "file_path") and isinstance(v, str)], cwd
|
|
205
|
+
)
|
|
206
|
+
if isinstance(args.get("max_output_tokens"), int):
|
|
207
|
+
b.max_output_tokens[call_id] = args["max_output_tokens"]
|
|
208
|
+
b.calls[call_id] = (tool, inp)
|
|
209
|
+
b.add(kind=EventKind.CALL, ts=ts, tool=tool, input=redact(inp), paths=paths, cwd=cwd)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _on_custom_tool_call(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
|
|
213
|
+
name = str(p.get("name", ""))
|
|
214
|
+
call_id = str(p.get("call_id", ""))
|
|
215
|
+
raw = p.get("input")
|
|
216
|
+
patch = raw if isinstance(raw, str) else json.dumps(raw, sort_keys=True)
|
|
217
|
+
tool = "Edit" if name == "apply_patch" else (name or "unknown")
|
|
218
|
+
paths = _abs_paths(_patch_paths(patch), b.cwd)
|
|
219
|
+
inp: dict[str, Any] = {"patch": patch} if tool == "Edit" else {"input": patch}
|
|
220
|
+
b.calls[call_id] = (tool, inp)
|
|
221
|
+
b.add(kind=EventKind.CALL, ts=ts, tool=tool, input=redact(inp), paths=paths, cwd=b.cwd)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _on_exec_command_end(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
|
|
225
|
+
call_id = str(p.get("call_id", ""))
|
|
226
|
+
_, call_input = b.calls.get(call_id, ("Bash", {}))
|
|
227
|
+
argv = _command_text(p.get("command"))
|
|
228
|
+
command = argv or str(call_input.get("command", ""))
|
|
229
|
+
cwd = p["cwd"] if isinstance(p.get("cwd"), str) else b.cwd
|
|
230
|
+
exit_code = p.get("exit_code") if isinstance(p.get("exit_code"), int) else None
|
|
231
|
+
output = _str(p.get("aggregated_output"))
|
|
232
|
+
if not output:
|
|
233
|
+
stdout, stderr = _str(p.get("stdout")), _str(p.get("stderr"))
|
|
234
|
+
output = stdout + (("\n" + stderr) if stderr else "")
|
|
235
|
+
flags = EventFlags(piped=is_piped(command) if command else False)
|
|
236
|
+
event = b.add_result(
|
|
237
|
+
call_id,
|
|
238
|
+
ts=ts,
|
|
239
|
+
tool="Bash",
|
|
240
|
+
exit_code=exit_code,
|
|
241
|
+
cwd=cwd,
|
|
242
|
+
duration_ms=_duration_ms(p.get("duration")),
|
|
243
|
+
flags=flags,
|
|
244
|
+
paths=_abs_paths([m.group(1) for m in _PATH_RE.finditer(command)], cwd),
|
|
245
|
+
)
|
|
246
|
+
b.store_output(event, output)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _on_patch_apply_end(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
|
|
250
|
+
call_id = str(p.get("call_id", ""))
|
|
251
|
+
changes = _obj(p.get("changes"))
|
|
252
|
+
stdout, stderr = _str(p.get("stdout")), _str(p.get("stderr"))
|
|
253
|
+
success = p.get("success")
|
|
254
|
+
event = b.add_result(
|
|
255
|
+
call_id,
|
|
256
|
+
ts=ts,
|
|
257
|
+
tool="Edit",
|
|
258
|
+
cwd=b.cwd,
|
|
259
|
+
paths=_abs_paths([str(k) for k in changes], b.cwd),
|
|
260
|
+
flags=EventFlags(error=success is False),
|
|
261
|
+
)
|
|
262
|
+
b.store_output(event, stdout + (("\n" + stderr) if stderr else ""))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _on_function_call_output(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
|
|
266
|
+
"""The model-facing view of a result: used only for truncation and a missing exit code."""
|
|
267
|
+
call_id = p.get("call_id")
|
|
268
|
+
raw = p.get("output")
|
|
269
|
+
text = (
|
|
270
|
+
raw if isinstance(raw, str) else json.dumps(raw, sort_keys=True) if raw is not None else ""
|
|
271
|
+
)
|
|
272
|
+
event = b.result_for(call_id)
|
|
273
|
+
if event is None:
|
|
274
|
+
tool = b.calls.get(str(call_id), ("Bash", {}))[0]
|
|
275
|
+
event = b.add_result(str(call_id) if call_id else None, ts=ts, tool=tool, cwd=b.cwd)
|
|
276
|
+
b.store_output(event, text)
|
|
277
|
+
if event.exit_code is None:
|
|
278
|
+
m = _EXIT_RE.search(text)
|
|
279
|
+
if m:
|
|
280
|
+
event.exit_code = int(m.group(1))
|
|
281
|
+
m = _TOKEN_COUNT_RE.search(text)
|
|
282
|
+
budget = b.max_output_tokens.get(str(call_id))
|
|
283
|
+
if m and budget is not None and int(m.group(1)) > budget:
|
|
284
|
+
event.flags.truncated = True
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _on_custom_tool_call_output(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
|
|
288
|
+
"""apply_patch's secondary result: exit code and duration for an already-recorded RESULT."""
|
|
289
|
+
call_id = p.get("call_id")
|
|
290
|
+
payload = _arguments(p.get("output"))
|
|
291
|
+
meta = _obj(payload.get("metadata"))
|
|
292
|
+
event = b.result_for(call_id)
|
|
293
|
+
if event is None:
|
|
294
|
+
tool = b.calls.get(str(call_id), ("Edit", {}))[0]
|
|
295
|
+
event = b.add_result(str(call_id) if call_id else None, ts=ts, tool=tool, cwd=b.cwd)
|
|
296
|
+
b.store_output(event, _str(payload.get("output")))
|
|
297
|
+
if event.exit_code is None and isinstance(meta.get("exit_code"), int):
|
|
298
|
+
event.exit_code = meta["exit_code"]
|
|
299
|
+
if event.duration_ms is None:
|
|
300
|
+
event.duration_ms = _duration_ms(meta.get("duration_seconds"))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _on_web_search_call(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
|
|
304
|
+
query = _str(p.get("query"))
|
|
305
|
+
call_id = str(p.get("call_id", ""))
|
|
306
|
+
b.calls[call_id] = ("WebSearch", {"query": query})
|
|
307
|
+
b.add(kind=EventKind.CALL, ts=ts, tool="WebSearch", input=redact({"query": query}), cwd=b.cwd)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _on_web_search_end(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
|
|
311
|
+
query = _str(p.get("query"))
|
|
312
|
+
event = b.add_result(
|
|
313
|
+
str(p.get("call_id")) if p.get("call_id") else None, ts=ts, tool="WebSearch", cwd=b.cwd
|
|
314
|
+
)
|
|
315
|
+
b.store_output(event, query)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _on_agent_message(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
|
|
319
|
+
text = p.get("message")
|
|
320
|
+
if isinstance(text, str) and text.strip():
|
|
321
|
+
event = b.add(kind=EventKind.TEXT, ts=ts, tool=None, cwd=b.cwd)
|
|
322
|
+
b.store_output(event, text)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _on_user_message(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
|
|
326
|
+
text = p.get("message")
|
|
327
|
+
if isinstance(text, str) and text.strip():
|
|
328
|
+
event = b.add(kind=EventKind.USER, ts=ts, tool=None, cwd=b.cwd)
|
|
329
|
+
b.store_output(event, text)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _on_task_complete(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
|
|
333
|
+
text = p.get("last_agent_message")
|
|
334
|
+
if isinstance(text, str) and text.strip():
|
|
335
|
+
event = b.add(kind=EventKind.TEXT, ts=ts, tool=None, cwd=b.cwd)
|
|
336
|
+
b.store_output(event, text)
|
|
337
|
+
b.report = text
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _on_turn_aborted(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
|
|
341
|
+
"""An aborted turn has no report; anything the agent said mid-turn is not one."""
|
|
342
|
+
b.report = None
|
|
343
|
+
b.add(
|
|
344
|
+
kind=EventKind.META,
|
|
345
|
+
ts=ts,
|
|
346
|
+
tool=None,
|
|
347
|
+
cwd=b.cwd,
|
|
348
|
+
input=redact({"event": "turn_aborted", "reason": str(p.get("reason", ""))}),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _on_integrity(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
|
|
353
|
+
b.integrity_events += 1
|
|
354
|
+
b.add(
|
|
355
|
+
kind=EventKind.META,
|
|
356
|
+
ts=ts,
|
|
357
|
+
tool=None,
|
|
358
|
+
cwd=b.cwd,
|
|
359
|
+
input=redact(
|
|
360
|
+
{"event": str(p.get("type", "")), "note": "history before this point may be incomplete"}
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
_HANDLERS = {
|
|
366
|
+
"session_meta": _on_session_meta,
|
|
367
|
+
"turn_context": _on_turn_context,
|
|
368
|
+
"function_call": _on_function_call,
|
|
369
|
+
"custom_tool_call": _on_custom_tool_call,
|
|
370
|
+
"function_call_output": _on_function_call_output,
|
|
371
|
+
"custom_tool_call_output": _on_custom_tool_call_output,
|
|
372
|
+
"exec_command_end": _on_exec_command_end,
|
|
373
|
+
"patch_apply_end": _on_patch_apply_end,
|
|
374
|
+
"web_search_call": _on_web_search_call,
|
|
375
|
+
"web_search_end": _on_web_search_end,
|
|
376
|
+
"agent_message": _on_agent_message,
|
|
377
|
+
"user_message": _on_user_message,
|
|
378
|
+
"task_complete": _on_task_complete,
|
|
379
|
+
"turn_aborted": _on_turn_aborted,
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def parse(path: str) -> tuple[Session, list[LedgerEvent], str | None]:
|
|
384
|
+
"""Parse one Codex rollout into a chained ledger.
|
|
385
|
+
|
|
386
|
+
Returns the session, the chained events, and the report for the last completed turn
|
|
387
|
+
(`task_complete.last_agent_message`), or None when the last turn was aborted or never finished.
|
|
388
|
+
"""
|
|
389
|
+
b = _Builder()
|
|
390
|
+
with open(path, encoding="utf-8", errors="ignore") as fh:
|
|
391
|
+
for line in fh:
|
|
392
|
+
try:
|
|
393
|
+
rec = json.loads(line)
|
|
394
|
+
except json.JSONDecodeError:
|
|
395
|
+
continue
|
|
396
|
+
if not isinstance(rec, dict):
|
|
397
|
+
continue
|
|
398
|
+
payload = _obj(rec.get("payload"))
|
|
399
|
+
kind = str(payload.get("type") or rec.get("type") or "")
|
|
400
|
+
if kind in _IGNORED:
|
|
401
|
+
continue
|
|
402
|
+
ts = _ts(rec, payload, b.ended)
|
|
403
|
+
b.started = b.started or ts
|
|
404
|
+
b.ended = ts
|
|
405
|
+
body = payload or rec
|
|
406
|
+
if kind in _INTEGRITY:
|
|
407
|
+
_on_integrity(b, {**body, "type": kind}, ts)
|
|
408
|
+
continue
|
|
409
|
+
handler = _HANDLERS.get(kind)
|
|
410
|
+
if handler is not None:
|
|
411
|
+
handler(b, body, ts)
|
|
412
|
+
|
|
413
|
+
events = chain(b.events)
|
|
414
|
+
session = Session(
|
|
415
|
+
id=b.session_id or os.path.basename(path).removesuffix(".jsonl"),
|
|
416
|
+
source="codex",
|
|
417
|
+
agent=b.agent,
|
|
418
|
+
model=b.model,
|
|
419
|
+
started=b.started,
|
|
420
|
+
ended=b.ended if b.events else None,
|
|
421
|
+
cwd=b.cwd,
|
|
422
|
+
n_events=len(events),
|
|
423
|
+
ledger_root_hash=events[-1].hash if events else "",
|
|
424
|
+
integrity_score=1.0
|
|
425
|
+
if not b.integrity_events
|
|
426
|
+
else max(0.0, 1.0 - 0.25 * b.integrity_events),
|
|
427
|
+
)
|
|
428
|
+
return session, events, b.report
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def find_last_session(sessions_dir: str | None = None) -> str:
|
|
432
|
+
"""Most recently modified rollout under ~/.codex/sessions (for `custos-code check --last`)."""
|
|
433
|
+
root = sessions_dir or os.path.expanduser("~/.codex/sessions")
|
|
434
|
+
files = glob.glob(os.path.join(root, "**", "rollout-*.jsonl"), recursive=True)
|
|
435
|
+
if not files:
|
|
436
|
+
raise FileNotFoundError(f"no Codex rollouts under {root}")
|
|
437
|
+
return max(files, key=os.path.getmtime)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""GitHub Copilot coding agent (class R): the agent runs on GitHub, the report is the PR body.
|
|
2
|
+
|
|
3
|
+
Copilot's session logs are linked from the commits it pushes and are downloadable as the Actions
|
|
4
|
+
job log for the `Copilot` workflow run; the machine-readable session export is still
|
|
5
|
+
NEEDS-DECISION(ananya): A3, so this adapter takes a bundle and treats the log as optional.
|
|
6
|
+
|
|
7
|
+
Bundle (JSON, one object):
|
|
8
|
+
{"pull_request": {"number","title","body","html_url","head"},
|
|
9
|
+
"session": {"id","agent","model","started_at","completed_at","workspace"},
|
|
10
|
+
"log": [ {"type":"tool_call","name":"bash","arguments":{...},"id":...},
|
|
11
|
+
{"type":"tool_result","tool_call_id":...,"exit_code":0,"output":"..."} ],
|
|
12
|
+
"commits": [...], "checks": [...]} # same shapes as the Devin bundle
|
|
13
|
+
|
|
14
|
+
`commits`/`checks` reuse the Devin Path C builders: class R evidence is class R evidence whoever
|
|
15
|
+
opened the PR. Without `log`, shell claims come out `unrecorded` (the record is known-incomplete),
|
|
16
|
+
never `unwitnessed`.
|
|
17
|
+
|
|
18
|
+
Owner: Ananya.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
from datetime import UTC, datetime
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ..ledger import chain, redact
|
|
29
|
+
from ..models import EventFlags, EventKind, LedgerEvent, Session
|
|
30
|
+
from ..parsers import is_piped
|
|
31
|
+
from .state import Builder, as_dict, as_list, as_text, as_ts, checks, commits, no_tool_log
|
|
32
|
+
|
|
33
|
+
_SHELL_TOOLS = frozenset({"bash", "shell", "run", "terminal", "execute_bash"})
|
|
34
|
+
_EDIT_TOOLS = frozenset(
|
|
35
|
+
{"str_replace_editor", "create", "edit", "write", "apply_patch", "edit_file"}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _tool_name(name: str) -> str:
|
|
40
|
+
low = name.lower()
|
|
41
|
+
if low in _SHELL_TOOLS:
|
|
42
|
+
return "Bash"
|
|
43
|
+
if low in _EDIT_TOOLS:
|
|
44
|
+
return "Edit"
|
|
45
|
+
if low in ("read", "view", "open"):
|
|
46
|
+
return "Read"
|
|
47
|
+
return name or "Tool"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _log(b: Builder, entries: list[Any], fallback: datetime) -> datetime:
|
|
51
|
+
calls: dict[str, tuple[str, dict[str, Any]]] = {}
|
|
52
|
+
ts = fallback
|
|
53
|
+
for raw in entries:
|
|
54
|
+
entry = as_dict(raw)
|
|
55
|
+
ts = as_ts(entry.get("timestamp") or entry.get("ts"), ts)
|
|
56
|
+
etype = str(entry.get("type", ""))
|
|
57
|
+
if etype in ("tool_call", "function_call"):
|
|
58
|
+
name = _tool_name(str(entry.get("name", "")))
|
|
59
|
+
args = as_dict(entry.get("arguments") or entry.get("input"))
|
|
60
|
+
command = str(args.get("command") or args.get("cmd") or "")
|
|
61
|
+
path = str(args.get("path") or args.get("file_path") or "")
|
|
62
|
+
call_id = str(entry.get("id") or entry.get("tool_call_id") or f"call-{b.seq}")
|
|
63
|
+
calls[call_id] = (name, args)
|
|
64
|
+
b.add(
|
|
65
|
+
kind=EventKind.CALL,
|
|
66
|
+
ts=ts,
|
|
67
|
+
tool=name,
|
|
68
|
+
input=redact(dict(args)),
|
|
69
|
+
paths=[path] if path else [],
|
|
70
|
+
flags=EventFlags(piped=bool(command) and is_piped(command)),
|
|
71
|
+
)
|
|
72
|
+
elif etype in ("tool_result", "function_call_output"):
|
|
73
|
+
call_id = str(entry.get("tool_call_id") or entry.get("id") or "")
|
|
74
|
+
name, args = calls.get(call_id, ("Tool", {}))
|
|
75
|
+
command = str(args.get("command") or args.get("cmd") or "")
|
|
76
|
+
path = str(args.get("path") or args.get("file_path") or "")
|
|
77
|
+
exit_code = entry.get("exit_code")
|
|
78
|
+
event = b.add(
|
|
79
|
+
kind=EventKind.RESULT,
|
|
80
|
+
ts=ts,
|
|
81
|
+
tool=name,
|
|
82
|
+
paths=[path] if path else [],
|
|
83
|
+
exit_code=int(exit_code) if isinstance(exit_code, int) else None,
|
|
84
|
+
flags=EventFlags(
|
|
85
|
+
piped=bool(command) and is_piped(command),
|
|
86
|
+
error=bool(entry.get("is_error")) or bool(exit_code),
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
b.store_output(event, as_text(entry.get("output") or entry.get("content")))
|
|
90
|
+
elif etype in ("assistant_message", "message", "text"):
|
|
91
|
+
event = b.add(kind=EventKind.TEXT, ts=ts)
|
|
92
|
+
b.store_output(event, as_text(entry.get("content") or entry.get("message")))
|
|
93
|
+
return ts
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse(path: str) -> tuple[Session, list[LedgerEvent], str | None]:
|
|
97
|
+
with open(path, encoding="utf-8") as fh:
|
|
98
|
+
bundle = json.load(fh)
|
|
99
|
+
if not isinstance(bundle, dict):
|
|
100
|
+
raise ValueError(f"{path}: expected a Copilot bundle object")
|
|
101
|
+
session = as_dict(bundle.get("session"))
|
|
102
|
+
pr = as_dict(bundle.get("pull_request"))
|
|
103
|
+
git = as_dict(bundle.get("git")) or {"commits": as_list(bundle.get("commits"))}
|
|
104
|
+
entries = as_list(bundle.get("log"))
|
|
105
|
+
|
|
106
|
+
session_id = str(session.get("id") or f"copilot-pr-{pr.get('number', 'unknown')}")
|
|
107
|
+
cwd = str(session.get("workspace") or git.get("root") or "") or None
|
|
108
|
+
started = as_ts(
|
|
109
|
+
session.get("started_at") or pr.get("created_at"), datetime.fromtimestamp(0, tz=UTC)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
b = Builder(session_id, cwd)
|
|
113
|
+
b.add(
|
|
114
|
+
kind=EventKind.META,
|
|
115
|
+
ts=started,
|
|
116
|
+
input=redact(
|
|
117
|
+
{
|
|
118
|
+
"event": "session_meta",
|
|
119
|
+
"class": "R",
|
|
120
|
+
"pull_request": str(pr.get("html_url") or pr.get("url") or ""),
|
|
121
|
+
"log_present": bool(entries),
|
|
122
|
+
}
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
if not entries:
|
|
126
|
+
no_tool_log(b, started, "Copilot session log not attached; evidence is git + CI only")
|
|
127
|
+
ts = _log(b, entries, started)
|
|
128
|
+
ts = commits(b, git, ts)
|
|
129
|
+
ts = checks(b, as_list(bundle.get("checks")), ts)
|
|
130
|
+
|
|
131
|
+
events = chain(b.events)
|
|
132
|
+
body = as_text(pr.get("body"))
|
|
133
|
+
report = f"## Pull request {pr.get('html_url') or ''}\n{body}".strip() if body else None
|
|
134
|
+
meta = Session(
|
|
135
|
+
id=session_id,
|
|
136
|
+
source="copilot",
|
|
137
|
+
agent=str(session.get("agent") or "copilot-coding-agent"),
|
|
138
|
+
model=str(session.get("model")) if session.get("model") else None,
|
|
139
|
+
started=started,
|
|
140
|
+
ended=as_ts(session.get("completed_at"), ts),
|
|
141
|
+
cwd=cwd,
|
|
142
|
+
git_branch=str(git.get("branch") or pr.get("head") or "") or None,
|
|
143
|
+
n_events=len(events),
|
|
144
|
+
ledger_root_hash=events[-1].hash if events else "",
|
|
145
|
+
# the bundle is assembled by the caller from the GitHub API, not signed by a harness:
|
|
146
|
+
# class R cannot reach 1.0 until the log's provenance is checkable (NEEDS-DECISION: A3)
|
|
147
|
+
integrity_score=0.8 if entries else 0.5,
|
|
148
|
+
)
|
|
149
|
+
return meta, events, report
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def find_last_session(sessions_dir: str | None = None) -> str:
|
|
153
|
+
root = sessions_dir or os.path.expanduser("~/.custos-code/copilot")
|
|
154
|
+
bundles = [os.path.join(root, f) for f in os.listdir(root)] if os.path.isdir(root) else []
|
|
155
|
+
files = [p for p in bundles if p.endswith(".json") and os.path.isfile(p)]
|
|
156
|
+
if not files:
|
|
157
|
+
raise FileNotFoundError(f"no Copilot bundles under {root}")
|
|
158
|
+
return max(files, key=os.path.getmtime)
|