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.
- agent_tracedown/__init__.py +32 -0
- agent_tracedown/exporters/__init__.py +173 -0
- agent_tracedown/integrations/__init__.py +66 -0
- agent_tracedown/integrations/autogen.py +116 -0
- agent_tracedown/integrations/claude_agent.py +166 -0
- agent_tracedown/integrations/crewai.py +180 -0
- agent_tracedown/integrations/google_adk.py +215 -0
- agent_tracedown/integrations/langchain.py +214 -0
- agent_tracedown/integrations/llamaindex.py +136 -0
- agent_tracedown/integrations/mcp.py +133 -0
- agent_tracedown/integrations/openai_agents.py +194 -0
- agent_tracedown/integrations/pydantic_ai.py +151 -0
- agent_tracedown/integrations/semantic_kernel.py +55 -0
- agent_tracedown/plugins/__init__.py +47 -0
- agent_tracedown/plugins/anthropic_plugin.py +65 -0
- agent_tracedown/plugins/openai_plugin.py +77 -0
- agent_tracedown/redaction.py +33 -0
- agent_tracedown/spans.py +102 -0
- agent_tracedown/tracer.py +694 -0
- agent_tracedown-0.3.0.dist-info/METADATA +121 -0
- agent_tracedown-0.3.0.dist-info/RECORD +24 -0
- agent_tracedown-0.3.0.dist-info/WHEEL +5 -0
- agent_tracedown-0.3.0.dist-info/licenses/LICENSE +202 -0
- agent_tracedown-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|