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/__init__.py +50 -0
- handoffmap/__main__.py +5 -0
- handoffmap/adapters/__init__.py +70 -0
- handoffmap/adapters/genai.py +56 -0
- handoffmap/adapters/openinference.py +40 -0
- handoffmap/checks.py +33 -0
- handoffmap/cli.py +169 -0
- handoffmap/errors.py +37 -0
- handoffmap/i18n.py +262 -0
- handoffmap/model.py +156 -0
- handoffmap/normalize.py +195 -0
- handoffmap/otlp.py +166 -0
- handoffmap/py.typed +0 -0
- handoffmap/render/__init__.py +1 -0
- handoffmap/render/explain.py +42 -0
- handoffmap/render/jsonout.py +54 -0
- handoffmap/render/markdown.py +30 -0
- handoffmap/render/mermaid.py +115 -0
- handoffmap/render/table.py +45 -0
- handoffmap/samples/SOURCES.md +10 -0
- handoffmap/samples/openinference_agents.otlp.json +556 -0
- handoffmap/samples/pydantic_ai_v2.otlp.json +1032 -0
- handoffmap/samples/pydantic_ai_v5.otlp.json +1032 -0
- handoffmap/samples/pydantic_ai_v6.otlp.json +1032 -0
- handoffmap/usage.py +35 -0
- handoffmap-0.2.0.dist-info/METADATA +425 -0
- handoffmap-0.2.0.dist-info/RECORD +30 -0
- handoffmap-0.2.0.dist-info/WHEEL +4 -0
- handoffmap-0.2.0.dist-info/entry_points.txt +3 -0
- handoffmap-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|
handoffmap/normalize.py
ADDED
|
@@ -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
|
+
)
|