agentx-python 0.8.26__py3-none-any.whl → 0.8.27__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
  import asyncio
15
15
  import inspect
16
16
  import json
17
+ import time
17
18
  from typing import Any, Callable, Dict, Optional
18
19
 
19
20
  from agentx.tracing.tracer import Tracer, _safe_serialize
@@ -107,6 +108,7 @@ def finish_llm_call(
107
108
  cache_read_tokens: Optional[int] = None,
108
109
  cache_write_tokens: Optional[int] = None,
109
110
  tool_definitions: Optional[list] = None,
111
+ call_metadata: Optional[Dict[str, Any]] = None,
110
112
  ) -> None:
111
113
  """
112
114
  Close out one raw-client LLM call - shared by the ``on_finish``/exit
@@ -150,13 +152,19 @@ def finish_llm_call(
150
152
  output_tokens=output_tokens,
151
153
  cache_read_tokens=cache_read_tokens,
152
154
  cache_write_tokens=cache_write_tokens,
155
+ metadata=call_metadata,
153
156
  )
154
157
  return
155
158
 
156
159
  # A patched provider call outside any active span becomes its own root trace - it is a bare
157
160
  # model call, so stamp it "llm" rather than leaving the kind unset.
158
161
  span = tracer.trace(
159
- name, metadata=metadata, framework=framework, model=model, session_id=session_id, span_kind="llm"
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",
160
168
  )
161
169
  span.__enter__()
162
170
  span._start = start_t
@@ -173,3 +181,215 @@ def finish_llm_call(
173
181
  if cache_write_tokens:
174
182
  span._cache_write_tokens = cache_write_tokens
175
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
 
@@ -17,8 +17,11 @@ Usage::
17
17
 
18
18
  Works with both ``openai.OpenAI`` and ``openai.AsyncOpenAI`` clients.
19
19
 
20
- Streaming calls (``stream=True``) are passed through untouched and are not
21
- currently traced - see ``patch_openai_client``'s docstring.
20
+ Streaming calls (``stream=True``) are traced too: the returned stream is
21
+ wrapped in a transparent proxy that assembles the reply from the chunks as the
22
+ caller consumes them, so the trace carries the full text, tool calls, and
23
+ (with ``stream_options={"include_usage": True}``) token usage, plus the time
24
+ to first token.
22
25
 
23
26
  Requires: ``pip install "agentx-python[openai]"``
24
27
  """
@@ -28,7 +31,13 @@ import time
28
31
  from typing import Any, Dict, Optional, Tuple
29
32
 
30
33
  from agentx.tracing.tracer import Tracer, _safe_serialize
31
- from agentx.integrations._traced_call import capture_tool_definitions, call_and_trace, finish_llm_call
34
+ from agentx.integrations._traced_call import (
35
+ StreamAccumulator,
36
+ capture_tool_definitions,
37
+ call_and_trace,
38
+ finish_llm_call,
39
+ trace_stream,
40
+ )
32
41
 
33
42
 
34
43
  def _extract_output_text(response: Any) -> Optional[str]:
@@ -75,6 +84,68 @@ def _extract_usage_tokens(usage: Any) -> Tuple[Optional[int], Optional[int], Opt
75
84
  return getattr(usage, "prompt_tokens", None), getattr(usage, "completion_tokens", None), cached_tokens
76
85
 
77
86
 
87
+ class _ChatCompletionStreamAccumulator(StreamAccumulator):
88
+ """
89
+ Rebuild a ``ChatCompletion``-shaped result from ``ChatCompletionChunk``s:
90
+ text deltas concatenate per choice, tool-call deltas merge by index (name
91
+ arrives once, arguments arrive as fragments), and the ``usage`` block -
92
+ present only on the final chunk, and only when the caller asked for it
93
+ with ``stream_options={"include_usage": True}`` - is kept when it appears.
94
+ """
95
+
96
+ def __init__(self, start_t: float) -> None:
97
+ self._start_t = start_t
98
+ self._texts: Dict[int, list] = {}
99
+ self._tool_calls: Dict[int, Dict[str, Any]] = {}
100
+ self._usage: Any = None
101
+ self._model: Optional[str] = None
102
+
103
+ def feed(self, chunk: Any) -> None:
104
+ usage = getattr(chunk, "usage", None)
105
+ if usage is not None:
106
+ self._usage = usage
107
+ model = getattr(chunk, "model", None)
108
+ if model and not self._model:
109
+ self._model = model
110
+ for choice in getattr(chunk, "choices", None) or []:
111
+ index = getattr(choice, "index", 0) or 0
112
+ delta = getattr(choice, "delta", None)
113
+ if delta is None:
114
+ continue
115
+ content = getattr(delta, "content", None)
116
+ if content:
117
+ self._texts.setdefault(index, []).append(content)
118
+ for tc in getattr(delta, "tool_calls", None) or []:
119
+ key = getattr(tc, "index", 0) or 0
120
+ entry = self._tool_calls.setdefault(key, {"name": None, "arguments": []})
121
+ fn = getattr(tc, "function", None)
122
+ fn_name = getattr(fn, "name", None) if fn is not None else None
123
+ fn_args = getattr(fn, "arguments", None) if fn is not None else None
124
+ if fn_name:
125
+ entry["name"] = fn_name
126
+ if fn_args:
127
+ entry["arguments"].append(fn_args)
128
+
129
+ def result(self) -> Dict[str, Any]:
130
+ texts = ["".join(parts) for _, parts in sorted(self._texts.items())]
131
+ output: Optional[str] = "\n".join(t for t in texts if t) or None
132
+ if output is None and self._tool_calls:
133
+ described = [
134
+ f"{entry['name'] or 'unknown'}({''.join(entry['arguments'])})"
135
+ for _, entry in sorted(self._tool_calls.items())
136
+ ]
137
+ output = "[tool call] " + ", ".join(described)
138
+ input_tokens, output_tokens, cache_read_tokens = _extract_usage_tokens(self._usage)
139
+ return {
140
+ "_start_t": self._start_t,
141
+ "output": output,
142
+ "model": self._model,
143
+ "input_tokens": input_tokens,
144
+ "output_tokens": output_tokens,
145
+ "cache_read_tokens": cache_read_tokens,
146
+ }
147
+
148
+
78
149
  def patch_openai_client(
79
150
  client: Any,
80
151
  tracer: Tracer,
@@ -92,12 +163,14 @@ def patch_openai_client(
92
163
  client's ``create()`` returns a coroutine, which is detected and awaited
93
164
  before the trace is built.
94
165
 
95
- Calls made with ``stream=True`` are passed through untouched and are not
96
- traced by this function: safely wrapping a (sync or async) chunk
97
- iterator without disrupting the caller's own consumption of it needs
98
- different handling than a single request/response call, so it's left
99
- unpatched rather than risking a partially-consumed or double-consumed
100
- stream for the caller.
166
+ Calls made with ``stream=True`` return a transparent proxy over the
167
+ provider's stream (see ``_traced_call.TracedStream``): iteration, ``with``,
168
+ ``close()`` and attribute access all pass through to the real stream, and
169
+ the trace is built from the chunks the caller actually consumed - text and
170
+ tool calls assembled from the deltas, token usage from the final chunk
171
+ when ``stream_options={"include_usage": True}`` was requested (OpenAI omits
172
+ usage from streams otherwise), latency to the last chunk, and the time to
173
+ first token in the trace metadata.
101
174
  """
102
175
  chat = getattr(client, "chat", None)
103
176
  completions = getattr(chat, "completions", None) if chat is not None else None
@@ -123,17 +196,41 @@ def _patch_chat_completions_create(
123
196
  return # already patched
124
197
 
125
198
  def patched_create(*args, **kwargs):
126
- if kwargs.get("stream"):
127
- # Not traced - see patch_openai_client's docstring. Passed
128
- # through completely untouched, sync or async.
129
- return original(*args, **kwargs)
130
-
131
199
  start_t = time.time()
132
200
  input_messages = kwargs.get("messages") or (args[0] if args else None)
133
201
  model = kwargs.get("model")
134
202
  input_repr = _safe_serialize(input_messages)
135
203
  tool_definitions = capture_tool_definitions(kwargs.get("tools"))
136
204
 
205
+ if kwargs.get("stream"):
206
+ def on_stream_finish(collected: Dict[str, Any], error: Optional[str]) -> None:
207
+ ttft = collected.get("time_to_first_token_ms")
208
+ finish_llm_call(
209
+ tracer,
210
+ name=name,
211
+ framework=framework,
212
+ metadata=metadata,
213
+ call_metadata={"streaming": True, "timeToFirstTokenMs": ttft},
214
+ session_id=session_id,
215
+ start_t=start_t,
216
+ end_t=collected.get("end_t") or time.time(),
217
+ input_repr=input_repr,
218
+ output=collected.get("output"),
219
+ model=collected.get("model") or model,
220
+ input_tokens=collected.get("input_tokens"),
221
+ output_tokens=collected.get("output_tokens"),
222
+ cache_read_tokens=collected.get("cache_read_tokens"),
223
+ error=error,
224
+ tool_definitions=tool_definitions,
225
+ )
226
+
227
+ try:
228
+ result = original(*args, **kwargs)
229
+ except Exception as exc:
230
+ on_stream_finish({}, str(exc))
231
+ raise
232
+ return trace_stream(result, _ChatCompletionStreamAccumulator(start_t), on_stream_finish)
233
+
137
234
  def on_finish(response: Optional[Any], error: Optional[str]) -> None:
138
235
  end_t = time.time()
139
236
  output = None
@@ -12,12 +12,16 @@ from agentx.monitor.patterns import MonitorPatternBuilder, MonitorPatternClient
12
12
  from agentx.monitor.profile import MonitorProfileClient
13
13
  from agentx.monitor.review_queue import ReviewQueueClient, ReviewQueueItem
14
14
  from agentx.monitor.rules import MonitorRule, MonitorRulesClient
15
+ from agentx.monitor.alert_rules import AlertEvent, AlertRule, AlertRulesClient
15
16
  from agentx.monitor.scorers import AgentXScorersError, ScorersClient
16
17
  from agentx.monitor.scorer_groups import AgentXScorerGroupsError, ScorerGroup, ScorerGroupsClient
17
18
  from agentx.monitor.sessions import MonitorSessionClient
18
19
  from agentx.monitor.signals import MonitorSignalClient
19
20
 
20
21
  __all__ = [
22
+ "AlertEvent",
23
+ "AlertRule",
24
+ "AlertRulesClient",
21
25
  "AgentXImprovementGroupsError",
22
26
  "AgentXJudgeScorersError",
23
27
  "AgentXMonitorError",
@@ -0,0 +1,209 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List, Optional, TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from agentx.monitor.client import MonitorClient
7
+
8
+ ALERT_METRICS = ("failureRate", "toolFailureRate", "p95LatencyMs", "estimatedCostUsd", "judgeFailures", "traceCount")
9
+ ALERT_CHANNEL_KINDS = ("slack", "teams", "pagerduty", "email", "webhook")
10
+
11
+ # snake_case kwargs -> wire keys. The engine reads camelCase only (the wire convention); an
12
+ # unknown snake_case key would be silently ignored, so update() refuses it instead.
13
+ _ALIASES = {
14
+ "window_minutes": "windowMinutes",
15
+ "agent_id": "agentId",
16
+ "cooldown_minutes": "cooldownMinutes",
17
+ }
18
+
19
+
20
+ class AlertRule(dict):
21
+ """Wire object for one KPI alert rule (dict subclass so unknown fields round-trip)."""
22
+
23
+ @property
24
+ def id(self) -> str:
25
+ return self["_id"]
26
+
27
+ @property
28
+ def enabled(self) -> bool:
29
+ return bool(self.get("enabled"))
30
+
31
+ @property
32
+ def state(self) -> str:
33
+ """``"ok"`` or ``"firing"``."""
34
+ return str(self.get("state") or "ok")
35
+
36
+ @property
37
+ def last_value(self) -> Optional[float]:
38
+ return self.get("lastValue")
39
+
40
+ @property
41
+ def fired_count(self) -> int:
42
+ return int(self.get("firedCount") or 0)
43
+
44
+
45
+ class AlertEvent(dict):
46
+ """One row of a rule's notification history: ``kind`` is ``triggered`` / ``repeat`` /
47
+ ``resolved`` / ``test``; ``deliveries`` lists each channel's outcome."""
48
+
49
+ @property
50
+ def kind(self) -> str:
51
+ return str(self.get("kind"))
52
+
53
+ @property
54
+ def delivered(self) -> bool:
55
+ deliveries = self.get("deliveries") or []
56
+ return bool(deliveries) and all(bool(d.get("ok")) for d in deliveries)
57
+
58
+
59
+ def slack(url: str) -> Dict[str, str]:
60
+ """A Slack incoming-webhook channel."""
61
+ return {"kind": "slack", "target": url}
62
+
63
+
64
+ def teams(url: str) -> Dict[str, str]:
65
+ """A Microsoft Teams incoming-webhook (or Workflows) channel."""
66
+ return {"kind": "teams", "target": url}
67
+
68
+
69
+ def pagerduty(routing_key: str) -> Dict[str, str]:
70
+ """A PagerDuty Events API v2 integration - ``routing_key`` is the integration key."""
71
+ return {"kind": "pagerduty", "target": routing_key}
72
+
73
+
74
+ def email(address: str) -> Dict[str, str]:
75
+ """An email recipient (needs a mailer configured on the engine)."""
76
+ return {"kind": "email", "target": address}
77
+
78
+
79
+ def webhook(url: str) -> Dict[str, str]:
80
+ """A generic JSON webhook receiving the full structured notification."""
81
+ return {"kind": "webhook", "target": url}
82
+
83
+
84
+ class AlertRulesClient:
85
+ """Surfaced as ``client.monitor.alert_rules``: KPI alert rules.
86
+
87
+ An alert rule watches an AGGREGATE over a sliding window - one of
88
+ ``failureRate``, ``toolFailureRate``, ``p95LatencyMs``, ``estimatedCostUsd``,
89
+ ``judgeFailures``, ``traceCount`` - and pages typed channels (Slack, Teams,
90
+ PagerDuty, email, generic webhook) when it crosses a threshold. The engine
91
+ evaluates every enabled rule once a minute with an Alertmanager-style
92
+ lifecycle: one ``triggered`` notification when the rule starts breaching, a
93
+ ``repeat`` every ``cooldown_minutes`` while it keeps breaching, and a
94
+ ``resolved`` notification when it recovers. This is distinct from a scorer's
95
+ per-verdict alert threshold and from an automation rule's per-trace routing.
96
+
97
+ Example::
98
+
99
+ from agentx.monitor.alert_rules import slack, pagerduty
100
+
101
+ rule = client.monitor.alert_rules.create(
102
+ "Failure rate above 10%",
103
+ metric="failureRate", operator="gt", threshold=0.10, window_minutes=15,
104
+ severity="high",
105
+ channels=[slack("https://hooks.slack.com/services/..."), pagerduty("R0123...")],
106
+ )
107
+ client.monitor.alert_rules.test(rule.id) # sends a TEST page to every channel
108
+ """
109
+
110
+ def __init__(self, client: "MonitorClient"):
111
+ self._client = client
112
+
113
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
114
+ return self._client._request(method, path, base=self._client._api_root(), **kwargs)
115
+
116
+ def list(self) -> List[AlertRule]:
117
+ data = self._request("GET", "/agent-monitoring/alert-rules")
118
+ return [AlertRule(r) for r in data.get("rules", [])]
119
+
120
+ def get(self, rule_id: str) -> AlertRule:
121
+ data = self._request("GET", f"/agent-monitoring/alert-rules/{rule_id}")
122
+ return AlertRule(data.get("rule", data))
123
+
124
+ def create(
125
+ self,
126
+ name: str,
127
+ *,
128
+ metric: str,
129
+ operator: str,
130
+ threshold: float,
131
+ window_minutes: int,
132
+ channels: List[Dict[str, str]],
133
+ agent_id: Optional[str] = None,
134
+ severity: str = "high",
135
+ cooldown_minutes: int = 60,
136
+ enabled: bool = True,
137
+ ) -> AlertRule:
138
+ """Create a rule. ``operator`` is ``"gt"`` (above) or ``"lt"`` (below); rates are
139
+ fractions (``0.10`` = 10%), latency is milliseconds, cost is USD. ``channels`` takes the
140
+ 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}")
148
+ payload: Dict[str, Any] = {
149
+ "name": name,
150
+ "metric": metric,
151
+ "operator": operator,
152
+ "threshold": threshold,
153
+ "windowMinutes": window_minutes,
154
+ "channels": channels,
155
+ "severity": severity,
156
+ "cooldownMinutes": cooldown_minutes,
157
+ "enabled": enabled,
158
+ }
159
+ if agent_id is not None:
160
+ payload["agentId"] = agent_id
161
+ # Server-side write: a timeout retry would create a duplicate rule that pages twice on
162
+ # every incident - no transport retry (same posture as rules.create).
163
+ data = self._request("POST", "/agent-monitoring/alert-rules", json=payload, retry=False)
164
+ return AlertRule(data.get("rule", data))
165
+
166
+ def update(self, rule_id: str, **fields: Any) -> AlertRule:
167
+ """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."""
169
+ payload: Dict[str, Any] = {}
170
+ for key, value in fields.items():
171
+ wire_key = _ALIASES.get(key, key)
172
+ if "_" in wire_key:
173
+ raise ValueError(
174
+ f"Unknown alert rule field {key!r} - the engine reads camelCase keys and would "
175
+ "silently ignore this (see AlertRulesClient.create for the field names)."
176
+ )
177
+ payload[wire_key] = value
178
+ data = self._request("PUT", f"/agent-monitoring/alert-rules/{rule_id}", json=payload)
179
+ return AlertRule(data.get("rule", data))
180
+
181
+ 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."""
184
+ self._request("DELETE", f"/agent-monitoring/alert-rules/{rule_id}", retry=False)
185
+
186
+ def events(self, rule_id: str, limit: int = 50) -> List[AlertEvent]:
187
+ """The rule's notification history, newest first, with per-channel delivery results."""
188
+ data = self._request("GET", f"/agent-monitoring/alert-rules/{rule_id}/events", params={"limit": limit})
189
+ return [AlertEvent(e) for e in data.get("events", [])]
190
+
191
+ def test(self, rule_id: str) -> AlertEvent:
192
+ """Send a TEST notification to the rule's channels with the metric's live value and
193
+ return the recorded event - ``event.delivered`` says whether every channel accepted it.
194
+ Never changes the rule's firing state."""
195
+ data = self._request("POST", f"/agent-monitoring/alert-rules/{rule_id}/test", json={}, retry=False)
196
+ return AlertEvent(data.get("event", data))
197
+
198
+ def preview(self, metric: str, window_minutes: int, agent_id: Optional[str] = None) -> Dict[str, Any]:
199
+ """What ``metric`` reads right now over the last ``window_minutes`` - the same
200
+ computation the sweep runs. Returns ``{"value": float | None, "valueLabel": str, ...}``;
201
+ ``value`` is ``None`` when the window has no data for a rate metric."""
202
+ payload: Dict[str, Any] = {"metric": metric, "windowMinutes": window_minutes}
203
+ if agent_id is not None:
204
+ payload["agentId"] = agent_id
205
+ return self._request("POST", "/agent-monitoring/alert-rules/preview", json=payload)
206
+
207
+ def run_sweep(self) -> Dict[str, Any]:
208
+ """Evaluate this project's rules now instead of waiting for the next minute tick."""
209
+ return self._request("POST", "/agent-monitoring/alert-rules/sweep/run", json={}, retry=False)
agentx/monitor/client.py CHANGED
@@ -139,6 +139,11 @@ class MonitorClient:
139
139
 
140
140
  # Automation rules: route matching traffic into review / a dataset / a webhook.
141
141
  self.rules = MonitorRulesClient(self)
142
+ from agentx.monitor.alert_rules import AlertRulesClient
143
+
144
+ # KPI alert rules: threshold pages (Slack/Teams/PagerDuty/email/webhook) on failure
145
+ # rate, p95 latency, spend, judge failures, and traffic volume over a window.
146
+ self.alert_rules = AlertRulesClient(self)
142
147
  from agentx.monitor.scorers import ScorersClient
143
148
  # Scorers-catalog administration as code: template enable/disable, code/external scorer
144
149
  # CRUD and dry runs - full parity with the dashboard's Scorers page (P1.3).
agentx/tracing/tracer.py CHANGED
@@ -297,6 +297,7 @@ class _TraceSpan:
297
297
  output_tokens: Optional[int] = None,
298
298
  cache_read_tokens: Optional[int] = None,
299
299
  cache_write_tokens: Optional[int] = None,
300
+ metadata: Optional[Dict[str, Any]] = None,
300
301
  ) -> None:
301
302
  """Record one LLM-call child span (e.g. one patched Anthropic call) under this span -
302
303
  name left unset so _merge_child_run auto-numbers it "LLM Call N". ``framework`` lets the
@@ -315,6 +316,9 @@ class _TraceSpan:
315
316
  "outputTokenSize": output_tokens,
316
317
  "cacheReadTokenSize": cache_read_tokens,
317
318
  "cacheWriteTokenSize": cache_write_tokens,
319
+ # Per-call facts that belong on the child row (a streamed call's time to first
320
+ # token), not on the parent trace's metadata.
321
+ "metadata": metadata,
318
322
  }],
319
323
  input=input,
320
324
  output=output,
@@ -479,6 +483,7 @@ class _TraceSpan:
479
483
  output_tokens=step.get("outputTokenSize"),
480
484
  cache_read_tokens=step.get("cacheReadTokenSize"),
481
485
  cache_write_tokens=step.get("cacheWriteTokenSize"),
486
+ metadata=step.get("metadata") or None,
482
487
  # Stated, so a step named anything other than "LLM Call N" still classifies -
483
488
  # the backend's name regex was the only thing holding this together. Steps
484
489
  # may state their own kind (crewai.py's task steps carry "agent"); the
agentx/version.py CHANGED
@@ -1,7 +1,7 @@
1
- VERSION = "0.8.26"
1
+ VERSION = "0.8.27"
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.29"
7
+ ENGINE_VERSION = "v0.3.30"
@@ -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
@@ -10,7 +10,7 @@ agentx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  agentx/testing.py,sha256=0shZEid_vhJgBegpO_loUq6APYDxQrUgD3jvcRdIDV8,7372
11
11
  agentx/traces.py,sha256=Jh07n3GLS1SGRsDoLDTUaAbuMEdvjqxCCMA9gUcSZ0M,2331
12
12
  agentx/util.py,sha256=ivCuFQ5AGUw9fR9fJy5HrSp3lLwwngsDwppnvyZpq9E,1471
13
- agentx/version.py,sha256=mHuhKZSPSp8Xp5Pq7q93wgdcMtBUM_OBt-nWG4Qoh6g,366
13
+ agentx/version.py,sha256=KdSKPJMRW7kj12djMaC4Y9MJOtmKcvAm9zC6IV3Qeig,366
14
14
  agentx/evaluations/__init__.py,sha256=Erv7RGFlRxGTG4rVb2uHhCqLLEX_iAWKqkZKzK6CumE,262
15
15
  agentx/evaluations/_term.py,sha256=WFpiNzdgDBeJJ-Gg-6X7TwwxllobuE4OqFUTuDQvS3Y,2529
16
16
  agentx/evaluations/client.py,sha256=7c35tS8zM3fuvFfVDQk5etTTEFiIykmPjuJxkuFyFTY,38013
@@ -28,8 +28,8 @@ agentx/evaluations/adapters/http_endpoint.py,sha256=-Gika9lBcXjtbnyB-uNR6nYEVJhW
28
28
  agentx/evaluations/adapters/precomputed.py,sha256=vxnQELmpiU2NkQATfiVjG7v1ZmmkrEA70Y11ijfYtHw,1208
29
29
  agentx/evaluations/adapters/raw.py,sha256=FkDq_mdf21-xt5EHnGyIGcS6VZxkEkFcmM9VTd-T5sA,1130
30
30
  agentx/integrations/__init__.py,sha256=p-YHLIudYxQj1bBj77NiVPsP0VQHemX5E59phkI3Y-Q,711
31
- agentx/integrations/_traced_call.py,sha256=OuFmSLw5vjI54gpdte7-zOt7gld7faQmvhuoh_3OPlo,6861
32
- agentx/integrations/anthropic.py,sha256=z6o9cC9rBzxxbF1UBiMr-kjEgDUO-BCuPOFoqGN3qF4,10451
31
+ agentx/integrations/_traced_call.py,sha256=I8t37HUVp-VJYcHo26zkJs80A7S8ajh7F9JQ_IRfNCU,14740
32
+ agentx/integrations/anthropic.py,sha256=y-6A32X8CLrwttjSiJuDWunQmi9QuzMYhWHMuutfRJY,16655
33
33
  agentx/integrations/autogen.py,sha256=V1kvTjxQqOG9KsYd4dztaRYH7zTknW0_fqzKjIQG5dk,9507
34
34
  agentx/integrations/crewai.py,sha256=SCObRsrS40P8jOGoI_T5oW4IYuoBW5WjBNuc7zUBU70,14346
35
35
  agentx/integrations/databricks.py,sha256=vKXnur-LWMTzctFuIKte2N826sydXVlpi1qFXo2dfio,18044
@@ -40,12 +40,13 @@ agentx/integrations/litellm.py,sha256=eW8iCaNq0feRbZma8iOvmJ28WiAO7ZaRmlpPjHAtcv
40
40
  agentx/integrations/llamaindex.py,sha256=ruzMiC2TC3Xhub4xoXKPHjsvzV1sQ3QqhjkAdMxUUi4,18001
41
41
  agentx/integrations/moveworks.py,sha256=IyBswLE5LwMSp1VbIvG5izYV5BvXmv4atZelnrDn8SE,25670
42
42
  agentx/integrations/nvidia_nim.py,sha256=bvS6ebEnQZT1DVlIjQQ2RKLI4uPPTdlMKINgDwj0Z-o,2961
43
- agentx/integrations/openai.py,sha256=edJL-HXTO7w-vvIZGQChMu0AnPCY5z0duplIi8z2gXE,6936
43
+ agentx/integrations/openai.py,sha256=0DMKmbQ1Rdd-gIaJuGJ-JEcvMnQ6620d0EQcZrNC46Q,11249
44
44
  agentx/integrations/openai_agents.py,sha256=KSOQ55eRPFxxkqm7p4Dsz6HcJ_9uUEwlyywPI8dwbvY,14852
45
- agentx/monitor/__init__.py,sha256=Rkk-Xv1ymXwDFbx8fetmcOzXfSU1bvsazI_-x5Ati5g,1715
45
+ agentx/monitor/__init__.py,sha256=37zUS2j8FZO98TXdrzE42td9kRIWhypm3yqUR5sBVeg,1853
46
46
  agentx/monitor/_transport.py,sha256=ge0pmdvFdUtCJRkTLvAgjuTQVA8YOCYGQNoyRuselRk,2077
47
47
  agentx/monitor/agents.py,sha256=KhsHVhkqa6V0e0lPcuo04fh44_qSaJOaGcD6U6qXSFo,1622
48
- agentx/monitor/client.py,sha256=JoKp3AmFtQ4UDOsNH5nWEkTjvQtBrrihObGaPVMtBYo,27270
48
+ agentx/monitor/alert_rules.py,sha256=UgK6Cnrcz9F9p1QU-eAg1NsjrtfMoA-dwX8AWKwEcVc,8990
49
+ agentx/monitor/client.py,sha256=oZctmh77Ki5WnZI6vQaj_tDlmdP6XtkDYt3MKhnvGsU,27563
49
50
  agentx/monitor/improvement_groups.py,sha256=MrXexaBLPNzZHrqZjiCPfmwa-DSr-RHuFdP73SKrMJM,6143
50
51
  agentx/monitor/judge_scorers.py,sha256=Xyoafo9ljeq5RpLe5HxvsL0Y0FebFQ-2CxYz8-E1Wio,21025
51
52
  agentx/monitor/models.py,sha256=mNBQDXdVthGYHUyEeRgpFdKVOC9uMtdUgBlLfep1E14,9298
@@ -67,10 +68,10 @@ agentx/tracing/ci_types.py,sha256=b-W2LowRhNBVUdaoMPgd4bnKQy9AxhfiikftRwGPAqU,17
67
68
  agentx/tracing/eval_scope.py,sha256=ElMbPxpqpQBVuaUnHNlw9RIQu71oyOpd0yR55bDH8eI,2144
68
69
  agentx/tracing/framework_detect.py,sha256=uV4O7Th-4_2UkdooAyaWCOIA0jwLJbblFQ_ucFDjeJM,2638
69
70
  agentx/tracing/ingest_client.py,sha256=teukTPckFWPg5RhpbGbVDBejBzPIPXUO3_DJcuVh2uc,22660
70
- agentx/tracing/tracer.py,sha256=NS0bs5HbDDK3l0Z95KVKV3xCNJT7LINS0EoPFM-wzIk,68543
71
- agentx_python-0.8.26.dist-info/licenses/LICENSE,sha256=gZVsM-nLsE8vlaY6NXXsVoo6IlCClxkToAWmhgT3y_s,10762
72
- agentx_python-0.8.26.dist-info/METADATA,sha256=JH0SY8e_dBGzmK92xd_Cas62qpPweH0BoBLbDmE7xPA,22986
73
- agentx_python-0.8.26.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
74
- agentx_python-0.8.26.dist-info/entry_points.txt,sha256=rQqF1JTY3T1yfviBU1r8PU-5mBdfOk6P4bZ1L4BskIM,172
75
- agentx_python-0.8.26.dist-info/top_level.txt,sha256=s-q-HB9Gb_QdrZNacSeQyF_c25gQooMy7DlxzgLOHPk,7
76
- agentx_python-0.8.26.dist-info/RECORD,,
71
+ agentx/tracing/tracer.py,sha256=a9BALnb7UyyOUMAIZW6z-4QkevsO3TsBkY8GiJUEnS4,68848
72
+ agentx_python-0.8.27.dist-info/licenses/LICENSE,sha256=gZVsM-nLsE8vlaY6NXXsVoo6IlCClxkToAWmhgT3y_s,10762
73
+ agentx_python-0.8.27.dist-info/METADATA,sha256=j6fxEPZbrq1VwHmUYEUGAvRawZH52BlTrogVjrYCy-0,22986
74
+ agentx_python-0.8.27.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
75
+ agentx_python-0.8.27.dist-info/entry_points.txt,sha256=rQqF1JTY3T1yfviBU1r8PU-5mBdfOk6P4bZ1L4BskIM,172
76
+ agentx_python-0.8.27.dist-info/top_level.txt,sha256=s-q-HB9Gb_QdrZNacSeQyF_c25gQooMy7DlxzgLOHPk,7
77
+ agentx_python-0.8.27.dist-info/RECORD,,