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.
Files changed (54) hide show
  1. agentprof/__init__.py +7 -0
  2. agentprof/adapters/__init__.py +15 -0
  3. agentprof/adapters/base.py +79 -0
  4. agentprof/adapters/claude_code/__init__.py +3 -0
  5. agentprof/adapters/claude_code/adapter.py +133 -0
  6. agentprof/adapters/claude_code/discovery.py +79 -0
  7. agentprof/adapters/claude_code/prompts.py +44 -0
  8. agentprof/adapters/claude_code/tools.py +102 -0
  9. agentprof/adapters/claude_code/transcript.py +264 -0
  10. agentprof/adapters/claude_code/tree.py +492 -0
  11. agentprof/adapters/codex/__init__.py +3 -0
  12. agentprof/adapters/codex/adapter.py +164 -0
  13. agentprof/adapters/codex/discovery.py +117 -0
  14. agentprof/adapters/codex/prompts.py +16 -0
  15. agentprof/adapters/codex/rollout.py +328 -0
  16. agentprof/adapters/codex/tools.py +97 -0
  17. agentprof/adapters/codex/tree.py +311 -0
  18. agentprof/adapters/copilot_vscode/__init__.py +3 -0
  19. agentprof/adapters/copilot_vscode/adapter.py +166 -0
  20. agentprof/adapters/copilot_vscode/debuglog.py +136 -0
  21. agentprof/adapters/copilot_vscode/discovery.py +153 -0
  22. agentprof/adapters/copilot_vscode/session.py +281 -0
  23. agentprof/adapters/copilot_vscode/tools.py +127 -0
  24. agentprof/adapters/copilot_vscode/transcript.py +176 -0
  25. agentprof/adapters/copilot_vscode/tree.py +325 -0
  26. agentprof/adapters/execution.py +64 -0
  27. agentprof/adapters/timestamps.py +13 -0
  28. agentprof/adapters/turns.py +24 -0
  29. agentprof/analysis/__init__.py +3 -0
  30. agentprof/analysis/agent_summary.py +161 -0
  31. agentprof/analysis/call_context.py +101 -0
  32. agentprof/analysis/evidence_findings.py +151 -0
  33. agentprof/analysis/execution.py +39 -0
  34. agentprof/analysis/heuristics.py +306 -0
  35. agentprof/analysis/pipeline.py +31 -0
  36. agentprof/analysis/rollup.py +196 -0
  37. agentprof/cli.py +141 -0
  38. agentprof/model.py +341 -0
  39. agentprof/pricing.json +23 -0
  40. agentprof/pricing.py +120 -0
  41. agentprof/registry.py +304 -0
  42. agentprof/server/__init__.py +3 -0
  43. agentprof/server/app.py +399 -0
  44. agentprof/server/openapi.py +31 -0
  45. agentprof/server/pricing_schema.py +40 -0
  46. agentprof/server/schemas.py +579 -0
  47. agentprof/server/static/assets/index-C4HEqUVf.css +1 -0
  48. agentprof/server/static/assets/index-ZsPn1H3d.js +58 -0
  49. agentprof/server/static/index.html +14 -0
  50. agentprof-0.1.0.dist-info/METADATA +177 -0
  51. agentprof-0.1.0.dist-info/RECORD +54 -0
  52. agentprof-0.1.0.dist-info/WHEEL +4 -0
  53. agentprof-0.1.0.dist-info/entry_points.txt +8 -0
  54. 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
+ )