agentx-python 0.8.27__tar.gz → 0.8.29__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 (105) hide show
  1. {agentx_python-0.8.27 → agentx_python-0.8.29}/PKG-INFO +7 -1
  2. {agentx_python-0.8.27 → agentx_python-0.8.29}/README.md +6 -0
  3. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/evaluations/client.py +43 -1
  4. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/evaluations/tool_schemas.py +29 -5
  5. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/integrations/_traced_call.py +60 -9
  6. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/integrations/anthropic.py +53 -21
  7. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/integrations/nvidia_nim.py +8 -4
  8. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/integrations/openai.py +24 -1
  9. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/monitor/alert_rules.py +26 -12
  10. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/monitor/client.py +10 -0
  11. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/tracing/tracer.py +5 -1
  12. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/version.py +2 -2
  13. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx_python.egg-info/PKG-INFO +7 -1
  14. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx_python.egg-info/SOURCES.txt +1 -0
  15. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_alert_rules.py +12 -0
  16. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_integrations.py +124 -1
  17. agentx_python-0.8.29/tests/test_tool_schemas.py +81 -0
  18. {agentx_python-0.8.27 → agentx_python-0.8.29}/LICENSE +0 -0
  19. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/__init__.py +0 -0
  20. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/agentx.py +0 -0
  21. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/cli.py +0 -0
  22. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/evaluations/__init__.py +0 -0
  23. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/evaluations/_term.py +0 -0
  24. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/evaluations/adapters/__init__.py +0 -0
  25. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/evaluations/adapters/http_endpoint.py +0 -0
  26. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/evaluations/adapters/precomputed.py +0 -0
  27. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/evaluations/adapters/raw.py +0 -0
  28. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/evaluations/datasets.py +0 -0
  29. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/evaluations/evaluation_settings.py +0 -0
  30. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/evaluations/models.py +0 -0
  31. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/evaluations/prompts.py +0 -0
  32. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/evaluations/reporting.py +0 -0
  33. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/evaluations/results.py +0 -0
  34. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/evaluations/runner.py +0 -0
  35. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/evaluations/tracing.py +0 -0
  36. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/exceptions.py +0 -0
  37. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/export.py +0 -0
  38. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/feedback.py +0 -0
  39. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/integrations/__init__.py +0 -0
  40. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/integrations/autogen.py +0 -0
  41. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/integrations/crewai.py +0 -0
  42. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/integrations/databricks.py +0 -0
  43. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/integrations/google_adk.py +0 -0
  44. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/integrations/google_genai.py +0 -0
  45. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/integrations/langchain.py +0 -0
  46. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/integrations/litellm.py +0 -0
  47. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/integrations/llamaindex.py +0 -0
  48. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/integrations/moveworks.py +0 -0
  49. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/integrations/openai_agents.py +0 -0
  50. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/monitor/__init__.py +3 -3
  51. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/monitor/_transport.py +0 -0
  52. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/monitor/agents.py +0 -0
  53. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/monitor/improvement_groups.py +0 -0
  54. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/monitor/judge_scorers.py +0 -0
  55. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/monitor/models.py +0 -0
  56. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/monitor/online_evaluators.py +0 -0
  57. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/monitor/patterns.py +0 -0
  58. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/monitor/profile.py +0 -0
  59. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/monitor/review_queue.py +0 -0
  60. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/monitor/rules.py +0 -0
  61. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/monitor/scorer_groups.py +0 -0
  62. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/monitor/scorers.py +0 -0
  63. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/monitor/sessions.py +0 -0
  64. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/monitor/signals.py +0 -0
  65. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/outcomes.py +0 -0
  66. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/projects.py +0 -0
  67. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/py.typed +0 -0
  68. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/resources/__init__.py +0 -0
  69. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/resources/agent.py +0 -0
  70. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/resources/conversation.py +0 -0
  71. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/resources/workforce.py +0 -0
  72. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/testing.py +0 -0
  73. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/traces.py +0 -0
  74. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/tracing/__init__.py +0 -0
  75. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/tracing/ci_types.py +0 -0
  76. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/tracing/eval_scope.py +0 -0
  77. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/tracing/framework_detect.py +0 -0
  78. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/tracing/ingest_client.py +0 -0
  79. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx/util.py +0 -0
  80. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx_python.egg-info/dependency_links.txt +0 -0
  81. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx_python.egg-info/entry_points.txt +0 -0
  82. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx_python.egg-info/not-zip-safe +0 -0
  83. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx_python.egg-info/requires.txt +0 -0
  84. {agentx_python-0.8.27 → agentx_python-0.8.29}/agentx_python.egg-info/top_level.txt +0 -0
  85. {agentx_python-0.8.27 → agentx_python-0.8.29}/setup.cfg +0 -0
  86. {agentx_python-0.8.27 → agentx_python-0.8.29}/setup.py +0 -0
  87. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_cli_launcher.py +0 -0
  88. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_deep_dive_fixes.py +0 -0
  89. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_docs_match_sdk.py +0 -0
  90. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_error_taxonomy.py +0 -0
  91. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_eval_scope.py +0 -0
  92. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_framework_detect.py +0 -0
  93. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_integration.py +0 -0
  94. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_judge_scorers.py +0 -0
  95. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_multi_judge.py +0 -0
  96. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_pairwise.py +0 -0
  97. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_pattern_update_merge.py +0 -0
  98. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_review_queue.py +0 -0
  99. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_runner_features.py +0 -0
  100. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_selfhost_analysis_fallback.py +0 -0
  101. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_selfhost_compat.py +0 -0
  102. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_span_tree.py +0 -0
  103. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_testing.py +0 -0
  104. {agentx_python-0.8.27 → agentx_python-0.8.29}/tests/test_wire_models.py +0 -0
  105. {agentx_python-0.8.27 → agentx_python-0.8.29}/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.29
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
@@ -75,6 +75,22 @@ def _resolve_scorer_id(scorer_id: Optional[str], evaluation_settings_id: Optiona
75
75
  return scorer_id or evaluation_settings_id
76
76
 
77
77
 
78
+ # "Argument not given" for update_tool_schema_meta, where None is itself a meaningful value
79
+ # (clear the price).
80
+ _UNSET: object = object()
81
+
82
+
83
+ def _validated_tool_price(value: float) -> float:
84
+ """Local mirror of the engine's check: USD per call, finite and non-negative."""
85
+ try:
86
+ price = float(value)
87
+ except (TypeError, ValueError):
88
+ raise ValueError(f"price_per_call_usd must be a number of USD per call, got {value!r}") from None
89
+ if price != price or price in (float("inf"), float("-inf")) or price < 0:
90
+ raise ValueError(f"price_per_call_usd must be a finite, non-negative number, got {value!r}")
91
+ return price
92
+
93
+
78
94
  class EvaluationsClient:
79
95
  def __init__(
80
96
  self,
@@ -723,13 +739,39 @@ class EvaluationsClient:
723
739
  data = self._request("GET", "/evaluate/tool-schemas", base=self._api_root)
724
740
  return data.get("toolSchemas", []) if isinstance(data, dict) else data
725
741
 
726
- def create_tool_schema(self, *, name: str, definition: str, description: Optional[str] = None) -> dict:
742
+ def create_tool_schema(
743
+ self, *, name: str, definition: str, description: Optional[str] = None,
744
+ price_per_call_usd: Optional[float] = None,
745
+ ) -> dict:
727
746
  payload: dict = {"name": name, "definition": definition}
728
747
  if description is not None:
729
748
  payload["description"] = description
749
+ if price_per_call_usd is not None:
750
+ payload["pricePerCallUsd"] = _validated_tool_price(price_per_call_usd)
730
751
  # Server-side write - no transport retry (see init_run's comment).
731
752
  return self._request("POST", "/evaluate/tool-schemas", base=self._api_root, json=payload, retry=False)
732
753
 
754
+ def update_tool_schema_meta(
755
+ self, tool_schema_id: str, *, description: Optional[str] = None,
756
+ price_per_call_usd: Optional[float] = _UNSET, test_endpoint_url: Optional[str] = _UNSET, # type: ignore[assignment]
757
+ ) -> dict:
758
+ """Metadata-only edit (PATCH): description, per-call price, test endpoint. Never touches
759
+ the version log. ``price_per_call_usd=None`` clears the price (the tool becomes free)."""
760
+ payload: dict = {}
761
+ if description is not None:
762
+ payload["description"] = description
763
+ if price_per_call_usd is not _UNSET:
764
+ payload["pricePerCallUsd"] = None if price_per_call_usd is None else _validated_tool_price(price_per_call_usd)
765
+ if test_endpoint_url is not _UNSET:
766
+ payload["testEndpointUrl"] = test_endpoint_url
767
+ return self._request(
768
+ "PATCH", f"/evaluate/tool-schemas/{tool_schema_id}", base=self._api_root, json=payload, retry=False,
769
+ )
770
+
771
+ def delete_tool_schema(self, tool_schema_id: str) -> None:
772
+ """Deletes the tool schema and its version history."""
773
+ self._request("DELETE", f"/evaluate/tool-schemas/{tool_schema_id}", base=self._api_root, retry=False)
774
+
733
775
  def get_tool_schema_examples(self, tool_schema_id: str, window: Optional[str] = None) -> dict:
734
776
  params = {"window": window} if window else None
735
777
  return self._request(
@@ -19,15 +19,39 @@ class ToolSchemaClient:
19
19
  def list(self) -> List[dict]:
20
20
  return self._client.list_tool_schemas()
21
21
 
22
- def create(self, *, name: str, definition: str, description: Optional[str] = None) -> dict:
23
- return self._client.create_tool_schema(name=name, definition=definition, description=description)
22
+ def create(
23
+ self, *, name: str, definition: str, description: Optional[str] = None,
24
+ price_per_call_usd: Optional[float] = None,
25
+ ) -> dict:
26
+ """``price_per_call_usd`` is what one call of this tool costs you (a paid external API
27
+ billed per request). Every recorded call is charged, failed ones included; the spend
28
+ shows as the "Tool calls" segment of the Overview cost chart and counts toward the
29
+ ``estimatedCostUsd`` alert metric. Leave it unset for a free tool."""
30
+ return self._client.create_tool_schema(
31
+ name=name, definition=definition, description=description, price_per_call_usd=price_per_call_usd,
32
+ )
24
33
 
25
- def get_or_create(self, *, name: str, definition: str, description: Optional[str] = None) -> dict:
26
- """Idempotent register: returns the existing schema of this name if present."""
34
+ def get_or_create(
35
+ self, *, name: str, definition: str, description: Optional[str] = None,
36
+ price_per_call_usd: Optional[float] = None,
37
+ ) -> dict:
38
+ """Idempotent register: returns the existing schema of this name if present (its stored
39
+ price wins - use :meth:`set_price` to change it)."""
27
40
  existing = next((t for t in self.list() if t.get("name") == name), None)
28
41
  if existing:
29
42
  return existing
30
- return self.create(name=name, definition=definition, description=description)
43
+ return self.create(
44
+ name=name, definition=definition, description=description, price_per_call_usd=price_per_call_usd,
45
+ )
46
+
47
+ def set_price(self, tool_schema_id: str, price_per_call_usd: Optional[float]) -> dict:
48
+ """Set (or clear with ``None``) a registered tool's USD price per call. Metadata only -
49
+ the definition's version log is untouched."""
50
+ return self._client.update_tool_schema_meta(tool_schema_id, price_per_call_usd=price_per_call_usd)
51
+
52
+ def delete(self, tool_schema_id: str) -> None:
53
+ """Deletes the tool schema and its version history."""
54
+ self._client.delete_tool_schema(tool_schema_id)
31
55
 
32
56
  def examples(self, tool_schema_id: str, window: Optional[str] = None) -> dict:
33
57
  """Failure evidence recorded against this tool (agent-tool-failure signals, low-rated
@@ -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]:
@@ -360,6 +360,16 @@ class MonitorClient:
360
360
  params[key] = value
361
361
  return self._request("GET", "/metrics", params=params)
362
362
 
363
+ def cost_trend(self, window: str = "7d") -> dict:
364
+ """Overview's "Total cost" chart over a window ("24h", "7d", "30d"): priced spend per
365
+ bucket and in total, stacked by model, with two reserved segments - ``"eval runs"``
366
+ (offline-evaluation spend) and ``"tool calls"`` (registered tools' per-call prices, split
367
+ per tool in ``totalsByTool``). Unpriced models and tools contribute $0."""
368
+ return self._request(
369
+ "GET", "/agent-monitoring/cost-trend",
370
+ base=self._api_root(), params={"window": window},
371
+ )
372
+
363
373
  def topics(self, window: str = "7d") -> dict:
364
374
  """The Topics view's data over a window ("24h", "7d", "30d"): LLM-classified themes of
365
375
  sampled production traffic with per-topic counts and sentiment. Empty until Topics is
@@ -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.29"
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.32"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentx-python
3
- Version: 0.8.27
3
+ Version: 0.8.29
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
@@ -98,5 +98,6 @@ tests/test_selfhost_analysis_fallback.py
98
98
  tests/test_selfhost_compat.py
99
99
  tests/test_span_tree.py
100
100
  tests/test_testing.py
101
+ tests/test_tool_schemas.py
101
102
  tests/test_wire_models.py
102
103
  tests/test_workforce_binding.py