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.
- agentx/integrations/_traced_call.py +221 -1
- agentx/integrations/anthropic.py +146 -15
- agentx/integrations/openai.py +111 -14
- agentx/monitor/__init__.py +4 -0
- agentx/monitor/alert_rules.py +209 -0
- agentx/monitor/client.py +5 -0
- agentx/tracing/tracer.py +5 -0
- agentx/version.py +2 -2
- {agentx_python-0.8.26.dist-info → agentx_python-0.8.27.dist-info}/METADATA +1 -1
- {agentx_python-0.8.26.dist-info → agentx_python-0.8.27.dist-info}/RECORD +14 -13
- {agentx_python-0.8.26.dist-info → agentx_python-0.8.27.dist-info}/WHEEL +0 -0
- {agentx_python-0.8.26.dist-info → agentx_python-0.8.27.dist-info}/entry_points.txt +0 -0
- {agentx_python-0.8.26.dist-info → agentx_python-0.8.27.dist-info}/licenses/LICENSE +0 -0
- {agentx_python-0.8.26.dist-info → agentx_python-0.8.27.dist-info}/top_level.txt +0 -0
|
@@ -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,
|
|
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)
|
agentx/integrations/anthropic.py
CHANGED
|
@@ -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
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
agentx/integrations/openai.py
CHANGED
|
@@ -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
|
|
21
|
-
|
|
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
|
|
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``
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
agentx/monitor/__init__.py
CHANGED
|
@@ -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.
|
|
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.
|
|
7
|
+
ENGINE_VERSION = "v0.3.30"
|
|
@@ -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=
|
|
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=
|
|
32
|
-
agentx/integrations/anthropic.py,sha256=
|
|
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=
|
|
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=
|
|
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/
|
|
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=
|
|
71
|
-
agentx_python-0.8.
|
|
72
|
-
agentx_python-0.8.
|
|
73
|
-
agentx_python-0.8.
|
|
74
|
-
agentx_python-0.8.
|
|
75
|
-
agentx_python-0.8.
|
|
76
|
-
agentx_python-0.8.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|