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.
- {agentx_python-0.8.26 → agentx_python-0.8.27}/PKG-INFO +1 -1
- agentx_python-0.8.27/agentx/integrations/_traced_call.py +395 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/anthropic.py +146 -15
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/openai.py +111 -14
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/__init__.py +4 -0
- agentx_python-0.8.27/agentx/monitor/alert_rules.py +209 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/client.py +5 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/tracing/tracer.py +5 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/version.py +2 -2
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx_python.egg-info/PKG-INFO +1 -1
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx_python.egg-info/SOURCES.txt +2 -0
- agentx_python-0.8.27/tests/test_alert_rules.py +128 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_integrations.py +382 -28
- agentx_python-0.8.26/agentx/integrations/_traced_call.py +0 -175
- {agentx_python-0.8.26 → agentx_python-0.8.27}/LICENSE +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/README.md +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/__init__.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/agentx.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/cli.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/__init__.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/_term.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/adapters/__init__.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/adapters/http_endpoint.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/adapters/precomputed.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/adapters/raw.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/client.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/datasets.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/evaluation_settings.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/models.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/prompts.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/reporting.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/results.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/runner.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/tool_schemas.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/evaluations/tracing.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/exceptions.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/export.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/feedback.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/__init__.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/autogen.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/crewai.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/databricks.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/google_adk.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/google_genai.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/langchain.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/litellm.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/llamaindex.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/moveworks.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/nvidia_nim.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/integrations/openai_agents.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/_transport.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/agents.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/improvement_groups.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/judge_scorers.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/models.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/online_evaluators.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/patterns.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/profile.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/review_queue.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/rules.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/scorer_groups.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/scorers.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/sessions.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/monitor/signals.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/outcomes.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/projects.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/py.typed +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/resources/__init__.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/resources/agent.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/resources/conversation.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/resources/workforce.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/testing.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/traces.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/tracing/__init__.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/tracing/ci_types.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/tracing/eval_scope.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/tracing/framework_detect.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/tracing/ingest_client.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx/util.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx_python.egg-info/dependency_links.txt +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx_python.egg-info/entry_points.txt +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx_python.egg-info/not-zip-safe +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx_python.egg-info/requires.txt +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/agentx_python.egg-info/top_level.txt +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/setup.cfg +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/setup.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_cli_launcher.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_deep_dive_fixes.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_docs_match_sdk.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_error_taxonomy.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_eval_scope.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_framework_detect.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_integration.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_judge_scorers.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_multi_judge.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_pairwise.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_pattern_update_merge.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_review_queue.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_runner_features.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_selfhost_analysis_fallback.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_selfhost_compat.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_span_tree.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_testing.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_wire_models.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.27}/tests/test_workforce_binding.py +0 -0
|
@@ -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
|
|
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
|
|