agentprof 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.
- agentprof/__init__.py +7 -0
- agentprof/adapters/__init__.py +15 -0
- agentprof/adapters/base.py +79 -0
- agentprof/adapters/claude_code/__init__.py +3 -0
- agentprof/adapters/claude_code/adapter.py +133 -0
- agentprof/adapters/claude_code/discovery.py +79 -0
- agentprof/adapters/claude_code/prompts.py +44 -0
- agentprof/adapters/claude_code/tools.py +102 -0
- agentprof/adapters/claude_code/transcript.py +264 -0
- agentprof/adapters/claude_code/tree.py +492 -0
- agentprof/adapters/codex/__init__.py +3 -0
- agentprof/adapters/codex/adapter.py +164 -0
- agentprof/adapters/codex/discovery.py +117 -0
- agentprof/adapters/codex/prompts.py +16 -0
- agentprof/adapters/codex/rollout.py +328 -0
- agentprof/adapters/codex/tools.py +97 -0
- agentprof/adapters/codex/tree.py +311 -0
- agentprof/adapters/copilot_vscode/__init__.py +3 -0
- agentprof/adapters/copilot_vscode/adapter.py +166 -0
- agentprof/adapters/copilot_vscode/debuglog.py +136 -0
- agentprof/adapters/copilot_vscode/discovery.py +153 -0
- agentprof/adapters/copilot_vscode/session.py +281 -0
- agentprof/adapters/copilot_vscode/tools.py +127 -0
- agentprof/adapters/copilot_vscode/transcript.py +176 -0
- agentprof/adapters/copilot_vscode/tree.py +325 -0
- agentprof/adapters/execution.py +64 -0
- agentprof/adapters/timestamps.py +13 -0
- agentprof/adapters/turns.py +24 -0
- agentprof/analysis/__init__.py +3 -0
- agentprof/analysis/agent_summary.py +161 -0
- agentprof/analysis/call_context.py +101 -0
- agentprof/analysis/evidence_findings.py +151 -0
- agentprof/analysis/execution.py +39 -0
- agentprof/analysis/heuristics.py +306 -0
- agentprof/analysis/pipeline.py +31 -0
- agentprof/analysis/rollup.py +196 -0
- agentprof/cli.py +141 -0
- agentprof/model.py +341 -0
- agentprof/pricing.json +23 -0
- agentprof/pricing.py +120 -0
- agentprof/registry.py +304 -0
- agentprof/server/__init__.py +3 -0
- agentprof/server/app.py +399 -0
- agentprof/server/openapi.py +31 -0
- agentprof/server/pricing_schema.py +40 -0
- agentprof/server/schemas.py +579 -0
- agentprof/server/static/assets/index-C4HEqUVf.css +1 -0
- agentprof/server/static/assets/index-ZsPn1H3d.js +58 -0
- agentprof/server/static/index.html +14 -0
- agentprof-0.1.0.dist-info/METADATA +177 -0
- agentprof-0.1.0.dist-info/RECORD +54 -0
- agentprof-0.1.0.dist-info/WHEEL +4 -0
- agentprof-0.1.0.dist-info/entry_points.txt +8 -0
- agentprof-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 epicodic
|
|
3
|
+
"""Locate and group native Codex rollout files."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from agentprof.adapters.codex.rollout import ThreadSpawn
|
|
10
|
+
|
|
11
|
+
_SNIFF_LINES = 20
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class RolloutFile:
|
|
16
|
+
"""Discovery metadata for one recognized rollout file."""
|
|
17
|
+
|
|
18
|
+
path: Path
|
|
19
|
+
session_id: str
|
|
20
|
+
thread_id: str | None
|
|
21
|
+
parent_thread_id: str | None
|
|
22
|
+
agent_nickname: str | None
|
|
23
|
+
thread_spawn: ThreadSpawn | None
|
|
24
|
+
cwd: str | None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class SessionFiles:
|
|
29
|
+
"""All rollout files and metadata belonging to one root Codex session."""
|
|
30
|
+
|
|
31
|
+
session_id: str
|
|
32
|
+
main: Path | None
|
|
33
|
+
files: list[Path]
|
|
34
|
+
rollouts: list[RolloutFile]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def default_sessions_root(home: Path) -> Path:
|
|
38
|
+
"""The default local Codex sessions directory."""
|
|
39
|
+
return home / ".codex" / "sessions"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read_metadata(path: Path) -> RolloutFile | None:
|
|
43
|
+
"""Read only through the first valid `session_meta`, without parsing rollout activity."""
|
|
44
|
+
try:
|
|
45
|
+
with path.open("rb") as lines:
|
|
46
|
+
for _, raw_line in zip(range(_SNIFF_LINES), lines, strict=False):
|
|
47
|
+
try:
|
|
48
|
+
line = raw_line.decode("utf-8")
|
|
49
|
+
except UnicodeDecodeError:
|
|
50
|
+
continue
|
|
51
|
+
metadata = _metadata_from_line(path, line)
|
|
52
|
+
if metadata is not None:
|
|
53
|
+
return metadata
|
|
54
|
+
except OSError:
|
|
55
|
+
return None
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _metadata_from_line(path: Path, line: str) -> RolloutFile | None:
|
|
60
|
+
try:
|
|
61
|
+
entry = json.loads(line)
|
|
62
|
+
except json.JSONDecodeError:
|
|
63
|
+
return None
|
|
64
|
+
if not isinstance(entry, dict) or entry.get("type") != "session_meta":
|
|
65
|
+
return None
|
|
66
|
+
payload = entry.get("payload")
|
|
67
|
+
if not isinstance(payload, dict):
|
|
68
|
+
return None
|
|
69
|
+
session_id = _text(payload.get("session_id"))
|
|
70
|
+
if session_id is None:
|
|
71
|
+
return None
|
|
72
|
+
source = payload.get("source")
|
|
73
|
+
subagent = source.get("subagent") if isinstance(source, dict) else None
|
|
74
|
+
spawn = _thread_spawn(subagent.get("thread_spawn")) if isinstance(subagent, dict) else None
|
|
75
|
+
return RolloutFile(
|
|
76
|
+
path=path,
|
|
77
|
+
session_id=session_id,
|
|
78
|
+
thread_id=_text(payload.get("id")),
|
|
79
|
+
parent_thread_id=_text(payload.get("parent_thread_id")),
|
|
80
|
+
agent_nickname=_text(payload.get("agent_nickname")),
|
|
81
|
+
thread_spawn=spawn,
|
|
82
|
+
cwd=_text(payload.get("cwd")),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _text(value: object) -> str | None:
|
|
87
|
+
return value if isinstance(value, str) and value else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _thread_spawn(value: object) -> ThreadSpawn | None:
|
|
91
|
+
if not isinstance(value, dict):
|
|
92
|
+
return None
|
|
93
|
+
depth = value.get("depth")
|
|
94
|
+
return ThreadSpawn(
|
|
95
|
+
parent_thread_id=_text(value.get("parent_thread_id")),
|
|
96
|
+
depth=depth if isinstance(depth, int) and not isinstance(depth, bool) else None,
|
|
97
|
+
agent_path=_text(value.get("agent_path")),
|
|
98
|
+
agent_nickname=_text(value.get("agent_nickname")),
|
|
99
|
+
agent_role=_text(value.get("agent_role")),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def session_files(root: Path) -> list[SessionFiles]:
|
|
104
|
+
"""Find nested recognized rollout JSONLs, grouped by root session ID."""
|
|
105
|
+
if not root.is_dir():
|
|
106
|
+
return []
|
|
107
|
+
grouped: dict[str, list[RolloutFile]] = {}
|
|
108
|
+
for path in sorted(root.glob("**/*.jsonl")):
|
|
109
|
+
metadata = read_metadata(path)
|
|
110
|
+
if metadata is not None:
|
|
111
|
+
grouped.setdefault(metadata.session_id, []).append(metadata)
|
|
112
|
+
return [_session_files(session_id, rollouts) for session_id, rollouts in sorted(grouped.items())]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _session_files(session_id: str, rollouts: list[RolloutFile]) -> SessionFiles:
|
|
116
|
+
main = next((rollout.path for rollout in rollouts if rollout.parent_thread_id is None), None)
|
|
117
|
+
return SessionFiles(session_id, main, [rollout.path for rollout in rollouts], rollouts)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 epicodic
|
|
3
|
+
"""Derive compact topics from Codex user prompts."""
|
|
4
|
+
|
|
5
|
+
NO_PROMPT = "(no prompt)"
|
|
6
|
+
_TOPIC_LENGTH = 200
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def first_line(text: str, limit: int = _TOPIC_LENGTH) -> str:
|
|
10
|
+
"""Return the first non-empty prompt line, stripped and bounded."""
|
|
11
|
+
return next((line.strip()[:limit] for line in text.splitlines() if line.strip()), "")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def prompt_topic(prompt: str | None) -> str:
|
|
15
|
+
"""Return a human-readable topic for a Codex turn prompt."""
|
|
16
|
+
return first_line(prompt) if prompt else NO_PROMPT
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 epicodic
|
|
3
|
+
"""Parse native Codex rollout JSONL files."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from agentprof.adapters.timestamps import parse_iso_ms
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class UsageRecord:
|
|
15
|
+
"""Token usage in Agentprof's disjoint token categories."""
|
|
16
|
+
|
|
17
|
+
input: int
|
|
18
|
+
cache_read: int
|
|
19
|
+
cache_write: int
|
|
20
|
+
output: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class TokenUsage:
|
|
25
|
+
"""One timestamped native token-usage record."""
|
|
26
|
+
|
|
27
|
+
usage: UsageRecord
|
|
28
|
+
timestamp_ms: int | None
|
|
29
|
+
response_id: str | None
|
|
30
|
+
source_order: int | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class UserInput:
|
|
35
|
+
"""One native user message with its source identity and position."""
|
|
36
|
+
|
|
37
|
+
message_id: str | None
|
|
38
|
+
timestamp_ms: int | None
|
|
39
|
+
source_order: int | None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ToolResultRecord:
|
|
44
|
+
"""One native output record that did not match an earlier invocation."""
|
|
45
|
+
|
|
46
|
+
call_id: str | None
|
|
47
|
+
timestamp_ms: int | None
|
|
48
|
+
source_order: int | None
|
|
49
|
+
status: str | None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ThreadSpawn:
|
|
54
|
+
"""The subagent linkage stored in session metadata when available."""
|
|
55
|
+
|
|
56
|
+
parent_thread_id: str | None
|
|
57
|
+
depth: int | None
|
|
58
|
+
agent_path: str | None
|
|
59
|
+
agent_nickname: str | None
|
|
60
|
+
agent_role: str | None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class ToolCall:
|
|
65
|
+
"""One native function or custom-tool call and its optional output."""
|
|
66
|
+
|
|
67
|
+
call_id: str | None
|
|
68
|
+
name: str
|
|
69
|
+
input: str | None
|
|
70
|
+
start_ms: int | None
|
|
71
|
+
output: str | None = None
|
|
72
|
+
end_ms: int | None = None
|
|
73
|
+
status: str | None = None
|
|
74
|
+
output_status: str | None = None
|
|
75
|
+
start_order: int | None = None
|
|
76
|
+
result_order: int | None = None
|
|
77
|
+
result_recorded: bool = False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class Turn:
|
|
82
|
+
"""One native Codex task turn."""
|
|
83
|
+
|
|
84
|
+
turn_id: str
|
|
85
|
+
start_ms: int | None = None
|
|
86
|
+
end_ms: int | None = None
|
|
87
|
+
model: str | None = None
|
|
88
|
+
prompt: str | None = None
|
|
89
|
+
tools: list[ToolCall] = field(default_factory=list)
|
|
90
|
+
usage: UsageRecord | None = None
|
|
91
|
+
usages: list[UsageRecord] = field(default_factory=list)
|
|
92
|
+
token_usages: list[TokenUsage] = field(default_factory=list)
|
|
93
|
+
user_inputs: list[UserInput] = field(default_factory=list)
|
|
94
|
+
completion_recorded: bool = False
|
|
95
|
+
completion_order: int | None = None
|
|
96
|
+
unmatched_tool_results: list[ToolResultRecord] = field(default_factory=list)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Rollout:
|
|
101
|
+
"""The native records required for Codex session analysis."""
|
|
102
|
+
|
|
103
|
+
session_id: str | None = None
|
|
104
|
+
thread_id: str | None = None
|
|
105
|
+
parent_thread_id: str | None = None
|
|
106
|
+
agent_nickname: str | None = None
|
|
107
|
+
thread_spawn: ThreadSpawn | None = None
|
|
108
|
+
cwd: str | None = None
|
|
109
|
+
model: str | None = None
|
|
110
|
+
result: str | None = None
|
|
111
|
+
turns: list[Turn] = field(default_factory=list)
|
|
112
|
+
malformed_lines: int = 0
|
|
113
|
+
last_message_ms: int | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_USAGE_FIELDS = (
|
|
117
|
+
"input_tokens",
|
|
118
|
+
"cached_input_tokens",
|
|
119
|
+
"cache_write_input_tokens",
|
|
120
|
+
"output_tokens",
|
|
121
|
+
"reasoning_output_tokens",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _text(value: object) -> str | None:
|
|
126
|
+
return value if isinstance(value, str) and value else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _timestamp(value: object) -> int | None:
|
|
130
|
+
if not isinstance(value, str):
|
|
131
|
+
return None
|
|
132
|
+
try:
|
|
133
|
+
return parse_iso_ms(value)
|
|
134
|
+
except ValueError:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _content_text(content: object) -> str | None:
|
|
139
|
+
if isinstance(content, str):
|
|
140
|
+
return content or None
|
|
141
|
+
if not isinstance(content, list):
|
|
142
|
+
return None
|
|
143
|
+
parts = [block["text"] for block in content if isinstance(block, dict) and isinstance(block.get("text"), str)]
|
|
144
|
+
return "\n".join(parts) or None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _thread_spawn(value: object) -> ThreadSpawn | None:
|
|
148
|
+
if not isinstance(value, dict):
|
|
149
|
+
return None
|
|
150
|
+
depth = value.get("depth")
|
|
151
|
+
return ThreadSpawn(
|
|
152
|
+
parent_thread_id=_text(value.get("parent_thread_id")),
|
|
153
|
+
depth=depth if isinstance(depth, int) and not isinstance(depth, bool) else None,
|
|
154
|
+
agent_path=_text(value.get("agent_path")),
|
|
155
|
+
agent_nickname=_text(value.get("agent_nickname")),
|
|
156
|
+
agent_role=_text(value.get("agent_role")),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _usage(value: object) -> UsageRecord | None:
|
|
161
|
+
if not isinstance(value, dict) or not any(name in value for name in _USAGE_FIELDS):
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
def count(name: str) -> int:
|
|
165
|
+
item = value.get(name)
|
|
166
|
+
return item if isinstance(item, int) and not isinstance(item, bool) else 0
|
|
167
|
+
|
|
168
|
+
cache_read = count("cached_input_tokens")
|
|
169
|
+
cache_write = count("cache_write_input_tokens")
|
|
170
|
+
return UsageRecord(
|
|
171
|
+
input=max(count("input_tokens") - cache_read - cache_write, 0),
|
|
172
|
+
cache_read=cache_read,
|
|
173
|
+
cache_write=cache_write,
|
|
174
|
+
output=count("output_tokens"),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class _Parser:
|
|
179
|
+
def __init__(self) -> None:
|
|
180
|
+
self.rollout = Rollout()
|
|
181
|
+
self._turns: dict[str, Turn] = {}
|
|
182
|
+
self._tools: dict[str, ToolCall] = {}
|
|
183
|
+
self._active_turn: Turn | None = None
|
|
184
|
+
self._source_order = 0
|
|
185
|
+
|
|
186
|
+
def feed(self, entry: dict[str, Any]) -> None:
|
|
187
|
+
source_order = entry.get("ordinal")
|
|
188
|
+
if not isinstance(source_order, int) or isinstance(source_order, bool):
|
|
189
|
+
source_order = self._source_order
|
|
190
|
+
self._source_order += 1
|
|
191
|
+
entry_type = entry.get("type")
|
|
192
|
+
payload = entry.get("payload")
|
|
193
|
+
if not isinstance(entry_type, str) or not isinstance(payload, dict):
|
|
194
|
+
return
|
|
195
|
+
if entry_type == "session_meta":
|
|
196
|
+
self._session_meta(payload)
|
|
197
|
+
elif entry_type == "turn_context":
|
|
198
|
+
self._turn_context(payload)
|
|
199
|
+
elif entry_type == "event_msg":
|
|
200
|
+
self._event(payload, _timestamp(entry.get("timestamp")), source_order)
|
|
201
|
+
elif entry_type == "response_item":
|
|
202
|
+
timestamp_ms = _timestamp(entry.get("timestamp"))
|
|
203
|
+
if timestamp_ms is not None and (
|
|
204
|
+
payload.get("type") == "agent_message"
|
|
205
|
+
or (payload.get("type") == "message" and payload.get("role") in ("user", "assistant"))
|
|
206
|
+
):
|
|
207
|
+
self.rollout.last_message_ms = max(self.rollout.last_message_ms or timestamp_ms, timestamp_ms)
|
|
208
|
+
self._response_item(payload, timestamp_ms, source_order)
|
|
209
|
+
elif entry_type == "token_usage_record":
|
|
210
|
+
self._token_usage(payload, _timestamp(entry.get("timestamp")), source_order)
|
|
211
|
+
|
|
212
|
+
def _session_meta(self, payload: dict[str, Any]) -> None:
|
|
213
|
+
self.rollout.session_id = _text(payload.get("session_id")) or self.rollout.session_id
|
|
214
|
+
self.rollout.thread_id = _text(payload.get("id")) or self.rollout.thread_id
|
|
215
|
+
self.rollout.parent_thread_id = _text(payload.get("parent_thread_id")) or self.rollout.parent_thread_id
|
|
216
|
+
self.rollout.agent_nickname = _text(payload.get("agent_nickname")) or self.rollout.agent_nickname
|
|
217
|
+
source = payload.get("source")
|
|
218
|
+
subagent = source.get("subagent") if isinstance(source, dict) else None
|
|
219
|
+
spawn = _thread_spawn(subagent.get("thread_spawn")) if isinstance(subagent, dict) else None
|
|
220
|
+
self.rollout.thread_spawn = spawn or self.rollout.thread_spawn
|
|
221
|
+
self.rollout.cwd = _text(payload.get("cwd")) or self.rollout.cwd
|
|
222
|
+
self.rollout.model = _text(payload.get("model")) or self.rollout.model
|
|
223
|
+
|
|
224
|
+
def _turn_context(self, payload: dict[str, Any]) -> None:
|
|
225
|
+
turn_id = _text(payload.get("turn_id"))
|
|
226
|
+
if turn_id is not None:
|
|
227
|
+
turn = self._turn(turn_id)
|
|
228
|
+
turn.model = _text(payload.get("model")) or turn.model
|
|
229
|
+
|
|
230
|
+
def _event(self, payload: dict[str, Any], timestamp_ms: int | None, source_order: int) -> None:
|
|
231
|
+
event_type = payload.get("type")
|
|
232
|
+
turn_id = _text(payload.get("turn_id"))
|
|
233
|
+
if event_type == "task_started" and turn_id is not None:
|
|
234
|
+
turn = self._turn(turn_id)
|
|
235
|
+
turn.start_ms = _timestamp(payload.get("started_at")) or timestamp_ms or turn.start_ms
|
|
236
|
+
self._active_turn = turn
|
|
237
|
+
elif event_type == "task_complete" and turn_id is not None:
|
|
238
|
+
turn = self._turn(turn_id)
|
|
239
|
+
turn.end_ms = _timestamp(payload.get("completed_at")) or timestamp_ms or turn.end_ms
|
|
240
|
+
turn.completion_recorded = True
|
|
241
|
+
turn.completion_order = source_order
|
|
242
|
+
if self._active_turn is turn:
|
|
243
|
+
self._active_turn = None
|
|
244
|
+
|
|
245
|
+
def _response_item(self, payload: dict[str, Any], timestamp_ms: int | None, source_order: int) -> None:
|
|
246
|
+
turn = self._active_turn
|
|
247
|
+
if turn is None:
|
|
248
|
+
return
|
|
249
|
+
item_type = payload.get("type")
|
|
250
|
+
if item_type == "message" and payload.get("role") == "user":
|
|
251
|
+
turn.user_inputs.append(UserInput(_text(payload.get("id")), timestamp_ms, source_order))
|
|
252
|
+
turn.prompt = _content_text(payload.get("content")) or turn.prompt
|
|
253
|
+
return
|
|
254
|
+
if item_type == "agent_message":
|
|
255
|
+
self.rollout.result = _content_text(payload.get("content")) or self.rollout.result
|
|
256
|
+
return
|
|
257
|
+
if item_type in ("function_call", "custom_tool_call"):
|
|
258
|
+
name = _text(payload.get("name"))
|
|
259
|
+
if name is None:
|
|
260
|
+
return
|
|
261
|
+
call_id = _text(payload.get("call_id"))
|
|
262
|
+
raw_input = payload.get("arguments") if item_type == "function_call" else payload.get("input")
|
|
263
|
+
tool = ToolCall(
|
|
264
|
+
call_id,
|
|
265
|
+
name,
|
|
266
|
+
_text(raw_input),
|
|
267
|
+
timestamp_ms,
|
|
268
|
+
status=_text(payload.get("status")),
|
|
269
|
+
start_order=source_order,
|
|
270
|
+
)
|
|
271
|
+
turn.tools.append(tool)
|
|
272
|
+
if call_id is not None:
|
|
273
|
+
self._tools[call_id] = tool
|
|
274
|
+
return
|
|
275
|
+
if item_type in ("function_call_output", "custom_tool_call_output"):
|
|
276
|
+
call_id = _text(payload.get("call_id"))
|
|
277
|
+
tool = self._tools.get(call_id) if call_id is not None else None
|
|
278
|
+
if tool is not None:
|
|
279
|
+
output = payload.get("output")
|
|
280
|
+
tool.output = output if isinstance(output, str) else None
|
|
281
|
+
tool.end_ms = timestamp_ms
|
|
282
|
+
tool.output_status = _text(payload.get("status"))
|
|
283
|
+
tool.result_order = source_order
|
|
284
|
+
tool.result_recorded = True
|
|
285
|
+
else:
|
|
286
|
+
turn.unmatched_tool_results.append(
|
|
287
|
+
ToolResultRecord(call_id, timestamp_ms, source_order, _text(payload.get("status")))
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def _token_usage(self, payload: dict[str, Any], timestamp_ms: int | None, source_order: int) -> None:
|
|
291
|
+
usage = _usage(payload.get("usage"))
|
|
292
|
+
if usage is None:
|
|
293
|
+
return
|
|
294
|
+
turn_id = _text(payload.get("turn_id"))
|
|
295
|
+
turn = self._turns.get(turn_id) if turn_id is not None else self._active_turn
|
|
296
|
+
if turn is not None:
|
|
297
|
+
turn.usage = usage
|
|
298
|
+
turn.usages.append(usage)
|
|
299
|
+
turn.token_usages.append(TokenUsage(usage, timestamp_ms, _text(payload.get("response_id")), source_order))
|
|
300
|
+
|
|
301
|
+
def _turn(self, turn_id: str) -> Turn:
|
|
302
|
+
turn = self._turns.get(turn_id)
|
|
303
|
+
if turn is None:
|
|
304
|
+
turn = Turn(turn_id=turn_id, model=self.rollout.model)
|
|
305
|
+
self._turns[turn_id] = turn
|
|
306
|
+
self.rollout.turns.append(turn)
|
|
307
|
+
return turn
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def load_rollout(path: Path) -> Rollout:
|
|
311
|
+
"""Load one rollout JSONL file, skipping and counting malformed lines."""
|
|
312
|
+
parser = _Parser()
|
|
313
|
+
try:
|
|
314
|
+
lines = path.open("rb")
|
|
315
|
+
except OSError:
|
|
316
|
+
return parser.rollout
|
|
317
|
+
with lines:
|
|
318
|
+
for raw_line in lines:
|
|
319
|
+
if not raw_line.strip():
|
|
320
|
+
continue
|
|
321
|
+
try:
|
|
322
|
+
entry = json.loads(raw_line.decode("utf-8"))
|
|
323
|
+
if not isinstance(entry, dict):
|
|
324
|
+
raise TypeError("rollout line is not a JSON object")
|
|
325
|
+
parser.feed(entry)
|
|
326
|
+
except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError):
|
|
327
|
+
parser.rollout.malformed_lines += 1
|
|
328
|
+
return parser.rollout
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 epicodic
|
|
3
|
+
"""Map Codex tool names to neutral tool categories and normalised arguments."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from agentprof.model import ToolCategory, ToolInfo
|
|
8
|
+
|
|
9
|
+
_CATEGORIES: dict[str, ToolCategory] = {
|
|
10
|
+
"exec_command": ToolCategory.SHELL,
|
|
11
|
+
"exec": ToolCategory.SHELL,
|
|
12
|
+
"write_stdin": ToolCategory.SHELL_POLL,
|
|
13
|
+
"apply_patch": ToolCategory.EDIT,
|
|
14
|
+
"read_file": ToolCategory.READ,
|
|
15
|
+
"web.run": ToolCategory.WEB,
|
|
16
|
+
"spawn_agent": ToolCategory.SUBAGENT,
|
|
17
|
+
"collaboration.spawn_agent": ToolCategory.SUBAGENT,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
# Built-in harness tools without a dedicated category; they map to `other` but are not reported as unknown.
|
|
21
|
+
_KNOWN_OTHER_TOOL_IDS = {
|
|
22
|
+
"collaboration.followup_task",
|
|
23
|
+
"collaboration.send_message",
|
|
24
|
+
"collaboration.wait_agent",
|
|
25
|
+
"collaboration.interrupt_agent",
|
|
26
|
+
"collaboration.list_agents",
|
|
27
|
+
"create_goal",
|
|
28
|
+
"get_goal",
|
|
29
|
+
"update_goal",
|
|
30
|
+
"wait",
|
|
31
|
+
"sleep",
|
|
32
|
+
"wait_agent",
|
|
33
|
+
"image_gen",
|
|
34
|
+
"imagegen",
|
|
35
|
+
"image_gen.imagegen",
|
|
36
|
+
"image_gen__imagegen",
|
|
37
|
+
"request_plugin_install",
|
|
38
|
+
"tool_search",
|
|
39
|
+
"list_mcp_resources",
|
|
40
|
+
"list_mcp_resource_templates",
|
|
41
|
+
"read_mcp_resource",
|
|
42
|
+
}
|
|
43
|
+
_MCP_PREFIX = "mcp__"
|
|
44
|
+
_FILE_CATEGORIES = (ToolCategory.READ, ToolCategory.EDIT)
|
|
45
|
+
_PATCH_PATH = re.compile(r"^\*\*\* (?:Update|Add|Delete) File: (.+)$", re.MULTILINE)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _base_name(name: str) -> str:
|
|
49
|
+
"""Return the native name without Codex's custom-tool function namespace."""
|
|
50
|
+
return name.removeprefix("functions.")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_known_tool_id(name: str) -> bool:
|
|
54
|
+
"""Whether `name` is expected; unknown names are counted in session diagnostics."""
|
|
55
|
+
base_name = _base_name(name)
|
|
56
|
+
return base_name in _CATEGORIES or base_name in _KNOWN_OTHER_TOOL_IDS or base_name.startswith(_MCP_PREFIX)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _paths(arguments: dict[str, object]) -> tuple[str, ...]:
|
|
60
|
+
"""Return explicit file paths from native file arguments or an apply-patch payload."""
|
|
61
|
+
paths: list[str] = []
|
|
62
|
+
for key in ("path", "file_path", "filePath"):
|
|
63
|
+
path = arguments.get(key)
|
|
64
|
+
if isinstance(path, str) and path:
|
|
65
|
+
paths.append(path)
|
|
66
|
+
break
|
|
67
|
+
argument_paths = arguments.get("paths")
|
|
68
|
+
if isinstance(argument_paths, list):
|
|
69
|
+
paths.extend(path for path in argument_paths if isinstance(path, str) and path)
|
|
70
|
+
patch = arguments.get("patch")
|
|
71
|
+
if isinstance(patch, str):
|
|
72
|
+
paths.extend(
|
|
73
|
+
match.group(1).strip()
|
|
74
|
+
for match in _PATCH_PATH.finditer(patch.replace("\\n", "\n"))
|
|
75
|
+
if match.group(1).strip()
|
|
76
|
+
)
|
|
77
|
+
return tuple(dict.fromkeys(paths))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def tool_info(name: str, arguments: dict[str, object]) -> ToolInfo:
|
|
81
|
+
"""Build the neutral `ToolInfo` for one Codex tool call."""
|
|
82
|
+
base_name = _base_name(name)
|
|
83
|
+
category = (
|
|
84
|
+
ToolCategory.EDIT
|
|
85
|
+
if base_name == "exec" and "patch" in arguments
|
|
86
|
+
else _CATEGORIES.get(base_name, ToolCategory.OTHER)
|
|
87
|
+
)
|
|
88
|
+
paths = _paths(arguments) if category in _FILE_CATEGORIES else ()
|
|
89
|
+
command = arguments.get("cmd") or arguments.get("command")
|
|
90
|
+
return ToolInfo(
|
|
91
|
+
native_id=name,
|
|
92
|
+
category=category,
|
|
93
|
+
path=paths[0] if paths else None,
|
|
94
|
+
command=str(command) if isinstance(command, str) and command else None,
|
|
95
|
+
arguments=arguments,
|
|
96
|
+
paths=paths,
|
|
97
|
+
)
|