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/otlp.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Read OTLP JSON / JSONL trace exports into :class:`~handoffmap.model.Trace` objects.
|
|
2
|
+
|
|
3
|
+
Two id encodings exist in the wild:
|
|
4
|
+
|
|
5
|
+
* protobuf-JSON (what ``MessageToDict`` writes): ids are base64 strings, span
|
|
6
|
+
kinds are names such as ``SPAN_KIND_CLIENT``;
|
|
7
|
+
* the OTLP/JSON spec (Collector ``file`` exporter, JS SDK): ids are hex, kinds
|
|
8
|
+
are integers.
|
|
9
|
+
|
|
10
|
+
Both are accepted and normalized to lowercase hex ids and integer kinds.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import binascii
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .errors import NotOtlpError
|
|
23
|
+
from .model import Span, Trace
|
|
24
|
+
|
|
25
|
+
_HEX_ID = re.compile(r"^(?:[0-9a-fA-F]{16}|[0-9a-fA-F]{32})$")
|
|
26
|
+
_KINDS = {
|
|
27
|
+
"SPAN_KIND_UNSPECIFIED": 0,
|
|
28
|
+
"SPAN_KIND_INTERNAL": 1,
|
|
29
|
+
"SPAN_KIND_SERVER": 2,
|
|
30
|
+
"SPAN_KIND_CLIENT": 3,
|
|
31
|
+
"SPAN_KIND_PRODUCER": 4,
|
|
32
|
+
"SPAN_KIND_CONSUMER": 5,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def normalize_id(raw: Any) -> str | None:
|
|
37
|
+
"""Return a lowercase hex id, or None for a missing/all-zero id."""
|
|
38
|
+
if raw is None or raw == "":
|
|
39
|
+
return None
|
|
40
|
+
if not isinstance(raw, str):
|
|
41
|
+
raise NotOtlpError("err.id_type", type=type(raw).__name__)
|
|
42
|
+
if _HEX_ID.match(raw):
|
|
43
|
+
hex_id = raw.lower()
|
|
44
|
+
else:
|
|
45
|
+
try:
|
|
46
|
+
hex_id = base64.b64decode(raw, validate=True).hex()
|
|
47
|
+
except (binascii.Error, ValueError) as exc:
|
|
48
|
+
raise NotOtlpError("err.id_encoding", raw=raw) from exc
|
|
49
|
+
if set(hex_id) == {"0"}:
|
|
50
|
+
return None
|
|
51
|
+
return hex_id
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _decode_value(value: Any) -> Any:
|
|
55
|
+
"""Decode an OTLP AnyValue (``{"stringValue": ...}`` and friends) to a Python value."""
|
|
56
|
+
if not isinstance(value, dict):
|
|
57
|
+
return value
|
|
58
|
+
if "stringValue" in value:
|
|
59
|
+
return value["stringValue"]
|
|
60
|
+
if "intValue" in value:
|
|
61
|
+
try:
|
|
62
|
+
return int(value["intValue"]) # int64 is a JSON string in protobuf-JSON
|
|
63
|
+
except (TypeError, ValueError):
|
|
64
|
+
return value["intValue"]
|
|
65
|
+
if "doubleValue" in value:
|
|
66
|
+
return value["doubleValue"]
|
|
67
|
+
if "boolValue" in value:
|
|
68
|
+
return value["boolValue"]
|
|
69
|
+
if "arrayValue" in value:
|
|
70
|
+
return [_decode_value(v) for v in value["arrayValue"].get("values", [])]
|
|
71
|
+
if "kvlistValue" in value:
|
|
72
|
+
return _decode_attrs(value["kvlistValue"].get("values", []))
|
|
73
|
+
if "bytesValue" in value:
|
|
74
|
+
return value["bytesValue"]
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _decode_attrs(items: Any) -> dict[str, Any]:
|
|
79
|
+
out: dict[str, Any] = {}
|
|
80
|
+
for item in items or []:
|
|
81
|
+
if isinstance(item, dict) and "key" in item:
|
|
82
|
+
out[item["key"]] = _decode_value(item.get("value"))
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _kind(raw: Any) -> int:
|
|
87
|
+
if isinstance(raw, int):
|
|
88
|
+
return raw
|
|
89
|
+
if isinstance(raw, str):
|
|
90
|
+
if raw in _KINDS:
|
|
91
|
+
return _KINDS[raw]
|
|
92
|
+
if raw.isdigit():
|
|
93
|
+
return int(raw)
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _int(raw: Any) -> int:
|
|
98
|
+
try:
|
|
99
|
+
return int(raw)
|
|
100
|
+
except (TypeError, ValueError):
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _spans_of_request(request: Any) -> list[Span]:
|
|
105
|
+
if not isinstance(request, dict) or not isinstance(request.get("resourceSpans"), list):
|
|
106
|
+
raise NotOtlpError("err.no_resource_spans")
|
|
107
|
+
spans: list[Span] = []
|
|
108
|
+
for resource_spans in request["resourceSpans"]:
|
|
109
|
+
# older exporters use instrumentationLibrarySpans instead of scopeSpans
|
|
110
|
+
scope_list = resource_spans.get("scopeSpans") or resource_spans.get("instrumentationLibrarySpans") or []
|
|
111
|
+
for scope_spans in scope_list:
|
|
112
|
+
scope = scope_spans.get("scope") or scope_spans.get("instrumentationLibrary") or {}
|
|
113
|
+
for raw in scope_spans.get("spans", []):
|
|
114
|
+
trace_id = normalize_id(raw.get("traceId"))
|
|
115
|
+
span_id = normalize_id(raw.get("spanId"))
|
|
116
|
+
if trace_id is None or span_id is None:
|
|
117
|
+
raise NotOtlpError("err.span_no_ids")
|
|
118
|
+
spans.append(
|
|
119
|
+
Span(
|
|
120
|
+
trace_id=trace_id,
|
|
121
|
+
span_id=span_id,
|
|
122
|
+
parent_id=normalize_id(raw.get("parentSpanId")),
|
|
123
|
+
name=str(raw.get("name", "")),
|
|
124
|
+
kind=_kind(raw.get("kind")),
|
|
125
|
+
start_ns=_int(raw.get("startTimeUnixNano")),
|
|
126
|
+
end_ns=_int(raw.get("endTimeUnixNano")),
|
|
127
|
+
attributes=_decode_attrs(raw.get("attributes")),
|
|
128
|
+
scope=str(scope.get("name", "")),
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
return spans
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse(text: str) -> list[Trace]:
|
|
135
|
+
"""Parse OTLP JSON (one request) or JSONL (one request per line) text."""
|
|
136
|
+
requests: list[Any]
|
|
137
|
+
try:
|
|
138
|
+
requests = [json.loads(text)]
|
|
139
|
+
except json.JSONDecodeError as whole_error:
|
|
140
|
+
lines = [ln for ln in text.splitlines() if ln.strip()]
|
|
141
|
+
try:
|
|
142
|
+
requests = [json.loads(ln) for ln in lines]
|
|
143
|
+
except json.JSONDecodeError:
|
|
144
|
+
raise NotOtlpError("err.invalid_json", msg=whole_error.msg, line=whole_error.lineno) from None
|
|
145
|
+
if not requests:
|
|
146
|
+
raise NotOtlpError("err.empty") from None
|
|
147
|
+
|
|
148
|
+
traces: dict[str, Trace] = {}
|
|
149
|
+
for request in requests:
|
|
150
|
+
for span in _spans_of_request(request):
|
|
151
|
+
traces.setdefault(span.trace_id, Trace(span.trace_id)).spans.append(span)
|
|
152
|
+
if not traces:
|
|
153
|
+
raise NotOtlpError("err.no_spans")
|
|
154
|
+
return list(traces.values())
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def load(path: str | Path) -> list[Trace]:
|
|
158
|
+
"""Read an OTLP JSON or JSONL file and return one :class:`Trace` per trace id."""
|
|
159
|
+
p = Path(path)
|
|
160
|
+
try:
|
|
161
|
+
text = p.read_text(encoding="utf-8")
|
|
162
|
+
except FileNotFoundError:
|
|
163
|
+
raise NotOtlpError("err.file_not_found", path=p) from None
|
|
164
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
165
|
+
raise NotOtlpError("err.cannot_read", path=p, error=exc) from None
|
|
166
|
+
return parse(text)
|
handoffmap/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Renderers: Mermaid sequence diagram, Markdown table, full report, JSON."""
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""``--explain``: show which convention was detected and why each edge got its kind."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
|
|
7
|
+
from ..i18n import t
|
|
8
|
+
from ..model import Edge, Flow
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _edge_line(e: Edge, lang: str) -> str:
|
|
12
|
+
src = e.caller or t(lang, "ex.none")
|
|
13
|
+
extra = t(lang, "ex.via", via=e.via) if e.via else ""
|
|
14
|
+
return f" {src} -> {e.callee}: {e.kind}{extra} [{t(lang, 'why.' + e.kind)}]"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def render_explain(flow: Flow, lang: str = "en") -> str:
|
|
18
|
+
"""Describe the detection result; ``lang`` is ``en`` (default) or ``ko``."""
|
|
19
|
+
lines = [
|
|
20
|
+
t(lang, "ex.trace", trace=flow.trace_id),
|
|
21
|
+
t(lang, "ex.convention", convention=flow.convention),
|
|
22
|
+
t(lang, "ex.size", spans=flow.span_count, runs=len(flow.runs), agents=len(flow.agents)),
|
|
23
|
+
t(lang, "ex.agents", names=", ".join(a.name for a in flow.agents)),
|
|
24
|
+
t(lang, "ex.edges"),
|
|
25
|
+
]
|
|
26
|
+
lines += [_edge_line(e, lang) for e in flow.edges]
|
|
27
|
+
if flow.ignored:
|
|
28
|
+
counts = Counter(flow.ignored)
|
|
29
|
+
shown = ", ".join(f"'{name}' x{n}" for name, n in counts.items())
|
|
30
|
+
lines.append(t(lang, "ex.ignored", shown=shown))
|
|
31
|
+
else:
|
|
32
|
+
lines.append(t(lang, "ex.ignored_none"))
|
|
33
|
+
if flow.unknown_operations:
|
|
34
|
+
lines.append(t(lang, "ex.unknown_ops", names=", ".join(flow.unknown_operations)))
|
|
35
|
+
tot = flow.totals
|
|
36
|
+
if tot.input_tokens is None and tot.output_tokens is None:
|
|
37
|
+
lines.append(t(lang, "ex.tokens_none"))
|
|
38
|
+
else:
|
|
39
|
+
lines.append(t(lang, "ex.tokens", input=tot.input_tokens or 0, output=tot.output_tokens or 0))
|
|
40
|
+
if flow.reported_usage is not None:
|
|
41
|
+
lines.append(t(lang, "ex.reported", input=flow.reported_usage[0], output=flow.reported_usage[1]))
|
|
42
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Stable JSON output. Only names, ids, timings and counts: never message content."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..model import AgentStats, Flow
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _agent(a: AgentStats) -> dict[str, Any]:
|
|
11
|
+
return {
|
|
12
|
+
"name": a.name,
|
|
13
|
+
"runs": a.runs,
|
|
14
|
+
"llm_calls": a.llm_calls,
|
|
15
|
+
"tool_calls": a.tool_calls,
|
|
16
|
+
"input_tokens": a.input_tokens,
|
|
17
|
+
"output_tokens": a.output_tokens,
|
|
18
|
+
"cost": a.cost,
|
|
19
|
+
"duration_ms": round(a.duration_ms, 3),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def to_dict(flow: Flow) -> dict[str, Any]:
|
|
24
|
+
"""Convert a flow to plain dicts/lists (``json.dumps``-able)."""
|
|
25
|
+
t0 = min((r.start_ns for r in flow.runs.values()), default=0)
|
|
26
|
+
return {
|
|
27
|
+
"trace_id": flow.trace_id,
|
|
28
|
+
"convention": flow.convention,
|
|
29
|
+
"span_count": flow.span_count,
|
|
30
|
+
"agents": [_agent(a) for a in flow.agents],
|
|
31
|
+
"totals": _agent(flow.totals),
|
|
32
|
+
"edges": [
|
|
33
|
+
{
|
|
34
|
+
"kind": e.kind,
|
|
35
|
+
"from": e.caller,
|
|
36
|
+
"to": e.callee,
|
|
37
|
+
"via": e.via,
|
|
38
|
+
"start_ms": round((e.start_ns - t0) / 1e6, 3),
|
|
39
|
+
"duration_ms": round(e.duration_ms, 3),
|
|
40
|
+
}
|
|
41
|
+
for e in flow.edges
|
|
42
|
+
],
|
|
43
|
+
"parallel": [
|
|
44
|
+
{
|
|
45
|
+
"agent": g.agent,
|
|
46
|
+
"members": [
|
|
47
|
+
{"kind": m.kind, "name": m.name, "duration_ms": round((m.end_ns - m.start_ns) / 1e6, 3)}
|
|
48
|
+
for m in g.members
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
for g in flow.parallel
|
|
52
|
+
],
|
|
53
|
+
"warnings": [{"code": w.code, "message": w.message} for w in flow.warnings],
|
|
54
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""The default report: Mermaid diagram + agent table + warnings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..i18n import t
|
|
6
|
+
from ..model import Flow
|
|
7
|
+
from .mermaid import render_mermaid
|
|
8
|
+
from .table import render_table
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render_markdown(flow: Flow, *, show_tools: bool = True, heading: bool = True, lang: str = "en") -> str:
|
|
12
|
+
"""Render the full report; ``lang`` (``en`` default, or ``ko``) only changes the prose, not the diagram."""
|
|
13
|
+
parts: list[str] = []
|
|
14
|
+
if heading:
|
|
15
|
+
parts.append(
|
|
16
|
+
t(lang, "md.heading", trace=flow.trace_id[:8], convention=flow.convention, spans=flow.span_count) + "\n"
|
|
17
|
+
)
|
|
18
|
+
parts.append("```mermaid\n" + render_mermaid(flow, show_tools=show_tools) + "```\n")
|
|
19
|
+
parts.append(t(lang, "md.agents") + "\n")
|
|
20
|
+
parts.append(render_table(flow, lang=lang))
|
|
21
|
+
parts.append(t(lang, "md.footnote") + "\n")
|
|
22
|
+
if flow.warnings:
|
|
23
|
+
parts.append(t(lang, "md.warnings") + "\n")
|
|
24
|
+
lines = []
|
|
25
|
+
for w in flow.warnings:
|
|
26
|
+
# warnings built by find_warnings carry args; a hand-made one falls back to its English message
|
|
27
|
+
message = t(lang, f"warn.{w.code}", **w.args) if w.args else w.message
|
|
28
|
+
lines.append(f"- **{w.code}**: {message}")
|
|
29
|
+
parts.append("\n".join(lines) + "\n")
|
|
30
|
+
return "\n".join(parts)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Mermaid ``sequenceDiagram`` output.
|
|
2
|
+
|
|
3
|
+
Mermaid is picky about names: participants get plain aliases (``a1``, ``a2``)
|
|
4
|
+
and the real name goes after ``as``; ``#``, ``;``, ``<`` and ``>`` in any text
|
|
5
|
+
are written as Mermaid entity codes (``#59;``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from ..model import Edge, Flow, ParallelGroup, ToolCall
|
|
14
|
+
|
|
15
|
+
_SPECIAL = re.compile(r"[#;<>]")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def escape(text: str) -> str:
|
|
19
|
+
"""Make ``text`` safe for a Mermaid message or participant label."""
|
|
20
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
21
|
+
return _SPECIAL.sub(lambda m: f"#{ord(m.group())};", text)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _ms(value_ms: float) -> str:
|
|
25
|
+
return f"{value_ms:.1f} ms"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class _Act:
|
|
30
|
+
"""One thing an agent run did: start a sub-agent/handoff (edge) or call a tool."""
|
|
31
|
+
|
|
32
|
+
ref: str # callee run id (edge) or tool span id (tool)
|
|
33
|
+
start_ns: int
|
|
34
|
+
edge: Edge | None = None
|
|
35
|
+
tool: ToolCall | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def render_mermaid(flow: Flow, *, show_tools: bool = True) -> str:
|
|
39
|
+
"""Render the flow as a Mermaid sequence diagram (no code fence)."""
|
|
40
|
+
alias: dict[str, str] = {}
|
|
41
|
+
for run in sorted(flow.runs.values(), key=lambda r: (r.start_ns, r.end_ns)):
|
|
42
|
+
alias.setdefault(run.name, f"a{len(alias) + 1}")
|
|
43
|
+
|
|
44
|
+
lines = ["sequenceDiagram"]
|
|
45
|
+
for name, a in alias.items():
|
|
46
|
+
lines.append(f" participant {a} as {escape(name)}")
|
|
47
|
+
|
|
48
|
+
# what each agent run did, in time order
|
|
49
|
+
acts_by_run: dict[str, list[_Act]] = {}
|
|
50
|
+
for e in flow.edges:
|
|
51
|
+
if e.caller_run is not None:
|
|
52
|
+
acts_by_run.setdefault(e.caller_run, []).append(_Act(e.callee_run, e.start_ns, edge=e))
|
|
53
|
+
if show_tools:
|
|
54
|
+
for t in flow.tools:
|
|
55
|
+
if not t.wraps_agent: # the sub-agent's own edge already shows this call
|
|
56
|
+
acts_by_run.setdefault(t.run_id, []).append(_Act(t.span_id, t.start_ns, tool=t))
|
|
57
|
+
for acts in acts_by_run.values():
|
|
58
|
+
acts.sort(key=lambda a: a.start_ns)
|
|
59
|
+
|
|
60
|
+
groups_by_run: dict[str, list[ParallelGroup]] = {}
|
|
61
|
+
for g in flow.parallel:
|
|
62
|
+
groups_by_run.setdefault(g.run_id, []).append(g)
|
|
63
|
+
|
|
64
|
+
visited: set[str] = set() # a run is drawn once, even if the data has a handoff cycle
|
|
65
|
+
|
|
66
|
+
def act_line(act: _Act, indent: str) -> str:
|
|
67
|
+
if act.tool is not None:
|
|
68
|
+
t = act.tool
|
|
69
|
+
a = alias[t.agent]
|
|
70
|
+
return f"{indent}{a}->>{a}: {escape('tool ' + t.name)} ({_ms((t.end_ns - t.start_ns) / 1e6)})"
|
|
71
|
+
e = act.edge
|
|
72
|
+
assert e is not None
|
|
73
|
+
src, dst = alias[e.caller or ""], alias[e.callee]
|
|
74
|
+
if e.kind == "handoff":
|
|
75
|
+
return f"{indent}{src}-){dst}: handoff ({_ms(e.duration_ms)})" # -) = async, control does not come back
|
|
76
|
+
label = f"as_tool via {e.via}" if e.kind == "as_tool" and e.via else e.kind
|
|
77
|
+
return f"{indent}{src}->>{dst}: {escape(label)} ({_ms(e.duration_ms)})"
|
|
78
|
+
|
|
79
|
+
def emit_act(act: _Act, indent: str) -> None:
|
|
80
|
+
lines.append(act_line(act, indent))
|
|
81
|
+
if act.edge is not None:
|
|
82
|
+
emit_run(act.edge.callee_run, indent)
|
|
83
|
+
|
|
84
|
+
def emit_run(run_id: str, indent: str) -> None:
|
|
85
|
+
if run_id in visited:
|
|
86
|
+
return
|
|
87
|
+
visited.add(run_id)
|
|
88
|
+
acts = acts_by_run.get(run_id, [])
|
|
89
|
+
by_ref = {a.ref: a for a in acts}
|
|
90
|
+
group_of: dict[str, list[_Act]] = {}
|
|
91
|
+
for g in groups_by_run.get(run_id, []):
|
|
92
|
+
branches = [by_ref[m.ref] for m in g.members if m.ref in by_ref]
|
|
93
|
+
if len(branches) > 1: # with --no-tools a group can shrink to one branch: then no par
|
|
94
|
+
for b in branches:
|
|
95
|
+
group_of[b.ref] = branches
|
|
96
|
+
|
|
97
|
+
drawn: set[str] = set()
|
|
98
|
+
for act in acts:
|
|
99
|
+
branches = group_of.get(act.ref)
|
|
100
|
+
if branches is None:
|
|
101
|
+
emit_act(act, indent)
|
|
102
|
+
elif act.ref not in drawn:
|
|
103
|
+
lines.append(f"{indent}par concurrent")
|
|
104
|
+
for i, b in enumerate(branches):
|
|
105
|
+
if i:
|
|
106
|
+
lines.append(f"{indent}and")
|
|
107
|
+
emit_act(b, indent + " ")
|
|
108
|
+
drawn.add(b.ref)
|
|
109
|
+
lines.append(f"{indent}end")
|
|
110
|
+
|
|
111
|
+
for e in flow.edges:
|
|
112
|
+
if e.kind == "root":
|
|
113
|
+
lines.append(f" Note over {alias[e.callee]}: start")
|
|
114
|
+
emit_run(e.callee_run, " ")
|
|
115
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Per-agent Markdown table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..i18n import t
|
|
6
|
+
from ..model import AgentStats, Flow
|
|
7
|
+
|
|
8
|
+
DASH = "–" # shown when a value is not in the trace
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _cell(text: str) -> str:
|
|
12
|
+
# `|` would split the cell; `<`/`>` would be read as HTML by Markdown renderers
|
|
13
|
+
return text.replace("|", "\\|").replace("<", "<").replace(">", ">").replace("\n", " ")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _tokens(value: int | None) -> str:
|
|
17
|
+
return DASH if value is None else str(value)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _row(a: AgentStats, with_cost: bool, bold: bool = False, label: str | None = None) -> str:
|
|
21
|
+
shown = a.name if label is None else label # the totals row gets a translated label
|
|
22
|
+
name = f"**{_cell(shown)}**" if bold else _cell(shown)
|
|
23
|
+
cells = [name, str(a.runs), str(a.llm_calls), str(a.tool_calls), _tokens(a.input_tokens), _tokens(a.output_tokens)]
|
|
24
|
+
if with_cost:
|
|
25
|
+
cells.append(DASH if a.cost is None else f"{a.cost:.6f}".rstrip("0").rstrip("."))
|
|
26
|
+
cells.append(f"{a.duration_ms:.1f} ms")
|
|
27
|
+
return "| " + " | ".join(cells) + " |"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def render_table(flow: Flow, lang: str = "en") -> str:
|
|
31
|
+
"""Render one row per agent name plus a total row (when there is more than one agent)."""
|
|
32
|
+
with_cost = any(a.cost is not None for a in flow.agents)
|
|
33
|
+
keys = ["agent", "runs", "llm_calls", "tools", "input_tokens", "output_tokens"]
|
|
34
|
+
if with_cost:
|
|
35
|
+
keys.append("cost")
|
|
36
|
+
keys.append("time")
|
|
37
|
+
headers = [t(lang, f"table.{k}") for k in keys]
|
|
38
|
+
lines = [
|
|
39
|
+
"| " + " | ".join(headers) + " |",
|
|
40
|
+
"|" + "|".join(["---"] + ["---:"] * (len(headers) - 1)) + "|",
|
|
41
|
+
]
|
|
42
|
+
lines += [_row(a, with_cost) for a in flow.agents]
|
|
43
|
+
if len(flow.agents) > 1:
|
|
44
|
+
lines.append(_row(flow.totals, with_cost, bold=True, label=t(lang, "table.total")))
|
|
45
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Built-in sample traces
|
|
2
|
+
|
|
3
|
+
All four were recorded on 2026-11-11 with **mock models** (no network, no API key), so token counts and timings are practice values.
|
|
4
|
+
|
|
5
|
+
| File | Made with |
|
|
6
|
+
|---|---|
|
|
7
|
+
| `pydantic_ai_v2.otlp.json`, `pydantic_ai_v5.otlp.json`, `pydantic_ai_v6.otlp.json` | pydantic-ai-slim 2.67.0, `FunctionModel`, instrumentation `version=2/5/6`; see `examples/make_pydantic_trace.py` |
|
|
8
|
+
| `openinference_agents.otlp.json` | openai-agents 0.24.0 `ScriptedModel`, openinference-instrumentation-openai-agents 2.7.1; see `examples/make_openai_agents_trace.py` |
|
|
9
|
+
|
|
10
|
+
They use the protobuf-JSON form (base64 ids, `SPAN_KIND_*` names). Prompt/response text in them is mock text; handoffmap never prints it.
|