agent-observability-trace-cli 0.1.2__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.
Files changed (51) hide show
  1. agent_observability_trace_cli-0.1.2.dist-info/METADATA +336 -0
  2. agent_observability_trace_cli-0.1.2.dist-info/RECORD +51 -0
  3. agent_observability_trace_cli-0.1.2.dist-info/WHEEL +4 -0
  4. agent_observability_trace_cli-0.1.2.dist-info/entry_points.txt +2 -0
  5. agent_observability_trace_cli-0.1.2.dist-info/licenses/LICENSE +198 -0
  6. agent_trace/__init__.py +1182 -0
  7. agent_trace/_cli.py +1377 -0
  8. agent_trace/_inspect.py +2020 -0
  9. agent_trace/_replay/__init__.py +1 -0
  10. agent_trace/_replay/engine.py +268 -0
  11. agent_trace/_replay/fixture.py +868 -0
  12. agent_trace/core/__init__.py +1 -0
  13. agent_trace/core/clock.py +91 -0
  14. agent_trace/core/exceptions.py +51 -0
  15. agent_trace/core/span.py +271 -0
  16. agent_trace/core/trace.py +92 -0
  17. agent_trace/exporters/__init__.py +1 -0
  18. agent_trace/exporters/file.py +80 -0
  19. agent_trace/exporters/otlp.py +199 -0
  20. agent_trace/exporters/remote_fixture.py +307 -0
  21. agent_trace/exporters/stdout.py +258 -0
  22. agent_trace/integrations/__init__.py +69 -0
  23. agent_trace/integrations/agno.py +489 -0
  24. agent_trace/integrations/autogen.py +581 -0
  25. agent_trace/integrations/crewai.py +486 -0
  26. agent_trace/integrations/google_genai.py +731 -0
  27. agent_trace/integrations/haystack.py +254 -0
  28. agent_trace/integrations/langchain_core.py +361 -0
  29. agent_trace/integrations/langgraph.py +2093 -0
  30. agent_trace/integrations/langgraph_checkpoint.py +763 -0
  31. agent_trace/integrations/langgraph_state_diff.py +345 -0
  32. agent_trace/integrations/langgraph_stream_debug.py +202 -0
  33. agent_trace/integrations/llama_index.py +472 -0
  34. agent_trace/integrations/mcp.py +281 -0
  35. agent_trace/integrations/openai_agents.py +811 -0
  36. agent_trace/integrations/pydantic_ai.py +504 -0
  37. agent_trace/integrations/streaming.py +218 -0
  38. agent_trace/interceptor/__init__.py +64 -0
  39. agent_trace/interceptor/aiohttp_hook.py +152 -0
  40. agent_trace/interceptor/botocore_hook.py +223 -0
  41. agent_trace/interceptor/grpc_hook.py +651 -0
  42. agent_trace/interceptor/httpx_hook.py +866 -0
  43. agent_trace/interceptor/logging_hook.py +137 -0
  44. agent_trace/interceptor/requests_patch.py +184 -0
  45. agent_trace/interceptor/sse.py +176 -0
  46. agent_trace/interceptor/stdio_hook.py +245 -0
  47. agent_trace/interceptor/warnings_hook.py +132 -0
  48. agent_trace/interceptor/websocket_hook.py +323 -0
  49. agent_trace/plugins/__init__.py +35 -0
  50. agent_trace/plugins/base.py +111 -0
  51. agent_trace/py.typed +0 -0
@@ -0,0 +1,811 @@
1
+ """
2
+ OpenAI Agents SDK integration.
3
+
4
+ Wraps openai-agents Runner to emit spans for each agent turn, tool call,
5
+ and handoff.
6
+
7
+ Usage (hook-based — recommended)::
8
+
9
+ from agents import Agent, Runner
10
+ from agent_trace import Tracer
11
+ from agent_trace.integrations.openai_agents import AgentTraceHook
12
+
13
+ t = Tracer()
14
+ with t.start_trace("my_agent_run", record=True) as trace:
15
+ hook = AgentTraceHook(tracer=t, trace=trace)
16
+ result = await Runner.run(agent, "hello", hooks=hook)
17
+
18
+ Usage (convenience wrapper)::
19
+
20
+ from agent_trace.integrations.openai_agents import instrument_runner
21
+
22
+ result = await instrument_runner(agent, "hello", tracer=t, trace=trace)
23
+
24
+ Usage (streamed convenience wrapper)::
25
+
26
+ from agent_trace.integrations.openai_agents import instrument_runner_streamed
27
+
28
+ streamed = await instrument_runner_streamed(agent, "hello", tracer=t, trace=trace)
29
+ async for event in streamed.stream_events():
30
+ ...
31
+ print(streamed.final_output)
32
+
33
+ Note on the openai-agents SDK's hook interface (as of 0.17+/0.18.x): the
34
+ ``RunHooksBase`` class the SDK actually calls only exposes seven ``async def``
35
+ methods — ``on_agent_start``, ``on_agent_end``, ``on_handoff``,
36
+ ``on_llm_start``, ``on_llm_end``, ``on_tool_start``, ``on_tool_end``. There is
37
+ no ``on_agent_error``/``on_tool_error`` hook anywhere in the SDK; most
38
+ in-process errors (e.g. a failed tool call) are caught internally by the SDK
39
+ and turned into a normal-looking tool result, never reaching any hook at all.
40
+ Exception-to-span attribution is therefore handled by
41
+ ``instrument_runner``/``instrument_runner_streamed`` wrapping the ``Runner``
42
+ call itself in a try/except, not by a hook method — see
43
+ ``AgentTraceHook._close_open_spans_with_exception``.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import inspect
49
+ import logging
50
+ import re
51
+ import threading
52
+ from collections.abc import AsyncIterator
53
+ from typing import TYPE_CHECKING, Any
54
+
55
+ from agent_trace._inspect import _edit_distance
56
+ from agent_trace.core.span import Span, SpanStatus
57
+
58
+ if TYPE_CHECKING:
59
+ from agent_trace import Trace, Tracer
60
+
61
+ # ``agents`` (openai-agents) is an optional dependency — see the
62
+ # ``openai-agents`` extra in pyproject.toml. Resolve ``RunHooksBase`` at
63
+ # import time so ``AgentTraceHook`` genuinely satisfies the SDK's hook
64
+ # interface (``Runner.run(..., hooks=hook)`` calls ``validate_run_hooks()``,
65
+ # which rejects anything that isn't a ``RunHooksBase`` instance) when the
66
+ # package is installed, while still importing cleanly when it isn't.
67
+ try:
68
+ from agents.lifecycle import RunHooksBase as _RunHooksBase
69
+ except ImportError: # pragma: no cover - exercised by unit tests without the extra
70
+ _RunHooksBase = object # type: ignore[assignment,misc]
71
+
72
+ if TYPE_CHECKING:
73
+ from agents.realtime import RealtimeSession, RealtimeSessionEvent
74
+
75
+ __all__ = [
76
+ "AgentTraceHook",
77
+ "AgentTraceRealtimeHook",
78
+ "instrument_runner",
79
+ "instrument_runner_streamed",
80
+ ]
81
+
82
+ logger = logging.getLogger(__name__)
83
+
84
+ _INSTALL_HINT = (
85
+ "The OpenAI Agents integration requires the openai-agents package.\n"
86
+ "Install it with:\n\n"
87
+ " pip install openai-agents\n"
88
+ )
89
+
90
+ # Cap on how much of a tool result / stream-event payload gets persisted onto
91
+ # a span attribute. Attribute values must stay small, primitive strings —
92
+ # this is not a place to dump multi-megabyte tool outputs.
93
+ _MAX_ATTR_LEN = 4000
94
+
95
+ # `ModelResponse.output` item `.type` values that represent the model asking
96
+ # to invoke a tool (as opposed to a plain message/reasoning item). Sourced
97
+ # from the openai-agents SDK's `openai.types.responses` output-item union.
98
+ _TOOL_CALL_OUTPUT_TYPES = frozenset(
99
+ {
100
+ "function_call",
101
+ "custom_tool_call",
102
+ "computer_call",
103
+ "file_search_call",
104
+ "web_search_call",
105
+ "code_interpreter_call",
106
+ "local_shell_call",
107
+ "shell_call",
108
+ "apply_patch_call",
109
+ "mcp_call",
110
+ "image_generation_call",
111
+ }
112
+ )
113
+
114
+
115
+ def _require_openai_agents() -> Any:
116
+ """Lazy import guard — raises a clear error if openai-agents is absent."""
117
+ # Try the canonical package name first, then the legacy alias.
118
+ for module_name in ("agents", "openai_agents"):
119
+ try:
120
+ import importlib
121
+
122
+ return importlib.import_module(module_name)
123
+ except ImportError:
124
+ continue
125
+ raise ImportError(_INSTALL_HINT)
126
+
127
+
128
+ def _get_runner_cls(sdk: Any) -> Any:
129
+ runner_cls = getattr(sdk, "Runner", None)
130
+ if runner_cls is None:
131
+ import importlib
132
+
133
+ runner_mod = importlib.import_module("agents.run")
134
+ runner_cls = runner_mod.Runner
135
+ return runner_cls
136
+
137
+
138
+ # Matches the "Tool <name> not found" shape openai-agents' ModelBehaviorError
139
+ # raises when the model hallucinates a tool name (#1671), optionally
140
+ # single/double-quoted, e.g. `Tool 'lookup_wather' not found in agent
141
+ # 'voice-agent'` or `Tool transfer_back_to_supervisor not found`.
142
+ _TOOL_NOT_FOUND_RE = re.compile(r"Tool ['\"]?([\w\-.]+)['\"]? not found")
143
+
144
+
145
+ def _truncate(value: Any) -> str:
146
+ text = str(value)
147
+ if len(text) > _MAX_ATTR_LEN:
148
+ return text[:_MAX_ATTR_LEN] + f"...<truncated, {len(text)} chars total>"
149
+ return text
150
+
151
+
152
+ def _extract_reasoning_effort(model_settings: Any) -> str | None:
153
+ """Pull ``model_settings.reasoning["effort"]`` out, dict- or attr-style."""
154
+ reasoning = getattr(model_settings, "reasoning", None)
155
+ if reasoning is None:
156
+ return None
157
+ if isinstance(reasoning, dict):
158
+ effort = reasoning.get("effort")
159
+ else:
160
+ effort = getattr(reasoning, "effort", None)
161
+ return str(effort) if effort is not None else None
162
+
163
+
164
+ def _response_has_tool_calls(response: Any) -> bool:
165
+ """Whether a ``ModelResponse.output`` contains any tool-call-shaped item."""
166
+ output = getattr(response, "output", None)
167
+ if not output:
168
+ return False
169
+ for item in output:
170
+ if getattr(item, "type", None) in _TOOL_CALL_OUTPUT_TYPES:
171
+ return True
172
+ return False
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # AgentTraceHook
177
+ # ---------------------------------------------------------------------------
178
+
179
+
180
+ class AgentTraceHook(_RunHooksBase): # type: ignore[misc]
181
+ """Hook implementation for the openai-agents SDK lifecycle events.
182
+
183
+ Pass an instance of this class to a ``Runner`` (via ``hooks=hook``) to
184
+ receive callbacks on agent start/end, tool calls, LLM calls, and
185
+ handoffs. Subclasses ``agents.lifecycle.RunHooksBase`` so it satisfies
186
+ ``Runner.run()``'s ``validate_run_hooks()`` check, and every method is
187
+ ``async def`` to match the SDK's own hook interface (the SDK
188
+ ``await``s each hook via ``asyncio.gather(...)``).
189
+
190
+ Parameters
191
+ ----------
192
+ tracer:
193
+ The active :class:`~agent_trace.Tracer` instance.
194
+ trace:
195
+ The :class:`~agent_trace.Trace` that spans will be registered on.
196
+ """
197
+
198
+ def __init__(self, tracer: Tracer, trace: Trace) -> None:
199
+ self._tracer: Tracer = tracer
200
+ self._trace: Trace = trace
201
+ # Thread-safe span registry keyed by a string context key.
202
+ self._spans: dict[str, Span] = {}
203
+ # id(context) -> (from_agent_name, to_agent_name), set by on_handoff
204
+ # and consumed by on_agent_end to open a duration-based handoff span.
205
+ self._pending_handoffs: dict[int, tuple[str, str]] = {}
206
+ self._lock: threading.Lock = threading.Lock()
207
+
208
+ # ------------------------------------------------------------------
209
+ # Internal helpers
210
+ # ------------------------------------------------------------------
211
+
212
+ def _open_span(self, key: str, name: str, parent_key: str | None = None) -> Span:
213
+ parent_span_id: str | None = None
214
+ if parent_key is not None:
215
+ with self._lock:
216
+ parent_span = self._spans.get(parent_key)
217
+ if parent_span is not None:
218
+ parent_span_id = parent_span.span_id
219
+
220
+ span = self._tracer.start_span(name, parent_id=parent_span_id)
221
+ with self._lock:
222
+ self._spans[key] = span
223
+ return span
224
+
225
+ def _close_span(self, key: str, status: SpanStatus = SpanStatus.OK) -> Span | None:
226
+ with self._lock:
227
+ span = self._spans.pop(key, None)
228
+ if span is not None and span.end_time is None:
229
+ span.end(status)
230
+ return span
231
+
232
+ def _close_open_spans_with_exception(self, exc: BaseException) -> None:
233
+ """Close every still-open span this hook is tracking as an error.
234
+
235
+ The current openai-agents SDK has no ``on_agent_error``/
236
+ ``on_tool_error`` hook, so a crashed turn otherwise leaves its
237
+ ``agent:``/``tool:``/``llm:`` span open forever with no error status
238
+ and no exception recorded. Call this from the ``except`` branch
239
+ around ``Runner.run(...)``/``Runner.run_streamed(...)`` so the trace
240
+ shows *which* span was in flight when the run failed, not just that
241
+ it silently stopped.
242
+ """
243
+ with self._lock:
244
+ spans = list(self._spans.values())
245
+ self._spans.clear()
246
+ self._pending_handoffs.clear()
247
+ for span in spans:
248
+ if span.end_time is None:
249
+ span.record_exception(exc)
250
+ span.end(SpanStatus.ERROR)
251
+
252
+ # ------------------------------------------------------------------
253
+ # openai-agents hook interface (all async — see module docstring)
254
+ # ------------------------------------------------------------------
255
+
256
+ async def on_agent_start(self, context: Any, agent: Any) -> None:
257
+ """Called when an agent turn begins."""
258
+ agent_name: str = getattr(agent, "name", None) or "agent"
259
+ model: str = getattr(agent, "model", None) or "unknown"
260
+ ctx_id = id(context)
261
+
262
+ # If a handoff transition into this agent is pending, close its
263
+ # duration-based handoff span now that the incoming agent has
264
+ # actually started.
265
+ self._close_span(f"handoff:{ctx_id}:{agent_name}", SpanStatus.OK)
266
+
267
+ key = f"agent:{ctx_id}:{agent_name}"
268
+ span = self._open_span(key, f"agent:{agent_name}")
269
+ span.set_attribute("agent.name", agent_name)
270
+ span.set_attribute("agent.model", model)
271
+
272
+ async def on_agent_end(self, context: Any, agent: Any, output: Any) -> None:
273
+ """Called when an agent turn completes successfully."""
274
+ agent_name: str = getattr(agent, "name", None) or "agent"
275
+ ctx_id = id(context)
276
+ key = f"agent:{ctx_id}:{agent_name}"
277
+ closed_span = self._close_span(key, SpanStatus.OK)
278
+
279
+ # If on_handoff fired for this agent, open a bounded transition span
280
+ # now, to be closed by the next agent's on_agent_start. This gives
281
+ # the handoff itself an explicit duration_ms instead of only a
282
+ # point-in-time event on the outgoing agent's span.
283
+ with self._lock:
284
+ pending = self._pending_handoffs.pop(ctx_id, None)
285
+ if pending is not None:
286
+ from_name, to_name = pending
287
+ if from_name == agent_name:
288
+ parent_id = closed_span.parent_id if closed_span is not None else None
289
+ handoff_span = self._tracer.start_span(
290
+ f"handoff:{from_name}->{to_name}", parent_id=parent_id
291
+ )
292
+ handoff_span.set_attribute("handoff.from_agent", from_name)
293
+ handoff_span.set_attribute("handoff.to_agent", to_name)
294
+ with self._lock:
295
+ self._spans[f"handoff:{ctx_id}:{to_name}"] = handoff_span
296
+
297
+ async def on_tool_start(self, context: Any, agent: Any, tool: Any) -> None:
298
+ """Called when a tool invocation begins."""
299
+ tool_name: str = getattr(tool, "name", None) or "tool"
300
+ agent_name: str = getattr(agent, "name", None) or "agent"
301
+ parent_key = f"agent:{id(context)}:{agent_name}"
302
+ key = f"tool:{id(context)}:{tool_name}"
303
+ span = self._open_span(key, f"tool:{tool_name}", parent_key=parent_key)
304
+ span.set_attribute("tool.name", tool_name)
305
+
306
+ async def on_tool_end(
307
+ self, context: Any, agent: Any, tool: Any, result: Any
308
+ ) -> None:
309
+ """Called when a tool invocation completes."""
310
+ tool_name: str = getattr(tool, "name", None) or "tool"
311
+ key = f"tool:{id(context)}:{tool_name}"
312
+ span = self._close_span(key, SpanStatus.OK)
313
+ if span is not None:
314
+ try:
315
+ span.set_attribute(
316
+ "tool.result_length",
317
+ len(str(result)) if result is not None else 0,
318
+ )
319
+ # Persist the actual result content (not just its length) —
320
+ # this is frequently the only diagnostic text available,
321
+ # e.g. when the SDK's own error-handling wraps a tool
322
+ # exception into its "successful" string return value.
323
+ if result is not None:
324
+ span.set_attribute("tool.result", _truncate(result))
325
+ except Exception:
326
+ logger.debug(
327
+ "agent-trace: failed to record tool result for %r",
328
+ tool_name,
329
+ exc_info=True,
330
+ )
331
+
332
+ async def on_handoff(self, context: Any, from_agent: Any, to_agent: Any) -> None:
333
+ """Called when control is handed off from one agent to another."""
334
+ from_name: str = getattr(from_agent, "name", None) or "unknown"
335
+ to_name: str = getattr(to_agent, "name", None) or "unknown"
336
+ ctx_id = id(context)
337
+ parent_key = f"agent:{ctx_id}:{from_name}"
338
+
339
+ with self._lock:
340
+ parent_span = self._spans.get(parent_key)
341
+
342
+ if parent_span is not None:
343
+ parent_span.add_event(
344
+ "handoff",
345
+ attributes={
346
+ "handoff.from_agent": from_name,
347
+ "handoff.to_agent": to_name,
348
+ },
349
+ )
350
+
351
+ # Record the pending transition so on_agent_end (for from_agent) can
352
+ # open a duration-based handoff span, closed by on_agent_start (for
353
+ # to_agent).
354
+ with self._lock:
355
+ self._pending_handoffs[ctx_id] = (from_name, to_name)
356
+
357
+ async def on_llm_start(
358
+ self, context: Any, agent: Any, system_prompt: Any, input_items: Any
359
+ ) -> None:
360
+ """Called before each LLM request — record model + settings used."""
361
+ agent_name: str = getattr(agent, "name", None) or "agent"
362
+ model: str = getattr(agent, "model", None) or "unknown"
363
+ key = f"llm:{id(context)}:{agent_name}"
364
+ parent_key = f"agent:{id(context)}:{agent_name}"
365
+ span = self._open_span(key, f"llm:{model}", parent_key=parent_key)
366
+ span.set_attribute("llm.model", model)
367
+
368
+ model_settings = getattr(agent, "model_settings", None)
369
+ if model_settings is not None:
370
+ try:
371
+ effort = _extract_reasoning_effort(model_settings)
372
+ if effort is not None:
373
+ span.set_attribute("llm.model_settings.reasoning_effort", effort)
374
+
375
+ verbosity = getattr(model_settings, "verbosity", None)
376
+ if verbosity is not None:
377
+ span.set_attribute("llm.model_settings.verbosity", str(verbosity))
378
+ except Exception:
379
+ logger.debug(
380
+ "agent-trace: failed to record model_settings",
381
+ exc_info=True,
382
+ )
383
+
384
+ async def on_llm_end(self, context: Any, agent: Any, response: Any) -> None:
385
+ """Called after each LLM response — record token usage + tool-call presence."""
386
+ agent_name: str = getattr(agent, "name", None) or "agent"
387
+ key = f"llm:{id(context)}:{agent_name}"
388
+ with self._lock:
389
+ span = self._spans.get(key)
390
+ if span is not None:
391
+ try:
392
+ usage = getattr(response, "usage", None)
393
+ if usage is not None:
394
+ pt = getattr(usage, "input_tokens", None)
395
+ ct = getattr(usage, "output_tokens", None)
396
+ tt = getattr(usage, "total_tokens", None)
397
+ if pt is not None:
398
+ span.set_attribute("llm.usage.prompt_tokens", int(pt))
399
+ if ct is not None:
400
+ span.set_attribute("llm.usage.completion_tokens", int(ct))
401
+ # total_tokens: prefer explicit field, fall back to sum
402
+ if tt is not None:
403
+ span.set_attribute("llm.usage.total_tokens", int(tt))
404
+ elif pt is not None and ct is not None:
405
+ span.set_attribute("llm.usage.total_tokens", int(pt) + int(ct))
406
+
407
+ span.set_attribute(
408
+ "llm.response.has_tool_calls", _response_has_tool_calls(response)
409
+ )
410
+ except Exception:
411
+ logger.debug(
412
+ "agent-trace: failed to record LLM response attributes",
413
+ exc_info=True,
414
+ )
415
+ self._close_span(key, SpanStatus.OK)
416
+
417
+
418
+ # ---------------------------------------------------------------------------
419
+ # instrument_runner — high-level async wrapper (Runner.run)
420
+ # ---------------------------------------------------------------------------
421
+
422
+
423
+ async def instrument_runner(
424
+ agent: Any,
425
+ input: Any,
426
+ *,
427
+ tracer: Tracer,
428
+ trace: Trace,
429
+ **kwargs: Any,
430
+ ) -> Any:
431
+ """Run an openai-agents ``Agent`` through ``Runner.run`` with span instrumentation.
432
+
433
+ This is a convenience wrapper that combines ``AgentTraceHook`` with
434
+ ``Runner.run``. Pass an ``Agent`` and the input text; agent-trace handles
435
+ hooking in the span collection automatically. If the run raises, every
436
+ span still open in the hook's registry is closed as an error (with the
437
+ exception attached) before the exception is re-raised — see
438
+ ``AgentTraceHook._close_open_spans_with_exception``.
439
+
440
+ Parameters
441
+ ----------
442
+ agent:
443
+ An openai-agents ``Agent`` instance.
444
+ input:
445
+ The input string (or message list) to pass to ``Runner.run``.
446
+ tracer:
447
+ The active :class:`~agent_trace.Tracer`.
448
+ trace:
449
+ The current :class:`~agent_trace.Trace`.
450
+ **kwargs:
451
+ Additional keyword arguments forwarded to ``Runner.run``
452
+ (e.g. ``max_turns``, ``context``).
453
+
454
+ Returns
455
+ -------
456
+ Any
457
+ The ``RunResult`` from ``Runner.run``.
458
+ """
459
+ sdk = _require_openai_agents()
460
+ runner_cls = _get_runner_cls(sdk)
461
+
462
+ hook = AgentTraceHook(tracer=tracer, trace=trace)
463
+ root_span = tracer.start_span("agent_run")
464
+ result: Any = None
465
+
466
+ try:
467
+ raw = runner_cls.run(agent, input, hooks=hook, **kwargs)
468
+ if inspect.isawaitable(raw):
469
+ result = await raw
470
+ else:
471
+ result = raw
472
+
473
+ root_span.end(SpanStatus.OK)
474
+ except Exception as exc:
475
+ hook._close_open_spans_with_exception(exc)
476
+ root_span.record_exception(exc)
477
+ if root_span.end_time is None:
478
+ root_span.end(SpanStatus.ERROR)
479
+ raise
480
+
481
+ return result
482
+
483
+
484
+ # ---------------------------------------------------------------------------
485
+ # instrument_runner_streamed — high-level wrapper (Runner.run_streamed)
486
+ # ---------------------------------------------------------------------------
487
+
488
+
489
+ class _TracedRunResultStreaming:
490
+ """Wraps openai-agents' ``RunResultStreaming``, instrumenting ``stream_events()``.
491
+
492
+ Every attribute/method other than ``stream_events`` is proxied straight
493
+ through to the wrapped ``RunResultStreaming`` (``final_output``,
494
+ ``last_agent``, ``to_input_list``, ``cancel``, ...) so this is a drop-in
495
+ replacement for the object ``Runner.run_streamed()`` itself returns.
496
+ """
497
+
498
+ def __init__(
499
+ self, result_streaming: Any, root_span: Span, hook: AgentTraceHook
500
+ ) -> None:
501
+ self._result_streaming = result_streaming
502
+ self._root_span = root_span
503
+ self._hook = hook
504
+
505
+ def __getattr__(self, name: str) -> Any:
506
+ return getattr(self._result_streaming, name)
507
+
508
+ async def stream_events(self) -> AsyncIterator[Any]:
509
+ """Yield each event exactly as ``stream_events()`` would, recording it.
510
+
511
+ A ``SpanEvent`` is added to the root span for every event actually
512
+ delivered to this iterator — i.e. this observes the consumer's real
513
+ drain rate, not just whether ``RunHooks`` callbacks fired internally
514
+ (those are two distinct channels inside ``RunResultStreaming``).
515
+ """
516
+ try:
517
+ async for event in self._result_streaming.stream_events():
518
+ event_type = getattr(event, "type", type(event).__name__)
519
+ self._root_span.add_event(
520
+ "stream_event", attributes={"event.type": str(event_type)}
521
+ )
522
+ yield event
523
+ if self._root_span.end_time is None:
524
+ self._root_span.end(SpanStatus.OK)
525
+ except Exception as exc:
526
+ self._hook._close_open_spans_with_exception(exc)
527
+ self._root_span.record_exception(exc)
528
+ if self._root_span.end_time is None:
529
+ self._root_span.end(SpanStatus.ERROR)
530
+ raise
531
+
532
+
533
+ async def instrument_runner_streamed(
534
+ agent: Any,
535
+ input: Any,
536
+ *,
537
+ tracer: Tracer,
538
+ trace: Trace,
539
+ **kwargs: Any,
540
+ ) -> _TracedRunResultStreaming:
541
+ """Run an openai-agents ``Agent`` via ``Runner.run_streamed``, span-instrumented.
542
+
543
+ ``instrument_runner`` only ever calls ``Runner.run`` — a reporter calling
544
+ ``Runner.run_streamed()``/consuming ``stream_events()`` directly gets no
545
+ exception attribution at all, since ``RunHooks`` firing correctly does
546
+ not imply the corresponding event actually reached the consumer's
547
+ ``stream_events()`` loop. This wrapper covers that call shape: it
548
+ returns a :class:`_TracedRunResultStreaming` immediately (matching
549
+ ``Runner.run_streamed()``'s own non-blocking contract), whose
550
+ ``stream_events()`` is span-instrumented and whose "agent_run_streamed"
551
+ root span stays open until the caller's consumption of the stream
552
+ actually finishes (or raises).
553
+
554
+ Parameters
555
+ ----------
556
+ agent:
557
+ An openai-agents ``Agent`` instance.
558
+ input:
559
+ The input string (or message list) to pass to ``Runner.run_streamed``.
560
+ tracer:
561
+ The active :class:`~agent_trace.Tracer`.
562
+ trace:
563
+ The current :class:`~agent_trace.Trace`.
564
+ **kwargs:
565
+ Additional keyword arguments forwarded to ``Runner.run_streamed``
566
+ (e.g. ``max_turns``, ``context``).
567
+
568
+ Returns
569
+ -------
570
+ _TracedRunResultStreaming
571
+ A drop-in wrapper around the SDK's ``RunResultStreaming``.
572
+ """
573
+ sdk = _require_openai_agents()
574
+ runner_cls = _get_runner_cls(sdk)
575
+
576
+ hook = AgentTraceHook(tracer=tracer, trace=trace)
577
+ root_span = tracer.start_span("agent_run_streamed")
578
+
579
+ raw = runner_cls.run_streamed(agent, input, hooks=hook, **kwargs)
580
+ result_streaming = await raw if inspect.isawaitable(raw) else raw
581
+
582
+ return _TracedRunResultStreaming(result_streaming, root_span, hook)
583
+
584
+
585
+ # ---------------------------------------------------------------------------
586
+ # AgentTraceRealtimeHook — Realtime API (agents.realtime) instrumentation
587
+ # ---------------------------------------------------------------------------
588
+
589
+
590
+ class AgentTraceRealtimeHook:
591
+ """Span instrumentation for the OpenAI Agents SDK's Realtime API.
592
+
593
+ The Realtime API (``agents.realtime``) has no ``RunHooks``-style
594
+ callback surface at all — ``RealtimeRunner.run()`` doesn't take a
595
+ ``hooks=`` kwarg, and ``RealtimeSession`` exposes turns, tool calls, and
596
+ handoffs only by *iterating the session itself*
597
+ (``RealtimeSession`` is an ``AsyncIterator[RealtimeSessionEvent]``).
598
+ ``AgentTraceHook``, which is scoped entirely to the turn-based
599
+ ``Runner.run()``/``Runner.run_streamed()`` loop, never fires for a
600
+ ``RealtimeSession`` at all — so today a handoff, tool-call, or error
601
+ happening inside a realtime voice session produces no span whatsoever.
602
+
603
+ This class wraps that iteration instead, translating each
604
+ ``RealtimeSessionEvent`` into the same span open/close/event pattern
605
+ ``AgentTraceHook`` uses for the turn-based loop:
606
+
607
+ - ``agent_start`` / ``agent_end`` -> open/close an ``agent:<name>`` span
608
+ - ``tool_start`` / ``tool_end`` -> open/close a ``tool:<name>`` span
609
+ (child of the current agent span), persisting arguments/result
610
+ - ``handoff`` -> an event on the outgoing agent's span
611
+ - ``error`` / ``guardrail_tripped`` -> ``record_exception``-style event
612
+ on the session's root span, without ending the session (a realtime
613
+ session's error/guardrail events are not necessarily fatal). When an
614
+ ``error`` event's text matches the "Tool <name> not found" shape
615
+ (#1671's ``ModelBehaviorError`` traceback — an intermittent crash
616
+ inside a Realtime/WebSocket voice session, distinct from the
617
+ httpx-captured chat-completion exchanges
618
+ ``check_tool_call_name_fuzzy_match`` diagnoses), the offending name is
619
+ fuzzy-matched against the tool names registered on the most recently
620
+ started agent for this session (captured from the ``agent_start``
621
+ event's ``event.agent.tools``) and the nearest match + edit distance
622
+ are attached as ``exception.nearest_registered_tool``/
623
+ ``exception.edit_distance`` attributes on the exception event.
624
+
625
+ Usage::
626
+
627
+ from agents.realtime import RealtimeRunner
628
+ from agent_trace.integrations.openai_agents import AgentTraceRealtimeHook
629
+
630
+ session = await RealtimeRunner(realtime_agent).run()
631
+ rt_hook = AgentTraceRealtimeHook(tracer=t, trace=trace)
632
+ async with session:
633
+ async for event in rt_hook.wrap(session):
634
+ ... # your own event handling; spans are recorded as a side effect
635
+ """
636
+
637
+ def __init__(self, tracer: Tracer, trace: Trace) -> None:
638
+ self._tracer: Tracer = tracer
639
+ self._trace: Trace = trace
640
+ self._spans: dict[str, Span] = {}
641
+ self._lock: threading.Lock = threading.Lock()
642
+ # session_key -> registered tool names of the most recently started
643
+ # agent for that session (#1671). Populated from `agent_start`'s
644
+ # `event.agent.tools`, the only place a realtime session's tool
645
+ # roster is available — there is no separate "tools registered"
646
+ # event.
647
+ self._registered_tools: dict[str, set[str]] = {}
648
+
649
+ # ------------------------------------------------------------------
650
+ # Internal helpers (same pattern as AgentTraceHook)
651
+ # ------------------------------------------------------------------
652
+
653
+ def _open_span(self, key: str, name: str, parent_key: str | None = None) -> Span:
654
+ parent_span_id: str | None = None
655
+ if parent_key is not None:
656
+ with self._lock:
657
+ parent_span = self._spans.get(parent_key)
658
+ if parent_span is not None:
659
+ parent_span_id = parent_span.span_id
660
+
661
+ span = self._tracer.start_span(name, parent_id=parent_span_id)
662
+ with self._lock:
663
+ self._spans[key] = span
664
+ return span
665
+
666
+ def _close_span(self, key: str, status: SpanStatus = SpanStatus.OK) -> Span | None:
667
+ with self._lock:
668
+ span = self._spans.pop(key, None)
669
+ if span is not None and span.end_time is None:
670
+ span.end(status)
671
+ return span
672
+
673
+ def _diagnose_tool_not_found(
674
+ self, session_key: str, error_message: str
675
+ ) -> dict[str, Any]:
676
+ """Fuzzy-match a "Tool <name> not found" error's ``<name>`` against
677
+ this session's registered tool names (#1671 — an intermittent
678
+ ``ModelBehaviorError``-shaped crash inside a Realtime/WebSocket
679
+ voice session), reusing the same edit-distance helper
680
+ ``check_tool_call_name_fuzzy_match`` already applies to
681
+ httpx-captured chat-completion exchanges. Returns an empty dict
682
+ (no attributes added) when the error text doesn't match the
683
+ pattern, or no registered tool names were captured for this
684
+ session."""
685
+ match = _TOOL_NOT_FOUND_RE.search(error_message)
686
+ if match is None:
687
+ return {}
688
+ with self._lock:
689
+ registered = self._registered_tools.get(session_key)
690
+ if not registered:
691
+ return {}
692
+ called_name = match.group(1)
693
+ nearest = min(registered, key=lambda r: _edit_distance(called_name, r))
694
+ return {
695
+ "exception.nearest_registered_tool": nearest,
696
+ "exception.edit_distance": _edit_distance(called_name, nearest),
697
+ }
698
+
699
+ def _capture_registered_tools(self, session_key: str, agent: Any) -> None:
700
+ """Track *agent*'s registered tool names for *session_key* (#1671),
701
+ so a later "Tool <name> not found" error event on this session can
702
+ be fuzzy-matched against them (see _diagnose_tool_not_found)."""
703
+ tool_names = {
704
+ name
705
+ for tool in getattr(agent, "tools", None) or ()
706
+ if isinstance((name := getattr(tool, "name", None)), str)
707
+ }
708
+ if tool_names:
709
+ with self._lock:
710
+ self._registered_tools[session_key] = tool_names
711
+
712
+ def _handle_event(self, session_key: str, event: Any) -> None:
713
+ event_type = getattr(event, "type", None)
714
+
715
+ if event_type == "agent_start":
716
+ agent = getattr(event, "agent", None)
717
+ agent_name = getattr(agent, "name", None) or "agent"
718
+ self._open_span(
719
+ f"agent:{session_key}:{agent_name}",
720
+ f"agent:{agent_name}",
721
+ parent_key=session_key,
722
+ ).set_attribute("agent.name", agent_name)
723
+ self._capture_registered_tools(session_key, agent)
724
+
725
+ elif event_type == "agent_end":
726
+ agent_name = getattr(getattr(event, "agent", None), "name", None) or "agent"
727
+ self._close_span(f"agent:{session_key}:{agent_name}", SpanStatus.OK)
728
+
729
+ elif event_type == "tool_start":
730
+ agent_name = getattr(getattr(event, "agent", None), "name", None) or "agent"
731
+ tool_name = getattr(getattr(event, "tool", None), "name", None) or "tool"
732
+ span = self._open_span(
733
+ f"tool:{session_key}:{tool_name}",
734
+ f"tool:{tool_name}",
735
+ parent_key=f"agent:{session_key}:{agent_name}",
736
+ )
737
+ span.set_attribute("tool.name", tool_name)
738
+ arguments = getattr(event, "arguments", None)
739
+ if arguments is not None:
740
+ span.set_attribute("tool.arguments", _truncate(arguments))
741
+
742
+ elif event_type == "tool_end":
743
+ tool_name = getattr(getattr(event, "tool", None), "name", None) or "tool"
744
+ tool_key = f"tool:{session_key}:{tool_name}"
745
+ closed_span = self._close_span(tool_key, SpanStatus.OK)
746
+ if closed_span is not None:
747
+ output = getattr(event, "output", None)
748
+ if output is not None:
749
+ closed_span.set_attribute("tool.result", _truncate(output))
750
+ closed_span.set_attribute("tool.result_length", len(str(output)))
751
+
752
+ elif event_type == "handoff":
753
+ from_agent = getattr(event, "from_agent", None)
754
+ to_agent = getattr(event, "to_agent", None)
755
+ from_name = getattr(from_agent, "name", None) or "unknown"
756
+ to_name = getattr(to_agent, "name", None) or "unknown"
757
+ with self._lock:
758
+ parent_span = self._spans.get(f"agent:{session_key}:{from_name}")
759
+ if parent_span is not None:
760
+ parent_span.add_event(
761
+ "handoff",
762
+ attributes={
763
+ "handoff.from_agent": from_name,
764
+ "handoff.to_agent": to_name,
765
+ },
766
+ )
767
+
768
+ elif event_type in ("error", "guardrail_tripped"):
769
+ with self._lock:
770
+ root_span = self._spans.get(session_key)
771
+ if root_span is not None:
772
+ detail = (
773
+ getattr(event, "error", None)
774
+ or getattr(event, "message", None)
775
+ or event
776
+ )
777
+ message = _truncate(detail)
778
+ attributes = {
779
+ "exception.type": event_type,
780
+ "exception.message": message,
781
+ **(
782
+ self._diagnose_tool_not_found(session_key, message)
783
+ if event_type == "error"
784
+ else {}
785
+ ),
786
+ }
787
+ root_span.add_event(
788
+ "exception" if event_type == "error" else "guardrail_tripped",
789
+ attributes=attributes,
790
+ )
791
+
792
+ async def wrap(
793
+ self, session: RealtimeSession
794
+ ) -> AsyncIterator[RealtimeSessionEvent]:
795
+ """Iterate *session*, recording spans as a side effect; yields events as-is."""
796
+ session_key = f"realtime:{id(session)}"
797
+ self._open_span(session_key, "realtime_session")
798
+ try:
799
+ async for event in session:
800
+ self._handle_event(session_key, event)
801
+ yield event
802
+ self._close_span(session_key, SpanStatus.OK)
803
+ except Exception as exc:
804
+ with self._lock:
805
+ spans = list(self._spans.values())
806
+ self._spans.clear()
807
+ for span in spans:
808
+ if span.end_time is None:
809
+ span.record_exception(exc)
810
+ span.end(SpanStatus.ERROR)
811
+ raise