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,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
|