handoffmap 0.2.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.
handoffmap/i18n.py ADDED
@@ -0,0 +1,262 @@
1
+ """Message tables (English / Korean) and language selection for the human-readable output.
2
+
3
+ All user-facing wording lives in :data:`MESSAGES`. Machine output (``--format json`` and
4
+ ``--format mermaid``), edge kinds, warning codes and convention names are data and are
5
+ never translated.
6
+
7
+ The Python API defaults to English no matter what the environment says; only the CLI
8
+ passes the language it resolved with :func:`resolve_lang`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from collections.abc import Mapping
15
+
16
+ LANGS = ("en", "ko")
17
+ DEFAULT_LANG = "en"
18
+ ENV_VAR = "HANDOFFMAP_LANG"
19
+
20
+ MESSAGES: dict[str, dict[str, str]] = {
21
+ "en": {
22
+ # --- CLI help ---
23
+ "cli.description": "Fold an OTLP agent trace (OTel GenAI / OpenInference) into a Mermaid sequence diagram "
24
+ "and a per-agent table. Reads OTLP JSON or JSONL; needs no server.",
25
+ "cli.help.file": "OTLP JSON or JSONL trace file",
26
+ "cli.help.sample": "use a built-in practice trace (made with mock models)",
27
+ "cli.help.format": "output format (default: md)",
28
+ "cli.help.explain": "show the detected convention and why each edge got its kind",
29
+ "cli.help.trace": "only this trace (full id or unique prefix)",
30
+ "cli.help.max_parallel": "wide-parallel warning above N concurrent activities (default 8)",
31
+ "cli.help.repeat_threshold": "repeated-delegation warning at N identical delegations (default 3)",
32
+ "cli.help.no_tools": "hide plain tool calls in the diagram",
33
+ "cli.help.output": "write to a file instead of stdout",
34
+ "cli.help.lang": "output language: en or ko (default: HANDOFFMAP_LANG, then LC_ALL/LC_MESSAGES/LANG, else en)",
35
+ "cli.help.version": "show the version and exit",
36
+ # --- CLI errors and notes ---
37
+ "cli.err.not_whole": "{text!r} is not a whole number",
38
+ "cli.err.min_one": "must be 1 or more",
39
+ "cli.err.one_of": "give exactly one of FILE or --sample NAME",
40
+ "cli.err.no_trace": "no trace id starts with {prefix!r} in the input",
41
+ "cli.err.cannot_write": "cannot write {path}: {error}",
42
+ "cli.sample_note": "_Sample trace: recorded from practice (mock) models, so token counts and timings are made up._",
43
+ # --- parse / analyze errors ---
44
+ "err.id_type": "not an OTLP trace: span/trace id must be a string, got {type}",
45
+ "err.id_encoding": "not an OTLP trace: id {raw!r} is neither hex nor base64",
46
+ "err.no_resource_spans": "not an OTLP trace: expected a JSON object with a 'resourceSpans' list",
47
+ "err.span_no_ids": "not an OTLP trace: a span has no traceId/spanId",
48
+ "err.invalid_json": "not an OTLP trace: invalid JSON ({msg}, line {line})",
49
+ "err.empty": "not an OTLP trace: the input is empty",
50
+ "err.no_spans": "not an OTLP trace: no spans found",
51
+ "err.file_not_found": "file not found: {path}",
52
+ "err.cannot_read": "cannot read {path}: {error}",
53
+ "err.no_agent_spans": "no agent spans found in trace {trace_id} ({count} spans). "
54
+ "Expected spans with gen_ai.operation.name=invoke_agent (OTel GenAI) or "
55
+ "openinference.span.kind=AGENT with an agent.name (OpenInference).",
56
+ # --- warnings (human-readable output only; JSON keeps the English text) ---
57
+ "warn.repeated-delegation": "{caller} -> {callee} happened {count} times (threshold {threshold}); "
58
+ "check for a loop or a missing stop condition",
59
+ "warn.wide-parallel": "{agent} ran {count} activities at once (limit {limit})",
60
+ # --- markdown report ---
61
+ "md.heading": "## Agent flow: trace {trace} ({convention}, {spans} spans)",
62
+ "md.agents": "### Agents",
63
+ "md.footnote": "_Tokens are summed from LLM spans only. `time` is the wall time of each run, "
64
+ "including work it delegated. `–` means the trace does not contain the value._",
65
+ "md.warnings": "### Warnings",
66
+ # --- table ---
67
+ "table.agent": "agent",
68
+ "table.runs": "runs",
69
+ "table.llm_calls": "LLM calls",
70
+ "table.tools": "tools",
71
+ "table.input_tokens": "input tokens",
72
+ "table.output_tokens": "output tokens",
73
+ "table.cost": "cost",
74
+ "table.time": "time",
75
+ "table.total": "total",
76
+ # --- --explain ---
77
+ "why.root": "no agent above it and no handoff source: it started the run",
78
+ "why.call": "agent span nested inside the caller, no tool span in between",
79
+ "why.as_tool": "a tool span sits between the caller and this agent (agent used as a tool)",
80
+ "why.handoff": "no agent ancestor; the span is a sibling and names its predecessor (graph.node.parent_id)",
81
+ "ex.none": "(none)",
82
+ "ex.via": " via {via}",
83
+ "ex.trace": "trace: {trace}",
84
+ "ex.convention": "convention: {convention}",
85
+ "ex.size": "size: {spans} spans, {runs} agent runs, {agents} agents",
86
+ "ex.agents": "agents: {names}",
87
+ "ex.edges": "edges:",
88
+ "ex.ignored": "ignored wrappers (AGENT span without agent.name, not counted as agents): {shown}",
89
+ "ex.ignored_none": "ignored wrappers: none",
90
+ "ex.unknown_ops": "unknown gen_ai.operation.name values (passed through): {names}",
91
+ "ex.tokens_none": "tokens: none of the LLM spans carry token counts",
92
+ "ex.tokens": "tokens: {input} in / {output} out (summed from LLM spans only)",
93
+ "ex.reported": "agent spans report {input} in / {output} out (cross-check only, never added to the totals)",
94
+ # --- argparse built-ins ---
95
+ "argparse.usage": "usage: ",
96
+ "argparse.options": "options",
97
+ "argparse.positional": "positional arguments",
98
+ "argparse.help": "show this help message and exit",
99
+ "argparse.error_fmt": "%(prog)s: error: %(message)s\n",
100
+ "argparse.argument_fmt": "argument %(argument_name)s: %(message)s",
101
+ "argparse.invalid_choice": "invalid choice: %(value)r (choose from %(choices)s)",
102
+ "argparse.invalid_choice_hint": "invalid choice: %(value)r, maybe you meant %(closest)r? "
103
+ "(choose from %(choices)s)",
104
+ "argparse.invalid_value": "invalid %(type)s value: %(value)r",
105
+ "argparse.expected_one": "expected one argument",
106
+ "argparse.unrecognized": "unrecognized arguments: %s",
107
+ "argparse.required": "the following arguments are required: %s",
108
+ "argparse.default": " (default: %(default)s)",
109
+ "argparse.unexpected_option": "unexpected option string: %s",
110
+ "argparse.ambiguous": "ambiguous option: %(option)s could match %(matches)s",
111
+ "argparse.not_allowed": "not allowed with argument %s",
112
+ },
113
+ "ko": {
114
+ "cli.description": "OTLP 에이전트 트레이스(OTel GenAI / OpenInference)를 Mermaid 시퀀스 다이어그램과 "
115
+ "에이전트별 표로 정리합니다. OTLP JSON 또는 JSONL 을 읽으며 서버가 필요 없습니다.",
116
+ "cli.help.file": "OTLP JSON 또는 JSONL 트레이스 파일",
117
+ "cli.help.sample": "내장 연습용 트레이스를 사용합니다(목 모델로 만든 것)",
118
+ "cli.help.format": "출력 형식(기본값: md)",
119
+ "cli.help.explain": "감지한 규약과 각 엣지의 종류가 정해진 이유를 보여 줍니다",
120
+ "cli.help.trace": "이 트레이스만 처리합니다(전체 ID 또는 고유한 앞부분)",
121
+ "cli.help.max_parallel": "동시 활동이 N개를 넘으면 넓은 병렬 경고를 냅니다(기본값 8)",
122
+ "cli.help.repeat_threshold": "같은 위임이 N번 반복되면 반복 위임 경고를 냅니다(기본값 3)",
123
+ "cli.help.no_tools": "다이어그램에서 일반 도구 호출을 숨깁니다",
124
+ "cli.help.output": "표준 출력 대신 파일에 씁니다",
125
+ "cli.help.lang": "출력 언어: en 또는 ko (기본값: HANDOFFMAP_LANG, 그다음 LC_ALL/LC_MESSAGES/LANG, 없으면 en)",
126
+ "cli.help.version": "버전을 표시하고 종료합니다",
127
+ "cli.err.not_whole": "{text!r} 는 정수가 아닙니다",
128
+ "cli.err.min_one": "1 이상이어야 합니다",
129
+ "cli.err.one_of": "FILE 과 --sample NAME 중 정확히 하나만 지정하십시오",
130
+ "cli.err.no_trace": "입력에 {prefix!r} 로 시작하는 트레이스 ID 가 없습니다",
131
+ "cli.err.cannot_write": "{path} 에 쓸 수 없습니다: {error}",
132
+ "cli.sample_note": "_샘플 트레이스: 연습용(목) 모델로 기록한 것이므로 토큰 수와 시간은 임의의 값입니다._",
133
+ "err.id_type": "OTLP 트레이스가 아닙니다: span/trace ID 는 문자열이어야 하는데 {type} 입니다",
134
+ "err.id_encoding": "OTLP 트레이스가 아닙니다: ID {raw!r} 가 16진수도 base64 도 아닙니다",
135
+ "err.no_resource_spans": "OTLP 트레이스가 아닙니다: 'resourceSpans' 목록을 가진 JSON 객체가 필요합니다",
136
+ "err.span_no_ids": "OTLP 트레이스가 아닙니다: traceId/spanId 가 없는 스팬이 있습니다",
137
+ "err.invalid_json": "OTLP 트레이스가 아닙니다: JSON 이 올바르지 않습니다({msg}, {line}번째 줄)",
138
+ "err.empty": "OTLP 트레이스가 아닙니다: 입력이 비어 있습니다",
139
+ "err.no_spans": "OTLP 트레이스가 아닙니다: 스팬을 찾지 못했습니다",
140
+ "err.file_not_found": "파일을 찾을 수 없습니다: {path}",
141
+ "err.cannot_read": "{path} 을(를) 읽을 수 없습니다: {error}",
142
+ "err.no_agent_spans": "트레이스 {trace_id} 에서 에이전트 스팬을 찾지 못했습니다(스팬 {count}개). "
143
+ "gen_ai.operation.name=invoke_agent (OTel GenAI) 또는 "
144
+ "agent.name 이 있는 openinference.span.kind=AGENT (OpenInference) 스팬이 필요합니다.",
145
+ "warn.repeated-delegation": "{caller} -> {callee} 가 {count}번 발생했습니다(기준 {threshold}번). "
146
+ "반복 루프이거나 종료 조건이 빠져 있지 않은지 확인하십시오",
147
+ "warn.wide-parallel": "{agent} 가 {count}개의 활동을 동시에 실행했습니다(상한 {limit})",
148
+ "md.heading": "## 에이전트 흐름: 트레이스 {trace} ({convention}, 스팬 {spans}개)",
149
+ "md.agents": "### 에이전트",
150
+ "md.footnote": "_토큰은 LLM 스팬에서만 합산합니다. `시간` 은 각 실행의 벽시계 시간이며 "
151
+ "위임한 작업을 포함합니다. `–` 는 트레이스에 해당 값이 없다는 뜻입니다._",
152
+ "md.warnings": "### 경고",
153
+ "table.agent": "에이전트",
154
+ "table.runs": "실행",
155
+ "table.llm_calls": "LLM 호출",
156
+ "table.tools": "도구",
157
+ "table.input_tokens": "입력 토큰",
158
+ "table.output_tokens": "출력 토큰",
159
+ "table.cost": "비용",
160
+ "table.time": "시간",
161
+ "table.total": "합계",
162
+ "why.root": "위에 에이전트가 없고 핸드오프 출처도 없습니다. 실행을 시작한 에이전트입니다",
163
+ "why.call": "호출자 안에 중첩된 에이전트 스팬이며 사이에 도구 스팬이 없습니다",
164
+ "why.as_tool": "호출자와 이 에이전트 사이에 도구 스팬이 있습니다(에이전트를 도구로 사용)",
165
+ "why.handoff": "상위 에이전트가 없고, 스팬이 형제 관계이며 이전 에이전트를 명시합니다(graph.node.parent_id)",
166
+ "ex.none": "(없음)",
167
+ "ex.via": " 경유 {via}",
168
+ "ex.trace": "트레이스: {trace}",
169
+ "ex.convention": "규약: {convention}",
170
+ "ex.size": "규모: 스팬 {spans}개, 에이전트 실행 {runs}회, 에이전트 {agents}개",
171
+ "ex.agents": "에이전트: {names}",
172
+ "ex.edges": "엣지:",
173
+ "ex.ignored": "무시한 래퍼(agent.name 이 없는 AGENT 스팬, 에이전트로 세지 않음): {shown}",
174
+ "ex.ignored_none": "무시한 래퍼: 없음",
175
+ "ex.unknown_ops": "알 수 없는 gen_ai.operation.name 값(그대로 통과): {names}",
176
+ "ex.tokens_none": "토큰: 토큰 수를 가진 LLM 스팬이 없습니다",
177
+ "ex.tokens": "토큰: 입력 {input} / 출력 {output} (LLM 스팬에서만 합산)",
178
+ "ex.reported": "에이전트 스팬 보고값: 입력 {input} / 출력 {output} (교차 확인용이며 합계에는 더하지 않음)",
179
+ "argparse.usage": "사용법: ",
180
+ "argparse.options": "옵션",
181
+ "argparse.positional": "위치 인자",
182
+ "argparse.help": "도움말을 표시하고 종료합니다",
183
+ "argparse.error_fmt": "%(prog)s: 오류: %(message)s\n",
184
+ "argparse.argument_fmt": "인자 %(argument_name)s: %(message)s",
185
+ "argparse.invalid_choice": "잘못된 선택입니다: %(value)r (선택 가능: %(choices)s)",
186
+ "argparse.invalid_choice_hint": "잘못된 선택입니다: %(value)r, %(closest)r 를 의도하셨습니까? "
187
+ "(선택 가능: %(choices)s)",
188
+ "argparse.invalid_value": "%(type)s 값이 올바르지 않습니다: %(value)r",
189
+ "argparse.expected_one": "인자가 하나 필요합니다",
190
+ "argparse.unrecognized": "인식할 수 없는 인자입니다: %s",
191
+ "argparse.required": "다음 인자가 필요합니다: %s",
192
+ "argparse.default": " (기본값: %(default)s)",
193
+ "argparse.unexpected_option": "예상하지 못한 옵션 문자열입니다: %s",
194
+ "argparse.ambiguous": "모호한 옵션입니다: %(option)s 는 %(matches)s 와 일치할 수 있습니다",
195
+ "argparse.not_allowed": "다음 인자와 함께 쓸 수 없습니다: %s",
196
+ },
197
+ }
198
+
199
+ # argparse's own English msgid -> our table key (see ``argparse_translator``)
200
+ _ARGPARSE_IDS = {
201
+ "usage: ": "argparse.usage",
202
+ "options": "argparse.options",
203
+ "positional arguments": "argparse.positional",
204
+ "show this help message and exit": "argparse.help",
205
+ "%(prog)s: error: %(message)s\n": "argparse.error_fmt",
206
+ "argument %(argument_name)s: %(message)s": "argparse.argument_fmt",
207
+ "invalid choice: %(value)r (choose from %(choices)s)": "argparse.invalid_choice",
208
+ "invalid choice: %(value)r, maybe you meant %(closest)r? (choose from %(choices)s)": "argparse.invalid_choice_hint",
209
+ "invalid %(type)s value: %(value)r": "argparse.invalid_value",
210
+ "expected one argument": "argparse.expected_one",
211
+ "unrecognized arguments: %s": "argparse.unrecognized",
212
+ "the following arguments are required: %s": "argparse.required",
213
+ " (default: %(default)s)": "argparse.default",
214
+ "unexpected option string: %s": "argparse.unexpected_option",
215
+ "ambiguous option: %(option)s could match %(matches)s": "argparse.ambiguous",
216
+ "not allowed with argument %s": "argparse.not_allowed",
217
+ }
218
+
219
+
220
+ def t(lang: str, key: str, **params: object) -> str:
221
+ """Return the message ``key`` in ``lang`` (unknown languages fall back to English)."""
222
+ table = MESSAGES.get(lang, MESSAGES[DEFAULT_LANG])
223
+ text = table.get(key) or MESSAGES[DEFAULT_LANG][key]
224
+ return text.format(**params) if params else text
225
+
226
+
227
+ def lang_from_locale(value: str | None) -> str | None:
228
+ """Map a locale string such as ``ko_KR.UTF-8`` to ``ko``; empty means "not set"."""
229
+ if not value:
230
+ return None
231
+ return "ko" if value.strip().lower().startswith("ko") else DEFAULT_LANG
232
+
233
+
234
+ def resolve_lang(cli_value: str | None = None, env: Mapping[str, str] | None = None) -> str:
235
+ """Pick the output language: ``--lang`` > ``HANDOFFMAP_LANG`` > ``LC_ALL`` > ``LC_MESSAGES`` > ``LANG``.
236
+
237
+ Anything that is not Korean resolves to English. An unknown ``--lang`` value is ignored
238
+ here (argparse rejects it separately), so the error message can still be localized.
239
+ """
240
+ environ = os.environ if env is None else env
241
+ if cli_value in LANGS:
242
+ return str(cli_value)
243
+ explicit = environ.get(ENV_VAR, "").strip().lower()
244
+ if explicit in LANGS:
245
+ return explicit
246
+ for name in ("LC_ALL", "LC_MESSAGES", "LANG"):
247
+ found = lang_from_locale(environ.get(name))
248
+ if found:
249
+ return found
250
+ return DEFAULT_LANG
251
+
252
+
253
+ def argparse_translator(lang: str):
254
+ """Return a gettext-like function that renders argparse's built-in messages in ``lang``."""
255
+
256
+ def translate(msgid: str) -> str:
257
+ key = _ARGPARSE_IDS.get(msgid)
258
+ if key is None:
259
+ return msgid
260
+ return MESSAGES.get(lang, MESSAGES[DEFAULT_LANG])[key]
261
+
262
+ return translate
handoffmap/model.py ADDED
@@ -0,0 +1,156 @@
1
+ """Plain dataclasses shared by the loader, the normalizer and the renderers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Literal
7
+
8
+ EdgeKind = Literal["root", "call", "as_tool", "handoff"]
9
+
10
+
11
+ @dataclass
12
+ class Span:
13
+ """One OTLP span with normalized ids (lowercase hex) and decoded attributes."""
14
+
15
+ trace_id: str
16
+ span_id: str
17
+ parent_id: str | None
18
+ name: str
19
+ kind: int
20
+ start_ns: int
21
+ end_ns: int
22
+ attributes: dict[str, Any] = field(default_factory=dict)
23
+ scope: str = ""
24
+
25
+
26
+ @dataclass
27
+ class Trace:
28
+ """All spans that share one trace id, in the order they were read."""
29
+
30
+ trace_id: str
31
+ spans: list[Span] = field(default_factory=list)
32
+
33
+
34
+ @dataclass
35
+ class AgentRun:
36
+ """One execution of an agent (an instance, not a name)."""
37
+
38
+ run_id: str # span id of the agent span
39
+ name: str
40
+ start_ns: int
41
+ end_ns: int
42
+
43
+ @property
44
+ def duration_ms(self) -> float:
45
+ return (self.end_ns - self.start_ns) / 1e6
46
+
47
+
48
+ @dataclass
49
+ class Edge:
50
+ """Who started whom. ``caller`` is None for ``root``."""
51
+
52
+ kind: EdgeKind
53
+ caller: str | None
54
+ callee: str
55
+ caller_run: str | None
56
+ callee_run: str
57
+ via: str | None # tool name for as_tool, graph parent for handoff
58
+ start_ns: int
59
+ end_ns: int
60
+
61
+ @property
62
+ def duration_ms(self) -> float:
63
+ return (self.end_ns - self.start_ns) / 1e6
64
+
65
+ def key(self) -> tuple[str | None, str, str]:
66
+ return (self.caller, self.callee, self.kind)
67
+
68
+
69
+ @dataclass
70
+ class ToolCall:
71
+ """A tool invocation owned by an agent run."""
72
+
73
+ run_id: str
74
+ agent: str
75
+ name: str
76
+ span_id: str
77
+ start_ns: int
78
+ end_ns: int
79
+ wraps_agent: bool # True when a sub-agent ran inside this tool (as_tool)
80
+
81
+
82
+ @dataclass
83
+ class Member:
84
+ """One concurrent activity inside a parallel group."""
85
+
86
+ kind: Literal["agent", "tool"]
87
+ name: str
88
+ start_ns: int
89
+ end_ns: int
90
+ ref: str # callee run id (agent) or tool span id (tool)
91
+
92
+ @property
93
+ def label(self) -> str:
94
+ return f"{self.kind}:{self.name}"
95
+
96
+
97
+ @dataclass
98
+ class ParallelGroup:
99
+ run_id: str
100
+ agent: str
101
+ members: list[Member]
102
+
103
+ @property
104
+ def start_ns(self) -> int:
105
+ return min(m.start_ns for m in self.members)
106
+
107
+
108
+ @dataclass
109
+ class AgentStats:
110
+ name: str
111
+ runs: int = 0
112
+ llm_calls: int = 0
113
+ tool_calls: int = 0
114
+ input_tokens: int | None = None
115
+ output_tokens: int | None = None
116
+ cost: float | None = None
117
+ duration_ms: float = 0.0
118
+
119
+
120
+ @dataclass
121
+ class FlowWarning:
122
+ code: str
123
+ message: str # English; the JSON output keeps it as is
124
+ args: dict[str, Any] = field(default_factory=dict) # values for the localized message ``warn.<code>``
125
+
126
+
127
+ @dataclass
128
+ class Flow:
129
+ trace_id: str
130
+ convention: str
131
+ span_count: int
132
+ runs: dict[str, AgentRun]
133
+ edges: list[Edge]
134
+ tools: list[ToolCall]
135
+ parallel: list[ParallelGroup]
136
+ agents: list[AgentStats]
137
+ warnings: list[FlowWarning]
138
+ ignored: list[str] = field(default_factory=list) # wrapper spans not counted as agents
139
+ unknown_operations: list[str] = field(default_factory=list)
140
+ reported_usage: tuple[int, int] | None = None # invoke_agent usage attrs, never summed into totals
141
+
142
+ @property
143
+ def totals(self) -> AgentStats:
144
+ total = AgentStats(name="total")
145
+ for a in self.agents:
146
+ total.runs += a.runs
147
+ total.llm_calls += a.llm_calls
148
+ total.tool_calls += a.tool_calls
149
+ total.duration_ms += a.duration_ms
150
+ if a.input_tokens is not None:
151
+ total.input_tokens = (total.input_tokens or 0) + a.input_tokens
152
+ if a.output_tokens is not None:
153
+ total.output_tokens = (total.output_tokens or 0) + a.output_tokens
154
+ if a.cost is not None:
155
+ total.cost = (total.cost or 0.0) + a.cost
156
+ return total
@@ -0,0 +1,195 @@
1
+ """Fold a span tree into an agent-level flow.
2
+
3
+ The idea: an agent run is a span the adapter calls ``agent``. For every run we
4
+ walk up the parent chain until we meet another agent run. What lies on the way
5
+ decides the edge kind:
6
+
7
+ * ``as_tool`` - a tool span sits between them (the sub-agent runs inside a tool);
8
+ * ``call`` - nothing in between (nested agent span, no tool);
9
+ * ``handoff`` - no agent ancestor, but the adapter says which agent handed over
10
+ (OpenInference ``graph.node.parent_id``): the new agent is a *sibling* span;
11
+ * ``root`` - nobody started it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+
18
+ from .adapters import Classified, classify
19
+ from .checks import find_warnings
20
+ from .errors import NoAgentSpansError
21
+ from .model import AgentRun, Edge, Flow, Member, ParallelGroup, Span, ToolCall, Trace
22
+ from .usage import collect_stats
23
+
24
+
25
+ @dataclass
26
+ class _Context:
27
+ spans: dict[str, Span]
28
+ info: dict[str, Classified]
29
+ runs: dict[str, AgentRun]
30
+
31
+ def owner_of(self, span: Span) -> tuple[AgentRun | None, list[Span]]:
32
+ """Nearest agent run above ``span`` and the spans passed on the way up.
33
+
34
+ If the parent chain is broken (a parent span was never exported) we fall
35
+ back to the narrowest agent run whose time window contains the span.
36
+ """
37
+ chain: list[Span] = []
38
+ seen = {span.span_id}
39
+ current = span
40
+ while current.parent_id is not None:
41
+ parent = self.spans.get(current.parent_id)
42
+ if parent is None or parent.span_id in seen:
43
+ return self._owner_by_time(span), chain # chain is broken
44
+ if parent.span_id in self.runs:
45
+ return self.runs[parent.span_id], chain
46
+ chain.append(parent)
47
+ seen.add(parent.span_id)
48
+ current = parent
49
+ return None, chain
50
+
51
+ def _owner_by_time(self, span: Span) -> AgentRun | None:
52
+ best: AgentRun | None = None
53
+ for run in self.runs.values():
54
+ if run.run_id == span.span_id:
55
+ continue
56
+ if run.start_ns <= span.start_ns and span.end_ns <= run.end_ns:
57
+ if best is None or (run.end_ns - run.start_ns) < (best.end_ns - best.start_ns):
58
+ best = run
59
+ return best
60
+
61
+
62
+ def _detect_convention(info: dict[str, Classified]) -> str:
63
+ found = {c.convention for c in info.values()}
64
+ if len(found) == 1:
65
+ return next(iter(found))
66
+ return "mixed" if found else "unknown"
67
+
68
+
69
+ def _pick_handoff_source(ctx: _Context, run: AgentRun, source_name: str) -> AgentRun | None:
70
+ """The run named ``source_name`` that most plausibly handed over to ``run``."""
71
+ candidates = [r for r in ctx.runs.values() if r.name == source_name and r.run_id != run.run_id]
72
+ if not candidates:
73
+ return None
74
+ earlier = [r for r in candidates if r.start_ns <= run.start_ns]
75
+ pool = earlier or candidates
76
+ return max(pool, key=lambda r: r.start_ns)
77
+
78
+
79
+ def _parallel_groups(runs: dict[str, AgentRun], edges: list[Edge], tools: list[ToolCall]) -> list[ParallelGroup]:
80
+ """Group activities of one run whose [start, end) windows overlap."""
81
+ groups: list[ParallelGroup] = []
82
+ for run in sorted(runs.values(), key=lambda r: r.start_ns):
83
+ members = [
84
+ Member("agent", e.callee, e.start_ns, e.end_ns, e.callee_run)
85
+ for e in edges
86
+ if e.caller_run == run.run_id and e.kind in ("call", "as_tool")
87
+ ]
88
+ members += [
89
+ Member("tool", t.name, t.start_ns, t.end_ns, t.span_id)
90
+ for t in tools
91
+ if t.run_id == run.run_id and not t.wraps_agent
92
+ ]
93
+ members.sort(key=lambda m: (m.start_ns, m.end_ns))
94
+ cluster: list[Member] = []
95
+ cluster_end = 0
96
+ for m in members:
97
+ if cluster and m.start_ns < cluster_end: # half-open: touching is not overlapping
98
+ cluster.append(m)
99
+ cluster_end = max(cluster_end, m.end_ns)
100
+ continue
101
+ if len(cluster) > 1:
102
+ groups.append(ParallelGroup(run.run_id, run.name, cluster))
103
+ cluster, cluster_end = [m], m.end_ns
104
+ if len(cluster) > 1:
105
+ groups.append(ParallelGroup(run.run_id, run.name, cluster))
106
+ return groups
107
+
108
+
109
+ def analyze(trace: Trace, *, repeat_threshold: int = 3, max_parallel: int = 8) -> Flow:
110
+ """Turn one trace into a :class:`~handoffmap.model.Flow`.
111
+
112
+ Raises :class:`~handoffmap.errors.NoAgentSpansError` if the trace has no agent spans.
113
+ """
114
+ if repeat_threshold < 1 or max_parallel < 1:
115
+ raise ValueError("repeat_threshold and max_parallel must be >= 1")
116
+
117
+ unknown: set[str] = set()
118
+ spans = {s.span_id: s for s in trace.spans}
119
+ info: dict[str, Classified] = {}
120
+ for s in trace.spans:
121
+ c = classify(s, unknown)
122
+ if c is not None:
123
+ info[s.span_id] = c
124
+
125
+ runs = {
126
+ sid: AgentRun(sid, c.name, spans[sid].start_ns, spans[sid].end_ns)
127
+ for sid, c in info.items()
128
+ if c.role == "agent"
129
+ }
130
+ if not runs:
131
+ raise NoAgentSpansError("err.no_agent_spans", trace_id=trace.trace_id, count=len(trace.spans))
132
+ ctx = _Context(spans, info, runs)
133
+
134
+ edges: list[Edge] = []
135
+ wrapper_tools: set[str] = set()
136
+ for run in sorted(runs.values(), key=lambda r: (r.start_ns, r.end_ns)):
137
+ caller, chain = ctx.owner_of(spans[run.run_id])
138
+ tool_spans = [
139
+ s for s in chain if info.get(s.span_id) and info[s.span_id].role == "tool" and not info[s.span_id].handoff_marker
140
+ ]
141
+ if caller is not None:
142
+ if tool_spans:
143
+ via = tool_spans[-1] # the tool closest to the caller
144
+ wrapper_tools.add(via.span_id)
145
+ kind, via_name = "as_tool", info[via.span_id].name
146
+ else:
147
+ kind, via_name = "call", None
148
+ edges.append(Edge(kind, caller.name, run.name, caller.run_id, run.run_id, via_name, run.start_ns, run.end_ns))
149
+ continue
150
+ source_name = info[run.run_id].handoff_from
151
+ source = _pick_handoff_source(ctx, run, source_name) if source_name else None
152
+ if source is not None:
153
+ edges.append(Edge("handoff", source.name, run.name, source.run_id, run.run_id, None, run.start_ns, run.end_ns))
154
+ else:
155
+ edges.append(Edge("root", None, run.name, None, run.run_id, None, run.start_ns, run.end_ns))
156
+
157
+ tools: list[ToolCall] = []
158
+ for sid, c in info.items():
159
+ if c.role != "tool" or c.handoff_marker:
160
+ continue
161
+ owner, _ = ctx.owner_of(spans[sid])
162
+ if owner is None:
163
+ continue
164
+ s = spans[sid]
165
+ tools.append(ToolCall(owner.run_id, owner.name, c.name, sid, s.start_ns, s.end_ns, sid in wrapper_tools))
166
+ tools.sort(key=lambda t: t.start_ns)
167
+
168
+ llm_by_run: dict[str, list[Classified]] = {}
169
+ for sid, c in info.items():
170
+ if c.role == "llm":
171
+ owner, _ = ctx.owner_of(spans[sid])
172
+ if owner is not None:
173
+ llm_by_run.setdefault(owner.run_id, []).append(c)
174
+
175
+ parallel = _parallel_groups(runs, edges, tools)
176
+ agents = collect_stats(runs, tools, llm_by_run)
177
+ warnings = find_warnings(edges, parallel, repeat_threshold, max_parallel)
178
+
179
+ reported = [c.reported_usage for c in info.values() if c.role == "agent" and c.reported_usage]
180
+ reported_total = (sum(r[0] for r in reported), sum(r[1] for r in reported)) if reported else None
181
+
182
+ return Flow(
183
+ trace_id=trace.trace_id,
184
+ convention=_detect_convention(info),
185
+ span_count=len(trace.spans),
186
+ runs=runs,
187
+ edges=edges,
188
+ tools=tools,
189
+ parallel=parallel,
190
+ agents=agents,
191
+ warnings=warnings,
192
+ ignored=[c.name for c in info.values() if c.role == "ignored"],
193
+ unknown_operations=sorted(unknown),
194
+ reported_usage=reported_total,
195
+ )