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.
- agent_observability_trace_cli-0.1.2.dist-info/METADATA +336 -0
- agent_observability_trace_cli-0.1.2.dist-info/RECORD +51 -0
- agent_observability_trace_cli-0.1.2.dist-info/WHEEL +4 -0
- agent_observability_trace_cli-0.1.2.dist-info/entry_points.txt +2 -0
- agent_observability_trace_cli-0.1.2.dist-info/licenses/LICENSE +198 -0
- agent_trace/__init__.py +1182 -0
- agent_trace/_cli.py +1377 -0
- agent_trace/_inspect.py +2020 -0
- agent_trace/_replay/__init__.py +1 -0
- agent_trace/_replay/engine.py +268 -0
- agent_trace/_replay/fixture.py +868 -0
- agent_trace/core/__init__.py +1 -0
- agent_trace/core/clock.py +91 -0
- agent_trace/core/exceptions.py +51 -0
- agent_trace/core/span.py +271 -0
- agent_trace/core/trace.py +92 -0
- agent_trace/exporters/__init__.py +1 -0
- agent_trace/exporters/file.py +80 -0
- agent_trace/exporters/otlp.py +199 -0
- agent_trace/exporters/remote_fixture.py +307 -0
- agent_trace/exporters/stdout.py +258 -0
- agent_trace/integrations/__init__.py +69 -0
- agent_trace/integrations/agno.py +489 -0
- agent_trace/integrations/autogen.py +581 -0
- agent_trace/integrations/crewai.py +486 -0
- agent_trace/integrations/google_genai.py +731 -0
- agent_trace/integrations/haystack.py +254 -0
- agent_trace/integrations/langchain_core.py +361 -0
- agent_trace/integrations/langgraph.py +2093 -0
- agent_trace/integrations/langgraph_checkpoint.py +763 -0
- agent_trace/integrations/langgraph_state_diff.py +345 -0
- agent_trace/integrations/langgraph_stream_debug.py +202 -0
- agent_trace/integrations/llama_index.py +472 -0
- agent_trace/integrations/mcp.py +281 -0
- agent_trace/integrations/openai_agents.py +811 -0
- agent_trace/integrations/pydantic_ai.py +504 -0
- agent_trace/integrations/streaming.py +218 -0
- agent_trace/interceptor/__init__.py +64 -0
- agent_trace/interceptor/aiohttp_hook.py +152 -0
- agent_trace/interceptor/botocore_hook.py +223 -0
- agent_trace/interceptor/grpc_hook.py +651 -0
- agent_trace/interceptor/httpx_hook.py +866 -0
- agent_trace/interceptor/logging_hook.py +137 -0
- agent_trace/interceptor/requests_patch.py +184 -0
- agent_trace/interceptor/sse.py +176 -0
- agent_trace/interceptor/stdio_hook.py +245 -0
- agent_trace/interceptor/warnings_hook.py +132 -0
- agent_trace/interceptor/websocket_hook.py +323 -0
- agent_trace/plugins/__init__.py +35 -0
- agent_trace/plugins/base.py +111 -0
- 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
|