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,176 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """Parse `GitHub.copilot-chat/transcripts/<sid>.jsonl`: tool timings and LLM call attribution."""
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.copilot_vscode.tools import is_subagent_tool_id
11
+ from agentprof.adapters.timestamps import parse_iso_ms
12
+
13
+
14
+ @dataclass
15
+ class ToolTiming:
16
+ """Start/end time (epoch ms) and outcome of one tool call."""
17
+
18
+ start_ms: int | None = None
19
+ end_ms: int | None = None
20
+ success: bool | None = None
21
+ start_order: int | None = None
22
+ completion_order: int | None = None
23
+ completion_recorded: bool = False
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class UserMessage:
28
+ """One source-recorded Copilot user message."""
29
+
30
+ message_id: str | None
31
+ timestamp_ms: int
32
+ source_order: int
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ToolEventRecord:
37
+ """One native tool start or completion snapshot, including records without an invocation ID."""
38
+
39
+ kind: str
40
+ tool_call_id: str | None
41
+ timestamp_ms: int
42
+ source_order: int
43
+ owner_id: str | None
44
+ tool_name: str | None = None
45
+ success: bool | None = None
46
+
47
+
48
+ @dataclass
49
+ class Transcript:
50
+ """Per-`toolCallId` timing and arguments, and per-agent LLM call timestamps (key `None` is the main agent).
51
+
52
+ The arguments cover subagents' tool calls too, which the session file records without them.
53
+ """
54
+
55
+ tool_timings: dict[str, ToolTiming] = field(default_factory=dict)
56
+ tool_arguments: dict[str, dict[str, object]] = field(default_factory=dict)
57
+ llm_call_timestamps_ms: dict[str | None, list[int]] = field(default_factory=dict)
58
+ llm_call_source_orders: dict[str | None, list[int]] = field(default_factory=dict)
59
+ llm_call_source_request_ids: dict[str | None, list[str | None]] = field(default_factory=dict)
60
+ tool_request_ids: dict[str, str] = field(default_factory=dict)
61
+ user_messages: list[UserMessage] = field(default_factory=list)
62
+ tool_events: list[ToolEventRecord] = field(default_factory=list)
63
+ session_id: str | None = None
64
+ malformed_lines: int = 0
65
+
66
+
67
+ def _apply_event(transcript: Transcript, stack: list[str | None], event: dict[str, Any], source_order: int) -> None:
68
+ timestamp_ms = parse_iso_ms(event["timestamp"])
69
+ event_type = event["type"]
70
+
71
+ if event_type == "assistant.message":
72
+ transcript.llm_call_timestamps_ms.setdefault(stack[-1], []).append(timestamp_ms)
73
+ transcript.llm_call_source_orders.setdefault(stack[-1], []).append(source_order)
74
+ data = event["data"]
75
+ message_id = data.get("messageId") if isinstance(data, dict) else None
76
+ transcript.llm_call_source_request_ids.setdefault(stack[-1], []).append(
77
+ message_id if isinstance(message_id, str) else None
78
+ )
79
+ tool_requests = data.get("toolRequests", []) if isinstance(data, dict) else []
80
+ if isinstance(tool_requests, list) and isinstance(message_id, str):
81
+ for request in tool_requests:
82
+ if isinstance(request, dict):
83
+ tool_call_id = request.get("toolCallId")
84
+ if isinstance(tool_call_id, str) and tool_call_id:
85
+ transcript.tool_request_ids[tool_call_id] = message_id
86
+
87
+ elif event_type == "user.message":
88
+ transcript.user_messages.append(
89
+ UserMessage(
90
+ event.get("id") if isinstance(event.get("id"), str) else None,
91
+ timestamp_ms,
92
+ source_order,
93
+ )
94
+ )
95
+ if transcript.session_id is None:
96
+ session_id = event.get("sessionId")
97
+ if isinstance(session_id, str):
98
+ transcript.session_id = session_id
99
+
100
+ elif event_type == "session.start":
101
+ data = event.get("data")
102
+ session_id = data.get("sessionId") if isinstance(data, dict) else None
103
+ if isinstance(session_id, str):
104
+ transcript.session_id = session_id
105
+
106
+ elif event_type == "tool.execution_start":
107
+ data = event["data"]
108
+ tool_call_id = data.get("toolCallId")
109
+ transcript.tool_events.append(
110
+ ToolEventRecord(
111
+ kind=event_type,
112
+ tool_call_id=tool_call_id if isinstance(tool_call_id, str) and tool_call_id else None,
113
+ timestamp_ms=timestamp_ms,
114
+ source_order=source_order,
115
+ owner_id=stack[-1],
116
+ tool_name=data.get("toolName") if isinstance(data.get("toolName"), str) else None,
117
+ )
118
+ )
119
+ if not isinstance(tool_call_id, str) or not tool_call_id:
120
+ return
121
+ timing = transcript.tool_timings.setdefault(tool_call_id, ToolTiming())
122
+ timing.start_ms = timestamp_ms
123
+ timing.start_order = source_order
124
+ arguments = data.get("arguments")
125
+ if isinstance(arguments, dict):
126
+ transcript.tool_arguments[tool_call_id] = arguments
127
+ if is_subagent_tool_id(data["toolName"]):
128
+ stack.append(tool_call_id)
129
+
130
+ elif event_type == "tool.execution_complete":
131
+ data = event["data"]
132
+ tool_call_id = data.get("toolCallId")
133
+ success = data.get("success")
134
+ transcript.tool_events.append(
135
+ ToolEventRecord(
136
+ kind=event_type,
137
+ tool_call_id=tool_call_id if isinstance(tool_call_id, str) and tool_call_id else None,
138
+ timestamp_ms=timestamp_ms,
139
+ source_order=source_order,
140
+ owner_id=stack[-1],
141
+ success=success if isinstance(success, bool) else None,
142
+ )
143
+ )
144
+ if not isinstance(tool_call_id, str) or not tool_call_id:
145
+ return
146
+ timing = transcript.tool_timings.setdefault(tool_call_id, ToolTiming())
147
+ timing.end_ms = timestamp_ms
148
+ timing.success = data.get("success")
149
+ timing.completion_order = source_order
150
+ timing.completion_recorded = True
151
+ if stack[-1] == tool_call_id:
152
+ stack.pop()
153
+
154
+
155
+ def load_transcript(path: Path) -> Transcript:
156
+ """Parse a transcript file.
157
+
158
+ Args:
159
+ path: Path to the transcript file.
160
+
161
+ Returns:
162
+ Tool timings and LLM calls attributed to the agent whose subagent bracket was open at the time.
163
+ """
164
+ transcript = Transcript()
165
+ # The stack tracks which agent tool call's bracket is currently open; `None` is the main agent.
166
+ stack: list[str | None] = [None]
167
+
168
+ for source_order, line in enumerate(path.read_text().splitlines()):
169
+ if not line.strip():
170
+ continue
171
+ try:
172
+ _apply_event(transcript, stack, json.loads(line), source_order)
173
+ except (json.JSONDecodeError, KeyError, TypeError, ValueError):
174
+ transcript.malformed_lines += 1
175
+
176
+ return transcript
@@ -0,0 +1,325 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """Join a parsed Copilot session with its transcript and debug log into a neutral node tree."""
4
+
5
+ from collections import Counter
6
+
7
+ from agentprof.adapters.copilot_vscode.debuglog import DebugLlmCall, DebugLog
8
+ from agentprof.adapters.copilot_vscode.session import RawRequest, RawSession, RawToolCall
9
+ from agentprof.adapters.copilot_vscode.tools import tool_info
10
+ from agentprof.adapters.copilot_vscode.transcript import Transcript
11
+ from agentprof.adapters.execution import tool_execution_events
12
+ from agentprof.adapters.turns import split_by_turn
13
+ from agentprof.model import (
14
+ CostMetric,
15
+ EventCallLink,
16
+ ExecutionEvent,
17
+ LlmCall,
18
+ Metric,
19
+ Node,
20
+ NodeKind,
21
+ Provenance,
22
+ Tokens,
23
+ iter_nodes,
24
+ )
25
+
26
+ _CREDITS = "credits"
27
+ _NANO_AIU_PER_CREDIT = 1_000_000_000
28
+
29
+
30
+ def credits_cost(value: float | None, usd_per_credit: float) -> CostMetric:
31
+ """A cost in Copilot credits, convertible to USD at `usd_per_credit`; `n/a` if `value` is `None`."""
32
+ if value is None:
33
+ return CostMetric.not_available()
34
+ return CostMetric(value=value, unit=_CREDITS, provenance=Provenance.EXACT, usd_per_unit=usd_per_credit)
35
+
36
+
37
+ def _exact_or_not_available(value: int | None) -> Metric:
38
+ return Metric.not_available() if value is None else Metric.exact(value)
39
+
40
+
41
+ def _tokens(call: DebugLlmCall) -> Tokens:
42
+ if call.input_tokens is None:
43
+ return Tokens(output=_exact_or_not_available(call.output_tokens))
44
+ cached = call.cached_tokens or 0
45
+ return Tokens(
46
+ input=Metric.exact(call.input_tokens - cached),
47
+ output=_exact_or_not_available(call.output_tokens),
48
+ cache_read=Metric.exact(cached),
49
+ )
50
+
51
+
52
+ def _llm_call_from_debug_log(
53
+ call: DebugLlmCall, in_context: bool, source_stream_id: str | None, usd_per_credit: float
54
+ ) -> LlmCall:
55
+ return LlmCall(
56
+ start=Metric.exact(call.start_ms),
57
+ duration=Metric.exact(call.duration_ms),
58
+ tokens=_tokens(call),
59
+ model=call.model,
60
+ cost=credits_cost(
61
+ call.usage_nano_aiu / _NANO_AIU_PER_CREDIT if call.usage_nano_aiu is not None else None, usd_per_credit
62
+ ),
63
+ in_context=in_context,
64
+ source_request_id=call.source_request_id,
65
+ source_stream_id=source_stream_id,
66
+ timing_basis="request_start",
67
+ )
68
+
69
+
70
+ def _calls_from_debug_log(
71
+ calls: list[DebugLlmCall], source_stream_id: str | None, usd_per_credit: float
72
+ ) -> list[LlmCall]:
73
+ """The agent's conversation is its most frequent request kind; other kinds are side requests."""
74
+ conversation = Counter(call.debug_name for call in calls).most_common(1)[0][0] if calls else None
75
+ return [
76
+ _llm_call_from_debug_log(
77
+ call,
78
+ in_context=call.debug_name == conversation,
79
+ source_stream_id=source_stream_id,
80
+ usd_per_credit=usd_per_credit,
81
+ )
82
+ for call in calls
83
+ ]
84
+
85
+
86
+ def _llm_calls(
87
+ key: str | None,
88
+ transcript: Transcript | None,
89
+ debug_log: DebugLog | None,
90
+ source_stream_id: str | None,
91
+ usd_per_credit: float,
92
+ ) -> list[LlmCall] | None:
93
+ """LLM calls owned by `key` (`None` = main agent), or `None` if no source covers it."""
94
+ if debug_log is not None and key in debug_log.calls:
95
+ return _calls_from_debug_log(debug_log.calls[key], source_stream_id, usd_per_credit)
96
+ if transcript is not None:
97
+ orders = transcript.llm_call_source_orders.get(key, [])
98
+ request_ids = transcript.llm_call_source_request_ids.get(key, [])
99
+ return [
100
+ LlmCall(
101
+ start=Metric.exact(ts),
102
+ source_order=orders[index] if index < len(orders) else None,
103
+ source_stream_id=source_stream_id,
104
+ source_request_id=request_ids[index] if index < len(request_ids) else None,
105
+ timing_basis="assistant_message",
106
+ )
107
+ for index, ts in enumerate(transcript.llm_call_timestamps_ms.get(key, []))
108
+ ]
109
+ return None
110
+
111
+
112
+ def _set_llm_calls(node: Node, calls: list[LlmCall] | None) -> None:
113
+ if calls is None:
114
+ return
115
+ node.llm_calls = calls
116
+ node.llm_call_count = Metric.exact(len(calls))
117
+
118
+
119
+ def _build_tool_node(
120
+ tool_call: RawToolCall, transcript: Transcript | None, usd_per_credit: float, source_stream_id: str | None
121
+ ) -> Node:
122
+ arguments = tool_call.arguments
123
+ if not arguments and transcript is not None:
124
+ arguments = transcript.tool_arguments.get(tool_call.tool_call_id, {})
125
+ node = Node(
126
+ node_id=tool_call.tool_call_id,
127
+ kind=NodeKind.AGENT if tool_call.is_agent else NodeKind.TOOL,
128
+ topic=tool_call.subagent_description or tool_call.topic,
129
+ agent_uuid=tool_call.tool_call_id if tool_call.is_agent else None,
130
+ model=tool_call.subagent_model,
131
+ prompt=tool_call.subagent_prompt or "",
132
+ result=tool_call.subagent_result or "",
133
+ tool=tool_info(tool_call.tool_id, arguments),
134
+ cost_total=credits_cost(tool_call.subagent_credits, usd_per_credit),
135
+ )
136
+ timing = transcript.tool_timings.get(tool_call.tool_call_id) if transcript is not None else None
137
+ if timing is not None:
138
+ if timing.start_ms is not None:
139
+ node.start = Metric.exact(timing.start_ms)
140
+ if timing.end_ms is not None:
141
+ node.end = Metric.exact(timing.end_ms)
142
+ if timing.start_ms is not None and timing.end_ms is not None:
143
+ node.duration = Metric.exact(timing.end_ms - timing.start_ms)
144
+ node.success = timing.success
145
+ return node
146
+
147
+
148
+ def _build_turn(
149
+ request: RawRequest,
150
+ main_calls: list[LlmCall] | None,
151
+ transcript: Transcript | None,
152
+ debug_log: DebugLog | None,
153
+ usd_per_credit: float,
154
+ source_stream_id: str | None,
155
+ ) -> Node:
156
+ nodes_by_id = {
157
+ tc.tool_call_id: _build_tool_node(tc, transcript, usd_per_credit, source_stream_id) for tc in request.tool_calls
158
+ }
159
+ children_by_parent: dict[str | None, list[Node]] = {}
160
+ for tool_call in request.tool_calls:
161
+ # An orphan parent id (naming no tool call in this request) is treated as a direct child of the turn.
162
+ parent_id = tool_call.parent_tool_call_id
163
+ if parent_id is not None and parent_id not in nodes_by_id:
164
+ parent_id = None
165
+ children_by_parent.setdefault(parent_id, []).append(nodes_by_id[tool_call.tool_call_id])
166
+
167
+ for tool_call in request.tool_calls:
168
+ node = nodes_by_id[tool_call.tool_call_id]
169
+ node.children = children_by_parent.get(tool_call.tool_call_id, [])
170
+ if node.kind is NodeKind.AGENT:
171
+ child_stream_id = tool_call.tool_call_id
172
+ _set_llm_calls(
173
+ node, _llm_calls(tool_call.tool_call_id, transcript, debug_log, child_stream_id, usd_per_credit)
174
+ )
175
+
176
+ turn = Node(
177
+ node_id=request.request_id,
178
+ kind=NodeKind.TURN,
179
+ topic=request.text,
180
+ prompt=request.text,
181
+ model=request.model_id,
182
+ children=children_by_parent.get(None, []),
183
+ start=Metric.exact(request.timestamp_ms),
184
+ cost_total=credits_cost(request.credits, usd_per_credit),
185
+ user_wait=(
186
+ Metric.exact(request.time_spent_waiting_ms)
187
+ if request.time_spent_waiting_ms is not None
188
+ else Metric.not_available()
189
+ ),
190
+ )
191
+ if request.elapsed_ms is not None:
192
+ turn.end = Metric.exact(request.timestamp_ms + request.elapsed_ms)
193
+ turn.duration = Metric.exact(request.elapsed_ms)
194
+ _set_llm_calls(turn, main_calls)
195
+ _populate_tool_events(turn, transcript, source_stream_id, debug_log)
196
+ return turn
197
+
198
+
199
+ def _populate_tool_events(
200
+ owner: Node, transcript: Transcript | None, source_stream_id: str | None, debug_log: DebugLog | None
201
+ ) -> None:
202
+ """Attach native tool execution snapshots to the node that directly owns each invocation."""
203
+ agent_key = owner.agent_uuid if owner.kind is NodeKind.AGENT else None
204
+ calls = debug_log.calls.get(agent_key, []) if debug_log is not None else []
205
+ for child in owner.children:
206
+ timing = transcript.tool_timings.get(child.node_id) if transcript is not None else None
207
+ if timing is not None:
208
+ is_delegation = child.tool is not None and child.tool.category.value == "subagent"
209
+ request_ids = {
210
+ call.source_request_id
211
+ for call in calls
212
+ if call.source_request_id and child.node_id in call.requested_tool_ids
213
+ }
214
+ request_id = (
215
+ next(iter(request_ids))
216
+ if len(request_ids) == 1
217
+ else transcript.tool_request_ids.get(child.node_id)
218
+ if transcript is not None
219
+ else None
220
+ )
221
+ events = tool_execution_events(
222
+ invocation_id=child.node_id,
223
+ subject_node_id=child.node_id,
224
+ start=child.start,
225
+ end=child.end,
226
+ result_recorded=timing.completion_recorded,
227
+ success=timing.success if timing.completion_recorded else None,
228
+ delegation=is_delegation,
229
+ start_order=timing.start_order,
230
+ result_order=timing.completion_order,
231
+ source_stream_id=source_stream_id,
232
+ request_id=request_id,
233
+ )
234
+ if timing.start_ms is not None:
235
+ owner.execution_events.append(events[0])
236
+ if timing.completion_recorded:
237
+ consumers = [
238
+ call
239
+ for call in calls
240
+ if call.source_request_id
241
+ and child.node_id in call.consumed_tool_ids
242
+ and timing.end_ms is not None
243
+ and call.start_ms >= timing.end_ms
244
+ ]
245
+ first_start = min((call.start_ms for call in consumers), default=None)
246
+ first_ids = dict.fromkeys(
247
+ call.source_request_id
248
+ for call in consumers
249
+ if call.start_ms == first_start and call.source_request_id is not None
250
+ )
251
+ events[1].links.extend(
252
+ EventCallLink(source_request_id=request_id, relation="consumed_by", evidence="recorded")
253
+ for request_id in first_ids
254
+ )
255
+ owner.execution_events.append(events[1])
256
+ if child.kind is NodeKind.AGENT:
257
+ _populate_tool_events(child, transcript, child.agent_uuid, debug_log)
258
+ owner.execution_events.sort(key=lambda event: (event.source_order is None, event.source_order or 0))
259
+
260
+
261
+ def _call_start(call: LlmCall) -> float | None:
262
+ return call.start.value
263
+
264
+
265
+ def build_root(
266
+ raw: RawSession, transcript: Transcript | None, debug_log: DebugLog | None, usd_per_credit: float
267
+ ) -> Node:
268
+ """Build session -> turn -> agent -> tool; the main agent's LLM calls are split between turns by start time."""
269
+ stream_id = (transcript.session_id if transcript is not None else None) or raw.session_id
270
+ main_calls = _llm_calls(None, transcript, debug_log, stream_id, usd_per_credit)
271
+ groups = split_by_turn(main_calls or [], _call_start, [request.timestamp_ms for request in raw.requests])
272
+ turns = [
273
+ _build_turn(
274
+ request,
275
+ calls if main_calls is not None else None,
276
+ transcript,
277
+ debug_log,
278
+ usd_per_credit,
279
+ stream_id,
280
+ )
281
+ for request, calls in zip(raw.requests, groups, strict=True)
282
+ ]
283
+ root = Node(node_id="session", kind=NodeKind.SESSION, topic=raw.title or "session", children=turns)
284
+ if transcript is not None:
285
+ root.execution_events.extend(
286
+ ExecutionEvent(
287
+ kind="user_input",
288
+ event_id=f"user-input:{message.message_id}" if message.message_id else None,
289
+ start=Metric.exact(message.timestamp_ms),
290
+ source_order=message.source_order,
291
+ source_stream_id=stream_id,
292
+ )
293
+ for message in transcript.user_messages
294
+ )
295
+ _append_unrepresented_tool_events(root, transcript, stream_id)
296
+ root.execution_events.sort(key=lambda event: (event.source_order is None, event.source_order or 0))
297
+ return root
298
+
299
+
300
+ def _append_unrepresented_tool_events(root: Node, transcript: Transcript, source_stream_id: str | None) -> None:
301
+ """Keep source tool records visible when the session tree has no matching invocation node."""
302
+ nodes = list(iter_nodes(root))
303
+ represented_ids = {node.node_id for node in nodes if node.tool is not None}
304
+ for record in transcript.tool_events:
305
+ if record.tool_call_id is not None and record.tool_call_id in represented_ids:
306
+ continue
307
+ owner = next(
308
+ (node for node in nodes if record.owner_id is not None and node.agent_uuid == record.owner_id),
309
+ root,
310
+ )
311
+ is_start = record.kind == "tool.execution_start"
312
+ name_category = tool_info(record.tool_name, {}).category if record.tool_name is not None else None
313
+ events = tool_execution_events(
314
+ invocation_id=record.tool_call_id,
315
+ subject_node_id=None,
316
+ start=Metric.exact(record.timestamp_ms) if is_start else Metric.not_available(),
317
+ end=Metric.exact(record.timestamp_ms) if not is_start else Metric.not_available(),
318
+ result_recorded=not is_start,
319
+ success=record.success,
320
+ delegation=name_category is not None and name_category.value == "subagent",
321
+ start_order=record.source_order if is_start else None,
322
+ result_order=record.source_order if not is_start else None,
323
+ source_stream_id=record.owner_id or source_stream_id,
324
+ )
325
+ owner.execution_events.append(events[0] if is_start else events[1])
@@ -0,0 +1,64 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """Construct neutral source events for tool invocations and their results."""
4
+
5
+ from dataclasses import replace
6
+
7
+ from agentprof.model import EventCallLink, ExecutionEvent, Metric
8
+
9
+
10
+ def tool_execution_events(
11
+ *,
12
+ invocation_id: str | None,
13
+ subject_node_id: str | None,
14
+ start: Metric,
15
+ end: Metric,
16
+ result_recorded: bool,
17
+ success: bool | None = None,
18
+ delegation: bool = False,
19
+ start_order: int | None = None,
20
+ result_order: int | None = None,
21
+ source_stream_id: str | None = None,
22
+ request_id: str | None = None,
23
+ next_id: str | None = None,
24
+ ) -> list[ExecutionEvent]:
25
+ """Create start and optional result events while keeping each event's links independent."""
26
+ requested_links = (
27
+ [EventCallLink(source_request_id=request_id, relation="requested_by", evidence="recorded")]
28
+ if request_id is not None
29
+ else []
30
+ )
31
+ events = [
32
+ ExecutionEvent(
33
+ kind="delegation" if delegation else "tool_start",
34
+ event_id=f"tool-start:{invocation_id}" if invocation_id else None,
35
+ start=start,
36
+ source_order=start_order,
37
+ links=[replace(link) for link in requested_links],
38
+ subject_node_id=subject_node_id,
39
+ source_stream_id=source_stream_id,
40
+ execution_start=start,
41
+ execution_end=end,
42
+ )
43
+ ]
44
+ if result_recorded:
45
+ result_links = list(requested_links)
46
+ if next_id is not None:
47
+ result_links.append(
48
+ EventCallLink(source_request_id=next_id, relation="next_observed_call", evidence="observed_order")
49
+ )
50
+ events.append(
51
+ ExecutionEvent(
52
+ kind="tool_result",
53
+ event_id=f"tool-result:{invocation_id}" if invocation_id else None,
54
+ start=end,
55
+ source_order=result_order,
56
+ success=success,
57
+ links=[replace(link) for link in result_links],
58
+ subject_node_id=subject_node_id,
59
+ source_stream_id=source_stream_id,
60
+ execution_start=start,
61
+ execution_end=end,
62
+ )
63
+ )
64
+ return events
@@ -0,0 +1,13 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """Timestamp parsing shared by adapters."""
4
+
5
+ from datetime import UTC, datetime
6
+
7
+
8
+ def parse_iso_ms(iso_timestamp: str) -> int:
9
+ """Epoch milliseconds of an ISO 8601 timestamp; timestamps without an offset are taken as UTC."""
10
+ parsed = datetime.fromisoformat(iso_timestamp)
11
+ if parsed.tzinfo is None:
12
+ parsed = parsed.replace(tzinfo=UTC)
13
+ return int(parsed.timestamp() * 1000)
@@ -0,0 +1,24 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """Assign items that carry no turn id (LLM calls, tool calls) to turns by start time."""
4
+
5
+ from bisect import bisect_right
6
+ from collections.abc import Callable, Sequence
7
+
8
+
9
+ def split_by_turn[T](
10
+ items: Sequence[T], start_of: Callable[[T], float | None], turn_starts: Sequence[float]
11
+ ) -> list[list[T]]:
12
+ """Group `items` by turn; `turn_starts` must be ascending.
13
+
14
+ Each turn gets the items that start at or after its own start and before the next turn's start.
15
+ Items before the first turn, and items without a start, go to the first turn.
16
+ """
17
+ groups: list[list[T]] = [[] for _ in turn_starts]
18
+ if not groups:
19
+ return groups
20
+ for item in items:
21
+ start = start_of(item)
22
+ index = 0 if start is None else max(bisect_right(turn_starts, start) - 1, 0)
23
+ groups[index].append(item)
24
+ return groups
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """Agent-neutral analysis: roll-ups and waste heuristics."""