agentx-python 0.8.26__tar.gz → 0.8.27__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.26 → agentx_python-0.8.27}/PKG-INFO +1 -1
  2. agentx_python-0.8.27/agentx/integrations/_traced_call.py +395 -0
  3. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/anthropic.py +146 -15
  4. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/openai.py +111 -14
  5. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/__init__.py +4 -0
  6. agentx_python-0.8.27/agentx/monitor/alert_rules.py +209 -0
  7. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/client.py +5 -0
  8. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/tracing/tracer.py +5 -0
  9. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/version.py +2 -2
  10. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx_python.egg-info/PKG-INFO +1 -1
  11. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx_python.egg-info/SOURCES.txt +2 -0
  12. agentx_python-0.8.27/tests/test_alert_rules.py +128 -0
  13. {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_integrations.py +382 -28
  14. agentx_python-0.8.26/agentx/integrations/_traced_call.py +0 -175
  15. {agentx_python-0.8.26 → agentx_python-0.8.27}/LICENSE +0 -0
  16. {agentx_python-0.8.26 → agentx_python-0.8.27}/README.md +0 -0
  17. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/__init__.py +0 -0
  18. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/agentx.py +0 -0
  19. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/cli.py +0 -0
  20. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/__init__.py +0 -0
  21. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/_term.py +0 -0
  22. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/adapters/__init__.py +0 -0
  23. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/adapters/http_endpoint.py +0 -0
  24. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/adapters/precomputed.py +0 -0
  25. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/adapters/raw.py +0 -0
  26. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/client.py +0 -0
  27. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/datasets.py +0 -0
  28. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/evaluation_settings.py +0 -0
  29. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/models.py +0 -0
  30. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/prompts.py +0 -0
  31. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/reporting.py +0 -0
  32. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/results.py +0 -0
  33. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/runner.py +0 -0
  34. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/tool_schemas.py +0 -0
  35. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/tracing.py +0 -0
  36. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/exceptions.py +0 -0
  37. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/export.py +0 -0
  38. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/feedback.py +0 -0
  39. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/__init__.py +0 -0
  40. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/autogen.py +0 -0
  41. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/crewai.py +0 -0
  42. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/databricks.py +0 -0
  43. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/google_adk.py +0 -0
  44. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/google_genai.py +0 -0
  45. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/langchain.py +0 -0
  46. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/litellm.py +0 -0
  47. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/llamaindex.py +0 -0
  48. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/moveworks.py +0 -0
  49. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/nvidia_nim.py +0 -0
  50. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/openai_agents.py +0 -0
  51. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/_transport.py +0 -0
  52. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/agents.py +0 -0
  53. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/improvement_groups.py +0 -0
  54. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/judge_scorers.py +0 -0
  55. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/models.py +0 -0
  56. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/online_evaluators.py +0 -0
  57. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/patterns.py +0 -0
  58. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/profile.py +0 -0
  59. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/review_queue.py +0 -0
  60. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/rules.py +0 -0
  61. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/scorer_groups.py +0 -0
  62. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/scorers.py +0 -0
  63. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/sessions.py +0 -0
  64. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/signals.py +0 -0
  65. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/outcomes.py +0 -0
  66. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/projects.py +0 -0
  67. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/py.typed +0 -0
  68. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/resources/__init__.py +0 -0
  69. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/resources/agent.py +0 -0
  70. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/resources/conversation.py +0 -0
  71. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/resources/workforce.py +0 -0
  72. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/testing.py +0 -0
  73. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/traces.py +0 -0
  74. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/tracing/__init__.py +0 -0
  75. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/tracing/ci_types.py +0 -0
  76. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/tracing/eval_scope.py +0 -0
  77. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/tracing/framework_detect.py +0 -0
  78. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/tracing/ingest_client.py +0 -0
  79. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/util.py +0 -0
  80. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx_python.egg-info/dependency_links.txt +0 -0
  81. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx_python.egg-info/entry_points.txt +0 -0
  82. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx_python.egg-info/not-zip-safe +0 -0
  83. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx_python.egg-info/requires.txt +0 -0
  84. {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx_python.egg-info/top_level.txt +0 -0
  85. {agentx_python-0.8.26 → agentx_python-0.8.27}/setup.cfg +0 -0
  86. {agentx_python-0.8.26 → agentx_python-0.8.27}/setup.py +0 -0
  87. {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_cli_launcher.py +0 -0
  88. {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_deep_dive_fixes.py +0 -0
  89. {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_docs_match_sdk.py +0 -0
  90. {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_error_taxonomy.py +0 -0
  91. {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_eval_scope.py +0 -0
  92. {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_framework_detect.py +0 -0
  93. {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_integration.py +0 -0
  94. {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_judge_scorers.py +0 -0
  95. {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_multi_judge.py +0 -0
  96. {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_pairwise.py +0 -0
  97. {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_pattern_update_merge.py +0 -0
  98. {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_review_queue.py +0 -0
  99. {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_runner_features.py +0 -0
  100. {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_selfhost_analysis_fallback.py +0 -0
  101. {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_selfhost_compat.py +0 -0
  102. {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_span_tree.py +0 -0
  103. {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_testing.py +0 -0
  104. {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_wire_models.py +0 -0
  105. {agentx_python-0.8.26 → agentx_python-0.8.27}/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.26
3
+ Version: 0.8.27
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
@@ -0,0 +1,395 @@
1
+ """
2
+ Shared helper for tracing a raw SDK client call that may be sync or async.
3
+
4
+ Anthropic's and Google GenAI's raw clients expose the same method names for
5
+ both their sync and async client variants (``client.messages.create``,
6
+ ``client.models.generate_content``) - the only way to tell them apart is to
7
+ call the method and check whether the result is awaitable.
8
+ ``inspect.iscoroutinefunction`` is unreliable for this: it returns ``False``
9
+ even for ``AsyncAnthropic().messages.create``, since these SDKs don't
10
+ implement the async variant as a plain top-level ``async def``.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import inspect
16
+ import json
17
+ import time
18
+ from typing import Any, Callable, Dict, Optional
19
+
20
+ from agentx.tracing.tracer import Tracer, _safe_serialize
21
+
22
+
23
+ def call_and_trace(
24
+ original: Callable[..., Any],
25
+ args: tuple,
26
+ kwargs: dict,
27
+ on_finish: Callable[[Optional[Any], Optional[str]], None],
28
+ ) -> Any:
29
+ """
30
+ Call ``original(*args, **kwargs)``.
31
+
32
+ If the result is awaitable (async client), return a coroutine that awaits
33
+ it and calls ``on_finish(response, error)`` only after the real await
34
+ completes, so timing/output/tokens reflect the actual call rather than
35
+ the moment the coroutine object was constructed. If the result is a
36
+ normal value (sync client), call ``on_finish`` immediately.
37
+
38
+ Either way, the original call's own return value / exception behavior is
39
+ unchanged for the caller - this only affects when/how the trace is built.
40
+ """
41
+ try:
42
+ result = original(*args, **kwargs)
43
+ except Exception as exc:
44
+ on_finish(None, str(exc))
45
+ raise
46
+
47
+ if asyncio.iscoroutine(result) or inspect.isawaitable(result):
48
+ return _await_and_finish(result, on_finish)
49
+
50
+ on_finish(result, None)
51
+ return result
52
+
53
+
54
+ async def _await_and_finish(
55
+ awaitable: Any,
56
+ on_finish: Callable[[Optional[Any], Optional[str]], None],
57
+ ) -> Any:
58
+ try:
59
+ response = await awaitable
60
+ except Exception as exc:
61
+ on_finish(None, str(exc))
62
+ raise
63
+ on_finish(response, None)
64
+ return response
65
+
66
+
67
+ # The engine's unregistered-tool surfacing (Tools & MCPs -> Unregistered) reads a trace's
68
+ # metadata "tools" key to show the REAL definition instead of one inferred from observed
69
+ # arguments (see AgentX-trace-eval's toolSchemas.ts draftFromMetadata). Raw-client patches see
70
+ # the request's tools=[...] right in kwargs, so capture it - capped so a huge toolbox never
71
+ # blows up the trace's metadata budget.
72
+ _MAX_TOOL_DEFINITIONS = 20
73
+ _MAX_TOOL_DEFINITIONS_BYTES = 12_000
74
+
75
+
76
+ def capture_tool_definitions(tools: Any) -> Optional[list]:
77
+ """Return a metadata-ready copy of a request's ``tools=[...]`` list, or None."""
78
+ if not isinstance(tools, list) or not tools:
79
+ return None
80
+ # A plain JSON round-trip preserves nested schema objects exactly (default=str catches the
81
+ # odd SDK object inside); _safe_serialize would repr-stringify nested dicts, turning a
82
+ # parameters schema into an unusable string.
83
+ try:
84
+ text = json.dumps(tools[:_MAX_TOOL_DEFINITIONS], default=str)
85
+ if len(text) > _MAX_TOOL_DEFINITIONS_BYTES:
86
+ return None
87
+ serialized = json.loads(text)
88
+ except (TypeError, ValueError):
89
+ return None
90
+ return serialized if isinstance(serialized, list) else None
91
+
92
+
93
+ def finish_llm_call(
94
+ tracer: Tracer,
95
+ *,
96
+ name: str,
97
+ framework: str,
98
+ metadata: Optional[Dict[str, Any]],
99
+ session_id: Optional[str],
100
+ start_t: float,
101
+ end_t: float,
102
+ input_repr: Any,
103
+ output: Optional[str],
104
+ model: Optional[str],
105
+ input_tokens: Optional[int],
106
+ output_tokens: Optional[int],
107
+ error: Optional[str],
108
+ cache_read_tokens: Optional[int] = None,
109
+ cache_write_tokens: Optional[int] = None,
110
+ tool_definitions: Optional[list] = None,
111
+ call_metadata: Optional[Dict[str, Any]] = None,
112
+ ) -> None:
113
+ """
114
+ Close out one raw-client LLM call - shared by the ``on_finish``/exit
115
+ callbacks of every integration that patches a raw provider client
116
+ (``anthropic.py``, ``google_genai.py``, ``openai.py``, ``litellm.py``) rather than a
117
+ framework-level callback/plugin system.
118
+
119
+ If the call happened inside a ``with tracer.trace(...)`` block, it becomes that span's own
120
+ real child span (via _record_llm_call) instead of an independent trace - the same "part of a
121
+ multi-call agentic loop" behavior ``anthropic.py`` already had; folded in here so every
122
+ raw-client integration gets it instead of each having to remember to check
123
+ ``tracer.current_span`` itself. Otherwise it becomes its own real root span, opened/closed
124
+ directly here (not via ``tracer._send()``) so it still gets a real span_id/session_id and the
125
+ call's exact timing rather than wall-clock "now".
126
+ """
127
+ latency_ms = int((end_t - start_t) * 1000)
128
+
129
+ if tool_definitions:
130
+ metadata = {**(metadata or {}), "tools": tool_definitions}
131
+
132
+ active_span = tracer.current_span
133
+ if active_span is not None:
134
+ # The definitions describe the whole call's toolbox - attach them to the enclosing
135
+ # span's metadata (first capture wins) so the ROOT trace carries them for the
136
+ # unregistered-tool listing, same as the standalone-trace path below.
137
+ if tool_definitions and not (active_span._metadata or {}).get("tools"):
138
+ active_span._metadata = {**(active_span._metadata or {}), "tools": tool_definitions}
139
+ if error is not None:
140
+ active_span.set_error(error)
141
+ active_span._record_llm_call(
142
+ duration_ms=latency_ms,
143
+ start_time=start_t,
144
+ end_time=end_t,
145
+ input=input_repr,
146
+ output=output,
147
+ model=model,
148
+ # Stamp the provider literal on a span opened without one (adoption keeps an
149
+ # explicit framework= or a framework integration's label winning over this).
150
+ framework=framework,
151
+ input_tokens=input_tokens,
152
+ output_tokens=output_tokens,
153
+ cache_read_tokens=cache_read_tokens,
154
+ cache_write_tokens=cache_write_tokens,
155
+ metadata=call_metadata,
156
+ )
157
+ return
158
+
159
+ # A patched provider call outside any active span becomes its own root trace - it is a bare
160
+ # model call, so stamp it "llm" rather than leaving the kind unset.
161
+ span = tracer.trace(
162
+ name,
163
+ metadata={**(metadata or {}), **(call_metadata or {})} if (metadata or call_metadata) else None,
164
+ framework=framework,
165
+ model=model,
166
+ session_id=session_id,
167
+ span_kind="llm",
168
+ )
169
+ span.__enter__()
170
+ span._start = start_t
171
+ span.input = input_repr
172
+ span.output = output
173
+ if error:
174
+ span.set_error(error)
175
+ if input_tokens:
176
+ span._input_tokens = input_tokens
177
+ if output_tokens:
178
+ span._output_tokens = output_tokens
179
+ if cache_read_tokens:
180
+ span._cache_read_tokens = cache_read_tokens
181
+ if cache_write_tokens:
182
+ span._cache_write_tokens = cache_write_tokens
183
+ span.__exit__(None, None, None)
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # Streaming: wrap a provider's chunk stream so the trace is built from what
188
+ # was actually streamed, without touching the caller's consumption of it.
189
+ # ---------------------------------------------------------------------------
190
+
191
+ class StreamAccumulator:
192
+ """
193
+ What a streaming patch feeds each chunk into. Subclasses collect the
194
+ provider-specific pieces (text deltas, tool-call deltas, the usage block
195
+ that only arrives on the final chunk) and hand back the finished picture
196
+ in ``result()``.
197
+ """
198
+
199
+ def feed(self, chunk: Any) -> None: # pragma: no cover - interface
200
+ raise NotImplementedError
201
+
202
+ def result(self) -> Dict[str, Any]: # pragma: no cover - interface
203
+ raise NotImplementedError
204
+
205
+
206
+ class TracedStream:
207
+ """
208
+ Transparent proxy over a provider ``Stream``/``AsyncStream``: iterates the
209
+ real object, feeds every chunk to the accumulator, and calls ``on_finish``
210
+ exactly once when the stream is exhausted, raises, is closed (``close()``,
211
+ ``with``/``async with`` exit), or is dropped part-way and garbage
212
+ collected - so an abandoned stream still records what it streamed.
213
+
214
+ Latency is measured to the LAST chunk (the response as the caller saw it),
215
+ and the time to the FIRST chunk is reported separately as
216
+ ``time_to_first_token_ms`` - the two numbers a streaming call is judged by.
217
+
218
+ Attribute access falls through to the wrapped stream (``.response``,
219
+ provider helpers), and ``__iter__``/``__aiter__`` return ``self`` so early
220
+ ``break`` leaves no half-driven generator behind.
221
+ """
222
+
223
+ def __init__(
224
+ self,
225
+ stream: Any,
226
+ accumulator: StreamAccumulator,
227
+ on_finish: Callable[[Dict[str, Any], Optional[str]], None],
228
+ ) -> None:
229
+ self._stream = stream
230
+ self._accumulator = accumulator
231
+ self._on_finish = on_finish
232
+ self._done = False
233
+ self._first_chunk_t: Optional[float] = None
234
+ self._last_chunk_t: Optional[float] = None
235
+ self._sync_iter: Any = None
236
+ self._async_iter: Any = None
237
+
238
+ # -- bookkeeping ---------------------------------------------------------
239
+
240
+ def _observe(self, chunk: Any) -> None:
241
+ now = time.time()
242
+ if self._first_chunk_t is None:
243
+ self._first_chunk_t = now
244
+ self._last_chunk_t = now
245
+ try:
246
+ self._accumulator.feed(chunk)
247
+ except Exception:
248
+ # A malformed chunk must never break the caller's stream; it just
249
+ # goes uncounted in the trace.
250
+ pass
251
+
252
+ def _finish(self, error: Optional[str]) -> None:
253
+ if self._done:
254
+ return
255
+ self._done = True
256
+ try:
257
+ result = self._accumulator.result()
258
+ except Exception:
259
+ result = {}
260
+ start_t = result.pop("_start_t", None)
261
+ result["time_to_first_token_ms"] = (
262
+ int((self._first_chunk_t - start_t) * 1000) if self._first_chunk_t is not None and start_t is not None else None
263
+ )
264
+ # The response "ended" at its last chunk, not at whatever later moment the caller closed
265
+ # or dropped the stream - that is the latency the user experienced.
266
+ result["end_t"] = self._last_chunk_t if self._last_chunk_t is not None else time.time()
267
+ self._on_finish(result, error)
268
+
269
+ @property
270
+ def first_chunk_at(self) -> Optional[float]:
271
+ return self._first_chunk_t
272
+
273
+ # -- sync iteration ------------------------------------------------------
274
+
275
+ def __iter__(self) -> "TracedStream":
276
+ return self
277
+
278
+ def __next__(self) -> Any:
279
+ if self._sync_iter is None:
280
+ self._sync_iter = iter(self._stream)
281
+ try:
282
+ chunk = next(self._sync_iter)
283
+ except StopIteration:
284
+ self._finish(None)
285
+ raise
286
+ except BaseException as exc:
287
+ self._finish(str(exc))
288
+ raise
289
+ self._observe(chunk)
290
+ return chunk
291
+
292
+ # -- async iteration -----------------------------------------------------
293
+
294
+ def __aiter__(self) -> "TracedStream":
295
+ return self
296
+
297
+ async def __anext__(self) -> Any:
298
+ if self._async_iter is None:
299
+ self._async_iter = self._stream.__aiter__()
300
+ try:
301
+ chunk = await self._async_iter.__anext__()
302
+ except StopAsyncIteration:
303
+ self._finish(None)
304
+ raise
305
+ except BaseException as exc:
306
+ self._finish(str(exc))
307
+ raise
308
+ self._observe(chunk)
309
+ return chunk
310
+
311
+ # -- context managers / close --------------------------------------------
312
+
313
+ def __enter__(self) -> "TracedStream":
314
+ enter = getattr(self._stream, "__enter__", None)
315
+ if enter is not None:
316
+ enter()
317
+ return self
318
+
319
+ def __exit__(self, exc_type, exc_val, tb) -> Any:
320
+ exit_ = getattr(self._stream, "__exit__", None)
321
+ result = exit_(exc_type, exc_val, tb) if exit_ is not None else None
322
+ self._finish(str(exc_val) if exc_val else None)
323
+ return result
324
+
325
+ async def __aenter__(self) -> "TracedStream":
326
+ enter = getattr(self._stream, "__aenter__", None)
327
+ if enter is not None:
328
+ await enter()
329
+ return self
330
+
331
+ async def __aexit__(self, exc_type, exc_val, tb) -> Any:
332
+ exit_ = getattr(self._stream, "__aexit__", None)
333
+ result = await exit_(exc_type, exc_val, tb) if exit_ is not None else None
334
+ self._finish(str(exc_val) if exc_val else None)
335
+ return result
336
+
337
+ def close(self) -> None:
338
+ close = getattr(self._stream, "close", None)
339
+ try:
340
+ if close is not None:
341
+ close()
342
+ finally:
343
+ self._finish(None)
344
+
345
+ async def aclose(self) -> None:
346
+ # openai's AsyncStream spells its close as `async def close()`; httpx-style streams
347
+ # spell it `aclose()`. Await whichever one answers with an awaitable.
348
+ close = getattr(self._stream, "aclose", None) or getattr(self._stream, "close", None)
349
+ try:
350
+ if close is not None:
351
+ result = close()
352
+ if inspect.isawaitable(result):
353
+ await result
354
+ finally:
355
+ self._finish(None)
356
+
357
+ def __getattr__(self, item: str) -> Any:
358
+ return getattr(self._stream, item)
359
+
360
+ def __del__(self) -> None:
361
+ # Best effort only: a stream the caller stopped reading and dropped still records the
362
+ # chunks it did see. Never raises - a destructor exception is unactionable noise.
363
+ try:
364
+ self._finish(None)
365
+ except Exception:
366
+ pass
367
+
368
+
369
+ def trace_stream(
370
+ result: Any,
371
+ accumulator: StreamAccumulator,
372
+ on_finish: Callable[[Dict[str, Any], Optional[str]], None],
373
+ ) -> Any:
374
+ """
375
+ Wrap the value a patched ``create(..., stream=True)`` returned. A sync
376
+ client hands back the stream object directly; an async client hands back
377
+ a coroutine that resolves to it, so the wrapping is deferred until the
378
+ real stream exists - the caller's ``await`` is unchanged either way.
379
+ """
380
+ if asyncio.iscoroutine(result) or inspect.isawaitable(result):
381
+ return _await_and_wrap(result, accumulator, on_finish)
382
+ return TracedStream(result, accumulator, on_finish)
383
+
384
+
385
+ async def _await_and_wrap(
386
+ awaitable: Any,
387
+ accumulator: StreamAccumulator,
388
+ on_finish: Callable[[Dict[str, Any], Optional[str]], None],
389
+ ) -> Any:
390
+ try:
391
+ stream = await awaitable
392
+ except Exception as exc:
393
+ on_finish({}, str(exc))
394
+ raise
395
+ return TracedStream(stream, accumulator, on_finish)
@@ -13,6 +13,12 @@ Usage::
13
13
 
14
14
  Works with both ``anthropic.Anthropic`` and ``anthropic.AsyncAnthropic`` clients.
15
15
 
16
+ Both streaming shapes are traced: the ``client.messages.stream(...)`` helper
17
+ (a context manager with ``get_final_message()``) and the raw
18
+ ``messages.create(..., stream=True)`` event stream, which is wrapped in a
19
+ transparent proxy that assembles the reply, tool-use blocks, and token usage
20
+ from the events as the caller consumes them.
21
+
16
22
  Requires: ``pip install "agentx-python[anthropic]"``
17
23
  """
18
24
  from __future__ import annotations
@@ -22,7 +28,13 @@ import time
22
28
  from typing import Any, Dict, Optional, Tuple
23
29
 
24
30
  from agentx.tracing.tracer import Tracer, _safe_serialize
25
- from agentx.integrations._traced_call import capture_tool_definitions, call_and_trace, finish_llm_call
31
+ from agentx.integrations._traced_call import (
32
+ StreamAccumulator,
33
+ capture_tool_definitions,
34
+ call_and_trace,
35
+ finish_llm_call,
36
+ trace_stream,
37
+ )
26
38
 
27
39
 
28
40
  def _extract_output_text(response: Any) -> Optional[str]:
@@ -92,6 +104,85 @@ def _extract_usage_tokens(
92
104
  return input_tokens, output_tokens, cache_read, cache_creation
93
105
 
94
106
 
107
+ class _MessageEventStreamAccumulator(StreamAccumulator):
108
+ """
109
+ Rebuild a ``Message`` from the raw ``create(stream=True)`` event sequence:
110
+ ``message_start`` carries the input-side usage, ``content_block_start`` opens
111
+ a text or tool_use block, ``content_block_delta`` appends ``text_delta`` /
112
+ ``input_json_delta`` fragments to it, ``message_delta`` carries the
113
+ output-token count. Token accounting mirrors ``_extract_usage_tokens``.
114
+ """
115
+
116
+ def __init__(self, start_t: float) -> None:
117
+ self._start_t = start_t
118
+ self._blocks: Dict[int, Dict[str, Any]] = {}
119
+ self._input_tokens: Optional[int] = None
120
+ self._output_tokens: Optional[int] = None
121
+ self._cache_read: Optional[int] = None
122
+ self._cache_write: Optional[int] = None
123
+ self._model: Optional[str] = None
124
+
125
+ def feed(self, event: Any) -> None:
126
+ event_type = getattr(event, "type", None)
127
+ if event_type == "message_start":
128
+ message = getattr(event, "message", None)
129
+ self._model = getattr(message, "model", None) or self._model
130
+ input_tokens, output_tokens, cache_read, cache_write = _extract_usage_tokens(getattr(message, "usage", None))
131
+ self._input_tokens = input_tokens
132
+ self._cache_read = cache_read
133
+ self._cache_write = cache_write
134
+ if output_tokens:
135
+ self._output_tokens = output_tokens
136
+ elif event_type == "content_block_start":
137
+ index = getattr(event, "index", 0) or 0
138
+ block = getattr(event, "content_block", None)
139
+ self._blocks[index] = {
140
+ "type": getattr(block, "type", None),
141
+ "name": getattr(block, "name", None),
142
+ "text": [getattr(block, "text", None) or ""] if getattr(block, "type", None) == "text" else [],
143
+ "json": [],
144
+ }
145
+ elif event_type == "content_block_delta":
146
+ index = getattr(event, "index", 0) or 0
147
+ delta = getattr(event, "delta", None)
148
+ entry = self._blocks.setdefault(index, {"type": None, "name": None, "text": [], "json": []})
149
+ delta_type = getattr(delta, "type", None)
150
+ if delta_type == "text_delta":
151
+ entry["type"] = entry["type"] or "text"
152
+ entry["text"].append(getattr(delta, "text", None) or "")
153
+ elif delta_type == "input_json_delta":
154
+ entry["type"] = entry["type"] or "tool_use"
155
+ entry["json"].append(getattr(delta, "partial_json", None) or "")
156
+ elif event_type == "message_delta":
157
+ usage = getattr(event, "usage", None)
158
+ output_tokens = getattr(usage, "output_tokens", None) if usage is not None else None
159
+ if output_tokens is not None:
160
+ self._output_tokens = output_tokens
161
+
162
+ def result(self) -> Dict[str, Any]:
163
+ texts = []
164
+ tool_calls = []
165
+ for _, block in sorted(self._blocks.items()):
166
+ if block["type"] == "text":
167
+ text = "".join(block["text"])
168
+ if text:
169
+ texts.append(text)
170
+ elif block["type"] == "tool_use":
171
+ tool_calls.append(f"{block['name'] or 'unknown'}({''.join(block['json'])})")
172
+ output: Optional[str] = "\n".join(texts) if texts else None
173
+ if output is None and tool_calls:
174
+ output = "[tool call] " + ", ".join(tool_calls)
175
+ return {
176
+ "_start_t": self._start_t,
177
+ "output": output,
178
+ "model": self._model,
179
+ "input_tokens": self._input_tokens,
180
+ "output_tokens": self._output_tokens,
181
+ "cache_read_tokens": self._cache_read,
182
+ "cache_write_tokens": self._cache_write,
183
+ }
184
+
185
+
95
186
  def patch_anthropic_client(
96
187
  client: Any,
97
188
  tracer: Tracer,
@@ -140,6 +231,35 @@ def _patch_create(
140
231
 
141
232
  input_repr = _safe_serialize(input_messages)
142
233
 
234
+ if kwargs.get("stream"):
235
+ def on_stream_finish(collected: Dict[str, Any], error: Optional[str]) -> None:
236
+ finish_llm_call(
237
+ tracer,
238
+ name=name,
239
+ framework="anthropic",
240
+ metadata=metadata,
241
+ call_metadata={"streaming": True, "timeToFirstTokenMs": collected.get("time_to_first_token_ms")},
242
+ session_id=session_id,
243
+ start_t=start_t,
244
+ end_t=collected.get("end_t") or time.time(),
245
+ input_repr=input_repr,
246
+ output=collected.get("output"),
247
+ model=collected.get("model") or model,
248
+ input_tokens=collected.get("input_tokens"),
249
+ output_tokens=collected.get("output_tokens"),
250
+ cache_read_tokens=collected.get("cache_read_tokens"),
251
+ cache_write_tokens=collected.get("cache_write_tokens"),
252
+ error=error,
253
+ tool_definitions=tool_definitions,
254
+ )
255
+
256
+ try:
257
+ result = original(*args, **kwargs)
258
+ except Exception as exc:
259
+ on_stream_finish({}, str(exc))
260
+ raise
261
+ return trace_stream(result, _MessageEventStreamAccumulator(start_t), on_stream_finish)
262
+
143
263
  def on_finish(response: Optional[Any], error: Optional[str]) -> None:
144
264
  end_t = time.time()
145
265
  output = None
@@ -237,36 +357,47 @@ def _patch_stream(
237
357
  )
238
358
 
239
359
  class _TracedStream:
240
- """Thin wrapper that records timing when the stream context exits."""
360
+ """
361
+ Thin wrapper that records the final message when the stream context exits.
362
+ ``ctx`` is the SDK's stream *manager*; the ``MessageStream`` it yields on enter is
363
+ what carries ``get_final_message()``, and it must be read BEFORE the manager's exit
364
+ closes it - reading it off the manager after close silently yielded no output.
365
+ """
366
+
367
+ _inner: Any = None
241
368
 
242
369
  def __enter__(self_inner):
243
- return ctx.__enter__()
370
+ self_inner._inner = ctx.__enter__()
371
+ return self_inner._inner
244
372
 
245
373
  def __exit__(self_inner, exc_type, exc_val, tb):
246
- result = ctx.__exit__(exc_type, exc_val, tb)
247
374
  end_t = time.time()
248
375
  error = str(exc_val) if exc_val else None
249
376
  final_message = None
250
- try:
251
- final_message = ctx.get_final_message()
252
- except Exception:
253
- pass
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)
254
383
  build_and_send(end_t, error, final_message)
255
384
  return result
256
385
 
257
386
  async def __aenter__(self_inner):
258
- return await ctx.__aenter__()
387
+ self_inner._inner = await ctx.__aenter__()
388
+ return self_inner._inner
259
389
 
260
390
  async def __aexit__(self_inner, exc_type, exc_val, tb):
261
- result = await ctx.__aexit__(exc_type, exc_val, tb)
262
391
  end_t = time.time()
263
392
  error = str(exc_val) if exc_val else None
264
393
  final_message = None
265
- try:
266
- raw = ctx.get_final_message()
267
- final_message = await raw if inspect.isawaitable(raw) else raw
268
- except Exception:
269
- pass
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)
270
401
  build_and_send(end_t, error, final_message)
271
402
  return result
272
403