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.
Files changed (104) hide show
  1. {agentx_python-0.8.27 → agentx_python-0.8.28}/PKG-INFO +7 -1
  2. {agentx_python-0.8.27 → agentx_python-0.8.28}/README.md +6 -0
  3. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/_traced_call.py +60 -9
  4. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/anthropic.py +53 -21
  5. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/nvidia_nim.py +8 -4
  6. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/openai.py +24 -1
  7. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/alert_rules.py +26 -12
  8. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/tracing/tracer.py +5 -1
  9. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/version.py +2 -2
  10. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx_python.egg-info/PKG-INFO +7 -1
  11. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_alert_rules.py +12 -0
  12. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_integrations.py +124 -1
  13. {agentx_python-0.8.27 → agentx_python-0.8.28}/LICENSE +0 -0
  14. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/__init__.py +0 -0
  15. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/agentx.py +0 -0
  16. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/cli.py +0 -0
  17. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/__init__.py +0 -0
  18. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/_term.py +0 -0
  19. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/adapters/__init__.py +0 -0
  20. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/adapters/http_endpoint.py +0 -0
  21. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/adapters/precomputed.py +0 -0
  22. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/adapters/raw.py +0 -0
  23. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/client.py +0 -0
  24. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/datasets.py +0 -0
  25. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/evaluation_settings.py +0 -0
  26. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/models.py +0 -0
  27. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/prompts.py +0 -0
  28. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/reporting.py +0 -0
  29. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/results.py +0 -0
  30. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/runner.py +0 -0
  31. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/tool_schemas.py +0 -0
  32. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/evaluations/tracing.py +0 -0
  33. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/exceptions.py +0 -0
  34. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/export.py +0 -0
  35. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/feedback.py +0 -0
  36. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/__init__.py +0 -0
  37. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/autogen.py +0 -0
  38. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/crewai.py +0 -0
  39. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/databricks.py +0 -0
  40. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/google_adk.py +0 -0
  41. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/google_genai.py +0 -0
  42. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/langchain.py +0 -0
  43. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/litellm.py +0 -0
  44. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/llamaindex.py +0 -0
  45. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/moveworks.py +0 -0
  46. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/integrations/openai_agents.py +0 -0
  47. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/__init__.py +3 -3
  48. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/_transport.py +0 -0
  49. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/agents.py +0 -0
  50. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/client.py +0 -0
  51. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/improvement_groups.py +0 -0
  52. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/judge_scorers.py +0 -0
  53. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/models.py +0 -0
  54. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/online_evaluators.py +0 -0
  55. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/patterns.py +0 -0
  56. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/profile.py +0 -0
  57. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/review_queue.py +0 -0
  58. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/rules.py +0 -0
  59. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/scorer_groups.py +0 -0
  60. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/scorers.py +0 -0
  61. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/sessions.py +0 -0
  62. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/monitor/signals.py +0 -0
  63. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/outcomes.py +0 -0
  64. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/projects.py +0 -0
  65. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/py.typed +0 -0
  66. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/resources/__init__.py +0 -0
  67. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/resources/agent.py +0 -0
  68. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/resources/conversation.py +0 -0
  69. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/resources/workforce.py +0 -0
  70. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/testing.py +0 -0
  71. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/traces.py +0 -0
  72. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/tracing/__init__.py +0 -0
  73. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/tracing/ci_types.py +0 -0
  74. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/tracing/eval_scope.py +0 -0
  75. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/tracing/framework_detect.py +0 -0
  76. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/tracing/ingest_client.py +0 -0
  77. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx/util.py +0 -0
  78. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx_python.egg-info/SOURCES.txt +0 -0
  79. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx_python.egg-info/dependency_links.txt +0 -0
  80. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx_python.egg-info/entry_points.txt +0 -0
  81. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx_python.egg-info/not-zip-safe +0 -0
  82. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx_python.egg-info/requires.txt +0 -0
  83. {agentx_python-0.8.27 → agentx_python-0.8.28}/agentx_python.egg-info/top_level.txt +0 -0
  84. {agentx_python-0.8.27 → agentx_python-0.8.28}/setup.cfg +0 -0
  85. {agentx_python-0.8.27 → agentx_python-0.8.28}/setup.py +0 -0
  86. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_cli_launcher.py +0 -0
  87. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_deep_dive_fixes.py +0 -0
  88. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_docs_match_sdk.py +0 -0
  89. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_error_taxonomy.py +0 -0
  90. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_eval_scope.py +0 -0
  91. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_framework_detect.py +0 -0
  92. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_integration.py +0 -0
  93. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_judge_scorers.py +0 -0
  94. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_multi_judge.py +0 -0
  95. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_pairwise.py +0 -0
  96. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_pattern_update_merge.py +0 -0
  97. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_review_queue.py +0 -0
  98. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_runner_features.py +0 -0
  99. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_selfhost_analysis_fallback.py +0 -0
  100. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_selfhost_compat.py +0 -0
  101. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_span_tree.py +0 -0
  102. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_testing.py +0 -0
  103. {agentx_python-0.8.27 → agentx_python-0.8.28}/tests/test_wire_models.py +0 -0
  104. {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.27
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
- active_span = tracer.current_span
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
- if self._done:
254
- return
255
- self._done = True
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
- self._on_finish(result, error)
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 BaseException as exc:
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 BaseException as exc:
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={"streaming": True, "timeToFirstTokenMs": collected.get("time_to_first_token_ms")},
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
- final_message = None
377
- if error is None and self_inner._inner is not None:
378
- try:
379
- final_message = self_inner._inner.get_final_message()
380
- except Exception:
381
- pass
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
- final_message = None
394
- if error is None and self_inner._inner is not None:
395
- try:
396
- raw = self_inner._inner.get_final_message()
397
- final_message = await raw if inspect.isawaitable(raw) else raw
398
- except Exception:
399
- pass
400
- result = await ctx.__aexit__(exc_type, exc_val, tb)
401
- build_and_send(end_t, error, final_message)
402
- return result
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 passed through untouched and are not
31
- currently traced - same posture as ``patch_openai_client``, see its docstring.
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 pass
60
- through untraced. Patching is idempotent - and because it shares the guard
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={"streaming": True, "timeToFirstTokenMs": ttft},
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
- if metric not in ALERT_METRICS:
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 PagerDuty incident the rule opened is not
183
- resolved by this - close it in PagerDuty."""
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
- latency_ms = int((time.time() - self._start) * 1000) if self._start else None
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.27"
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.30"
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.27
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
- def get_final_message(self):
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
@@ -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