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