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,161 @@
|
|
|
1
|
+
"""Aggregate a session by owning agent without adding descendant cost to own cost."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from agentprof.analysis.rollup import sum_tokens
|
|
6
|
+
from agentprof.model import CostMetric, Metric, Node, NodeKind, Provenance, Tokens, sum_costs, worst_provenance
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class AgentGroup:
|
|
11
|
+
agent_id: int
|
|
12
|
+
harness_agent_id: str | None
|
|
13
|
+
parent_agent_id: int | None
|
|
14
|
+
topic: str
|
|
15
|
+
models: list[str]
|
|
16
|
+
llm_calls: Metric
|
|
17
|
+
context_peak: Metric
|
|
18
|
+
tokens: Tokens
|
|
19
|
+
own_cost: CostMetric
|
|
20
|
+
subtree_cost: CostMetric
|
|
21
|
+
start: Metric
|
|
22
|
+
end: Metric
|
|
23
|
+
cost_parts: dict[str, CostMetric]
|
|
24
|
+
cache_ttl: str
|
|
25
|
+
cold_rewrites: Metric
|
|
26
|
+
cold_rewrite_cost: CostMetric
|
|
27
|
+
longest_gap: Metric
|
|
28
|
+
findings_count: int
|
|
29
|
+
resume_count: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def agent_ids(root: Node) -> dict[str, int]:
|
|
33
|
+
"""Assign chronological display IDs to distinct subagent identities."""
|
|
34
|
+
agents: list[Node] = []
|
|
35
|
+
|
|
36
|
+
def visit(node: Node) -> None:
|
|
37
|
+
if node.kind is NodeKind.AGENT:
|
|
38
|
+
agents.append(node)
|
|
39
|
+
for child in node.children:
|
|
40
|
+
visit(child)
|
|
41
|
+
|
|
42
|
+
visit(root)
|
|
43
|
+
if all(node.start.value is not None for node in agents):
|
|
44
|
+
agents.sort(key=lambda node: node.start.number())
|
|
45
|
+
identities = dict.fromkeys(node.agent_uuid or node.node_id for node in agents)
|
|
46
|
+
return {identity: index for index, identity in enumerate(identities, start=2)}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _sum_metrics(metrics: list[Metric]) -> Metric:
|
|
50
|
+
available = [metric for metric in metrics if metric.value is not None]
|
|
51
|
+
if not available:
|
|
52
|
+
return Metric.not_available()
|
|
53
|
+
provenance = worst_provenance(*(metric.provenance for metric in available))
|
|
54
|
+
if len(available) != len(metrics):
|
|
55
|
+
provenance = worst_provenance(provenance, Provenance.ESTIMATED)
|
|
56
|
+
return Metric(value=sum(metric.number() for metric in available), provenance=provenance)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _extreme(metrics: list[Metric], greatest: bool) -> Metric:
|
|
60
|
+
available = [metric for metric in metrics if metric.value is not None]
|
|
61
|
+
if not available:
|
|
62
|
+
return Metric.not_available()
|
|
63
|
+
chosen = (max if greatest else min)(available, key=Metric.number)
|
|
64
|
+
provenance = chosen.provenance
|
|
65
|
+
if len(available) != len(metrics):
|
|
66
|
+
provenance = worst_provenance(provenance, Provenance.ESTIMATED)
|
|
67
|
+
return Metric(value=chosen.value, provenance=provenance)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def agent_groups(root: Node) -> list[AgentGroup]:
|
|
71
|
+
"""Return one row for the main agent and each distinct subagent identity."""
|
|
72
|
+
ids = agent_ids(root)
|
|
73
|
+
owned: dict[int, list[Node]] = {1: []}
|
|
74
|
+
tops: dict[int, list[Node]] = {1: [root]}
|
|
75
|
+
parents: dict[int, set[int]] = {1: set()}
|
|
76
|
+
finding_counts: dict[int, int] = {1: 0}
|
|
77
|
+
|
|
78
|
+
def visit(node: Node, owner: int) -> None:
|
|
79
|
+
if node.kind is NodeKind.AGENT:
|
|
80
|
+
agent_id = ids[node.agent_uuid or node.node_id]
|
|
81
|
+
if agent_id != owner:
|
|
82
|
+
parents.setdefault(agent_id, set()).add(owner)
|
|
83
|
+
tops.setdefault(agent_id, []).append(node)
|
|
84
|
+
owner = agent_id
|
|
85
|
+
if node.kind in (NodeKind.TURN, NodeKind.AGENT):
|
|
86
|
+
owned.setdefault(owner, []).append(node)
|
|
87
|
+
finding_counts[owner] = finding_counts.get(owner, 0) + len(node.findings)
|
|
88
|
+
for child in node.children:
|
|
89
|
+
visit(child, owner)
|
|
90
|
+
|
|
91
|
+
visit(root, 1)
|
|
92
|
+
groups: list[AgentGroup] = []
|
|
93
|
+
for agent_id, nodes in sorted(owned.items()):
|
|
94
|
+
representative = root if agent_id == 1 else next(node for node in nodes if node.kind is NodeKind.AGENT)
|
|
95
|
+
observed = {
|
|
96
|
+
call.model for node in nodes for call in node.llm_calls if call.model and not call.model.startswith("<")
|
|
97
|
+
}
|
|
98
|
+
models = sorted(observed or {node.model for node in nodes if node.model is not None})
|
|
99
|
+
counts = [
|
|
100
|
+
node.llm_call_count
|
|
101
|
+
if node.llm_call_count.value is not None or not node.llm_calls
|
|
102
|
+
else Metric.exact(len(node.llm_calls))
|
|
103
|
+
for node in nodes
|
|
104
|
+
]
|
|
105
|
+
parent_ids = parents.get(agent_id, set())
|
|
106
|
+
calls = [call for node in nodes for call in node.llm_calls]
|
|
107
|
+
cost_kinds = ("input", "output", "cache_read", "cache_write_5m", "cache_write_1h")
|
|
108
|
+
cost_parts = {
|
|
109
|
+
kind: sum_costs([call.cost_parts.get(kind, CostMetric.not_available()) for call in calls])
|
|
110
|
+
for kind in cost_kinds
|
|
111
|
+
}
|
|
112
|
+
writes = [
|
|
113
|
+
call for call in calls if call.tokens.cache_write.value is not None and call.tokens.cache_write.number() > 0
|
|
114
|
+
]
|
|
115
|
+
known_ttls = [
|
|
116
|
+
call
|
|
117
|
+
for call in writes
|
|
118
|
+
if call.tokens.cache_write_5m.value is not None and call.tokens.cache_write_1h.value is not None
|
|
119
|
+
]
|
|
120
|
+
if not writes or len(known_ttls) != len(writes):
|
|
121
|
+
cache_ttl = "unknown"
|
|
122
|
+
else:
|
|
123
|
+
five = any(call.tokens.cache_write_5m.number() > 0 for call in known_ttls)
|
|
124
|
+
one = any(call.tokens.cache_write_1h.number() > 0 for call in known_ttls)
|
|
125
|
+
cache_ttl = "mixed" if five and one else "5m" if five else "1h"
|
|
126
|
+
cold_calls = [call for call in calls if call.cold_rewrite]
|
|
127
|
+
cold_costs = [
|
|
128
|
+
sum_costs(
|
|
129
|
+
[
|
|
130
|
+
call.cost_parts.get("cache_write_5m", CostMetric.not_available()),
|
|
131
|
+
call.cost_parts.get("cache_write_1h", CostMetric.not_available()),
|
|
132
|
+
]
|
|
133
|
+
)
|
|
134
|
+
for call in cold_calls
|
|
135
|
+
]
|
|
136
|
+
groups.append(
|
|
137
|
+
AgentGroup(
|
|
138
|
+
agent_id=agent_id,
|
|
139
|
+
harness_agent_id=representative.agent_uuid if agent_id != 1 else None,
|
|
140
|
+
parent_agent_id=next(iter(parent_ids)) if len(parent_ids) == 1 else None,
|
|
141
|
+
topic=representative.topic,
|
|
142
|
+
models=models,
|
|
143
|
+
llm_calls=_sum_metrics(counts),
|
|
144
|
+
context_peak=(
|
|
145
|
+
root.context_peak if agent_id == 1 else _extreme([node.context_peak for node in nodes], True)
|
|
146
|
+
),
|
|
147
|
+
tokens=sum_tokens([node.tokens for node in nodes]),
|
|
148
|
+
own_cost=sum_costs([node.cost_own for node in nodes]),
|
|
149
|
+
subtree_cost=sum_costs([node.cost_total for node in tops[agent_id]]),
|
|
150
|
+
start=_extreme([node.start for node in nodes], False),
|
|
151
|
+
end=_extreme([node.end for node in nodes], True),
|
|
152
|
+
cost_parts=cost_parts,
|
|
153
|
+
cache_ttl=cache_ttl,
|
|
154
|
+
cold_rewrites=Metric.exact(len(cold_calls)),
|
|
155
|
+
cold_rewrite_cost=sum_costs(cold_costs),
|
|
156
|
+
longest_gap=_extreme([call.gap for call in calls], True),
|
|
157
|
+
findings_count=finding_counts.get(agent_id, 0),
|
|
158
|
+
resume_count=sum(len(node.resume_times) for node in nodes),
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
return groups
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 epicodic
|
|
3
|
+
"""Same-agent call intervals and short references to intervening activity."""
|
|
4
|
+
|
|
5
|
+
from bisect import bisect_left, bisect_right
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from itertools import pairwise
|
|
8
|
+
|
|
9
|
+
from agentprof.model import CallEvent, LlmCall, Metric, Node, NodeKind, Provenance, worst_provenance
|
|
10
|
+
|
|
11
|
+
_LARGE_CACHE_WRITE_TOKENS = 50_000
|
|
12
|
+
_LOW_CACHE_READ_RATIO = 0.1
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_cold_rewrite(call: LlmCall) -> bool:
|
|
16
|
+
"""A measured large rewrite with little reuse, excluding an agent's first call."""
|
|
17
|
+
write = call.tokens.cache_write.value
|
|
18
|
+
read = call.tokens.cache_read.value
|
|
19
|
+
return (
|
|
20
|
+
call.gap.value is not None
|
|
21
|
+
and write is not None
|
|
22
|
+
and read is not None
|
|
23
|
+
and write >= _LARGE_CACHE_WRITE_TOKENS
|
|
24
|
+
and read <= write * _LOW_CACHE_READ_RATIO
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _event(node: Node) -> CallEvent | None:
|
|
29
|
+
if node.kind is NodeKind.AGENT:
|
|
30
|
+
if node.end.value is None:
|
|
31
|
+
return None
|
|
32
|
+
return CallEvent("child_completion", node.node_id, node.end, node.end, node.duration)
|
|
33
|
+
if node.kind is NodeKind.TOOL:
|
|
34
|
+
if node.start.value is None or node.end.value is None:
|
|
35
|
+
return None
|
|
36
|
+
return CallEvent("tool", node.node_id, node.start, node.end, node.duration)
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def annotate_call_context(root: Node) -> None:
|
|
41
|
+
"""Annotate calls using only timestamps that can place events in their interval.
|
|
42
|
+
|
|
43
|
+
The main agent owns all turns. Repeated subagent nodes with the same harness ID
|
|
44
|
+
are treated as one conversation, including a resumed agent.
|
|
45
|
+
"""
|
|
46
|
+
calls: dict[str, list[LlmCall]] = defaultdict(list)
|
|
47
|
+
events: dict[str, list[CallEvent]] = defaultdict(list)
|
|
48
|
+
compactions: dict[str, list[Metric]] = defaultdict(list)
|
|
49
|
+
|
|
50
|
+
def visit(node: Node, owner: str) -> None:
|
|
51
|
+
if node.kind is NodeKind.AGENT:
|
|
52
|
+
owner = node.agent_uuid or node.node_id
|
|
53
|
+
if node.kind in (NodeKind.TURN, NodeKind.AGENT):
|
|
54
|
+
calls[owner].extend(call for call in node.llm_calls if call.in_context and call.start.value is not None)
|
|
55
|
+
compactions[owner].extend(node.compactions)
|
|
56
|
+
if node.kind is NodeKind.TURN and node.start.value is not None:
|
|
57
|
+
events[owner].append(CallEvent("user_message", node.node_id, node.start, node.start))
|
|
58
|
+
for child in node.children:
|
|
59
|
+
event = _event(child)
|
|
60
|
+
if event is not None:
|
|
61
|
+
events[owner].append(event)
|
|
62
|
+
visit(child, owner)
|
|
63
|
+
|
|
64
|
+
visit(root, "main")
|
|
65
|
+
for owner, owned_calls in calls.items():
|
|
66
|
+
owned_calls.sort(key=lambda call: call.start.number())
|
|
67
|
+
timed_events = sorted(
|
|
68
|
+
(event for event in events[owner] if event.start.value is not None and event.end.value is not None),
|
|
69
|
+
key=lambda event: event.start.number(),
|
|
70
|
+
)
|
|
71
|
+
event_starts = [event.start.number() for event in timed_events]
|
|
72
|
+
compact_times = sorted(point.number() for point in compactions[owner] if point.value is not None)
|
|
73
|
+
if owned_calls:
|
|
74
|
+
owned_calls[0].cold_reason = "first_call"
|
|
75
|
+
for previous, current in pairwise(owned_calls):
|
|
76
|
+
baseline = previous.start
|
|
77
|
+
basis = "previous_start"
|
|
78
|
+
if previous.duration.value is not None:
|
|
79
|
+
baseline = Metric(
|
|
80
|
+
previous.start.number() + previous.duration.number(),
|
|
81
|
+
previous.start.provenance
|
|
82
|
+
if previous.duration.provenance is Provenance.EXACT
|
|
83
|
+
else Provenance.ESTIMATED,
|
|
84
|
+
)
|
|
85
|
+
basis = "previous_end"
|
|
86
|
+
if current.start.number() < baseline.number():
|
|
87
|
+
continue
|
|
88
|
+
provenance = worst_provenance(current.start.provenance, baseline.provenance)
|
|
89
|
+
if basis == "previous_start":
|
|
90
|
+
provenance = worst_provenance(provenance, Provenance.ESTIMATED)
|
|
91
|
+
current.gap = Metric(current.start.number() - baseline.number(), provenance)
|
|
92
|
+
current.gap_basis = basis
|
|
93
|
+
first = bisect_left(event_starts, baseline.number())
|
|
94
|
+
last = bisect_right(event_starts, current.start.number())
|
|
95
|
+
current.preceding_events = [
|
|
96
|
+
event for event in timed_events[first:last] if event.end.number() <= current.start.number()
|
|
97
|
+
]
|
|
98
|
+
compact_index = bisect_left(compact_times, baseline.number())
|
|
99
|
+
if compact_index < len(compact_times) and compact_times[compact_index] <= current.start.number():
|
|
100
|
+
current.cold_reason = "compaction"
|
|
101
|
+
current.cold_rewrite = is_cold_rewrite(current)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 epicodic
|
|
3
|
+
"""Findings that cite measured call intervals and cache behavior.
|
|
4
|
+
|
|
5
|
+
These are diagnostic hints. A large rewrite does not, by itself, establish
|
|
6
|
+
cache expiry or prove that its cost could have been avoided.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from itertools import pairwise
|
|
11
|
+
|
|
12
|
+
from agentprof.model import Finding, LlmCall, Node, NodeKind, context_size, iter_nodes, worst_provenance
|
|
13
|
+
|
|
14
|
+
_BLOCKING_WAIT_MS = 5 * 60 * 1000
|
|
15
|
+
_PARENT_IDLE_MS = 5 * 60 * 1000
|
|
16
|
+
_CONTEXT_GROWTH_TOKENS = 100_000
|
|
17
|
+
_CONTEXT_GROWTH_FACTOR = 2
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def find_evidence_findings(root: Node) -> list[Finding]:
|
|
21
|
+
"""Return evidence-backed findings over the neutral call tree."""
|
|
22
|
+
by_id = {node.node_id: node for node in iter_nodes(root)}
|
|
23
|
+
findings: list[Finding] = []
|
|
24
|
+
owned_calls: dict[str, list[tuple[LlmCall, Node]]] = defaultdict(list)
|
|
25
|
+
|
|
26
|
+
def gather(node: Node, owner: str) -> None:
|
|
27
|
+
if node.kind is NodeKind.AGENT:
|
|
28
|
+
owner = node.agent_uuid or node.node_id
|
|
29
|
+
if node.kind in (NodeKind.TURN, NodeKind.AGENT):
|
|
30
|
+
owned_calls[owner].extend((call, node) for call in node.llm_calls if call.in_context)
|
|
31
|
+
for child in node.children:
|
|
32
|
+
gather(child, owner)
|
|
33
|
+
|
|
34
|
+
gather(root, "main")
|
|
35
|
+
for node in by_id.values():
|
|
36
|
+
if node.kind is NodeKind.TOOL and node.tool is not None and node.tool.is_resume:
|
|
37
|
+
findings.append(
|
|
38
|
+
Finding(
|
|
39
|
+
heuristic_id="E5",
|
|
40
|
+
node_id=node.node_id,
|
|
41
|
+
severity="low",
|
|
42
|
+
message="Agent resume reported by a SendMessage result",
|
|
43
|
+
evidence={
|
|
44
|
+
"tool_node_id": node.node_id,
|
|
45
|
+
"target_agent_id": node.tool.target_agent_id,
|
|
46
|
+
"linked_agent_node_id": node.tool.linked_agent_node_id,
|
|
47
|
+
"provenance": "exact",
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
if node.kind not in (NodeKind.TURN, NodeKind.AGENT):
|
|
52
|
+
continue
|
|
53
|
+
for call in node.llm_calls:
|
|
54
|
+
for event in call.preceding_events:
|
|
55
|
+
if (
|
|
56
|
+
event.kind == "child_completion"
|
|
57
|
+
and event.end.value is not None
|
|
58
|
+
and call.start.value is not None
|
|
59
|
+
and call.start.number() - event.end.number() >= _PARENT_IDLE_MS
|
|
60
|
+
):
|
|
61
|
+
findings.append(
|
|
62
|
+
Finding(
|
|
63
|
+
heuristic_id="E4",
|
|
64
|
+
node_id=node.node_id,
|
|
65
|
+
severity="low",
|
|
66
|
+
message="Parent agent was idle after a child completed",
|
|
67
|
+
evidence={
|
|
68
|
+
"call_id": call.call_id,
|
|
69
|
+
"child_node_id": event.event_id,
|
|
70
|
+
"idle_ms": call.start.number() - event.end.number(),
|
|
71
|
+
"provenance": worst_provenance(call.start.provenance, event.end.provenance).value,
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
if not call.cold_rewrite:
|
|
76
|
+
continue
|
|
77
|
+
evidence: dict[str, object] = {
|
|
78
|
+
"call_id": call.call_id,
|
|
79
|
+
"call_start_ms": call.start.value,
|
|
80
|
+
"cache_write_tokens": call.tokens.cache_write.value,
|
|
81
|
+
"cache_read_tokens": call.tokens.cache_read.value,
|
|
82
|
+
"gap_ms": call.gap.value,
|
|
83
|
+
"cold_reason": call.cold_reason,
|
|
84
|
+
"provenance": worst_provenance(
|
|
85
|
+
call.tokens.cache_write.provenance, call.tokens.cache_read.provenance
|
|
86
|
+
).value,
|
|
87
|
+
}
|
|
88
|
+
findings.append(
|
|
89
|
+
Finding(
|
|
90
|
+
heuristic_id="E1",
|
|
91
|
+
node_id=node.node_id,
|
|
92
|
+
severity="medium",
|
|
93
|
+
message="Large cache rewrite with little cache reuse",
|
|
94
|
+
evidence=evidence,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
for event in call.preceding_events:
|
|
98
|
+
tool_node = by_id.get(event.event_id)
|
|
99
|
+
if (
|
|
100
|
+
event.kind == "tool"
|
|
101
|
+
and tool_node is not None
|
|
102
|
+
and tool_node.tool is not None
|
|
103
|
+
and tool_node.tool.native_id.casefold() == "askuserquestion"
|
|
104
|
+
and event.duration.value is not None
|
|
105
|
+
and event.duration.number() >= _BLOCKING_WAIT_MS
|
|
106
|
+
):
|
|
107
|
+
findings.append(
|
|
108
|
+
Finding(
|
|
109
|
+
heuristic_id="E2",
|
|
110
|
+
node_id=node.node_id,
|
|
111
|
+
severity="medium",
|
|
112
|
+
message="Blocking user wait preceded a large cache rewrite",
|
|
113
|
+
evidence={
|
|
114
|
+
**evidence,
|
|
115
|
+
"tool_node_id": event.event_id,
|
|
116
|
+
"wait_ms": event.duration.value,
|
|
117
|
+
"wait_provenance": event.duration.provenance.value,
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
break
|
|
122
|
+
for calls in owned_calls.values():
|
|
123
|
+
ordered = sorted(
|
|
124
|
+
((call, node) for call, node in calls if call.start.value is not None),
|
|
125
|
+
key=lambda item: item[0].start.number(),
|
|
126
|
+
)
|
|
127
|
+
for (earlier, _), (later, node) in pairwise(ordered):
|
|
128
|
+
before = context_size(earlier.tokens)
|
|
129
|
+
after = context_size(later.tokens)
|
|
130
|
+
if (
|
|
131
|
+
before.value is not None
|
|
132
|
+
and after.value is not None
|
|
133
|
+
and after.number() >= _CONTEXT_GROWTH_TOKENS
|
|
134
|
+
and after.number() >= before.number() * _CONTEXT_GROWTH_FACTOR
|
|
135
|
+
):
|
|
136
|
+
findings.append(
|
|
137
|
+
Finding(
|
|
138
|
+
heuristic_id="E3",
|
|
139
|
+
node_id=node.node_id,
|
|
140
|
+
severity="low",
|
|
141
|
+
message="Model context grew sharply between calls",
|
|
142
|
+
evidence={
|
|
143
|
+
"earlier_call_id": earlier.call_id,
|
|
144
|
+
"later_call_id": later.call_id,
|
|
145
|
+
"earlier_context_tokens": before.value,
|
|
146
|
+
"later_context_tokens": after.value,
|
|
147
|
+
"provenance": worst_provenance(before.provenance, after.provenance).value,
|
|
148
|
+
},
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
return findings
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 epicodic
|
|
3
|
+
"""Resolve source-record relationships to model calls."""
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
|
|
7
|
+
from agentprof.model import EventCallLink, LlmCall, Node, NodeKind
|
|
8
|
+
|
|
9
|
+
_VALID_EVIDENCE = {
|
|
10
|
+
"requested_by": "recorded",
|
|
11
|
+
"consumed_by": "recorded",
|
|
12
|
+
"next_observed_call": "observed_order",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def resolve_execution_links(root: Node) -> None:
|
|
17
|
+
"""Resolve event links to one request in the same logical agent stream."""
|
|
18
|
+
calls: dict[str, dict[str, list[tuple[str, LlmCall]]]] = defaultdict(lambda: defaultdict(list))
|
|
19
|
+
pending: list[tuple[str, EventCallLink]] = []
|
|
20
|
+
|
|
21
|
+
def visit(node: Node, owner: str) -> None:
|
|
22
|
+
if node.kind is NodeKind.AGENT:
|
|
23
|
+
owner = node.agent_uuid or node.node_id
|
|
24
|
+
if node.kind in (NodeKind.SESSION, NodeKind.TURN, NodeKind.AGENT):
|
|
25
|
+
for call in node.llm_calls:
|
|
26
|
+
if call.source_request_id:
|
|
27
|
+
calls[owner][call.source_request_id].append((node.node_id, call))
|
|
28
|
+
pending.extend((owner, link) for event in node.execution_events for link in event.links)
|
|
29
|
+
for child in node.children:
|
|
30
|
+
visit(child, owner)
|
|
31
|
+
|
|
32
|
+
visit(root, "main")
|
|
33
|
+
for owner, link in pending:
|
|
34
|
+
link.owner_id = None
|
|
35
|
+
if _VALID_EVIDENCE.get(link.relation) != link.evidence:
|
|
36
|
+
continue
|
|
37
|
+
matches = calls[owner].get(link.source_request_id, [])
|
|
38
|
+
if len(matches) == 1:
|
|
39
|
+
link.owner_id = matches[0][0]
|