agent-tracedown 0.3.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.
@@ -0,0 +1,32 @@
1
+ """Tracedown: the flight recorder for AI agents."""
2
+ from . import plugins, redaction
3
+ from .exporters import ConsoleExporter, FanoutExporter, HttpExporter, MemoryExporter, OtlpHttpExporter, to_otlp_json
4
+ from .tracer import (
5
+ SDK_VERSION as __version__,
6
+ ManualTrace,
7
+ ReplayDivergence,
8
+ ToolError,
9
+ agent,
10
+ current_trace_id,
11
+ declare_tools,
12
+ decision,
13
+ fetch_bundle,
14
+ handoff,
15
+ init,
16
+ llm_call,
17
+ memory_read,
18
+ memory_write,
19
+ replay,
20
+ retrieval,
21
+ run,
22
+ tool_call,
23
+ traced_run,
24
+ validate_args,
25
+ )
26
+
27
+ __all__ = [
28
+ "init", "run", "agent", "llm_call", "tool_call", "retrieval", "memory_read", "memory_write",
29
+ "decision", "handoff", "declare_tools", "OtlpHttpExporter", "FanoutExporter", "replay", "fetch_bundle", "current_trace_id", "validate_args",
30
+ "MemoryExporter", "ConsoleExporter", "HttpExporter", "to_otlp_json",
31
+ "ManualTrace", "ReplayDivergence", "ToolError", "traced_run", "plugins", "__version__",
32
+ ]
@@ -0,0 +1,173 @@
1
+ """Exporters ship finished runs (spans + content blobs) somewhere.
2
+
3
+ All exporters are stdlib-only so the SDK adds no dependencies to an agent.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import sys
9
+ import urllib.error
10
+ import urllib.request
11
+ from typing import Any, Dict, List, Optional
12
+
13
+
14
+ class Exporter:
15
+ def export(self, spans: List[Dict[str, Any]], blobs: Dict[str, str]) -> None: # pragma: no cover
16
+ raise NotImplementedError
17
+
18
+
19
+ class MemoryExporter(Exporter):
20
+ """Keeps runs in memory. Used by tests and by replay."""
21
+
22
+ def __init__(self) -> None:
23
+ self.spans: List[Dict[str, Any]] = []
24
+ self.blobs: Dict[str, str] = {}
25
+
26
+ def export(self, spans, blobs):
27
+ self.spans.extend(spans)
28
+ self.blobs.update(blobs)
29
+
30
+ def trace(self, trace_id: str) -> List[Dict[str, Any]]:
31
+ return [s for s in self.spans if s["trace_id"] == trace_id]
32
+
33
+ def clear(self) -> None:
34
+ self.spans.clear()
35
+ self.blobs.clear()
36
+
37
+
38
+ class ConsoleExporter(Exporter):
39
+ """Prints a compact tree of each run to stderr."""
40
+
41
+ def __init__(self, stream=None) -> None:
42
+ self.stream = stream or sys.stderr
43
+
44
+ def export(self, spans, blobs):
45
+ children: Dict[Optional[str], list] = {}
46
+ for s in spans:
47
+ children.setdefault(s["parent_span_id"], []).append(s)
48
+
49
+ def walk(parent, depth):
50
+ for s in sorted(children.get(parent, []), key=lambda x: x["start_time"]):
51
+ ms = ((s["end_time"] or s["start_time"]) - s["start_time"]) / 1e6
52
+ mark = "x" if s["status"]["code"] == "ERROR" else "-"
53
+ step = s["attributes"].get("tdn.step", "")
54
+ print(f"{' ' * depth}{mark} [{step}] {s['kind']:<9} {s['name']} {ms:.1f}ms", file=self.stream)
55
+ walk(s["span_id"], depth + 1)
56
+
57
+ walk(None, 0)
58
+
59
+
60
+ class HttpExporter(Exporter):
61
+ """Posts spans and blobs to an TDN server's ingest API."""
62
+
63
+ def __init__(self, endpoint: str, api_key: Optional[str] = None, project: str = "default", timeout: float = 5.0):
64
+ self.endpoint = endpoint.rstrip("/")
65
+ self.api_key = api_key
66
+ self.project = project
67
+ self.timeout = timeout
68
+ self.last_error: Optional[str] = None
69
+
70
+ def _post(self, path: str, payload: Dict[str, Any]) -> None:
71
+ req = urllib.request.Request(
72
+ self.endpoint + path,
73
+ data=json.dumps(payload, default=str).encode("utf-8"),
74
+ headers={"Content-Type": "application/json"},
75
+ method="POST",
76
+ )
77
+ if self.api_key:
78
+ req.add_header("Authorization", f"Bearer {self.api_key}")
79
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
80
+ resp.read()
81
+
82
+ def export(self, spans, blobs):
83
+ # Telemetry must never crash the agent: failures are recorded, not raised.
84
+ try:
85
+ if blobs:
86
+ self._post("/v1/ingest/blobs", {"project": self.project, "blobs": blobs})
87
+ self._post("/v1/ingest/spans", {"project": self.project, "spans": spans})
88
+ self.last_error = None
89
+ except (urllib.error.URLError, OSError) as exc:
90
+ self.last_error = str(exc)
91
+ print(f"[agent-tracedown] export failed: {exc}", file=sys.stderr)
92
+
93
+
94
+ class OtlpHttpExporter(Exporter):
95
+ """Send runs straight to any OTLP/HTTP endpoint: an OpenTelemetry Collector, Jaeger
96
+ (``:4318``), Grafana Tempo or Alloy, or the Datadog Agent's OTLP intake.
97
+
98
+ tdn.init(exporter=tdn.OtlpHttpExporter("http://localhost:4318/v1/traces"))
99
+
100
+ Payload content is not sent (spans carry digests); pair with ``HttpExporter`` via
101
+ ``FanoutExporter`` to keep replay and RCA in Tracedown.
102
+ """
103
+
104
+ def __init__(self, endpoint: str = "http://localhost:4318/v1/traces", headers: Optional[Dict[str, str]] = None,
105
+ service_name: str = "agent", timeout: float = 5.0) -> None:
106
+ self.endpoint, self.headers, self.service_name, self.timeout = endpoint, headers or {}, service_name, timeout
107
+ self.last_error: Optional[str] = None
108
+
109
+ def export(self, spans, blobs):
110
+ req = urllib.request.Request(self.endpoint, method="POST",
111
+ data=json.dumps(to_otlp_json(spans, self.service_name)).encode(),
112
+ headers={"Content-Type": "application/json", **self.headers})
113
+ try:
114
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
115
+ r.read()
116
+ self.last_error = None
117
+ except (urllib.error.URLError, OSError) as exc:
118
+ self.last_error = str(exc)
119
+ print(f"[agent-tracedown] OTLP export failed: {exc}", file=sys.stderr)
120
+
121
+
122
+ class FanoutExporter(Exporter):
123
+ """Send every run to several exporters (e.g. TDN server + OTLP collector)."""
124
+
125
+ def __init__(self, *exporters: Exporter) -> None:
126
+ self.exporters = exporters
127
+
128
+ def export(self, spans, blobs):
129
+ for e in self.exporters:
130
+ e.export(spans, blobs)
131
+
132
+
133
+ def to_otlp_json(spans: List[Dict[str, Any]], service_name: str = "agent") -> Dict[str, Any]:
134
+ """Convert TDN spans to an OTLP/JSON ExportTraceServiceRequest body.
135
+
136
+ Lets TDN data go to Jaeger, Tempo, Datadog or any OTLP endpoint unchanged.
137
+ """
138
+
139
+ def attr(k, v):
140
+ if isinstance(v, bool):
141
+ val = {"boolValue": v}
142
+ elif isinstance(v, int):
143
+ val = {"intValue": str(v)}
144
+ elif isinstance(v, float):
145
+ val = {"doubleValue": v}
146
+ else:
147
+ val = {"stringValue": v if isinstance(v, str) else json.dumps(v, default=str)}
148
+ return {"key": k, "value": val}
149
+
150
+ status_map = {"UNSET": 0, "OK": 1, "ERROR": 2}
151
+ otlp_spans = []
152
+ for s in spans:
153
+ attrs = dict(s["attributes"])
154
+ attrs["tdn.kind"] = s["kind"]
155
+ if s.get("input_digest"):
156
+ attrs["tdn.input.digest"] = s["input_digest"]
157
+ if s.get("output_digest"):
158
+ attrs["tdn.output.digest"] = s["output_digest"]
159
+ otlp_spans.append({
160
+ "traceId": s["trace_id"],
161
+ "spanId": s["span_id"],
162
+ "parentSpanId": s["parent_span_id"] or "",
163
+ "name": s["name"],
164
+ "kind": 1 if s["kind"] in ("agent", "decision", "memory") else 3, # INTERNAL / CLIENT
165
+ "startTimeUnixNano": str(s["start_time"]),
166
+ "endTimeUnixNano": str(s["end_time"] or s["start_time"]),
167
+ "attributes": [attr(k, v) for k, v in attrs.items()],
168
+ "status": {"code": status_map[s["status"]["code"]], "message": s["status"]["message"]},
169
+ })
170
+ return {"resourceSpans": [{
171
+ "resource": {"attributes": [attr("service.name", service_name)]},
172
+ "scopeSpans": [{"scope": {"name": "agent-tracedown", "version": "0.3.0"}, "spans": otlp_spans}],
173
+ }]}
@@ -0,0 +1,66 @@
1
+ """Agent framework integrations.
2
+
3
+ Each adapter turns a framework's own callbacks or events into TDN spans, so a run in
4
+ that framework shows up with its agents, LLM calls, tools, retrievals and handoffs.
5
+
6
+ | Framework | How to enable |
7
+ |-------------------|----------------------------------------------------------------------|
8
+ | LangChain/LangGraph | ``graph.invoke(x, config={"callbacks": [langchain.callback()]})`` |
9
+ | OpenAI Agents SDK | ``openai_agents.instrument()`` (adds a tracing processor) |
10
+ | Claude Agent SDK | ``async for m in claude_agent.trace_stream(query(...), name=...)`` |
11
+ | Google ADK | ``App(..., plugins=[google_adk.TracedownPlugin()])`` |
12
+ | Pydantic AI | ``await pydantic_ai.run(agent, prompt)`` (or ``run_sync``) |
13
+ | MCP clients | ``mcp.instrument(client)`` or ``mcp.instrument_all()`` |
14
+ | CrewAI | ``crewai.instrument()`` (registers an event listener) |
15
+ | AutoGen AgentChat | ``async for m in autogen.trace_stream(team.run_stream(task=t), ...)``|
16
+ | Semantic Kernel | ``semantic_kernel.instrument(kernel)`` inside ``tdn.run(...)`` |
17
+ | LlamaIndex | ``llamaindex.instrument()`` (span + event handlers) |
18
+
19
+ Adapters import their framework lazily: installing ``agent-tracedown`` never pulls in
20
+ a framework. Replay of adapter-traced runs works for steps that go through an
21
+ instrumented model client (``tdn.plugins.instrument("openai", client)``); framework
22
+ callbacks can observe but not substitute outputs.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from typing import Any
28
+
29
+
30
+ def to_jsonable(value: Any, depth: int = 0) -> Any:
31
+ """Best-effort conversion of framework objects (pydantic models, dataclasses,
32
+ messages) into JSON-safe values for payload capture."""
33
+ if depth > 6:
34
+ return str(value)
35
+ if value is None or isinstance(value, (str, int, float, bool)):
36
+ return value
37
+ if isinstance(value, dict):
38
+ return {str(k): to_jsonable(v, depth + 1) for k, v in value.items()}
39
+ if isinstance(value, (list, tuple, set)):
40
+ return [to_jsonable(v, depth + 1) for v in value]
41
+ for attr in ("model_dump", "dict", "to_dict"):
42
+ fn = getattr(value, attr, None)
43
+ if callable(fn):
44
+ try:
45
+ return to_jsonable(fn(), depth + 1)
46
+ except Exception: # noqa: BLE001
47
+ pass
48
+ if hasattr(value, "__dataclass_fields__"):
49
+ return {k: to_jsonable(getattr(value, k, None), depth + 1) for k in value.__dataclass_fields__}
50
+ content = getattr(value, "content", None)
51
+ if isinstance(content, str):
52
+ return content
53
+ try:
54
+ json.dumps(value)
55
+ return value
56
+ except TypeError:
57
+ return str(value)
58
+
59
+
60
+ def maybe_json(text: Any) -> Any:
61
+ if isinstance(text, str) and text[:1] in "{[":
62
+ try:
63
+ return json.loads(text)
64
+ except ValueError:
65
+ return text
66
+ return text
@@ -0,0 +1,116 @@
1
+ """AutoGen AgentChat (0.4+).
2
+
3
+ from agent_tracedown.integrations import autogen as tdn_ag
4
+ async for msg in tdn_ag.trace_stream(team.run_stream(task=task), name="SupportTeam", task=task):
5
+ ... # messages pass through unchanged
6
+
7
+ or, after the fact: ``tdn_ag.trace_result(task_result, name="SupportTeam", task=task)``.
8
+
9
+ Each change of speaker opens an agent span; model turns become LLM spans (with token
10
+ usage), ``ToolCallRequestEvent`` / ``ToolCallExecutionEvent`` pairs become tool spans
11
+ matched by call id, and ``HandoffMessage`` becomes a handoff. AutoGen Core also emits
12
+ OpenTelemetry spans; point its tracer provider at ``/v1/traces`` to keep those too.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, AsyncIterator, Iterable, Optional
17
+
18
+ from ..tracer import ManualTrace
19
+ from . import maybe_json, to_jsonable
20
+
21
+
22
+ class _Builder:
23
+ def __init__(self, name: str, task: Any = None) -> None:
24
+ self.t = ManualTrace(name, input=to_jsonable(task) if task is not None else None)
25
+ self.agent_key: Optional[str] = None
26
+ self.agent: Optional[str] = None
27
+ self.n = 0
28
+ self.final: Any = None
29
+ self.error: Optional[str] = None
30
+
31
+ def _speaker(self, source: str) -> Optional[str]:
32
+ if source == "user":
33
+ return None
34
+ if source != self.agent:
35
+ if self.agent_key:
36
+ self.t.end(self.agent_key)
37
+ self.n += 1
38
+ self.agent_key = f"agent:{self.n}"
39
+ self.t.start(self.agent_key, f"invoke_agent {source}", "agent", attrs={"gen_ai.agent.name": source})
40
+ self.agent = source
41
+ return self.agent_key
42
+
43
+ def _llm(self, parent: Optional[str], msg: Any, output: Any) -> None:
44
+ usage = getattr(msg, "models_usage", None)
45
+ if usage is None:
46
+ return # no model call behind this message (e.g. a tool summary)
47
+ self.n += 1
48
+ key = f"llm:{self.n}"
49
+ self.t.start(key, "chat", "llm", parent=parent)
50
+ self.t.end(key, output={"text": to_jsonable(output)},
51
+ **{"gen_ai.usage.input_tokens": getattr(usage, "prompt_tokens", None),
52
+ "gen_ai.usage.output_tokens": getattr(usage, "completion_tokens", None)})
53
+
54
+ def feed(self, msg: Any) -> None:
55
+ kind = type(msg).__name__
56
+ if kind == "TaskResult":
57
+ if getattr(msg, "stop_reason", None):
58
+ self.t.root.set("tdn.autogen.stop_reason", msg.stop_reason)
59
+ return
60
+ source = getattr(msg, "source", "agent")
61
+ parent = self._speaker(source)
62
+ content = getattr(msg, "content", None)
63
+ if kind == "ToolCallRequestEvent":
64
+ self._llm(parent, msg, [{"name": c.name, "arguments": maybe_json(c.arguments)} for c in content])
65
+ for call in content:
66
+ self.t.start(f"tool:{call.id}", f"execute_tool {call.name}", "tool", parent=parent,
67
+ input=maybe_json(call.arguments), attrs={"gen_ai.tool.name": call.name})
68
+ elif kind == "ToolCallExecutionEvent":
69
+ for res in content:
70
+ self.t.end(f"tool:{res.call_id}", output=None if res.is_error else maybe_json(res.content),
71
+ error=str(res.content)[:500] if res.is_error else None)
72
+ elif kind == "HandoffMessage":
73
+ self.n += 1
74
+ key = f"handoff:{self.n}"
75
+ self.t.start(key, f"handoff {source} -> {msg.target}", "handoff", parent=parent,
76
+ input=to_jsonable(content), attrs={"tdn.handoff.from": source, "tdn.handoff.to": msg.target})
77
+ self.t.end(key)
78
+ elif kind in ("TextMessage", "StructuredMessage", "ToolCallSummaryMessage", "ThoughtEvent"):
79
+ if source == "user":
80
+ if self.t.root.input_digest is None:
81
+ from ..tracer import _put_blob
82
+ self.t.root.input_digest = _put_blob(self.t.state, to_jsonable(content))
83
+ return
84
+ if kind != "ToolCallSummaryMessage":
85
+ self._llm(parent, msg, content)
86
+ if kind != "ThoughtEvent":
87
+ self.final = to_jsonable(content)
88
+
89
+ def finish(self) -> str:
90
+ if self.agent_key:
91
+ self.t.end(self.agent_key)
92
+ return self.t.finish(output=self.final, error=self.error)
93
+
94
+
95
+ async def trace_stream(stream: AsyncIterator[Any], name: str = "AutoGenTeam", task: Any = None) -> AsyncIterator[Any]:
96
+ b = _Builder(name, task)
97
+ try:
98
+ async for msg in stream:
99
+ b.feed(msg)
100
+ yield msg
101
+ except Exception as exc:
102
+ b.error = f"{type(exc).__name__}: {exc}"
103
+ raise
104
+ finally:
105
+ b.finish()
106
+
107
+
108
+ def trace_result(result: Any, name: str = "AutoGenTeam", task: Any = None) -> str:
109
+ """Record a finished ``TaskResult`` (or any iterable of messages). Returns the trace id."""
110
+ b = _Builder(name, task)
111
+ messages: Iterable[Any] = getattr(result, "messages", result)
112
+ for m in messages:
113
+ b.feed(m)
114
+ if type(result).__name__ == "TaskResult":
115
+ b.feed(result)
116
+ return b.finish()
@@ -0,0 +1,166 @@
1
+ """Claude Agent SDK (``claude-agent-sdk``).
2
+
3
+ from claude_agent_sdk import query, ClaudeAgentOptions
4
+ from agent_tracedown.integrations import claude_agent as tdn_claude
5
+
6
+ async for msg in tdn_claude.trace_stream(query(prompt=p, options=opts), name="CodingAgent", prompt=p):
7
+ ... # messages pass through unchanged
8
+
9
+ Works the same with ``ClaudeSDKClient.receive_response()``. After the fact:
10
+ ``tdn_claude.trace_messages(messages, name=..., prompt=...)``.
11
+
12
+ Each ``AssistantMessage`` becomes an LLM span (model, token usage); each
13
+ ``ToolUseBlock`` opens a tool span that the matching ``ToolResultBlock`` closes, with
14
+ ``is_error`` results marked as failures. Subagents started through the ``Task`` /
15
+ ``Agent`` tool become agent spans, and messages carrying their ``parent_tool_use_id``
16
+ nest under them. The ``ResultMessage`` sets the outcome, the final answer and the cost
17
+ (``tdn.run.cost_usd``); the init ``SystemMessage`` declares the available tools.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from typing import Any, AsyncIterator, Dict, Iterable, Optional
22
+
23
+ from ..tracer import ManualTrace, _put_blob
24
+ from . import maybe_json, to_jsonable
25
+
26
+ SUBAGENT_TOOLS = ("Task", "Agent")
27
+
28
+
29
+ def _usage(u: Optional[Dict[str, Any]]) -> Dict[str, Any]:
30
+ if not isinstance(u, dict):
31
+ return {}
32
+ inp = u.get("input_tokens")
33
+ if inp is not None: # cached prompt tokens are input tokens too
34
+ inp += (u.get("cache_read_input_tokens") or 0) + (u.get("cache_creation_input_tokens") or 0)
35
+ return {"gen_ai.usage.input_tokens": inp, "gen_ai.usage.output_tokens": u.get("output_tokens")}
36
+
37
+
38
+ def _result_payload(content: Any) -> Any:
39
+ if isinstance(content, list): # [{"type": "text", "text": ...}, ...]
40
+ texts = [c.get("text") for c in content if isinstance(c, dict) and c.get("type") == "text"]
41
+ if texts and len(texts) == len(content):
42
+ return maybe_json("\n".join(texts))
43
+ return to_jsonable(content)
44
+ return maybe_json(content)
45
+
46
+
47
+ class _Builder:
48
+ def __init__(self, name: str, prompt: Any = None) -> None:
49
+ self.t = ManualTrace(name, input=prompt if isinstance(prompt, (str, dict, list)) else None)
50
+ self.n = 0
51
+ self.final: Any = None
52
+ self.error: Optional[str] = None
53
+ self.subagents: Dict[str, str] = {} # tool_use_id -> agent span key
54
+
55
+ def _parent(self, msg: Any) -> Optional[str]:
56
+ pid = getattr(msg, "parent_tool_use_id", None)
57
+ return self.subagents.get(pid) if pid else None
58
+
59
+ def feed(self, msg: Any) -> None:
60
+ kind = type(msg).__name__
61
+ if kind == "SystemMessage":
62
+ data = getattr(msg, "data", None) or {}
63
+ if getattr(msg, "subtype", None) == "init":
64
+ if data.get("tools"):
65
+ self.t.declare_tools([{"name": n} for n in data["tools"] if isinstance(n, str)])
66
+ if data.get("model"):
67
+ self.t.root.set("gen_ai.request.model", data["model"])
68
+ if data.get("session_id"):
69
+ self.t.root.set("tdn.claude.session_id", data["session_id"])
70
+ elif kind == "UserMessage":
71
+ content = msg.content
72
+ if isinstance(content, str):
73
+ if self.t.root.input_digest is None and not getattr(msg, "parent_tool_use_id", None):
74
+ self.t.root.input_digest = _put_blob(self.t.state, content)
75
+ return
76
+ for block in content:
77
+ if type(block).__name__ != "ToolResultBlock":
78
+ continue
79
+ key = f"tool:{block.tool_use_id}"
80
+ agent_key = self.subagents.get(block.tool_use_id)
81
+ err = str(block.content)[:500] if block.is_error else None
82
+ out = None if block.is_error else _result_payload(block.content)
83
+ if agent_key:
84
+ self.t.end(agent_key, output=out, error=err)
85
+ else:
86
+ self.t.end(key, output=out, error=err)
87
+ elif kind == "AssistantMessage":
88
+ parent = self._parent(msg)
89
+ self.n += 1
90
+ llm = f"llm:{self.n}"
91
+ texts, calls = [], []
92
+ for block in msg.content:
93
+ b = type(block).__name__
94
+ if b == "TextBlock":
95
+ texts.append(block.text)
96
+ elif b in ("ToolUseBlock", "ServerToolUseBlock"):
97
+ calls.append({"name": block.name, "arguments": to_jsonable(block.input)})
98
+ self.t.start(llm, f"chat {msg.model}", "llm", parent=parent,
99
+ attrs={"gen_ai.request.model": msg.model, "gen_ai.provider.name": "anthropic",
100
+ "gen_ai.response.finish_reasons": [msg.stop_reason] if getattr(msg, "stop_reason", None) else None})
101
+ output: Dict[str, Any] = {"text": "\n".join(texts)}
102
+ if calls:
103
+ output["tool_calls"] = calls
104
+ api_error = getattr(msg, "error", None)
105
+ self.t.end(llm, output=output, error=f"API error: {api_error}" if api_error else None,
106
+ **_usage(getattr(msg, "usage", None)))
107
+ for block in msg.content:
108
+ b = type(block).__name__
109
+ if b == "ToolUseBlock" and block.name in SUBAGENT_TOOLS:
110
+ inp = block.input or {}
111
+ agent = inp.get("subagent_type") or inp.get("description") or "subagent"
112
+ key = f"agent:{block.id}"
113
+ self.t.start(key, f"invoke_agent {agent}", "agent", parent=parent, input=to_jsonable(inp),
114
+ attrs={"gen_ai.agent.name": agent, "tdn.agent.role": inp.get("description")})
115
+ self.subagents[block.id] = key
116
+ elif b in ("ToolUseBlock", "ServerToolUseBlock"):
117
+ self.t.start(f"tool:{block.id}", f"execute_tool {block.name}", "tool", parent=parent,
118
+ input=to_jsonable(block.input),
119
+ attrs={"gen_ai.tool.name": block.name, "gen_ai.tool.call.id": block.id,
120
+ "tdn.tool.server_side": True if b == "ServerToolUseBlock" else None})
121
+ elif b == "ServerToolResultBlock":
122
+ self.t.end(f"tool:{block.tool_use_id}", output=to_jsonable(block.content))
123
+ if texts and not parent:
124
+ self.final = "\n".join(texts)
125
+ elif kind == "ResultMessage":
126
+ root = self.t.root
127
+ if msg.total_cost_usd is not None:
128
+ root.set("tdn.run.cost_usd", float(msg.total_cost_usd))
129
+ root.set("tdn.llm.cost_usd", float(msg.total_cost_usd))
130
+ root.set("tdn.claude.num_turns", msg.num_turns)
131
+ root.set("tdn.claude.stop_reason", getattr(msg, "terminal_reason", None) or msg.subtype)
132
+ root.set("tdn.claude.session_id", msg.session_id)
133
+ u = _usage(getattr(msg, "usage", None))
134
+ for k, v in u.items():
135
+ if v is not None:
136
+ root.set(k, v)
137
+ if msg.result is not None:
138
+ self.final = msg.result
139
+ if msg.is_error:
140
+ detail = "; ".join(getattr(msg, "errors", None) or []) or msg.result or msg.subtype
141
+ self.error = f"{msg.subtype}: {detail}"[:500]
142
+
143
+ def finish(self) -> str:
144
+ return self.t.finish(output=self.final, error=self.error)
145
+
146
+
147
+ async def trace_stream(stream: AsyncIterator[Any], name: str = "ClaudeAgent", prompt: Any = None) -> AsyncIterator[Any]:
148
+ """Wrap ``query(...)`` or ``client.receive_response()``; yields every message unchanged."""
149
+ b = _Builder(name, prompt)
150
+ try:
151
+ async for msg in stream:
152
+ b.feed(msg)
153
+ yield msg
154
+ except Exception as exc:
155
+ b.error = f"{type(exc).__name__}: {exc}"
156
+ raise
157
+ finally:
158
+ b.finish()
159
+
160
+
161
+ def trace_messages(messages: Iterable[Any], name: str = "ClaudeAgent", prompt: Any = None) -> str:
162
+ """Record messages already collected from a run. Returns the trace id."""
163
+ b = _Builder(name, prompt)
164
+ for m in messages:
165
+ b.feed(m)
166
+ return b.finish()