agentx-python 0.8.27__tar.gz → 0.8.28__tar.gz
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.
- {agentx_python-0.8.27 → agentx_python-0.8.28}/PKG-INFO +7 -1
- {agentx_python-0.8.27 → agentx_python-0.8.28}/README.md +6 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/_traced_call.py +60 -9
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/anthropic.py +53 -21
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/nvidia_nim.py +8 -4
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/openai.py +24 -1
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/alert_rules.py +26 -12
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/tracing/tracer.py +5 -1
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/version.py +2 -2
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx_python.egg-info/PKG-INFO +7 -1
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_alert_rules.py +12 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_integrations.py +124 -1
- {agentx_python-0.8.27 → agentx_python-0.8.28}/LICENSE +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/__init__.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/agentx.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/cli.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/__init__.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/_term.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/adapters/__init__.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/adapters/http_endpoint.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/adapters/precomputed.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/adapters/raw.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/client.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/datasets.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/evaluation_settings.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/models.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/prompts.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/reporting.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/results.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/runner.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/tool_schemas.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/tracing.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/exceptions.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/export.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/feedback.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/__init__.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/autogen.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/crewai.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/databricks.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/google_adk.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/google_genai.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/langchain.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/litellm.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/llamaindex.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/moveworks.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/openai_agents.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/__init__.py +3 -3
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/_transport.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/agents.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/client.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/improvement_groups.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/judge_scorers.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/models.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/online_evaluators.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/patterns.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/profile.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/review_queue.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/rules.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/scorer_groups.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/scorers.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/sessions.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/signals.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/outcomes.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/projects.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/py.typed +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/resources/__init__.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/resources/agent.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/resources/conversation.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/resources/workforce.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/testing.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/traces.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/tracing/__init__.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/tracing/ci_types.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/tracing/eval_scope.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/tracing/framework_detect.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/tracing/ingest_client.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/util.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx_python.egg-info/SOURCES.txt +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx_python.egg-info/dependency_links.txt +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx_python.egg-info/entry_points.txt +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx_python.egg-info/not-zip-safe +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx_python.egg-info/requires.txt +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx_python.egg-info/top_level.txt +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/setup.cfg +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/setup.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_cli_launcher.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_deep_dive_fixes.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_docs_match_sdk.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_error_taxonomy.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_eval_scope.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_framework_detect.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_integration.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_judge_scorers.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_multi_judge.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_pairwise.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_pattern_update_merge.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_review_queue.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_runner_features.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_selfhost_analysis_fallback.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_selfhost_compat.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_span_tree.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_testing.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_wire_models.py +0 -0
- {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_workforce_binding.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentx-python
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.28
|
|
4
4
|
Summary: Official Python SDK for AgentX (https://www.agentx.so/)
|
|
5
5
|
Home-page: https://github.com/AgentX-ai/AgentX-python
|
|
6
6
|
Author: Robin Wang and AgentX Team
|
|
@@ -260,6 +260,12 @@ extra:
|
|
|
260
260
|
| LlamaIndex | `pip install "agentx-python[llamaindex]"` | `AgentXLlamaIndexHandler` |
|
|
261
261
|
| AutoGen | `pip install "agentx-python[autogen]"` | `AgentXAutoGenObserver` |
|
|
262
262
|
|
|
263
|
+
Raw-client patches (`patch_openai_client`, `patch_nim_client`, `patch_anthropic_client`) trace
|
|
264
|
+
streaming calls too: the returned stream is a transparent proxy that assembles the reply from the
|
|
265
|
+
chunks you consume, with latency measured to the last chunk and the time to first token in the
|
|
266
|
+
trace metadata. OpenAI-compatible endpoints only send token usage on streams when you pass
|
|
267
|
+
`stream_options={"include_usage": True}`.
|
|
268
|
+
|
|
263
269
|
> **Warning: pick ONE instrumentation layer per LLM call.** Do not combine
|
|
264
270
|
> `AgentXCallbackHandler` (or any framework integration) with a patched provider client
|
|
265
271
|
> (`patch_openai_client`, `patch_anthropic_client`, `patch_genai_client`) on the same code
|
|
@@ -193,6 +193,12 @@ extra:
|
|
|
193
193
|
| LlamaIndex | `pip install "agentx-python[llamaindex]"` | `AgentXLlamaIndexHandler` |
|
|
194
194
|
| AutoGen | `pip install "agentx-python[autogen]"` | `AgentXAutoGenObserver` |
|
|
195
195
|
|
|
196
|
+
Raw-client patches (`patch_openai_client`, `patch_nim_client`, `patch_anthropic_client`) trace
|
|
197
|
+
streaming calls too: the returned stream is a transparent proxy that assembles the reply from the
|
|
198
|
+
chunks you consume, with latency measured to the last chunk and the time to first token in the
|
|
199
|
+
trace metadata. OpenAI-compatible endpoints only send token usage on streams when you pass
|
|
200
|
+
`stream_options={"include_usage": True}`.
|
|
201
|
+
|
|
196
202
|
> **Warning: pick ONE instrumentation layer per LLM call.** Do not combine
|
|
197
203
|
> `AgentXCallbackHandler` (or any framework integration) with a patched provider client
|
|
198
204
|
> (`patch_openai_client`, `patch_anthropic_client`, `patch_genai_client`) on the same code
|
|
@@ -14,9 +14,16 @@ from __future__ import annotations
|
|
|
14
14
|
import asyncio
|
|
15
15
|
import inspect
|
|
16
16
|
import json
|
|
17
|
+
import logging
|
|
18
|
+
import threading
|
|
17
19
|
import time
|
|
18
20
|
from typing import Any, Callable, Dict, Optional
|
|
19
21
|
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# Sentinel for finish_llm_call's `active_span`: "not passed" is distinct from "passed None".
|
|
25
|
+
_UNSET: Any = object()
|
|
26
|
+
|
|
20
27
|
from agentx.tracing.tracer import Tracer, _safe_serialize
|
|
21
28
|
|
|
22
29
|
|
|
@@ -109,6 +116,7 @@ def finish_llm_call(
|
|
|
109
116
|
cache_write_tokens: Optional[int] = None,
|
|
110
117
|
tool_definitions: Optional[list] = None,
|
|
111
118
|
call_metadata: Optional[Dict[str, Any]] = None,
|
|
119
|
+
active_span: Any = _UNSET,
|
|
112
120
|
) -> None:
|
|
113
121
|
"""
|
|
114
122
|
Close out one raw-client LLM call - shared by the ``on_finish``/exit
|
|
@@ -129,7 +137,11 @@ def finish_llm_call(
|
|
|
129
137
|
if tool_definitions:
|
|
130
138
|
metadata = {**(metadata or {}), "tools": tool_definitions}
|
|
131
139
|
|
|
132
|
-
|
|
140
|
+
# The parent is the span that was active when the CALL was made. Streaming patches pass it
|
|
141
|
+
# explicitly: a stream finalizes later (exhaustion, close, or garbage collection), by which
|
|
142
|
+
# time a different span may be active, and the call must not be grafted onto it.
|
|
143
|
+
if active_span is _UNSET:
|
|
144
|
+
active_span = tracer.current_span
|
|
133
145
|
if active_span is not None:
|
|
134
146
|
# The definitions describe the whole call's toolbox - attach them to the enclosing
|
|
135
147
|
# span's metadata (first capture wins) so the ROOT trace carries them for the
|
|
@@ -168,6 +180,9 @@ def finish_llm_call(
|
|
|
168
180
|
)
|
|
169
181
|
span.__enter__()
|
|
170
182
|
span._start = start_t
|
|
183
|
+
# The call ended at end_t (a stream's last chunk), not at whatever later moment this
|
|
184
|
+
# runs - __exit__ honors the override instead of measuring to time.time().
|
|
185
|
+
span._end_override = end_t
|
|
171
186
|
span.input = input_repr
|
|
172
187
|
span.output = output
|
|
173
188
|
if error:
|
|
@@ -217,7 +232,13 @@ class TracedStream:
|
|
|
217
232
|
|
|
218
233
|
Attribute access falls through to the wrapped stream (``.response``,
|
|
219
234
|
provider helpers), and ``__iter__``/``__aiter__`` return ``self`` so early
|
|
220
|
-
``break`` leaves no half-driven generator behind.
|
|
235
|
+
``break`` leaves no half-driven generator behind. It is a proxy, not a
|
|
236
|
+
subclass: ``isinstance(stream, openai.Stream)`` is False and ``repr()``
|
|
237
|
+
shows the proxy - branch on ``stream=True`` in your own code, not on type.
|
|
238
|
+
|
|
239
|
+
Tracing never breaks the caller: a failure while building or sending the
|
|
240
|
+
trace is logged and swallowed, and the stream's own iteration/close
|
|
241
|
+
semantics are untouched.
|
|
221
242
|
"""
|
|
222
243
|
|
|
223
244
|
def __init__(
|
|
@@ -230,6 +251,8 @@ class TracedStream:
|
|
|
230
251
|
self._accumulator = accumulator
|
|
231
252
|
self._on_finish = on_finish
|
|
232
253
|
self._done = False
|
|
254
|
+
# A watchdog close() racing the reader's StopIteration must not finalize twice.
|
|
255
|
+
self._done_lock = threading.Lock()
|
|
233
256
|
self._first_chunk_t: Optional[float] = None
|
|
234
257
|
self._last_chunk_t: Optional[float] = None
|
|
235
258
|
self._sync_iter: Any = None
|
|
@@ -250,9 +273,10 @@ class TracedStream:
|
|
|
250
273
|
pass
|
|
251
274
|
|
|
252
275
|
def _finish(self, error: Optional[str]) -> None:
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
276
|
+
with self._done_lock:
|
|
277
|
+
if self._done:
|
|
278
|
+
return
|
|
279
|
+
self._done = True
|
|
256
280
|
try:
|
|
257
281
|
result = self._accumulator.result()
|
|
258
282
|
except Exception:
|
|
@@ -264,7 +288,12 @@ class TracedStream:
|
|
|
264
288
|
# The response "ended" at its last chunk, not at whatever later moment the caller closed
|
|
265
289
|
# or dropped the stream - that is the latency the user experienced.
|
|
266
290
|
result["end_t"] = self._last_chunk_t if self._last_chunk_t is not None else time.time()
|
|
267
|
-
|
|
291
|
+
try:
|
|
292
|
+
self._on_finish(result, error)
|
|
293
|
+
except Exception:
|
|
294
|
+
# Building or sending the trace failed. The caller's stream ended normally and must
|
|
295
|
+
# see it end normally - tracing is never allowed to raise into inference code.
|
|
296
|
+
logger.debug("Streamed call could not be traced", exc_info=True)
|
|
268
297
|
|
|
269
298
|
@property
|
|
270
299
|
def first_chunk_at(self) -> Optional[float]:
|
|
@@ -283,9 +312,14 @@ class TracedStream:
|
|
|
283
312
|
except StopIteration:
|
|
284
313
|
self._finish(None)
|
|
285
314
|
raise
|
|
286
|
-
except
|
|
315
|
+
except Exception as exc:
|
|
287
316
|
self._finish(str(exc))
|
|
288
317
|
raise
|
|
318
|
+
except BaseException:
|
|
319
|
+
# KeyboardInterrupt / GeneratorExit: a cancellation, not the provider failing -
|
|
320
|
+
# record what streamed so far without inventing an error message.
|
|
321
|
+
self._finish(None)
|
|
322
|
+
raise
|
|
289
323
|
self._observe(chunk)
|
|
290
324
|
return chunk
|
|
291
325
|
|
|
@@ -302,9 +336,12 @@ class TracedStream:
|
|
|
302
336
|
except StopAsyncIteration:
|
|
303
337
|
self._finish(None)
|
|
304
338
|
raise
|
|
305
|
-
except
|
|
339
|
+
except Exception as exc:
|
|
306
340
|
self._finish(str(exc))
|
|
307
341
|
raise
|
|
342
|
+
except BaseException:
|
|
343
|
+
self._finish(None)
|
|
344
|
+
raise
|
|
308
345
|
self._observe(chunk)
|
|
309
346
|
return chunk
|
|
310
347
|
|
|
@@ -338,7 +375,17 @@ class TracedStream:
|
|
|
338
375
|
close = getattr(self._stream, "close", None)
|
|
339
376
|
try:
|
|
340
377
|
if close is not None:
|
|
341
|
-
close()
|
|
378
|
+
result = close()
|
|
379
|
+
if inspect.isawaitable(result):
|
|
380
|
+
# openai's AsyncStream spells its close `async def close()`. A sync close()
|
|
381
|
+
# on it (an easy slip inside async code) would drop the coroutine and leak
|
|
382
|
+
# the connection; run it on the loop when there is one, else at least don't
|
|
383
|
+
# leave an un-awaited coroutine behind.
|
|
384
|
+
try:
|
|
385
|
+
asyncio.get_running_loop().create_task(result)
|
|
386
|
+
except RuntimeError:
|
|
387
|
+
result.close() # type: ignore[union-attr]
|
|
388
|
+
logger.warning("close() called on an async stream outside an event loop - use aclose()")
|
|
342
389
|
finally:
|
|
343
390
|
self._finish(None)
|
|
344
391
|
|
|
@@ -355,6 +402,10 @@ class TracedStream:
|
|
|
355
402
|
self._finish(None)
|
|
356
403
|
|
|
357
404
|
def __getattr__(self, item: str) -> Any:
|
|
405
|
+
# Only public attributes delegate. Private names must resolve on the proxy itself, or a
|
|
406
|
+
# half-constructed instance (no _stream yet) would recurse forever looking for it.
|
|
407
|
+
if item.startswith("_"):
|
|
408
|
+
raise AttributeError(item)
|
|
358
409
|
return getattr(self._stream, item)
|
|
359
410
|
|
|
360
411
|
def __del__(self) -> None:
|
|
@@ -232,13 +232,21 @@ def _patch_create(
|
|
|
232
232
|
input_repr = _safe_serialize(input_messages)
|
|
233
233
|
|
|
234
234
|
if kwargs.get("stream"):
|
|
235
|
+
# Parent fixed at call time - see openai.py's patched_create for why.
|
|
236
|
+
parent = tracer.current_span
|
|
237
|
+
|
|
235
238
|
def on_stream_finish(collected: Dict[str, Any], error: Optional[str]) -> None:
|
|
239
|
+
ttft = collected.get("time_to_first_token_ms")
|
|
240
|
+
call_metadata: Dict[str, Any] = {"streaming": True}
|
|
241
|
+
if ttft is not None:
|
|
242
|
+
call_metadata["timeToFirstTokenMs"] = ttft
|
|
236
243
|
finish_llm_call(
|
|
237
244
|
tracer,
|
|
238
245
|
name=name,
|
|
239
246
|
framework="anthropic",
|
|
240
247
|
metadata=metadata,
|
|
241
|
-
call_metadata=
|
|
248
|
+
call_metadata=call_metadata,
|
|
249
|
+
active_span=parent,
|
|
242
250
|
session_id=session_id,
|
|
243
251
|
start_t=start_t,
|
|
244
252
|
end_t=collected.get("end_t") or time.time(),
|
|
@@ -318,8 +326,9 @@ def _patch_stream(
|
|
|
318
326
|
# only shows up in whether `with`/`async with` and
|
|
319
327
|
# `get_final_message()` are used, handled inside `_TracedStream`.
|
|
320
328
|
start_t = time.time()
|
|
329
|
+
parent = tracer.current_span
|
|
321
330
|
ctx = original_stream(*args, **kwargs)
|
|
322
|
-
input_repr = _safe_serialize(_prepend_system(kwargs.get("messages"), kwargs.get("system")))
|
|
331
|
+
input_repr = _safe_serialize(_prepend_system(kwargs.get("messages") or (args[0] if args else None), kwargs.get("system")))
|
|
323
332
|
model = kwargs.get("model")
|
|
324
333
|
tool_definitions = capture_tool_definitions(kwargs.get("tools"))
|
|
325
334
|
|
|
@@ -365,6 +374,29 @@ def _patch_stream(
|
|
|
365
374
|
"""
|
|
366
375
|
|
|
367
376
|
_inner: Any = None
|
|
377
|
+
_sent: bool = False
|
|
378
|
+
|
|
379
|
+
# What streamed so far, WITHOUT draining the rest of the response: the SDK's
|
|
380
|
+
# get_final_message() calls until_done(), which would turn an early `break` into a
|
|
381
|
+
# blocking read of every remaining token. The snapshot is the final message once the
|
|
382
|
+
# stream was consumed, and honestly partial when the caller stopped early.
|
|
383
|
+
def _snapshot(self_inner):
|
|
384
|
+
inner = self_inner._inner
|
|
385
|
+
if inner is None:
|
|
386
|
+
return None
|
|
387
|
+
try:
|
|
388
|
+
return getattr(inner, "current_message_snapshot", None)
|
|
389
|
+
except Exception:
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
def _send_once(self_inner, end_t: float, error: Optional[str], snapshot: Any) -> None:
|
|
393
|
+
if self_inner._sent:
|
|
394
|
+
return
|
|
395
|
+
self_inner._sent = True
|
|
396
|
+
try:
|
|
397
|
+
build_and_send(end_t, error, snapshot)
|
|
398
|
+
except Exception:
|
|
399
|
+
pass # tracing never raises into the caller
|
|
368
400
|
|
|
369
401
|
def __enter__(self_inner):
|
|
370
402
|
self_inner._inner = ctx.__enter__()
|
|
@@ -373,15 +405,12 @@ def _patch_stream(
|
|
|
373
405
|
def __exit__(self_inner, exc_type, exc_val, tb):
|
|
374
406
|
end_t = time.time()
|
|
375
407
|
error = str(exc_val) if exc_val else None
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
result = ctx.__exit__(exc_type, exc_val, tb)
|
|
383
|
-
build_and_send(end_t, error, final_message)
|
|
384
|
-
return result
|
|
408
|
+
# Snapshot BEFORE the manager closes the stream (the earlier bug read it after).
|
|
409
|
+
snapshot = self_inner._snapshot()
|
|
410
|
+
try:
|
|
411
|
+
return ctx.__exit__(exc_type, exc_val, tb)
|
|
412
|
+
finally:
|
|
413
|
+
self_inner._send_once(end_t, error, snapshot)
|
|
385
414
|
|
|
386
415
|
async def __aenter__(self_inner):
|
|
387
416
|
self_inner._inner = await ctx.__aenter__()
|
|
@@ -390,16 +419,19 @@ def _patch_stream(
|
|
|
390
419
|
async def __aexit__(self_inner, exc_type, exc_val, tb):
|
|
391
420
|
end_t = time.time()
|
|
392
421
|
error = str(exc_val) if exc_val else None
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
422
|
+
snapshot = self_inner._snapshot()
|
|
423
|
+
try:
|
|
424
|
+
return await ctx.__aexit__(exc_type, exc_val, tb)
|
|
425
|
+
finally:
|
|
426
|
+
self_inner._send_once(end_t, error, snapshot)
|
|
427
|
+
|
|
428
|
+
def __del__(self_inner):
|
|
429
|
+
# A helper stream that was entered but never exited still records what it saw.
|
|
430
|
+
try:
|
|
431
|
+
if self_inner._inner is not None:
|
|
432
|
+
self_inner._send_once(time.time(), None, self_inner._snapshot())
|
|
433
|
+
except Exception:
|
|
434
|
+
pass
|
|
403
435
|
|
|
404
436
|
def __iter__(self_inner):
|
|
405
437
|
return iter(ctx)
|
|
@@ -27,8 +27,11 @@ Works with both ``openai.OpenAI`` and ``openai.AsyncOpenAI`` clients. Token
|
|
|
27
27
|
usage comes straight off the response's OpenAI-shaped ``usage`` block; NIM
|
|
28
28
|
reports no prompt-cache fields, so cache token counts stay unset.
|
|
29
29
|
|
|
30
|
-
Streaming calls (``stream=True``) are
|
|
31
|
-
|
|
30
|
+
Streaming calls (``stream=True``) are traced too, exactly as
|
|
31
|
+
``patch_openai_client`` traces them: the stream is wrapped in a transparent
|
|
32
|
+
proxy that assembles the reply from the consumed chunks (token usage when the
|
|
33
|
+
endpoint sends it on the final chunk, e.g. with
|
|
34
|
+
``stream_options={"include_usage": True}``).
|
|
32
35
|
|
|
33
36
|
Requires: ``pip install "agentx-python[nvidia-nim]"`` (installs the ``openai``
|
|
34
37
|
client package; there is no separate NIM SDK dependency).
|
|
@@ -56,8 +59,9 @@ def patch_nim_client(
|
|
|
56
59
|
call with ``framework="nvidia-nim"``.
|
|
57
60
|
|
|
58
61
|
The original method is still called and its return value passed through
|
|
59
|
-
unchanged. Sync and async clients both work; ``stream=True`` calls
|
|
60
|
-
through
|
|
62
|
+
unchanged. Sync and async clients both work; ``stream=True`` calls are
|
|
63
|
+
traced through the same stream proxy as ``patch_openai_client``. Patching
|
|
64
|
+
is idempotent - and because it shares the guard
|
|
61
65
|
with ``patch_openai_client``, whichever of the two patched a given client
|
|
62
66
|
first wins (patch each client with the integration that matches where its
|
|
63
67
|
``base_url`` actually points).
|
|
@@ -30,7 +30,14 @@ from __future__ import annotations
|
|
|
30
30
|
import time
|
|
31
31
|
from typing import Any, Dict, Optional, Tuple
|
|
32
32
|
|
|
33
|
+
import logging
|
|
34
|
+
|
|
33
35
|
from agentx.tracing.tracer import Tracer, _safe_serialize
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
# Warn once per process, not per call: a streamed OpenAI call carries no usage unless the caller
|
|
39
|
+
# asked for it, and a silent zero would under-report every streaming app's spend.
|
|
40
|
+
_warned_stream_usage = False
|
|
34
41
|
from agentx.integrations._traced_call import (
|
|
35
42
|
StreamAccumulator,
|
|
36
43
|
capture_tool_definitions,
|
|
@@ -203,14 +210,30 @@ def _patch_chat_completions_create(
|
|
|
203
210
|
tool_definitions = capture_tool_definitions(kwargs.get("tools"))
|
|
204
211
|
|
|
205
212
|
if kwargs.get("stream"):
|
|
213
|
+
# The parent is fixed at call time: the stream finalizes later, possibly inside an
|
|
214
|
+
# unrelated span (or none), and must not attach to whatever is active then.
|
|
215
|
+
parent = tracer.current_span
|
|
216
|
+
|
|
206
217
|
def on_stream_finish(collected: Dict[str, Any], error: Optional[str]) -> None:
|
|
218
|
+
global _warned_stream_usage
|
|
207
219
|
ttft = collected.get("time_to_first_token_ms")
|
|
220
|
+
call_metadata: Dict[str, Any] = {"streaming": True}
|
|
221
|
+
if ttft is not None:
|
|
222
|
+
call_metadata["timeToFirstTokenMs"] = ttft
|
|
223
|
+
if error is None and collected.get("input_tokens") is None and not _warned_stream_usage:
|
|
224
|
+
_warned_stream_usage = True
|
|
225
|
+
logger.warning(
|
|
226
|
+
"Streamed %s call carried no token usage - pass stream_options={\"include_usage\": True} "
|
|
227
|
+
"so traces (and cost) reflect streamed traffic.",
|
|
228
|
+
framework,
|
|
229
|
+
)
|
|
208
230
|
finish_llm_call(
|
|
209
231
|
tracer,
|
|
210
232
|
name=name,
|
|
211
233
|
framework=framework,
|
|
212
234
|
metadata=metadata,
|
|
213
|
-
call_metadata=
|
|
235
|
+
call_metadata=call_metadata,
|
|
236
|
+
active_span=parent,
|
|
214
237
|
session_id=session_id,
|
|
215
238
|
start_t=start_t,
|
|
216
239
|
end_t=collected.get("end_t") or time.time(),
|
|
@@ -17,6 +17,22 @@ _ALIASES = {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
def _validate(
|
|
21
|
+
metric: Optional[str] = None, operator: Optional[str] = None, channels: Optional[List[Dict[str, str]]] = None
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Local checks for the fields the engine would otherwise 400 on - ``None`` means "not given"
|
|
24
|
+
(an update that leaves the field alone)."""
|
|
25
|
+
if metric is not None and metric not in ALERT_METRICS:
|
|
26
|
+
raise ValueError(f"metric must be one of {ALERT_METRICS}, got {metric!r}")
|
|
27
|
+
if operator is not None and operator not in ("gt", "lt"):
|
|
28
|
+
raise ValueError(f"operator must be 'gt' or 'lt', got {operator!r}")
|
|
29
|
+
if channels is not None:
|
|
30
|
+
for channel in channels:
|
|
31
|
+
kind = channel.get("kind") if isinstance(channel, dict) else None
|
|
32
|
+
if kind not in ALERT_CHANNEL_KINDS:
|
|
33
|
+
raise ValueError(f"channel kind must be one of {ALERT_CHANNEL_KINDS}, got {kind!r}")
|
|
34
|
+
|
|
35
|
+
|
|
20
36
|
class AlertRule(dict):
|
|
21
37
|
"""Wire object for one KPI alert rule (dict subclass so unknown fields round-trip)."""
|
|
22
38
|
|
|
@@ -48,12 +64,12 @@ class AlertEvent(dict):
|
|
|
48
64
|
|
|
49
65
|
@property
|
|
50
66
|
def kind(self) -> str:
|
|
51
|
-
return str(self.get("kind"))
|
|
67
|
+
return str(self.get("kind") or "")
|
|
52
68
|
|
|
53
69
|
@property
|
|
54
70
|
def delivered(self) -> bool:
|
|
55
71
|
deliveries = self.get("deliveries") or []
|
|
56
|
-
return bool(deliveries) and all(bool(d.get("ok")) for d in deliveries)
|
|
72
|
+
return bool(deliveries) and all(isinstance(d, dict) and bool(d.get("ok")) for d in deliveries)
|
|
57
73
|
|
|
58
74
|
|
|
59
75
|
def slack(url: str) -> Dict[str, str]:
|
|
@@ -138,13 +154,7 @@ class AlertRulesClient:
|
|
|
138
154
|
"""Create a rule. ``operator`` is ``"gt"`` (above) or ``"lt"`` (below); rates are
|
|
139
155
|
fractions (``0.10`` = 10%), latency is milliseconds, cost is USD. ``channels`` takes the
|
|
140
156
|
dicts the module-level helpers build (``slack(url)``, ``pagerduty(key)``, ...)."""
|
|
141
|
-
|
|
142
|
-
raise ValueError(f"metric must be one of {ALERT_METRICS}, got {metric!r}")
|
|
143
|
-
if operator not in ("gt", "lt"):
|
|
144
|
-
raise ValueError(f"operator must be 'gt' or 'lt', got {operator!r}")
|
|
145
|
-
for channel in channels:
|
|
146
|
-
if channel.get("kind") not in ALERT_CHANNEL_KINDS:
|
|
147
|
-
raise ValueError(f"channel kind must be one of {ALERT_CHANNEL_KINDS}, got {channel.get('kind')!r}")
|
|
157
|
+
_validate(metric=metric, operator=operator, channels=channels)
|
|
148
158
|
payload: Dict[str, Any] = {
|
|
149
159
|
"name": name,
|
|
150
160
|
"metric": metric,
|
|
@@ -165,7 +175,10 @@ class AlertRulesClient:
|
|
|
165
175
|
|
|
166
176
|
def update(self, rule_id: str, **fields: Any) -> AlertRule:
|
|
167
177
|
"""Sparse update; snake_case kwargs are mapped to the wire. Changing the metric,
|
|
168
|
-
operator, threshold, window, or agent resets the rule's firing state
|
|
178
|
+
operator, threshold, window, or agent resets the rule's firing state, and a rule that
|
|
179
|
+
was firing sends its channels a final ``resolved`` notification first. The same local
|
|
180
|
+
checks as ``create`` apply to whichever of ``metric``, ``operator``, ``channels`` are
|
|
181
|
+
given."""
|
|
169
182
|
payload: Dict[str, Any] = {}
|
|
170
183
|
for key, value in fields.items():
|
|
171
184
|
wire_key = _ALIASES.get(key, key)
|
|
@@ -175,12 +188,13 @@ class AlertRulesClient:
|
|
|
175
188
|
"silently ignore this (see AlertRulesClient.create for the field names)."
|
|
176
189
|
)
|
|
177
190
|
payload[wire_key] = value
|
|
191
|
+
_validate(metric=payload.get("metric"), operator=payload.get("operator"), channels=payload.get("channels"))
|
|
178
192
|
data = self._request("PUT", f"/agent-monitoring/alert-rules/{rule_id}", json=payload)
|
|
179
193
|
return AlertRule(data.get("rule", data))
|
|
180
194
|
|
|
181
195
|
def delete(self, rule_id: str) -> None:
|
|
182
|
-
"""Deletes the rule and its history. A
|
|
183
|
-
resolved
|
|
196
|
+
"""Deletes the rule and its history. A rule that is firing sends its channels a final
|
|
197
|
+
``resolved`` notification (closing the PagerDuty incident it opened) before it goes."""
|
|
184
198
|
self._request("DELETE", f"/agent-monitoring/alert-rules/{rule_id}", retry=False)
|
|
185
199
|
|
|
186
200
|
def events(self, rule_id: str, limit: int = 50) -> List[AlertEvent]:
|
|
@@ -165,6 +165,9 @@ class _TraceSpan:
|
|
|
165
165
|
self.tool_calls: list = []
|
|
166
166
|
|
|
167
167
|
self._start: Optional[float] = None
|
|
168
|
+
# Set by callers that know when the work actually ended (a streamed LLM call's last
|
|
169
|
+
# chunk) so __exit__ does not measure to "now" - see finish_llm_call's root path.
|
|
170
|
+
self._end_override: Optional[float] = None
|
|
168
171
|
self._error: Optional[str] = None
|
|
169
172
|
|
|
170
173
|
self._captured_model: Optional[str] = None
|
|
@@ -212,7 +215,8 @@ class _TraceSpan:
|
|
|
212
215
|
|
|
213
216
|
def __exit__(self, exc_type, exc_val, tb):
|
|
214
217
|
self._tracer._pop_active_span(self)
|
|
215
|
-
|
|
218
|
+
ended_at = self._end_override if self._end_override is not None else time.time()
|
|
219
|
+
latency_ms = int((ended_at - self._start) * 1000) if self._start else None
|
|
216
220
|
if exc_val is not None and self._error is None:
|
|
217
221
|
self._error = str(exc_val)
|
|
218
222
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
VERSION = "0.8.
|
|
1
|
+
VERSION = "0.8.28"
|
|
2
2
|
|
|
3
3
|
# The AgentX-trace-eval release this SDK version is tested against - what `agentx-trace-eval`
|
|
4
4
|
# installs and converges to (see agentx/cli.py). Bump together with VERSION when releasing, so
|
|
5
5
|
# every published SDK names a known-good engine+dashboard pair. Users can override with
|
|
6
6
|
# AGENTX_TRACE_EVAL_VERSION=<tag|latest>.
|
|
7
|
-
ENGINE_VERSION = "v0.3.
|
|
7
|
+
ENGINE_VERSION = "v0.3.31"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentx-python
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.28
|
|
4
4
|
Summary: Official Python SDK for AgentX (https://www.agentx.so/)
|
|
5
5
|
Home-page: https://github.com/AgentX-ai/AgentX-python
|
|
6
6
|
Author: Robin Wang and AgentX Team
|
|
@@ -260,6 +260,12 @@ extra:
|
|
|
260
260
|
| LlamaIndex | `pip install "agentx-python[llamaindex]"` | `AgentXLlamaIndexHandler` |
|
|
261
261
|
| AutoGen | `pip install "agentx-python[autogen]"` | `AgentXAutoGenObserver` |
|
|
262
262
|
|
|
263
|
+
Raw-client patches (`patch_openai_client`, `patch_nim_client`, `patch_anthropic_client`) trace
|
|
264
|
+
streaming calls too: the returned stream is a transparent proxy that assembles the reply from the
|
|
265
|
+
chunks you consume, with latency measured to the last chunk and the time to first token in the
|
|
266
|
+
trace metadata. OpenAI-compatible endpoints only send token usage on streams when you pass
|
|
267
|
+
`stream_options={"include_usage": True}`.
|
|
268
|
+
|
|
263
269
|
> **Warning: pick ONE instrumentation layer per LLM call.** Do not combine
|
|
264
270
|
> `AgentXCallbackHandler` (or any framework integration) with a patched provider client
|
|
265
271
|
> (`patch_openai_client`, `patch_anthropic_client`, `patch_genai_client`) on the same code
|
|
@@ -89,6 +89,18 @@ def test_update_maps_snake_case_and_refuses_unknown_keys():
|
|
|
89
89
|
AlertRulesClient(fake).update("a1", sample_rate=0.5) # type: ignore[arg-type]
|
|
90
90
|
|
|
91
91
|
|
|
92
|
+
def test_update_applies_the_same_local_checks_as_create():
|
|
93
|
+
fake = FakeMonitorClient([])
|
|
94
|
+
client = AlertRulesClient(fake) # type: ignore[arg-type]
|
|
95
|
+
with pytest.raises(ValueError, match="metric"):
|
|
96
|
+
client.update("a1", metric="vibes")
|
|
97
|
+
with pytest.raises(ValueError, match="operator"):
|
|
98
|
+
client.update("a1", operator="ge")
|
|
99
|
+
with pytest.raises(ValueError, match="channel kind"):
|
|
100
|
+
client.update("a1", channels=[{"kind": "sms", "target": "1"}])
|
|
101
|
+
assert fake.calls == []
|
|
102
|
+
|
|
103
|
+
|
|
92
104
|
def test_events_test_preview_and_sweep_paths():
|
|
93
105
|
fake = FakeMonitorClient(
|
|
94
106
|
[
|
|
@@ -278,10 +278,17 @@ def test_anthropic_stream_helper_records_the_final_message():
|
|
|
278
278
|
self.closed = False
|
|
279
279
|
self.text_stream = iter(["streamed ", "reply"])
|
|
280
280
|
|
|
281
|
-
|
|
281
|
+
@property
|
|
282
|
+
def current_message_snapshot(self):
|
|
282
283
|
assert not self.closed, "must be read before the manager closes the stream"
|
|
283
284
|
return final
|
|
284
285
|
|
|
286
|
+
def get_final_message(self):
|
|
287
|
+
# The SDK's get_final_message() drains the rest of the response (until_done()):
|
|
288
|
+
# an early `break` would block until the model finished. The wrapper must never
|
|
289
|
+
# call it.
|
|
290
|
+
raise AssertionError("get_final_message() drains the stream - use the snapshot")
|
|
291
|
+
|
|
285
292
|
class FakeManager:
|
|
286
293
|
def __init__(self):
|
|
287
294
|
self.stream = FakeMessageStream()
|
|
@@ -314,6 +321,122 @@ def test_anthropic_stream_helper_records_the_final_message():
|
|
|
314
321
|
assert kwargs["output_tokens"] == 4
|
|
315
322
|
|
|
316
323
|
|
|
324
|
+
def test_stream_root_trace_latency_ends_at_the_last_chunk_not_at_finalization():
|
|
325
|
+
# The proxy hands finish_llm_call the last-chunk time; the root span must honor it instead
|
|
326
|
+
# of measuring to whatever later moment the caller dropped the stream.
|
|
327
|
+
from agentx.integrations.openai import patch_openai_client
|
|
328
|
+
|
|
329
|
+
stream = _FakeStream(_stream_chunks())
|
|
330
|
+
|
|
331
|
+
class FakeCompletions:
|
|
332
|
+
def create(self, **kwargs):
|
|
333
|
+
return stream
|
|
334
|
+
|
|
335
|
+
client = _fake_openai_client(FakeCompletions())
|
|
336
|
+
tracer = make_tracer()
|
|
337
|
+
patch_openai_client(client, tracer, name="gpt-agent")
|
|
338
|
+
result = client.chat.completions.create(model="gpt-4o-mini", messages=[], stream=True)
|
|
339
|
+
for _ in result:
|
|
340
|
+
pass
|
|
341
|
+
time.sleep(0.15) # the caller holds the exhausted stream a while before closing it
|
|
342
|
+
result.close()
|
|
343
|
+
_, kwargs = tracer._send.call_args
|
|
344
|
+
assert kwargs["latency_ms"] < 100
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_stream_tracing_failure_never_escapes_into_the_callers_loop():
|
|
348
|
+
from agentx.integrations.openai import patch_openai_client
|
|
349
|
+
|
|
350
|
+
class FakeCompletions:
|
|
351
|
+
def create(self, **kwargs):
|
|
352
|
+
return _FakeStream(_stream_chunks())
|
|
353
|
+
|
|
354
|
+
client = _fake_openai_client(FakeCompletions())
|
|
355
|
+
tracer = make_tracer()
|
|
356
|
+
tracer._send = MagicMock(side_effect=RuntimeError("ingest exploded"))
|
|
357
|
+
patch_openai_client(client, tracer, name="gpt-agent")
|
|
358
|
+
chunks = list(client.chat.completions.create(model="gpt-4o-mini", messages=[], stream=True))
|
|
359
|
+
assert len(chunks) == 4 # the loop ended normally despite the tracer raising
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_stream_parent_is_the_span_active_at_call_time_not_at_finalization():
|
|
363
|
+
from agentx.integrations.openai import patch_openai_client
|
|
364
|
+
|
|
365
|
+
class FakeCompletions:
|
|
366
|
+
def create(self, **kwargs):
|
|
367
|
+
return _FakeStream(_stream_chunks())
|
|
368
|
+
|
|
369
|
+
client = _fake_openai_client(FakeCompletions())
|
|
370
|
+
tracer = make_tracer()
|
|
371
|
+
tracer._dispatch = MagicMock(return_value=None)
|
|
372
|
+
patch_openai_client(client, tracer, name="gpt-agent")
|
|
373
|
+
|
|
374
|
+
# Created with NO active span, consumed, then finalized while an unrelated span is active.
|
|
375
|
+
stream = client.chat.completions.create(model="gpt-4o-mini", messages=[], stream=True)
|
|
376
|
+
for _ in stream:
|
|
377
|
+
pass
|
|
378
|
+
with tracer.trace("unrelated-task") as unrelated:
|
|
379
|
+
stream.close()
|
|
380
|
+
assert unrelated._child_span_count == 0, "the stream must not graft onto the unrelated span"
|
|
381
|
+
# It became its own root trace instead (two _send calls: the stream's root + unrelated-task).
|
|
382
|
+
assert tracer._send.call_count == 2
|
|
383
|
+
names = [c.kwargs["name"] for c in tracer._send.call_args_list]
|
|
384
|
+
assert "gpt-agent" in names and "unrelated-task" in names
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def test_stream_proxy_private_attributes_never_delegate():
|
|
388
|
+
from agentx.integrations._traced_call import TracedStream
|
|
389
|
+
|
|
390
|
+
proxy = TracedStream.__new__(TracedStream) # half-constructed: no _stream yet
|
|
391
|
+
with pytest.raises(AttributeError):
|
|
392
|
+
_ = proxy._done
|
|
393
|
+
del proxy # __del__ on the half-built object must not recurse or raise
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def test_stream_keyboard_interrupt_records_partial_output_without_an_error():
|
|
397
|
+
from agentx.integrations.openai import patch_openai_client
|
|
398
|
+
|
|
399
|
+
class InterruptingStream(_FakeStream):
|
|
400
|
+
def __iter__(self):
|
|
401
|
+
yield _chunk(content="Hel")
|
|
402
|
+
raise KeyboardInterrupt()
|
|
403
|
+
|
|
404
|
+
class FakeCompletions:
|
|
405
|
+
def create(self, **kwargs):
|
|
406
|
+
return InterruptingStream([])
|
|
407
|
+
|
|
408
|
+
client = _fake_openai_client(FakeCompletions())
|
|
409
|
+
tracer = make_tracer()
|
|
410
|
+
patch_openai_client(client, tracer, name="gpt-agent")
|
|
411
|
+
with pytest.raises(KeyboardInterrupt):
|
|
412
|
+
list(client.chat.completions.create(model="gpt-4o-mini", messages=[], stream=True))
|
|
413
|
+
_, kwargs = tracer._send.call_args
|
|
414
|
+
assert kwargs["output"] == "Hel"
|
|
415
|
+
assert kwargs["error"] is None
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def test_stream_without_a_first_chunk_omits_time_to_first_token():
|
|
419
|
+
from agentx.integrations.openai import patch_openai_client
|
|
420
|
+
|
|
421
|
+
class DeadStream(_FakeStream):
|
|
422
|
+
def __iter__(self):
|
|
423
|
+
raise RuntimeError("connection refused before the first chunk")
|
|
424
|
+
yield # noqa: unreachable - makes this a generator like the real Stream
|
|
425
|
+
|
|
426
|
+
class FakeCompletions:
|
|
427
|
+
def create(self, **kwargs):
|
|
428
|
+
return DeadStream([])
|
|
429
|
+
|
|
430
|
+
client = _fake_openai_client(FakeCompletions())
|
|
431
|
+
tracer = make_tracer()
|
|
432
|
+
patch_openai_client(client, tracer, name="gpt-agent")
|
|
433
|
+
with pytest.raises(RuntimeError):
|
|
434
|
+
list(client.chat.completions.create(model="gpt-4o-mini", messages=[], stream=True))
|
|
435
|
+
_, kwargs = tracer._send.call_args
|
|
436
|
+
assert kwargs["metadata"]["streaming"] is True
|
|
437
|
+
assert "timeToFirstTokenMs" not in kwargs["metadata"]
|
|
438
|
+
|
|
439
|
+
|
|
317
440
|
def test_abandoned_stream_still_records_what_it_saw_when_collected():
|
|
318
441
|
import gc
|
|
319
442
|
from agentx.integrations.openai import patch_openai_client
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -19,14 +19,14 @@ from agentx.monitor.sessions import MonitorSessionClient
|
|
|
19
19
|
from agentx.monitor.signals import MonitorSignalClient
|
|
20
20
|
|
|
21
21
|
__all__ = [
|
|
22
|
-
"AlertEvent",
|
|
23
|
-
"AlertRule",
|
|
24
|
-
"AlertRulesClient",
|
|
25
22
|
"AgentXImprovementGroupsError",
|
|
26
23
|
"AgentXJudgeScorersError",
|
|
27
24
|
"AgentXMonitorError",
|
|
28
25
|
"AgentXScorerGroupsError",
|
|
29
26
|
"AgentXScorersError",
|
|
27
|
+
"AlertEvent",
|
|
28
|
+
"AlertRule",
|
|
29
|
+
"AlertRulesClient",
|
|
30
30
|
"ImprovementGroupsClient",
|
|
31
31
|
"JudgeScorer",
|
|
32
32
|
"JudgeScorerBuilder",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|