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 ADDED
@@ -0,0 +1,50 @@
1
+ """handoffmap: fold OTLP agent traces into a Mermaid flow and a per-agent table."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .errors import HandoffmapError, NoAgentSpansError, NotOtlpError
6
+ from .model import (
7
+ AgentRun,
8
+ AgentStats,
9
+ Edge,
10
+ Flow,
11
+ FlowWarning,
12
+ Member,
13
+ ParallelGroup,
14
+ Span,
15
+ ToolCall,
16
+ Trace,
17
+ )
18
+ from .normalize import analyze
19
+ from .otlp import load, parse
20
+ from .render.jsonout import to_dict
21
+ from .render.explain import render_explain
22
+ from .render.markdown import render_markdown
23
+ from .render.mermaid import render_mermaid
24
+ from .render.table import render_table
25
+
26
+ __version__ = "0.2.0"
27
+
28
+ __all__ = [
29
+ "AgentRun",
30
+ "AgentStats",
31
+ "Edge",
32
+ "Flow",
33
+ "FlowWarning",
34
+ "HandoffmapError",
35
+ "Member",
36
+ "NoAgentSpansError",
37
+ "NotOtlpError",
38
+ "ParallelGroup",
39
+ "Span",
40
+ "ToolCall",
41
+ "Trace",
42
+ "analyze",
43
+ "load",
44
+ "parse",
45
+ "render_explain",
46
+ "render_markdown",
47
+ "render_mermaid",
48
+ "render_table",
49
+ "to_dict",
50
+ ]
handoffmap/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,70 @@
1
+ """Convention adapters: decide what role a span plays (agent, tool, LLM call).
2
+
3
+ Each adapter looks at attributes only, never at the span name, because the
4
+ OTel GenAI conventions are still in development and frameworks name spans
5
+ differently (Pydantic AI instrumentation v2 says ``agent run``, v5 says
6
+ ``invoke_agent orchestrator``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from ..model import Span
14
+
15
+ GENAI = "otel-genai"
16
+ OPENINFERENCE = "openinference"
17
+
18
+
19
+ @dataclass
20
+ class Classified:
21
+ """What an adapter learned about one span."""
22
+
23
+ role: str # "agent" | "tool" | "llm" | "ignored"
24
+ convention: str
25
+ name: str = ""
26
+ input_tokens: int | None = None
27
+ output_tokens: int | None = None
28
+ cost: float | None = None
29
+ handoff_marker: bool = False # a tool span that only records "transfer to X"
30
+ handoff_from: str | None = None # OpenInference graph.node.parent_id
31
+ reported_usage: tuple[int, int] | None = None # usage attributes on an agent span
32
+
33
+
34
+ def classify(span: Span, unknown_operations: set[str] | None = None) -> Classified | None:
35
+ """Classify a span, or return None if it is neither an agent, a tool nor an LLM call."""
36
+ from . import genai, openinference
37
+
38
+ if "gen_ai.operation.name" in span.attributes:
39
+ result = genai.classify(span, unknown_operations)
40
+ if result is not None or "openinference.span.kind" not in span.attributes:
41
+ return result
42
+ if "openinference.span.kind" in span.attributes:
43
+ return openinference.classify(span)
44
+ return None
45
+
46
+
47
+ def as_int(value: object) -> int | None:
48
+ """Return value as int when it is a number (or numeric string), else None."""
49
+ if isinstance(value, bool):
50
+ return None
51
+ if isinstance(value, int):
52
+ return value
53
+ if isinstance(value, float):
54
+ return int(value)
55
+ if isinstance(value, str) and value.strip().lstrip("-").isdigit():
56
+ return int(value)
57
+ return None
58
+
59
+
60
+ def as_float(value: object) -> float | None:
61
+ if isinstance(value, bool):
62
+ return None
63
+ if isinstance(value, (int, float)):
64
+ return float(value)
65
+ if isinstance(value, str):
66
+ try:
67
+ return float(value)
68
+ except ValueError:
69
+ return None
70
+ return None
@@ -0,0 +1,56 @@
1
+ """OTel GenAI semantic conventions (``gen_ai.operation.name`` based)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..model import Span
6
+ from . import GENAI, Classified, as_int
7
+
8
+ LLM_OPERATIONS = {"chat", "text_completion", "generate_content"}
9
+ # Operations the conventions define that carry no agent/tool/LLM role here.
10
+ OTHER_KNOWN_OPERATIONS = {"create_agent", "invoke_workflow", "embeddings", "retrieval"}
11
+
12
+
13
+ def _strip_prefix(name: str, prefix: str) -> str:
14
+ return name[len(prefix) :].strip() if name.startswith(prefix) else name
15
+
16
+
17
+ def classify(span: Span, unknown_operations: set[str] | None = None) -> Classified | None:
18
+ attrs = span.attributes
19
+ operation = attrs.get("gen_ai.operation.name")
20
+
21
+ if operation == "invoke_agent":
22
+ name = (
23
+ attrs.get("gen_ai.agent.name")
24
+ or attrs.get("agent_name") # Pydantic AI also sets this non-standard key
25
+ or _strip_prefix(span.name, "invoke_agent")
26
+ or attrs.get("gen_ai.agent.id")
27
+ or "agent"
28
+ )
29
+ reported = _reported_usage(attrs)
30
+ return Classified(role="agent", convention=GENAI, name=str(name), reported_usage=reported)
31
+
32
+ if operation == "execute_tool":
33
+ name = attrs.get("gen_ai.tool.name") or _strip_prefix(span.name, "execute_tool") or "tool"
34
+ return Classified(role="tool", convention=GENAI, name=str(name))
35
+
36
+ if operation in LLM_OPERATIONS:
37
+ return Classified(
38
+ role="llm",
39
+ convention=GENAI,
40
+ input_tokens=as_int(attrs.get("gen_ai.usage.input_tokens")),
41
+ output_tokens=as_int(attrs.get("gen_ai.usage.output_tokens")),
42
+ )
43
+
44
+ if operation not in OTHER_KNOWN_OPERATIONS and unknown_operations is not None:
45
+ unknown_operations.add(str(operation))
46
+ return None
47
+
48
+
49
+ def _reported_usage(attrs: dict) -> tuple[int, int] | None:
50
+ """Usage attributes on an agent span. Used only as a cross-check, never summed."""
51
+ for prefix in ("gen_ai.aggregated_usage", "gen_ai.usage"):
52
+ i = as_int(attrs.get(f"{prefix}.input_tokens"))
53
+ o = as_int(attrs.get(f"{prefix}.output_tokens"))
54
+ if i is not None or o is not None:
55
+ return (i or 0, o or 0)
56
+ return None
@@ -0,0 +1,40 @@
1
+ """OpenInference conventions (``openinference.span.kind`` based)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..model import Span
6
+ from . import OPENINFERENCE, Classified, as_float, as_int
7
+
8
+
9
+ def classify(span: Span) -> Classified | None:
10
+ attrs = span.attributes
11
+ kind = str(attrs.get("openinference.span.kind", "")).upper()
12
+
13
+ if kind == "AGENT":
14
+ name = attrs.get("agent.name") or attrs.get("graph.node.id")
15
+ if not name:
16
+ # e.g. the OpenAI Agents SDK "Agent workflow" wrapper: an AGENT span with no agent behind it
17
+ return Classified(role="ignored", convention=OPENINFERENCE, name=span.name)
18
+ parent = attrs.get("graph.node.parent_id")
19
+ return Classified(
20
+ role="agent",
21
+ convention=OPENINFERENCE,
22
+ name=str(name),
23
+ handoff_from=str(parent) if parent else None,
24
+ )
25
+
26
+ if kind == "TOOL":
27
+ name = attrs.get("tool.name") or span.name or "tool"
28
+ # The Agents SDK records a handoff as a TOOL span named "handoff to <agent>".
29
+ marker = span.name.lower().startswith("handoff to ")
30
+ return Classified(role="tool", convention=OPENINFERENCE, name=str(name), handoff_marker=marker)
31
+
32
+ if kind == "LLM":
33
+ return Classified(
34
+ role="llm",
35
+ convention=OPENINFERENCE,
36
+ input_tokens=as_int(attrs.get("llm.token_count.prompt")),
37
+ output_tokens=as_int(attrs.get("llm.token_count.completion")),
38
+ cost=as_float(attrs.get("llm.cost.total")),
39
+ )
40
+ return None
handoffmap/checks.py ADDED
@@ -0,0 +1,33 @@
1
+ """Flow warnings: things that usually mean a loop or a runaway fan-out."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+
7
+ from .i18n import t
8
+ from .model import Edge, FlowWarning, ParallelGroup
9
+
10
+
11
+ def find_warnings(
12
+ edges: list[Edge],
13
+ parallel: list[ParallelGroup],
14
+ repeat_threshold: int = 3,
15
+ max_parallel: int = 8,
16
+ ) -> list[FlowWarning]:
17
+ """Return ``repeated-delegation`` and ``wide-parallel`` warnings.
18
+
19
+ ``message`` is English; ``args`` lets the renderers rebuild it in another language.
20
+ """
21
+ warnings: list[FlowWarning] = []
22
+
23
+ pairs = Counter((e.caller, e.callee) for e in edges if e.kind != "root")
24
+ for (caller, callee), count in pairs.items():
25
+ if count >= repeat_threshold:
26
+ args = {"caller": caller, "callee": callee, "count": count, "threshold": repeat_threshold}
27
+ warnings.append(FlowWarning("repeated-delegation", t("en", "warn.repeated-delegation", **args), args))
28
+
29
+ for group in parallel:
30
+ if len(group.members) > max_parallel:
31
+ args = {"agent": group.agent, "count": len(group.members), "limit": max_parallel}
32
+ warnings.append(FlowWarning("wide-parallel", t("en", "warn.wide-parallel", **args), args))
33
+ return warnings
handoffmap/cli.py ADDED
@@ -0,0 +1,169 @@
1
+ """Command line interface.
2
+
3
+ Exit codes: 0 ok, 1 no agent spans in the trace, 2 bad input (not OTLP, missing file, bad option).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import sys
11
+ from collections.abc import Iterator
12
+ from contextlib import contextmanager
13
+ from importlib import resources
14
+ from pathlib import Path
15
+
16
+ from . import __version__
17
+ from .errors import HandoffmapError, NoAgentSpansError, NotOtlpError
18
+ from .i18n import LANGS, argparse_translator, resolve_lang, t
19
+ from .model import Flow, Trace
20
+ from .normalize import analyze
21
+ from .otlp import load, parse
22
+ from .render.explain import render_explain
23
+ from .render.jsonout import to_dict
24
+ from .render.markdown import render_markdown
25
+ from .render.mermaid import render_mermaid
26
+ from .render.table import render_table
27
+
28
+ SAMPLES = {
29
+ "pydantic-ai": "pydantic_ai_v5.otlp.json",
30
+ "pydantic-ai-v2": "pydantic_ai_v2.otlp.json",
31
+ "pydantic-ai-v6": "pydantic_ai_v6.otlp.json",
32
+ "openinference": "openinference_agents.otlp.json",
33
+ }
34
+ FORMATS = ("md", "mermaid", "table", "json")
35
+
36
+
37
+ def _positive_for(lang: str):
38
+ """Return an argparse ``type=`` function whose error messages are in ``lang``."""
39
+
40
+ def _positive(text: str) -> int:
41
+ try:
42
+ value = int(text)
43
+ except ValueError:
44
+ raise argparse.ArgumentTypeError(t(lang, "cli.err.not_whole", text=text)) from None
45
+ if value < 1:
46
+ raise argparse.ArgumentTypeError(t(lang, "cli.err.min_one"))
47
+ return value
48
+
49
+ return _positive
50
+
51
+
52
+ def _peek_lang(argv: list[str]) -> str:
53
+ """Read only ``--lang`` from argv first, so help and parse errors can already be localized."""
54
+ peek = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
55
+ peek.add_argument("--lang")
56
+ known, _rest = peek.parse_known_args(argv)
57
+ return resolve_lang(known.lang)
58
+
59
+
60
+ @contextmanager
61
+ def _argparse_in(lang: str) -> Iterator[None]:
62
+ """Render argparse's built-in messages (usage, choices errors, ...) in ``lang`` while parsing."""
63
+ original = argparse._ # type: ignore[attr-defined]
64
+ argparse._ = argparse_translator(lang) # type: ignore[attr-defined]
65
+ try:
66
+ yield
67
+ finally:
68
+ argparse._ = original # type: ignore[attr-defined]
69
+
70
+
71
+ def build_parser(lang: str = "en") -> argparse.ArgumentParser:
72
+ """Build the argument parser with help text in ``lang`` (``en`` or ``ko``)."""
73
+ positive = _positive_for(lang)
74
+ p = argparse.ArgumentParser(prog="handoffmap", description=t(lang, "cli.description"), add_help=False)
75
+ p.add_argument("-h", "--help", action="help", help=t(lang, "argparse.help"))
76
+ p.add_argument("file", nargs="?", help=t(lang, "cli.help.file"))
77
+ p.add_argument("--sample", choices=sorted(SAMPLES), help=t(lang, "cli.help.sample"))
78
+ p.add_argument("--format", "-f", choices=FORMATS, default="md", help=t(lang, "cli.help.format"))
79
+ p.add_argument("--explain", action="store_true", help=t(lang, "cli.help.explain"))
80
+ p.add_argument("--trace", metavar="TRACE_ID", help=t(lang, "cli.help.trace"))
81
+ p.add_argument("--max-parallel", type=positive, default=8, metavar="N", help=t(lang, "cli.help.max_parallel"))
82
+ p.add_argument("--repeat-threshold", type=positive, default=3, metavar="N", help=t(lang, "cli.help.repeat_threshold"))
83
+ p.add_argument("--no-tools", action="store_true", help=t(lang, "cli.help.no_tools"))
84
+ p.add_argument("-o", "--output", metavar="OUT", help=t(lang, "cli.help.output"))
85
+ p.add_argument("--lang", choices=LANGS, metavar="{en,ko}", help=t(lang, "cli.help.lang"))
86
+ p.add_argument("--version", action="version", version=f"handoffmap {__version__}", help=t(lang, "cli.help.version"))
87
+ return p
88
+
89
+
90
+ def _load_traces(args: argparse.Namespace) -> list[Trace]:
91
+ if args.sample:
92
+ text = resources.files("handoffmap").joinpath("samples", SAMPLES[args.sample]).read_text(encoding="utf-8")
93
+ traces = parse(text)
94
+ else:
95
+ traces = load(args.file)
96
+ if args.trace:
97
+ traces = [t for t in traces if t.trace_id.startswith(args.trace.lower())]
98
+ if not traces:
99
+ raise NotOtlpError("cli.err.no_trace", prefix=args.trace)
100
+ return traces
101
+
102
+
103
+ def _analyze_all(traces: list[Trace], args: argparse.Namespace) -> list[Flow]:
104
+ flows: list[Flow] = []
105
+ last_error: NoAgentSpansError | None = None
106
+ for trace in traces:
107
+ try:
108
+ flows.append(analyze(trace, repeat_threshold=args.repeat_threshold, max_parallel=args.max_parallel))
109
+ except NoAgentSpansError as exc:
110
+ last_error = exc # other traces in the file may still have agents
111
+ if not flows:
112
+ assert last_error is not None
113
+ raise last_error
114
+ return flows
115
+
116
+
117
+ def _render(flows: list[Flow], args: argparse.Namespace, lang: str) -> tuple[str, str]:
118
+ """Return (report, explain) text. ``json`` and ``mermaid`` ignore ``lang`` (machine output)."""
119
+ show_tools = not args.no_tools
120
+ explain = (
121
+ "".join(render_explain(f, lang) + ("\n" if len(flows) > 1 else "") for f in flows) if args.explain else ""
122
+ )
123
+ if args.format == "json":
124
+ payload = [to_dict(f) for f in flows]
125
+ report = json.dumps(payload[0] if len(payload) == 1 else {"traces": payload}, indent=2, ensure_ascii=False) + "\n"
126
+ elif args.format == "mermaid":
127
+ report = "\n".join(render_mermaid(f, show_tools=show_tools) for f in flows)
128
+ elif args.format == "table":
129
+ report = "\n".join(render_table(f, lang=lang) for f in flows)
130
+ else:
131
+ report = "\n".join(render_markdown(f, show_tools=show_tools, heading=True, lang=lang) for f in flows)
132
+ if args.sample and args.format == "md":
133
+ report += "\n" + t(lang, "cli.sample_note") + "\n"
134
+ return report, explain
135
+
136
+
137
+ def main(argv: list[str] | None = None) -> int:
138
+ for stream in (sys.stdout, sys.stderr):
139
+ if hasattr(stream, "reconfigure"):
140
+ stream.reconfigure(encoding="utf-8") # the table uses an en dash; C locales would choke
141
+ raw_args = sys.argv[1:] if argv is None else list(argv)
142
+ lang = _peek_lang(raw_args) # --lang > HANDOFFMAP_LANG > LC_ALL/LC_MESSAGES/LANG > en
143
+ with _argparse_in(lang): # the parser must be built inside: argparse looks up some titles at construction
144
+ parser = build_parser(lang)
145
+ args = parser.parse_args(raw_args)
146
+ if bool(args.file) == bool(args.sample):
147
+ parser.error(t(lang, "cli.err.one_of"))
148
+
149
+ try:
150
+ flows = _analyze_all(_load_traces(args), args)
151
+ report, explain = _render(flows, args, lang)
152
+ except HandoffmapError as exc:
153
+ print(f"handoffmap: {exc.localized(lang)}", file=sys.stderr)
154
+ return exc.exit_code
155
+
156
+ # explain goes before the report for md/table; to stderr for mermaid/json so stdout stays machine-readable
157
+ if explain and args.format in ("mermaid", "json"):
158
+ print(explain, file=sys.stderr, end="")
159
+ explain = ""
160
+ text = (explain + "\n" if explain else "") + report
161
+ if args.output:
162
+ try:
163
+ Path(args.output).write_text(text, encoding="utf-8")
164
+ except OSError as exc:
165
+ print(f"handoffmap: {t(lang, 'cli.err.cannot_write', path=args.output, error=exc)}", file=sys.stderr)
166
+ return 2
167
+ else:
168
+ sys.stdout.write(text)
169
+ return 0
handoffmap/errors.py ADDED
@@ -0,0 +1,37 @@
1
+ """Exceptions raised by handoffmap. The CLI maps them to exit codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .i18n import MESSAGES, t
6
+
7
+
8
+ class HandoffmapError(Exception):
9
+ """Base class for all handoffmap errors.
10
+
11
+ Built from a message key of :mod:`handoffmap.i18n` plus its parameters. ``str(exc)`` is
12
+ always English; the CLI calls :meth:`localized` to show the user's language.
13
+ """
14
+
15
+ exit_code = 2
16
+
17
+ def __init__(self, key: str, **params: object) -> None:
18
+ self.key = key
19
+ self.params = params
20
+ # a key that is not in the table is treated as a plain English message
21
+ super().__init__(t("en", key, **params) if key in MESSAGES["en"] else key)
22
+
23
+ def localized(self, lang: str) -> str:
24
+ """Return the message in ``lang`` (``en`` or ``ko``)."""
25
+ return t(lang, self.key, **self.params) if self.key in MESSAGES["en"] else str(self)
26
+
27
+
28
+ class NotOtlpError(HandoffmapError):
29
+ """The input is not an OTLP trace export (exit code 2)."""
30
+
31
+ exit_code = 2
32
+
33
+
34
+ class NoAgentSpansError(HandoffmapError):
35
+ """The trace has no agent spans, so there is no flow to draw (exit code 1)."""
36
+
37
+ exit_code = 1