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,581 @@
1
+ """
2
+ AutoGen integration for the modern autogen-agentchat / autogen-ext
3
+ architecture (v0.4+/v0.7.x).
4
+
5
+ AutoGen's agentchat layer has no LangChain-style pluggable callback list, so
6
+ this integration works by wrapping the async-generator methods that already
7
+ exist on agents, teams, and code executors (``on_messages_stream``,
8
+ ``run_stream``, ``execute_code_blocks``) at the *instance* level. Because
9
+ ``on_messages``/``run`` internally call ``self.on_messages_stream``/
10
+ ``self.run_stream`` (attribute lookup resolves to the instance override
11
+ first), wrapping the stream method transparently covers both the streaming
12
+ and non-streaming call paths with a single patch point.
13
+
14
+ Provides
15
+ --------
16
+ ``instrument_agent(agent, tracer, trace)``
17
+ Wraps a single ``BaseChatAgent`` (e.g. ``AssistantAgent``,
18
+ ``CodeExecutorAgent``) so every turn gets an ``agent:<name>`` span
19
+ tagged with the agent's name -- so a captured LLM/tool exchange can be
20
+ attributed to which agent in a multi-agent team produced it -- plus
21
+ tool-call, handoff, thought, and token-usage span events. Any
22
+ exception raised anywhere during the turn (including before any HTTP
23
+ request is ever built, e.g. a tool-schema conversion ``TypeError``) is
24
+ recorded on the span and the span is closed ``ERROR`` before the
25
+ exception propagates.
26
+
27
+ ``instrument_team(team, tracer, trace)``
28
+ Wraps a ``BaseGroupChat`` (``SelectorGroupChat``,
29
+ ``RoundRobinGroupChat``, ...): recursively instruments every
30
+ participant (agent or nested team) via ``instrument_agent``/
31
+ ``instrument_team``, and wraps the team's own ``run_stream`` for a root
32
+ span that records routing/speaker-selection events as they occur.
33
+
34
+ ``instrument_code_executor(executor, tracer, trace)``
35
+ Wraps ``CodeExecutor.execute_code_blocks`` (e.g.
36
+ ``LocalCommandLineCodeExecutor``) to record the executed code, working
37
+ directory, combined stdout+stderr output (AutoGen's own
38
+ ``CodeResult``/``CommandLineCodeResult`` merges stdout and stderr into
39
+ a single ``output`` field -- there is no separate stdout/stderr split
40
+ anywhere in the executor's return type), and exit code as a span
41
+ event. This is independent of, and in addition to, any LLM HTTP
42
+ capture: code execution never touches HTTP, so the httpx/requests
43
+ interceptor has zero visibility into it regardless of how well it is
44
+ wired up.
45
+
46
+ ``recording_http_client(fixture, is_async=True, inner=None)``
47
+ Builds an httpx client pre-wired with agent-trace's
48
+ ``RecordingTransport``/``AsyncRecordingTransport``, for passing as the
49
+ ``http_client=`` kwarg into autogen-ext's ``OpenAIChatCompletionClient``/
50
+ ``AzureOpenAIChatCompletionClient`` (confirmed: both forward any kwarg
51
+ matching ``AsyncOpenAI.__init__``'s keyword-only args -- including
52
+ ``http_client`` -- straight through, via
53
+ ``autogen_ext/models/openai/_openai_client.py``'s
54
+ ``openai_init_kwargs = set(inspect.getfullargspec(``
55
+ ``AsyncOpenAI.__init__).kwonlyargs)``)
56
+ or into legacy AutoGen 0.2's ``config_list`` dicts (same passthrough
57
+ mechanism, via ``OpenAIWrapper.openai_kwargs`` in ``autogen/oai/client.py``).
58
+
59
+ Note this is usually *not required* for the modern v0.4+ path: any
60
+ ``httpx.AsyncClient`` constructed with no explicit ``transport=`` kwarg
61
+ *after* entering ``tracer.start_trace(..., record=True)`` is already
62
+ captured automatically by agent-trace's global ``httpx.AsyncClient``
63
+ patch (confirmed: OpenAI's own ``AsyncHttpxClientWrapper.__init__`` only
64
+ ever calls ``kwargs.setdefault(...)`` for timeout/limits/redirects, never
65
+ setting ``transport`` itself, so agent-trace's own ``setdefault`` fires).
66
+ Use ``recording_http_client`` when you want an explicit, documented,
67
+ zero-surprise wiring path instead of relying on that global patch -- most
68
+ importantly for legacy AutoGen 0.2's ``config_list``, which requires an
69
+ actual client object as a dict value rather than relying on any global
70
+ state.
71
+
72
+ Documented caveat (see module docstring "Known limitations" below):
73
+ this pattern does **not** work for legacy AutoGen 0.2's
74
+ ``GPTAssistantAgent`` specifically, because
75
+ ``_process_assistant_config()`` (``gpt_assistant_agent.py:490``) does
76
+ ``copy.deepcopy(llm_config)`` before constructing the client, and a live
77
+ ``httpx.Client``/``httpx.AsyncClient`` instance is not deep-copyable
78
+ (``TypeError: cannot pickle '_thread.RLock' object``). For
79
+ ``GPTAssistantAgent``, use agent-trace's own
80
+ ``Tracer(...).start_trace(record=True)`` context manager instead --
81
+ ``GPTAssistantAgent``'s underlying ``openai.OpenAI()`` client never
82
+ passes an explicit ``transport=`` kwarg, so the global patch captures it
83
+ with zero AutoGen-specific wiring at all.
84
+
85
+ Usage
86
+ -----
87
+ Agent/message-routing spans::
88
+
89
+ from agent_trace import tracer
90
+ from agent_trace.integrations.autogen import instrument_agent
91
+
92
+ with tracer.start_trace("my_run", record=True) as trace:
93
+ agent = AssistantAgent("writer", model_client=model_client)
94
+ instrument_agent(agent, tracer=tracer, trace=trace)
95
+ result = await agent.run(task="write a haiku")
96
+
97
+ Multi-agent team::
98
+
99
+ from agent_trace.integrations.autogen import instrument_team
100
+
101
+ with tracer.start_trace("my_team_run", record=True) as trace:
102
+ team = SelectorGroupChat([planner, writer], model_client=model_client)
103
+ instrument_team(team, tracer=tracer, trace=trace)
104
+ result = await team.run(task="write a plan and a haiku")
105
+
106
+ Code-execution capture::
107
+
108
+ from agent_trace.integrations.autogen import instrument_code_executor
109
+
110
+ executor = LocalCommandLineCodeExecutor(work_dir="./coding")
111
+ instrument_code_executor(executor, tracer=tracer, trace=trace)
112
+ agent = CodeExecutorAgent("executor", code_executor=executor)
113
+
114
+ Explicit http_client wiring (modern v0.4+/v0.7.x)::
115
+
116
+ from agent_trace import Fixture
117
+ from agent_trace.integrations.autogen import recording_http_client
118
+
119
+ fixture = Fixture(run_dir / "fixture.db")
120
+ model_client = OpenAIChatCompletionClient(
121
+ model="gpt-4o-mini",
122
+ http_client=recording_http_client(fixture),
123
+ )
124
+
125
+ Explicit http_client wiring (legacy AutoGen 0.2 config_list)::
126
+
127
+ config_list = [{
128
+ "model": "gpt-4o-mini",
129
+ "api_key": "...",
130
+ "http_client": recording_http_client(fixture, is_async=False),
131
+ }]
132
+ """
133
+
134
+ from __future__ import annotations
135
+
136
+ import logging
137
+ from typing import TYPE_CHECKING, Any
138
+
139
+ from agent_trace.core.span import Span, SpanStatus
140
+
141
+ if TYPE_CHECKING:
142
+ import httpx
143
+
144
+ from agent_trace import Trace, Tracer
145
+ from agent_trace._replay.fixture import Fixture
146
+
147
+ __all__ = [
148
+ "instrument_agent",
149
+ "instrument_code_executor",
150
+ "instrument_team",
151
+ "recording_http_client",
152
+ ]
153
+
154
+ logger = logging.getLogger(__name__)
155
+
156
+ _TRUNCATE = 2000 # max chars persisted per captured code/output field
157
+
158
+
159
+ def _truncate(value: str, limit: int = _TRUNCATE) -> str:
160
+ if len(value) <= limit:
161
+ return value
162
+ return value[:limit] + f"... [truncated, {len(value)} chars total]"
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # recording_http_client — a documented http_client wiring helper so
167
+ # config_list-based frameworks route through RecordingTransport
168
+ # ---------------------------------------------------------------------------
169
+
170
+
171
+ def recording_http_client(
172
+ fixture: Fixture,
173
+ *,
174
+ is_async: bool = True,
175
+ inner: Any | None = None,
176
+ ) -> httpx.Client | httpx.AsyncClient:
177
+ """Build an httpx client wired with agent-trace's RecordingTransport.
178
+
179
+ Pass the result as the ``http_client=`` kwarg into
180
+ ``autogen_ext.models.openai.OpenAIChatCompletionClient``/
181
+ ``AzureOpenAIChatCompletionClient`` (modern v0.4+/v0.7.x), or as a
182
+ ``config_list`` dict value's ``"http_client"`` key (legacy AutoGen 0.2).
183
+
184
+ Parameters
185
+ ----------
186
+ fixture:
187
+ Open :class:`~agent_trace.Fixture` to record exchanges into.
188
+ is_async:
189
+ Build an ``httpx.AsyncClient`` (the default -- required by
190
+ ``OpenAIChatCompletionClient``/``AsyncOpenAI``) when True, or a
191
+ synchronous ``httpx.Client`` (required by legacy AutoGen 0.2's
192
+ synchronous ``OpenAI`` client) when False.
193
+ inner:
194
+ Optional real transport to forward requests through. Defaults to
195
+ ``httpx.AsyncHTTPTransport()``/``httpx.HTTPTransport()``.
196
+
197
+ Returns
198
+ -------
199
+ httpx.Client | httpx.AsyncClient
200
+ Ready to pass directly as ``http_client=``.
201
+
202
+ Not usable for legacy AutoGen 0.2's ``GPTAssistantAgent`` -- see the
203
+ module docstring's "Known limitations" section for why and what to use
204
+ instead.
205
+ """
206
+ import httpx
207
+
208
+ from agent_trace.interceptor.httpx_hook import (
209
+ AsyncRecordingTransport,
210
+ RecordingTransport,
211
+ )
212
+
213
+ if is_async:
214
+ return httpx.AsyncClient(
215
+ transport=AsyncRecordingTransport(fixture, inner=inner)
216
+ )
217
+ return httpx.Client(transport=RecordingTransport(fixture, inner=inner))
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # Shared span-enrichment helpers
222
+ # ---------------------------------------------------------------------------
223
+
224
+
225
+ def _record_usage(span: Span, message: Any) -> None:
226
+ """If *message* carries a non-None ``models_usage`` (RequestUsage),
227
+ accumulate prompt/completion/total token counts onto *span*.
228
+
229
+ Every AutoGen agentchat message/event type (``BaseChatMessage``,
230
+ ``BaseAgentEvent``, and ``Response.chat_message``) carries an optional
231
+ ``models_usage: RequestUsage | None`` field -- this is the one place
232
+ token usage surfaces regardless of which concrete message type produced
233
+ it, so a single check here covers every LLM call in the turn without
234
+ needing to hook ``_call_llm`` directly.
235
+ """
236
+ usage = getattr(message, "models_usage", None)
237
+ if usage is None:
238
+ return
239
+ try:
240
+ prompt = int(getattr(usage, "prompt_tokens", 0) or 0)
241
+ completion = int(getattr(usage, "completion_tokens", 0) or 0)
242
+ except (TypeError, ValueError):
243
+ return
244
+ prior_prompt = int(span.attributes.get("llm.usage.prompt_tokens", 0) or 0)
245
+ prior_completion = int(span.attributes.get("llm.usage.completion_tokens", 0) or 0)
246
+ span.set_attribute("llm.usage.prompt_tokens", prior_prompt + prompt)
247
+ span.set_attribute("llm.usage.completion_tokens", prior_completion + completion)
248
+ span.set_attribute(
249
+ "llm.usage.total_tokens",
250
+ prior_prompt + prompt + prior_completion + completion,
251
+ )
252
+
253
+
254
+ def _record_event(span: Span, event: Any) -> None:
255
+ """Enrich *span* with routing/tool/handoff context from one yielded
256
+ item of ``on_messages_stream``/``run_stream``.
257
+
258
+ This is what gives a captured trace agent/message-routing context: a
259
+ tool call, handoff, or code-execution result is tagged with the
260
+ concrete AutoGen event type and the agent (``source``) that produced
261
+ it, rather than being visible only as an undifferentiated raw HTTP body.
262
+ """
263
+ type_name = type(event).__name__
264
+ source = getattr(event, "source", None)
265
+
266
+ _record_usage(span, event)
267
+ if type_name == "Response":
268
+ # Response itself never carries models_usage -- only its nested
269
+ # chat_message does (confirmed: Response(chat_message=TextMessage(
270
+ # ..., models_usage=RequestUsage(...)), ...)).
271
+ chat_message = getattr(event, "chat_message", None)
272
+ if chat_message is not None:
273
+ _record_usage(span, chat_message)
274
+
275
+ if type_name == "ToolCallRequestEvent":
276
+ calls = getattr(event, "content", None) or []
277
+ names = [getattr(c, "name", "?") for c in calls]
278
+ span.add_event(
279
+ "tool_call_request",
280
+ attributes={"tool.names": ",".join(names), "source": source or ""},
281
+ )
282
+ elif type_name == "ToolCallExecutionEvent":
283
+ results = getattr(event, "content", None) or []
284
+ names = [getattr(r, "name", "?") for r in results]
285
+ any_error = any(bool(getattr(r, "is_error", False)) for r in results)
286
+ span.add_event(
287
+ "tool_call_execution",
288
+ attributes={
289
+ "tool.names": ",".join(names),
290
+ "tool.any_error": any_error,
291
+ "source": source or "",
292
+ },
293
+ )
294
+ elif type_name == "HandoffMessage":
295
+ span.add_event(
296
+ "handoff",
297
+ attributes={
298
+ "handoff.from_agent": source or "",
299
+ "handoff.target": getattr(event, "target", "") or "",
300
+ },
301
+ )
302
+ elif type_name == "ThoughtEvent":
303
+ content = getattr(event, "content", "") or ""
304
+ span.add_event(
305
+ "thought",
306
+ attributes={
307
+ "thought.content": _truncate(str(content)),
308
+ "source": source or "",
309
+ },
310
+ )
311
+ elif type_name in ("CodeGenerationEvent", "CodeExecutionEvent"):
312
+ # Belt-and-suspenders: some agent configurations surface these
313
+ # events directly in the message stream. The primary, reliable
314
+ # capture path for code-execution results is
315
+ # instrument_code_executor() below (see its docstring for why).
316
+ attrs: dict[str, Any] = {"source": source or ""}
317
+ if type_name == "CodeGenerationEvent":
318
+ blocks = getattr(event, "code_blocks", None) or []
319
+ attrs["code"] = _truncate(
320
+ "\n---\n".join(getattr(b, "code", "") for b in blocks)
321
+ )
322
+ else:
323
+ result = getattr(event, "result", None)
324
+ attrs["exit_code"] = int(getattr(result, "exit_code", -1))
325
+ attrs["output"] = _truncate(str(getattr(result, "output", "")))
326
+ span.add_event(type_name, attributes=attrs)
327
+ elif type_name in ("SelectorEvent", "SelectSpeakerEvent"):
328
+ span.add_event(
329
+ "speaker_selection",
330
+ attributes={
331
+ "content": _truncate(str(getattr(event, "content", ""))),
332
+ "source": source or "",
333
+ },
334
+ )
335
+
336
+
337
+ # ---------------------------------------------------------------------------
338
+ # instrument_agent
339
+ # ---------------------------------------------------------------------------
340
+
341
+
342
+ def instrument_agent(agent: Any, *, tracer: Tracer, trace: Trace) -> Any:
343
+ """Wrap *agent* so every turn emits an agent-attributed span.
344
+
345
+ *agent* must implement the ``BaseChatAgent`` protocol's
346
+ ``on_messages_stream(messages, cancellation_token)`` async-generator
347
+ method (true for ``AssistantAgent``, ``CodeExecutorAgent``,
348
+ ``UserProxyAgent``, ``SocietyOfMindAgent``, and any custom
349
+ ``BaseChatAgent`` subclass). The wrap is applied at the *instance*
350
+ level (``agent.on_messages_stream = wrapped``) so ``agent.run()``/
351
+ ``agent.run_stream()``/``agent.on_messages()`` -- all of which resolve
352
+ ``self.on_messages_stream`` via normal attribute lookup -- are covered
353
+ by this single patch point without needing to hook each one separately.
354
+
355
+ Idempotent: instrumenting the same agent instance twice is a no-op on
356
+ the second call.
357
+
358
+ Returns *agent* for chaining.
359
+ """
360
+ if getattr(agent, "_agent_trace_instrumented", False):
361
+ return agent
362
+
363
+ original_stream = agent.on_messages_stream
364
+ agent_name: str = getattr(agent, "name", None) or "agent"
365
+
366
+ async def _wrapped_stream(messages: Any, cancellation_token: Any) -> Any:
367
+ span = tracer.start_span(f"agent:{agent_name}")
368
+ span.set_attribute("agent.name", agent_name)
369
+ span.set_attribute("agent.type", type(agent).__name__)
370
+ try:
371
+ async for event in original_stream(messages, cancellation_token):
372
+ try:
373
+ _record_event(span, event)
374
+ except Exception:
375
+ logger.debug(
376
+ "agent-trace: failed to enrich span for autogen event %r",
377
+ type(event).__name__,
378
+ exc_info=True,
379
+ )
380
+ if type(event).__name__ == "Response" and span.end_time is None:
381
+ # Response is always the last item on_messages_stream
382
+ # yields. Close the span synchronously *before*
383
+ # yielding it rather than waiting for this generator to
384
+ # naturally finish, because BaseChatAgent.on_messages()
385
+ # returns as soon as it sees a Response and never calls
386
+ # anext() again -- this generator's eventual
387
+ # GeneratorExit-based cleanup is then scheduled
388
+ # asynchronously by the event loop's asyncgen finalizer
389
+ # hook and is not guaranteed to run before the caller's
390
+ # code continues, which would otherwise leave the span
391
+ # open (status UNSET) indefinitely.
392
+ span.end(SpanStatus.OK)
393
+ yield event
394
+ except GeneratorExit:
395
+ # The caller stopped iterating without draining the generator --
396
+ # e.g. BaseChatAgent.on_messages() returns as soon as it sees the
397
+ # final Response and never calls anext() again, so this
398
+ # generator is torn down (via GeneratorExit, or in practice
399
+ # asyncio.run()'s implicit shutdown_asyncgens() cleanup, which
400
+ # has been observed to surface as asyncio.CancelledError instead
401
+ # of GeneratorExit here -- see the broader except clause below)
402
+ # rather than ever reaching normal completion. In the common
403
+ # case the Response-triggered proactive close above has already
404
+ # set end_time, so this is a no-op; it only fires if the stream
405
+ # was abandoned before ever yielding a Response, which is still
406
+ # expected/successful termination, not a failure.
407
+ if span.end_time is None:
408
+ span.end(SpanStatus.OK)
409
+ raise
410
+ except BaseException as exc:
411
+ # Guard on end_time rather than unconditionally recording: if
412
+ # the span was already closed above (Response already seen and
413
+ # yielded), a CancelledError/GeneratorExit arriving afterwards
414
+ # is just interpreter/event-loop teardown of an abandoned
415
+ # generator, not a real turn failure -- record_exception() would
416
+ # otherwise unconditionally flip an already-OK span to ERROR.
417
+ if span.end_time is None:
418
+ span.record_exception(exc)
419
+ span.end(SpanStatus.ERROR)
420
+ raise
421
+ else:
422
+ if span.end_time is None:
423
+ span.end(SpanStatus.OK)
424
+
425
+ agent.on_messages_stream = _wrapped_stream
426
+ agent._agent_trace_instrumented = True
427
+ return agent
428
+
429
+
430
+ # ---------------------------------------------------------------------------
431
+ # instrument_team
432
+ # ---------------------------------------------------------------------------
433
+
434
+
435
+ def instrument_team(team: Any, *, tracer: Tracer, trace: Trace) -> Any:
436
+ """Recursively instrument every participant of *team*, plus a root span
437
+ for the team's own ``run_stream``.
438
+
439
+ *team* must implement the ``TaskRunner`` protocol's
440
+ ``run_stream(task, cancellation_token)`` async-generator method (true
441
+ for ``SelectorGroupChat``, ``RoundRobinGroupChat``, ``Swarm``, and any
442
+ other ``BaseGroupChat`` subclass).
443
+
444
+ Participants are discovered via the ``_participants`` attribute set by
445
+ ``BaseGroupChat.__init__`` -- AutoGen does not expose a public accessor
446
+ for the participant list, so this reaches into the same underscore-
447
+ prefixed attribute confirmed by reading ``_base_group_chat.py``
448
+ directly. Each participant
449
+ is instrumented via :func:`instrument_agent` (if it looks like a
450
+ ``BaseChatAgent``) or recursively via :func:`instrument_team` (if it
451
+ looks like a nested ``Team``, e.g. inside a ``SocietyOfMindAgent``).
452
+
453
+ Idempotent: instrumenting the same team instance twice is a no-op on
454
+ the second call for the team's own root span (participants already
455
+ instrumented are also left untouched, since :func:`instrument_agent`/
456
+ :func:`instrument_team` are themselves idempotent).
457
+
458
+ Returns *team* for chaining.
459
+ """
460
+ for participant in getattr(team, "_participants", None) or []:
461
+ if hasattr(participant, "on_messages_stream"):
462
+ instrument_agent(participant, tracer=tracer, trace=trace)
463
+ elif hasattr(participant, "run_stream"):
464
+ instrument_team(participant, tracer=tracer, trace=trace)
465
+
466
+ if getattr(team, "_agent_trace_instrumented", False):
467
+ return team
468
+
469
+ original_run_stream = team.run_stream
470
+ team_name: str = getattr(team, "name", None) or type(team).__name__
471
+
472
+ async def _wrapped_run_stream(*args: Any, **kwargs: Any) -> Any:
473
+ span = tracer.start_span(f"team:{team_name}")
474
+ span.set_attribute("team.name", team_name)
475
+ try:
476
+ async for event in original_run_stream(*args, **kwargs):
477
+ try:
478
+ _record_event(span, event)
479
+ except Exception:
480
+ logger.debug(
481
+ "agent-trace: failed to enrich span for autogen team event %r",
482
+ type(event).__name__,
483
+ exc_info=True,
484
+ )
485
+ if type(event).__name__ == "TaskResult" and span.end_time is None:
486
+ # Same proactive-close reasoning as instrument_agent()'s
487
+ # Response check: some callers may iterate run_stream()
488
+ # directly and stop as soon as they see the TaskResult,
489
+ # never resuming this generator.
490
+ span.end(SpanStatus.OK)
491
+ yield event
492
+ except GeneratorExit:
493
+ # Same early-close case as instrument_agent()'s _wrapped_stream.
494
+ if span.end_time is None:
495
+ span.end(SpanStatus.OK)
496
+ raise
497
+ except BaseException as exc:
498
+ # See instrument_agent()'s matching guard: don't let a
499
+ # CancelledError/GeneratorExit from abandoned-generator teardown
500
+ # retroactively flip an already-closed OK span to ERROR.
501
+ if span.end_time is None:
502
+ span.record_exception(exc)
503
+ span.end(SpanStatus.ERROR)
504
+ raise
505
+ else:
506
+ if span.end_time is None:
507
+ span.end(SpanStatus.OK)
508
+
509
+ team.run_stream = _wrapped_run_stream
510
+ team._agent_trace_instrumented = True
511
+ return team
512
+
513
+
514
+ # ---------------------------------------------------------------------------
515
+ # instrument_code_executor
516
+ # ---------------------------------------------------------------------------
517
+
518
+
519
+ def instrument_code_executor(executor: Any, *, tracer: Tracer, trace: Trace) -> Any:
520
+ """Wrap *executor* so every ``execute_code_blocks`` call is recorded as
521
+ a span, independent of and in addition to any LLM HTTP capture.
522
+
523
+ *executor* must implement the ``CodeExecutor`` protocol's
524
+ ``execute_code_blocks(code_blocks, cancellation_token) -> CodeResult``
525
+ coroutine method (true for ``LocalCommandLineCodeExecutor``,
526
+ ``DockerCommandLineCodeExecutor``, ``JupyterCodeExecutor``, and any
527
+ other ``CodeExecutor`` implementation).
528
+
529
+ Records the executed code (each ``CodeBlock.code``/``.language``), the
530
+ executor's working directory (``executor.work_dir`` when present), and
531
+ the returned ``CodeResult``'s ``exit_code`` and ``output``. AutoGen's
532
+ ``CodeResult``/``CommandLineCodeResult`` merges stdout and stderr into a
533
+ single ``output`` string with no separate split anywhere in the
534
+ executor's own return type -- confirmed by reading both
535
+ ``autogen_core.code_executor.CodeResult`` and
536
+ ``autogen_ext.code_executors.local.CommandLineCodeResult`` -- so
537
+ ``output`` here is that combined stream, not stdout alone.
538
+
539
+ Idempotent: instrumenting the same executor instance twice is a no-op
540
+ on the second call.
541
+
542
+ Returns *executor* for chaining.
543
+ """
544
+ if getattr(executor, "_agent_trace_instrumented", False):
545
+ return executor
546
+
547
+ original_execute = executor.execute_code_blocks
548
+
549
+ async def _wrapped_execute(code_blocks: Any, cancellation_token: Any) -> Any:
550
+ span = tracer.start_span("code_execution")
551
+ work_dir = getattr(executor, "work_dir", None)
552
+ if work_dir is not None:
553
+ span.set_attribute("code_execution.work_dir", str(work_dir))
554
+ span.set_attribute(
555
+ "code_execution.code",
556
+ _truncate("\n---\n".join(getattr(b, "code", "") for b in code_blocks)),
557
+ )
558
+ span.set_attribute(
559
+ "code_execution.languages",
560
+ ",".join(getattr(b, "language", "") for b in code_blocks),
561
+ )
562
+ try:
563
+ result = await original_execute(code_blocks, cancellation_token)
564
+ except BaseException as exc:
565
+ span.record_exception(exc)
566
+ if span.end_time is None:
567
+ span.end(SpanStatus.ERROR)
568
+ raise
569
+
570
+ exit_code = int(getattr(result, "exit_code", -1))
571
+ span.set_attribute("code_execution.exit_code", exit_code)
572
+ span.set_attribute(
573
+ "code_execution.output",
574
+ _truncate(str(getattr(result, "output", ""))),
575
+ )
576
+ span.end(SpanStatus.OK if exit_code == 0 else SpanStatus.ERROR)
577
+ return result
578
+
579
+ executor.execute_code_blocks = _wrapped_execute
580
+ executor._agent_trace_instrumented = True
581
+ return executor