agentx-python 0.5.1__tar.gz → 0.6.1__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.5.1/agentx_python.egg-info → agentx_python-0.6.1}/PKG-INFO +1 -1
- agentx_python-0.6.1/agentx/integrations/_perf.py +116 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/integrations/anthropic.py +48 -4
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/integrations/google_adk.py +91 -24
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/integrations/google_genai.py +50 -4
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/integrations/langchain.py +184 -20
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/integrations/openai_agents.py +56 -1
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/tracing/tracer.py +36 -4
- agentx_python-0.6.1/agentx/version.py +1 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1/agentx_python.egg-info}/PKG-INFO +1 -1
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx_python.egg-info/SOURCES.txt +1 -0
- agentx_python-0.5.1/agentx/version.py +0 -1
- {agentx_python-0.5.1 → agentx_python-0.6.1}/LICENSE +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/README.md +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/__init__.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/agentx.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/evaluations/__init__.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/evaluations/_term.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/evaluations/adapters/__init__.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/evaluations/adapters/http_endpoint.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/evaluations/adapters/precomputed.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/evaluations/adapters/raw.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/evaluations/client.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/evaluations/datasets.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/evaluations/models.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/evaluations/redaction.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/evaluations/reporting.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/evaluations/results.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/evaluations/runner.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/evaluations/tracing.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/exceptions.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/integrations/__init__.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/integrations/crewai.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/resources/__init__.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/resources/agent.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/resources/conversation.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/resources/workforce.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/tracing/__init__.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/tracing/ci_types.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/tracing/ingest_client.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx/util.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx_python.egg-info/dependency_links.txt +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx_python.egg-info/requires.txt +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/agentx_python.egg-info/top_level.txt +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/setup.cfg +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/setup.py +0 -0
- {agentx_python-0.5.1 → agentx_python-0.6.1}/tests/test_integration.py +0 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared helper for building a performance_summary that matches the AgentX
|
|
3
|
+
platform agent format.
|
|
4
|
+
|
|
5
|
+
External framework integrations collect:
|
|
6
|
+
- execution_steps — LLM inference calls (name, duration_ms, start_time, end_time)
|
|
7
|
+
- tool_call_steps — function/tool invocations (same shape)
|
|
8
|
+
|
|
9
|
+
``build_performance_summary`` merges them into the full structure expected by
|
|
10
|
+
the backend ingest endpoint and the AgentX UI.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_performance_summary(
|
|
18
|
+
total_duration_ms: float,
|
|
19
|
+
execution_steps: Optional[List[Dict[str, Any]]] = None,
|
|
20
|
+
tool_call_steps: Optional[List[Dict[str, Any]]] = None,
|
|
21
|
+
retrieval_steps: Optional[List[Dict[str, Any]]] = None,
|
|
22
|
+
has_errors: bool = False,
|
|
23
|
+
) -> Dict[str, Any]:
|
|
24
|
+
"""
|
|
25
|
+
Return a ``performance_summary`` dict compatible with the AgentX platform
|
|
26
|
+
agent format.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
total_duration_ms: Wall-clock duration of the entire run.
|
|
30
|
+
execution_steps: LLM call entries — each must have ``name``,
|
|
31
|
+
``duration_ms``, and optionally ``start_time`` /
|
|
32
|
+
``end_time`` as Unix floats.
|
|
33
|
+
tool_call_steps: Tool / function call entries — same shape.
|
|
34
|
+
retrieval_steps: RAG / vector-store retrieval entries — same shape,
|
|
35
|
+
plus optional ``query`` (str) and ``doc_count`` (int).
|
|
36
|
+
has_errors: Set to True when the run ended with an exception.
|
|
37
|
+
"""
|
|
38
|
+
execution_steps = execution_steps or []
|
|
39
|
+
tool_call_steps = tool_call_steps or []
|
|
40
|
+
retrieval_steps = retrieval_steps or []
|
|
41
|
+
|
|
42
|
+
# Merge all items with their phase type, sort by start_time
|
|
43
|
+
all_items: List[tuple[Dict[str, Any], str]] = (
|
|
44
|
+
[(s, "execution_step") for s in execution_steps]
|
|
45
|
+
+ [(t, "tool_call") for t in tool_call_steps]
|
|
46
|
+
+ [(r, "retrieval") for r in retrieval_steps]
|
|
47
|
+
)
|
|
48
|
+
all_items.sort(key=lambda x: x[0].get("start_time") or 0)
|
|
49
|
+
|
|
50
|
+
unified: List[Dict[str, Any]] = []
|
|
51
|
+
out_steps: List[Dict[str, Any]] = []
|
|
52
|
+
out_tools: List[Dict[str, Any]] = []
|
|
53
|
+
out_retrievals: List[Dict[str, Any]] = []
|
|
54
|
+
|
|
55
|
+
for order, (item, phase_type) in enumerate(all_items, start=1):
|
|
56
|
+
entry: Dict[str, Any] = {
|
|
57
|
+
"name": item["name"],
|
|
58
|
+
"duration_ms": round(float(item["duration_ms"]), 3),
|
|
59
|
+
"start_order": order,
|
|
60
|
+
"phase_type": phase_type,
|
|
61
|
+
}
|
|
62
|
+
if item.get("start_time") is not None:
|
|
63
|
+
entry["start_time"] = item["start_time"]
|
|
64
|
+
if item.get("end_time") is not None:
|
|
65
|
+
entry["end_time"] = item["end_time"]
|
|
66
|
+
# Retrieval-specific fields
|
|
67
|
+
if phase_type == "retrieval":
|
|
68
|
+
if item.get("query"):
|
|
69
|
+
entry["query"] = item["query"]
|
|
70
|
+
if item.get("doc_count") is not None:
|
|
71
|
+
entry["doc_count"] = item["doc_count"]
|
|
72
|
+
unified.append(entry)
|
|
73
|
+
|
|
74
|
+
flat = {k: v for k, v in entry.items() if k != "phase_type"}
|
|
75
|
+
if phase_type == "execution_step":
|
|
76
|
+
out_steps.append(flat)
|
|
77
|
+
elif phase_type == "tool_call":
|
|
78
|
+
out_tools.append(flat)
|
|
79
|
+
else:
|
|
80
|
+
out_retrievals.append(flat)
|
|
81
|
+
|
|
82
|
+
tools_total_ms = round(sum(t["duration_ms"] for t in out_tools), 3)
|
|
83
|
+
retrievals_total_ms = round(sum(r["duration_ms"] for r in out_retrievals), 3)
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
"total_duration_ms": round(float(total_duration_ms), 3),
|
|
87
|
+
"todo_tasks_enabled": None,
|
|
88
|
+
"main_phases": [],
|
|
89
|
+
"tool_calls": out_tools,
|
|
90
|
+
"mcp_tool_calls": [],
|
|
91
|
+
"action_tool_calls": [],
|
|
92
|
+
"delegate_calls": [],
|
|
93
|
+
"execution_steps": out_steps,
|
|
94
|
+
"knowledge_retrievals": out_retrievals,
|
|
95
|
+
"todo_operations": [],
|
|
96
|
+
"detailed_phases": [],
|
|
97
|
+
"active_phases": [],
|
|
98
|
+
"has_errors": has_errors,
|
|
99
|
+
"memory_actions": None,
|
|
100
|
+
"unified_timeline": unified,
|
|
101
|
+
"statistics": {
|
|
102
|
+
"total_main_phases": 0,
|
|
103
|
+
"total_tool_calls": len(out_tools),
|
|
104
|
+
"total_mcp_tool_calls": 0,
|
|
105
|
+
"total_action_tool_calls": 0,
|
|
106
|
+
"total_delegate_calls": 0,
|
|
107
|
+
"total_execution_steps": len(out_steps),
|
|
108
|
+
"total_knowledge_retrievals": len(out_retrievals),
|
|
109
|
+
"total_todo_operations": 0,
|
|
110
|
+
"main_phases_total_ms": 0,
|
|
111
|
+
"tool_calls_total_ms": tools_total_ms,
|
|
112
|
+
"knowledge_retrievals_total_ms": retrievals_total_ms,
|
|
113
|
+
"mcp_tool_calls_total_ms": 0,
|
|
114
|
+
"todo_operations_total_ms": 0,
|
|
115
|
+
},
|
|
116
|
+
}
|
|
@@ -19,6 +19,7 @@ import time
|
|
|
19
19
|
from typing import Any, Dict, Optional
|
|
20
20
|
|
|
21
21
|
from agentx.tracing.tracer import Tracer, _safe_serialize
|
|
22
|
+
from agentx.integrations._perf import build_performance_summary
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
def patch_anthropic_client(
|
|
@@ -58,7 +59,7 @@ def _patch_create(
|
|
|
58
59
|
return # already patched
|
|
59
60
|
|
|
60
61
|
def patched_create(*args, **kwargs):
|
|
61
|
-
|
|
62
|
+
start_t = time.time()
|
|
62
63
|
error: Optional[str] = None
|
|
63
64
|
response = None
|
|
64
65
|
try:
|
|
@@ -68,15 +69,35 @@ def _patch_create(
|
|
|
68
69
|
error = str(exc)
|
|
69
70
|
raise
|
|
70
71
|
finally:
|
|
71
|
-
|
|
72
|
+
end_t = time.time()
|
|
73
|
+
latency_ms = int((end_t - start_t) * 1000)
|
|
72
74
|
input_messages = kwargs.get("messages") or (args[0] if args else None)
|
|
73
75
|
model = kwargs.get("model")
|
|
74
76
|
output = None
|
|
77
|
+
input_tokens = None
|
|
78
|
+
output_tokens = None
|
|
75
79
|
if response is not None:
|
|
76
80
|
try:
|
|
77
81
|
output = response.content[0].text if response.content else None
|
|
78
82
|
except Exception:
|
|
79
83
|
output = str(response)[:500]
|
|
84
|
+
try:
|
|
85
|
+
usage = getattr(response, "usage", None)
|
|
86
|
+
if usage is not None:
|
|
87
|
+
input_tokens = getattr(usage, "input_tokens", None)
|
|
88
|
+
output_tokens = getattr(usage, "output_tokens", None)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
perf = build_performance_summary(
|
|
92
|
+
total_duration_ms=latency_ms,
|
|
93
|
+
execution_steps=[{
|
|
94
|
+
"name": "LLM Call 1",
|
|
95
|
+
"duration_ms": latency_ms,
|
|
96
|
+
"start_time": start_t,
|
|
97
|
+
"end_time": end_t,
|
|
98
|
+
}],
|
|
99
|
+
has_errors=error is not None,
|
|
100
|
+
)
|
|
80
101
|
tracer._send(
|
|
81
102
|
name=name,
|
|
82
103
|
input=_safe_serialize(input_messages),
|
|
@@ -87,6 +108,9 @@ def _patch_create(
|
|
|
87
108
|
model=model,
|
|
88
109
|
metadata=metadata,
|
|
89
110
|
session_id=session_id,
|
|
111
|
+
performance_summary=perf,
|
|
112
|
+
input_tokens=input_tokens,
|
|
113
|
+
output_tokens=output_tokens,
|
|
90
114
|
)
|
|
91
115
|
|
|
92
116
|
patched_create._agentx_patched = True
|
|
@@ -105,7 +129,7 @@ def _patch_stream(
|
|
|
105
129
|
return
|
|
106
130
|
|
|
107
131
|
def patched_stream(*args, **kwargs):
|
|
108
|
-
|
|
132
|
+
start_t = time.time()
|
|
109
133
|
ctx = original_stream(*args, **kwargs)
|
|
110
134
|
|
|
111
135
|
class _TracedStream:
|
|
@@ -116,14 +140,31 @@ def _patch_stream(
|
|
|
116
140
|
|
|
117
141
|
def __exit__(self_inner, exc_type, exc_val, tb):
|
|
118
142
|
result = ctx.__exit__(exc_type, exc_val, tb)
|
|
119
|
-
|
|
143
|
+
end_t = time.time()
|
|
144
|
+
latency_ms = int((end_t - start_t) * 1000)
|
|
120
145
|
error = str(exc_val) if exc_val else None
|
|
121
146
|
output = None
|
|
147
|
+
input_tokens = None
|
|
148
|
+
output_tokens = None
|
|
122
149
|
try:
|
|
123
150
|
final = ctx.get_final_message()
|
|
124
151
|
output = final.content[0].text if final.content else None
|
|
152
|
+
usage = getattr(final, "usage", None)
|
|
153
|
+
if usage is not None:
|
|
154
|
+
input_tokens = getattr(usage, "input_tokens", None)
|
|
155
|
+
output_tokens = getattr(usage, "output_tokens", None)
|
|
125
156
|
except Exception:
|
|
126
157
|
pass
|
|
158
|
+
perf = build_performance_summary(
|
|
159
|
+
total_duration_ms=latency_ms,
|
|
160
|
+
execution_steps=[{
|
|
161
|
+
"name": "LLM Call 1",
|
|
162
|
+
"duration_ms": latency_ms,
|
|
163
|
+
"start_time": start_t,
|
|
164
|
+
"end_time": end_t,
|
|
165
|
+
}],
|
|
166
|
+
has_errors=error is not None,
|
|
167
|
+
)
|
|
127
168
|
tracer._send(
|
|
128
169
|
name=name,
|
|
129
170
|
input=_safe_serialize(kwargs.get("messages")),
|
|
@@ -134,6 +175,9 @@ def _patch_stream(
|
|
|
134
175
|
model=kwargs.get("model"),
|
|
135
176
|
metadata=metadata,
|
|
136
177
|
session_id=session_id,
|
|
178
|
+
performance_summary=perf,
|
|
179
|
+
input_tokens=input_tokens,
|
|
180
|
+
output_tokens=output_tokens,
|
|
137
181
|
)
|
|
138
182
|
return result
|
|
139
183
|
|
|
@@ -24,6 +24,7 @@ import time
|
|
|
24
24
|
from typing import Any, Dict, List, Optional
|
|
25
25
|
|
|
26
26
|
from agentx.tracing.tracer import Tracer, _safe_serialize
|
|
27
|
+
from agentx.integrations._perf import build_performance_summary
|
|
27
28
|
|
|
28
29
|
try:
|
|
29
30
|
from google.adk.plugins.base_plugin import BasePlugin
|
|
@@ -56,7 +57,7 @@ class AgentXADKPlugin(BasePlugin):
|
|
|
56
57
|
Google ADK plugin that sends one AgentX trace per runner invocation.
|
|
57
58
|
|
|
58
59
|
Captures input (user message), output (final model reply), model name,
|
|
59
|
-
tool calls, and
|
|
60
|
+
tool calls, latency, and a performance_summary via the ADK plugin callbacks.
|
|
60
61
|
|
|
61
62
|
Register via the ``plugins`` list when constructing the ADK ``Runner``.
|
|
62
63
|
"""
|
|
@@ -75,43 +76,58 @@ class AgentXADKPlugin(BasePlugin):
|
|
|
75
76
|
self._session_id = session_id
|
|
76
77
|
# invocation_id → accumulated run state
|
|
77
78
|
self._runs: Dict[str, Dict[str, Any]] = {}
|
|
79
|
+
# invocation_id → pre-buffered user input text
|
|
80
|
+
# (on_user_message_callback fires *before* before_run_callback)
|
|
81
|
+
self._pending_inputs: Dict[str, str] = {}
|
|
78
82
|
# id(tool_context) → start time float
|
|
79
83
|
self._tool_starts: Dict[int, float] = {}
|
|
84
|
+
# invocation_id → stack of model call start times (FIFO)
|
|
85
|
+
# ADK creates new CallbackContext objects for before/after model callbacks,
|
|
86
|
+
# so we cannot use id(callback_context) as a key — use invocation_id instead.
|
|
87
|
+
self._model_starts: Dict[str, List[float]] = {}
|
|
80
88
|
|
|
81
89
|
# ------------------------------------------------------------------
|
|
82
90
|
# Run lifecycle
|
|
83
91
|
# ------------------------------------------------------------------
|
|
84
92
|
|
|
93
|
+
async def on_user_message_callback(
|
|
94
|
+
self, *, invocation_context: Any, user_message: Any
|
|
95
|
+
) -> None:
|
|
96
|
+
# Called *before* before_run_callback, so self._runs doesn't exist yet.
|
|
97
|
+
# Buffer the input and consume it in before_run_callback.
|
|
98
|
+
inv_id = invocation_context.invocation_id
|
|
99
|
+
text = _content_to_text(user_message)
|
|
100
|
+
if text:
|
|
101
|
+
self._pending_inputs[inv_id] = text
|
|
102
|
+
|
|
85
103
|
async def before_run_callback(self, *, invocation_context: Any) -> None:
|
|
86
104
|
inv_id = invocation_context.invocation_id
|
|
87
105
|
agent_name = getattr(invocation_context.agent, "name", None) or self._agent_name
|
|
88
106
|
self._runs[inv_id] = {
|
|
89
107
|
"start": time.time(),
|
|
90
108
|
"name": agent_name,
|
|
91
|
-
"input": None,
|
|
109
|
+
"input": self._pending_inputs.pop(inv_id, None),
|
|
92
110
|
"output": None,
|
|
93
111
|
"model": None,
|
|
94
112
|
"tool_calls": [],
|
|
95
113
|
"error": None,
|
|
114
|
+
"execution_steps": [],
|
|
115
|
+
"perf_tool_calls": [],
|
|
116
|
+
"input_tokens": 0,
|
|
117
|
+
"output_tokens": 0,
|
|
96
118
|
}
|
|
97
119
|
|
|
98
|
-
async def on_user_message_callback(
|
|
99
|
-
self, *, invocation_context: Any, user_message: Any
|
|
100
|
-
) -> None:
|
|
101
|
-
inv_id = invocation_context.invocation_id
|
|
102
|
-
state = self._runs.get(inv_id)
|
|
103
|
-
if state is None:
|
|
104
|
-
return
|
|
105
|
-
text = _content_to_text(user_message)
|
|
106
|
-
if text and state["input"] is None:
|
|
107
|
-
state["input"] = text
|
|
108
|
-
|
|
109
120
|
async def after_run_callback(self, *, invocation_context: Any) -> None:
|
|
110
121
|
inv_id = invocation_context.invocation_id
|
|
111
122
|
state = self._runs.pop(inv_id, None)
|
|
112
123
|
if state is None:
|
|
113
124
|
return
|
|
114
125
|
latency_ms = int((time.time() - state["start"]) * 1000)
|
|
126
|
+
perf = build_performance_summary(
|
|
127
|
+
total_duration_ms=latency_ms,
|
|
128
|
+
execution_steps=state["execution_steps"],
|
|
129
|
+
tool_call_steps=state["perf_tool_calls"],
|
|
130
|
+
)
|
|
115
131
|
self._tracer._send(
|
|
116
132
|
name=state["name"],
|
|
117
133
|
input=state["input"],
|
|
@@ -122,10 +138,13 @@ class AgentXADKPlugin(BasePlugin):
|
|
|
122
138
|
tool_calls=state["tool_calls"] or None,
|
|
123
139
|
metadata=self._metadata,
|
|
124
140
|
session_id=self._session_id,
|
|
141
|
+
performance_summary=perf,
|
|
142
|
+
input_tokens=state["input_tokens"] or None,
|
|
143
|
+
output_tokens=state["output_tokens"] or None,
|
|
125
144
|
)
|
|
126
145
|
|
|
127
146
|
# ------------------------------------------------------------------
|
|
128
|
-
# Model callbacks — capture model name and
|
|
147
|
+
# Model callbacks — capture model name, output, and LLM step timing
|
|
129
148
|
# ------------------------------------------------------------------
|
|
130
149
|
|
|
131
150
|
async def before_model_callback(
|
|
@@ -133,22 +152,52 @@ class AgentXADKPlugin(BasePlugin):
|
|
|
133
152
|
) -> None:
|
|
134
153
|
inv_id = callback_context.get_invocation_context().invocation_id
|
|
135
154
|
state = self._runs.get(inv_id)
|
|
136
|
-
if state and not state["model"]
|
|
137
|
-
|
|
155
|
+
if state and not state["model"]:
|
|
156
|
+
model = getattr(llm_request, "model", None)
|
|
157
|
+
if model:
|
|
158
|
+
state["model"] = str(model)
|
|
159
|
+
# Push start time onto the per-invocation stack.
|
|
160
|
+
# ADK creates different CallbackContext objects for before vs after, so
|
|
161
|
+
# id(callback_context) cannot be used as a key across the two calls.
|
|
162
|
+
if inv_id not in self._model_starts:
|
|
163
|
+
self._model_starts[inv_id] = []
|
|
164
|
+
self._model_starts[inv_id].append(time.time())
|
|
138
165
|
|
|
139
166
|
async def after_model_callback(
|
|
140
167
|
self, *, callback_context: Any, llm_response: Any
|
|
141
168
|
) -> None:
|
|
142
169
|
inv_id = callback_context.get_invocation_context().invocation_id
|
|
143
170
|
state = self._runs.get(inv_id)
|
|
171
|
+
# Pop the earliest queued start time (FIFO — model calls are sequential)
|
|
172
|
+
starts = self._model_starts.get(inv_id, [])
|
|
173
|
+
start_t = starts.pop(0) if starts else None
|
|
174
|
+
end_t = time.time()
|
|
175
|
+
|
|
144
176
|
if state is None:
|
|
145
177
|
return
|
|
178
|
+
|
|
146
179
|
content = getattr(llm_response, "content", None)
|
|
147
180
|
text = _content_to_text(content)
|
|
148
181
|
# Keep updating — the last non-empty model reply is the final answer
|
|
149
182
|
if text:
|
|
150
183
|
state["output"] = text
|
|
151
184
|
|
|
185
|
+
# Execution step
|
|
186
|
+
if start_t is not None:
|
|
187
|
+
steps = state["execution_steps"]
|
|
188
|
+
steps.append({
|
|
189
|
+
"name": f"LLM Call {len(steps) + 1}",
|
|
190
|
+
"duration_ms": (end_t - start_t) * 1000,
|
|
191
|
+
"start_time": start_t,
|
|
192
|
+
"end_time": end_t,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
# Token counts
|
|
196
|
+
usage = getattr(llm_response, "usage_metadata", None)
|
|
197
|
+
if usage is not None:
|
|
198
|
+
state["input_tokens"] += int(getattr(usage, "prompt_token_count", None) or 0)
|
|
199
|
+
state["output_tokens"] += int(getattr(usage, "candidates_token_count", None) or 0)
|
|
200
|
+
|
|
152
201
|
# ------------------------------------------------------------------
|
|
153
202
|
# Tool callbacks
|
|
154
203
|
# ------------------------------------------------------------------
|
|
@@ -170,15 +219,24 @@ class AgentXADKPlugin(BasePlugin):
|
|
|
170
219
|
state = self._runs.get(inv_id)
|
|
171
220
|
if state is None:
|
|
172
221
|
return
|
|
173
|
-
|
|
222
|
+
start_t = self._tool_starts.pop(id(tool_context), None)
|
|
223
|
+
end_t = time.time()
|
|
224
|
+
tool_name = getattr(tool, "name", "unknown")
|
|
174
225
|
tool_call: Dict[str, Any] = {
|
|
175
|
-
"name":
|
|
226
|
+
"name": tool_name,
|
|
176
227
|
"input": _safe_serialize(tool_args),
|
|
177
228
|
"output": str(result)[:500] if result is not None else None,
|
|
178
229
|
}
|
|
179
|
-
if
|
|
180
|
-
tool_call["latency_ms"] = max(0, int((
|
|
230
|
+
if start_t is not None:
|
|
231
|
+
tool_call["latency_ms"] = max(0, int((end_t - start_t) * 1000))
|
|
181
232
|
state["tool_calls"].append(tool_call)
|
|
233
|
+
if start_t is not None:
|
|
234
|
+
state["perf_tool_calls"].append({
|
|
235
|
+
"name": tool_name,
|
|
236
|
+
"duration_ms": (end_t - start_t) * 1000,
|
|
237
|
+
"start_time": start_t,
|
|
238
|
+
"end_time": end_t,
|
|
239
|
+
})
|
|
182
240
|
|
|
183
241
|
async def on_tool_error_callback(
|
|
184
242
|
self,
|
|
@@ -192,12 +250,21 @@ class AgentXADKPlugin(BasePlugin):
|
|
|
192
250
|
state = self._runs.get(inv_id)
|
|
193
251
|
if state is None:
|
|
194
252
|
return
|
|
195
|
-
|
|
253
|
+
start_t = self._tool_starts.pop(id(tool_context), None)
|
|
254
|
+
end_t = time.time()
|
|
255
|
+
tool_name = getattr(tool, "name", "unknown")
|
|
196
256
|
tool_call: Dict[str, Any] = {
|
|
197
|
-
"name":
|
|
257
|
+
"name": tool_name,
|
|
198
258
|
"input": _safe_serialize(tool_args),
|
|
199
259
|
"output": f"ERROR: {error}",
|
|
200
260
|
}
|
|
201
|
-
if
|
|
202
|
-
tool_call["latency_ms"] = max(0, int((
|
|
261
|
+
if start_t is not None:
|
|
262
|
+
tool_call["latency_ms"] = max(0, int((end_t - start_t) * 1000))
|
|
203
263
|
state["tool_calls"].append(tool_call)
|
|
264
|
+
if start_t is not None:
|
|
265
|
+
state["perf_tool_calls"].append({
|
|
266
|
+
"name": tool_name,
|
|
267
|
+
"duration_ms": (end_t - start_t) * 1000,
|
|
268
|
+
"start_time": start_t,
|
|
269
|
+
"end_time": end_t,
|
|
270
|
+
})
|
|
@@ -19,6 +19,7 @@ import time
|
|
|
19
19
|
from typing import Any, Dict, Optional
|
|
20
20
|
|
|
21
21
|
from agentx.tracing.tracer import Tracer, _safe_serialize
|
|
22
|
+
from agentx.integrations._perf import build_performance_summary
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
def patch_genai_client(
|
|
@@ -77,7 +78,7 @@ def _patch_generate_content(
|
|
|
77
78
|
return
|
|
78
79
|
|
|
79
80
|
def patched(*args, **kwargs):
|
|
80
|
-
|
|
81
|
+
start_t = time.time()
|
|
81
82
|
error: Optional[str] = None
|
|
82
83
|
response = None
|
|
83
84
|
try:
|
|
@@ -87,10 +88,28 @@ def _patch_generate_content(
|
|
|
87
88
|
error = str(exc)
|
|
88
89
|
raise
|
|
89
90
|
finally:
|
|
90
|
-
|
|
91
|
+
end_t = time.time()
|
|
92
|
+
latency_ms = int((end_t - start_t) * 1000)
|
|
91
93
|
model = kwargs.get("model") or (args[0] if args else None)
|
|
92
94
|
contents = kwargs.get("contents") or (args[1] if len(args) > 1 else None)
|
|
93
95
|
output = _extract_response_text(response) if response is not None else None
|
|
96
|
+
input_tokens = None
|
|
97
|
+
output_tokens = None
|
|
98
|
+
if response is not None:
|
|
99
|
+
usage = getattr(response, "usage_metadata", None)
|
|
100
|
+
if usage is not None:
|
|
101
|
+
input_tokens = getattr(usage, "prompt_token_count", None)
|
|
102
|
+
output_tokens = getattr(usage, "candidates_token_count", None)
|
|
103
|
+
perf = build_performance_summary(
|
|
104
|
+
total_duration_ms=latency_ms,
|
|
105
|
+
execution_steps=[{
|
|
106
|
+
"name": "LLM Call 1",
|
|
107
|
+
"duration_ms": latency_ms,
|
|
108
|
+
"start_time": start_t,
|
|
109
|
+
"end_time": end_t,
|
|
110
|
+
}],
|
|
111
|
+
has_errors=error is not None,
|
|
112
|
+
)
|
|
94
113
|
tracer._send(
|
|
95
114
|
name=name,
|
|
96
115
|
input=contents if isinstance(contents, str) else _safe_serialize(contents),
|
|
@@ -101,6 +120,9 @@ def _patch_generate_content(
|
|
|
101
120
|
model=str(model) if model else None,
|
|
102
121
|
metadata=metadata,
|
|
103
122
|
session_id=session_id,
|
|
123
|
+
performance_summary=perf,
|
|
124
|
+
input_tokens=input_tokens,
|
|
125
|
+
output_tokens=output_tokens,
|
|
104
126
|
)
|
|
105
127
|
|
|
106
128
|
patched._agentx_patched = True
|
|
@@ -119,8 +141,9 @@ def _patch_generate_content_stream(
|
|
|
119
141
|
return
|
|
120
142
|
|
|
121
143
|
def patched_stream(*args, **kwargs):
|
|
122
|
-
|
|
144
|
+
start_t = time.time()
|
|
123
145
|
accumulated_text: list[str] = []
|
|
146
|
+
last_usage_metadata = None
|
|
124
147
|
error: Optional[str] = None
|
|
125
148
|
|
|
126
149
|
try:
|
|
@@ -128,14 +151,34 @@ def _patch_generate_content_stream(
|
|
|
128
151
|
text = getattr(chunk, "text", None)
|
|
129
152
|
if text:
|
|
130
153
|
accumulated_text.append(text)
|
|
154
|
+
# Track usage_metadata from the last chunk (Gemini includes it there)
|
|
155
|
+
chunk_usage = getattr(chunk, "usage_metadata", None)
|
|
156
|
+
if chunk_usage is not None:
|
|
157
|
+
last_usage_metadata = chunk_usage
|
|
131
158
|
yield chunk
|
|
132
159
|
except Exception as exc:
|
|
133
160
|
error = str(exc)
|
|
134
161
|
raise
|
|
135
162
|
finally:
|
|
136
|
-
|
|
163
|
+
end_t = time.time()
|
|
164
|
+
latency_ms = int((end_t - start_t) * 1000)
|
|
137
165
|
model = kwargs.get("model") or (args[0] if args else None)
|
|
138
166
|
contents = kwargs.get("contents") or (args[1] if len(args) > 1 else None)
|
|
167
|
+
input_tokens = None
|
|
168
|
+
output_tokens = None
|
|
169
|
+
if last_usage_metadata is not None:
|
|
170
|
+
input_tokens = getattr(last_usage_metadata, "prompt_token_count", None)
|
|
171
|
+
output_tokens = getattr(last_usage_metadata, "candidates_token_count", None)
|
|
172
|
+
perf = build_performance_summary(
|
|
173
|
+
total_duration_ms=latency_ms,
|
|
174
|
+
execution_steps=[{
|
|
175
|
+
"name": "LLM Call 1",
|
|
176
|
+
"duration_ms": latency_ms,
|
|
177
|
+
"start_time": start_t,
|
|
178
|
+
"end_time": end_t,
|
|
179
|
+
}],
|
|
180
|
+
has_errors=error is not None,
|
|
181
|
+
)
|
|
139
182
|
tracer._send(
|
|
140
183
|
name=name,
|
|
141
184
|
input=contents if isinstance(contents, str) else _safe_serialize(contents),
|
|
@@ -146,6 +189,9 @@ def _patch_generate_content_stream(
|
|
|
146
189
|
model=str(model) if model else None,
|
|
147
190
|
metadata=metadata,
|
|
148
191
|
session_id=session_id,
|
|
192
|
+
performance_summary=perf,
|
|
193
|
+
input_tokens=input_tokens,
|
|
194
|
+
output_tokens=output_tokens,
|
|
149
195
|
)
|
|
150
196
|
|
|
151
197
|
patched_stream._agentx_patched = True
|
|
@@ -25,6 +25,7 @@ from typing import Any, Dict, List, Optional, Union
|
|
|
25
25
|
from uuid import UUID
|
|
26
26
|
|
|
27
27
|
from agentx.tracing.tracer import Tracer, _safe_serialize
|
|
28
|
+
from agentx.integrations._perf import build_performance_summary
|
|
28
29
|
|
|
29
30
|
try:
|
|
30
31
|
from langchain_core.callbacks.base import BaseCallbackHandler
|
|
@@ -62,7 +63,6 @@ def _extract_output(outputs: Any) -> Any:
|
|
|
62
63
|
if content and isinstance(content, str) and content.strip():
|
|
63
64
|
return content
|
|
64
65
|
if isinstance(content, list):
|
|
65
|
-
# Multi-part content blocks
|
|
66
66
|
texts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"]
|
|
67
67
|
joined = " ".join(t for t in texts if t).strip()
|
|
68
68
|
if joined:
|
|
@@ -177,6 +177,11 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
177
177
|
self._top_level: Dict[UUID, bool] = {}
|
|
178
178
|
# Full parent-chain map so _find_top_ancestor can walk arbitrary depth
|
|
179
179
|
self._parents: Dict[UUID, Optional[UUID]] = {}
|
|
180
|
+
# Retrieval steps that fire before on_chain_start (pre-run RAG pattern).
|
|
181
|
+
# Consumed and attached when the next top-level chain starts.
|
|
182
|
+
self._pending_retrieval_steps: List[Dict[str, Any]] = []
|
|
183
|
+
# run_id → {"start": float, "query": str}
|
|
184
|
+
self._retrieval_starts: Dict[UUID, Dict[str, Any]] = {}
|
|
180
185
|
|
|
181
186
|
# ------------------------------------------------------------------
|
|
182
187
|
# Chain lifecycle
|
|
@@ -195,11 +200,20 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
195
200
|
is_top = parent_run_id is None
|
|
196
201
|
self._top_level[run_id] = is_top
|
|
197
202
|
if is_top:
|
|
203
|
+
# Consume any retrieval steps that ran before this chain started
|
|
204
|
+
# (pre-run RAG: retriever.invoke() called before agent.invoke())
|
|
205
|
+
pending = self._pending_retrieval_steps[:]
|
|
206
|
+
self._pending_retrieval_steps.clear()
|
|
198
207
|
self._runs[run_id] = {
|
|
199
208
|
"start": time.time(),
|
|
200
209
|
"input": _extract_input(inputs),
|
|
201
210
|
"tool_calls": [],
|
|
202
211
|
"model": None,
|
|
212
|
+
"execution_steps": [],
|
|
213
|
+
"perf_tool_calls": [],
|
|
214
|
+
"retrieval_steps": pending,
|
|
215
|
+
"input_tokens": 0,
|
|
216
|
+
"output_tokens": 0,
|
|
203
217
|
}
|
|
204
218
|
|
|
205
219
|
def on_chain_end(
|
|
@@ -217,8 +231,13 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
217
231
|
return
|
|
218
232
|
latency_ms = int((time.time() - state["start"]) * 1000)
|
|
219
233
|
output = _extract_output(outputs)
|
|
220
|
-
# If callbacks missed tool calls (deep nesting), extract from message history
|
|
221
234
|
tool_calls = state["tool_calls"] or _extract_tool_calls_from_messages(outputs)
|
|
235
|
+
perf = build_performance_summary(
|
|
236
|
+
total_duration_ms=latency_ms,
|
|
237
|
+
execution_steps=state["execution_steps"],
|
|
238
|
+
tool_call_steps=state["perf_tool_calls"],
|
|
239
|
+
retrieval_steps=state["retrieval_steps"],
|
|
240
|
+
)
|
|
222
241
|
self._tracer._send(
|
|
223
242
|
name=self._name,
|
|
224
243
|
input=state["input"],
|
|
@@ -229,6 +248,9 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
229
248
|
tool_calls=tool_calls or None,
|
|
230
249
|
metadata=self._metadata,
|
|
231
250
|
session_id=self._session_id,
|
|
251
|
+
performance_summary=perf,
|
|
252
|
+
input_tokens=state["input_tokens"] or None,
|
|
253
|
+
output_tokens=state["output_tokens"] or None,
|
|
232
254
|
)
|
|
233
255
|
self._top_level.pop(run_id, None)
|
|
234
256
|
self._parents.pop(run_id, None)
|
|
@@ -247,6 +269,13 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
247
269
|
if state is None:
|
|
248
270
|
return
|
|
249
271
|
latency_ms = int((time.time() - state["start"]) * 1000)
|
|
272
|
+
perf = build_performance_summary(
|
|
273
|
+
total_duration_ms=latency_ms,
|
|
274
|
+
execution_steps=state["execution_steps"],
|
|
275
|
+
tool_call_steps=state["perf_tool_calls"],
|
|
276
|
+
retrieval_steps=state["retrieval_steps"],
|
|
277
|
+
has_errors=True,
|
|
278
|
+
)
|
|
250
279
|
self._tracer._send(
|
|
251
280
|
name=self._name,
|
|
252
281
|
input=state["input"],
|
|
@@ -257,6 +286,9 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
257
286
|
tool_calls=state["tool_calls"] or None,
|
|
258
287
|
metadata=self._metadata,
|
|
259
288
|
session_id=self._session_id,
|
|
289
|
+
performance_summary=perf,
|
|
290
|
+
input_tokens=state["input_tokens"] or None,
|
|
291
|
+
output_tokens=state["output_tokens"] or None,
|
|
260
292
|
)
|
|
261
293
|
self._top_level.pop(run_id, None)
|
|
262
294
|
self._parents.pop(run_id, None)
|
|
@@ -265,6 +297,29 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
265
297
|
# LLM lifecycle
|
|
266
298
|
# ------------------------------------------------------------------
|
|
267
299
|
|
|
300
|
+
def _record_llm_start(
|
|
301
|
+
self,
|
|
302
|
+
serialized: Dict[str, Any],
|
|
303
|
+
run_id: UUID,
|
|
304
|
+
parent_run_id: Optional[UUID],
|
|
305
|
+
kwargs: Dict[str, Any],
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Shared logic for on_llm_start and on_chat_model_start."""
|
|
308
|
+
self._parents[run_id] = parent_run_id
|
|
309
|
+
kw = serialized.get("kwargs", {})
|
|
310
|
+
model = (
|
|
311
|
+
kw.get("model_name")
|
|
312
|
+
or kw.get("model")
|
|
313
|
+
or kwargs.get("invocation_params", {}).get("model")
|
|
314
|
+
or kwargs.get("invocation_params", {}).get("model_name")
|
|
315
|
+
or serialized.get("name")
|
|
316
|
+
)
|
|
317
|
+
model = str(model) if model and model not in ("None", "none") else None
|
|
318
|
+
self._runs[run_id] = {"llm_start": time.time(), "model": model}
|
|
319
|
+
top = self._find_top_ancestor(parent_run_id)
|
|
320
|
+
if top and not self._runs[top].get("model") and model:
|
|
321
|
+
self._runs[top]["model"] = model
|
|
322
|
+
|
|
268
323
|
def on_llm_start(
|
|
269
324
|
self,
|
|
270
325
|
serialized: Dict[str, Any],
|
|
@@ -274,22 +329,18 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
274
329
|
parent_run_id: Optional[UUID] = None,
|
|
275
330
|
**kwargs,
|
|
276
331
|
) -> None:
|
|
277
|
-
self.
|
|
278
|
-
self._runs.setdefault(run_id, {})["llm_start"] = time.time()
|
|
332
|
+
self._record_llm_start(serialized, run_id, parent_run_id, kwargs)
|
|
279
333
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
)
|
|
291
|
-
if model and model not in ("None", "none"):
|
|
292
|
-
self._runs[top]["model"] = str(model)
|
|
334
|
+
def on_chat_model_start(
|
|
335
|
+
self,
|
|
336
|
+
serialized: Dict[str, Any],
|
|
337
|
+
messages: List[Any],
|
|
338
|
+
*,
|
|
339
|
+
run_id: UUID,
|
|
340
|
+
parent_run_id: Optional[UUID] = None,
|
|
341
|
+
**kwargs,
|
|
342
|
+
) -> None:
|
|
343
|
+
self._record_llm_start(serialized, run_id, parent_run_id, kwargs)
|
|
293
344
|
|
|
294
345
|
def on_llm_end(
|
|
295
346
|
self,
|
|
@@ -299,8 +350,40 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
299
350
|
parent_run_id: Optional[UUID] = None,
|
|
300
351
|
**kwargs,
|
|
301
352
|
) -> None:
|
|
302
|
-
self._runs.pop(run_id, None)
|
|
353
|
+
llm_state = self._runs.pop(run_id, None)
|
|
303
354
|
self._parents.pop(run_id, None)
|
|
355
|
+
if llm_state:
|
|
356
|
+
start_t = llm_state.get("llm_start")
|
|
357
|
+
end_t = time.time()
|
|
358
|
+
top = self._find_top_ancestor(parent_run_id)
|
|
359
|
+
if start_t is not None and top and top in self._runs:
|
|
360
|
+
steps = self._runs[top]["execution_steps"]
|
|
361
|
+
steps.append({
|
|
362
|
+
"name": f"LLM Call {len(steps) + 1}",
|
|
363
|
+
"duration_ms": (end_t - start_t) * 1000,
|
|
364
|
+
"start_time": start_t,
|
|
365
|
+
"end_time": end_t,
|
|
366
|
+
})
|
|
367
|
+
# Extract token usage from LLMResult
|
|
368
|
+
if top and top in self._runs:
|
|
369
|
+
usage = {}
|
|
370
|
+
if hasattr(response, "llm_output") and isinstance(response.llm_output, dict):
|
|
371
|
+
usage = response.llm_output.get("token_usage") or response.llm_output.get("usage") or {}
|
|
372
|
+
# Also check generations for token counts (some providers put it there)
|
|
373
|
+
if not usage and hasattr(response, "generations"):
|
|
374
|
+
for gen_list in (response.generations or []):
|
|
375
|
+
for gen in (gen_list or []):
|
|
376
|
+
gen_info = getattr(gen, "generation_info", None) or {}
|
|
377
|
+
if gen_info.get("prompt_tokens") or gen_info.get("completion_tokens"):
|
|
378
|
+
usage = gen_info
|
|
379
|
+
break
|
|
380
|
+
if usage:
|
|
381
|
+
self._runs[top]["input_tokens"] += int(
|
|
382
|
+
usage.get("prompt_tokens") or usage.get("input_tokens") or usage.get("prompt_token_count") or 0
|
|
383
|
+
)
|
|
384
|
+
self._runs[top]["output_tokens"] += int(
|
|
385
|
+
usage.get("completion_tokens") or usage.get("output_tokens") or usage.get("candidates_token_count") or 0
|
|
386
|
+
)
|
|
304
387
|
|
|
305
388
|
# ------------------------------------------------------------------
|
|
306
389
|
# Tool lifecycle
|
|
@@ -334,7 +417,9 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
334
417
|
self._parents.pop(run_id, None)
|
|
335
418
|
if state is None:
|
|
336
419
|
return
|
|
337
|
-
|
|
420
|
+
end_t = time.time()
|
|
421
|
+
start_t = state["start"]
|
|
422
|
+
latency_ms = int((end_t - start_t) * 1000)
|
|
338
423
|
tool_call = {
|
|
339
424
|
"name": state["tool_name"],
|
|
340
425
|
"input": state["tool_input"],
|
|
@@ -344,6 +429,12 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
344
429
|
top = self._find_top_ancestor(parent_run_id)
|
|
345
430
|
if top and top in self._runs:
|
|
346
431
|
self._runs[top]["tool_calls"].append(tool_call)
|
|
432
|
+
self._runs[top]["perf_tool_calls"].append({
|
|
433
|
+
"name": state["tool_name"],
|
|
434
|
+
"duration_ms": (end_t - start_t) * 1000,
|
|
435
|
+
"start_time": start_t,
|
|
436
|
+
"end_time": end_t,
|
|
437
|
+
})
|
|
347
438
|
|
|
348
439
|
def on_tool_error(
|
|
349
440
|
self,
|
|
@@ -357,15 +448,88 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
357
448
|
self._parents.pop(run_id, None)
|
|
358
449
|
if state is None:
|
|
359
450
|
return
|
|
451
|
+
end_t = time.time()
|
|
452
|
+
start_t = state.get("start", end_t)
|
|
360
453
|
tool_call = {
|
|
361
454
|
"name": state.get("tool_name", "unknown"),
|
|
362
455
|
"input": state.get("tool_input"),
|
|
363
456
|
"output": f"ERROR: {error}",
|
|
364
|
-
"latency_ms": int((
|
|
457
|
+
"latency_ms": int((end_t - start_t) * 1000),
|
|
365
458
|
}
|
|
366
459
|
top = self._find_top_ancestor(parent_run_id)
|
|
367
460
|
if top and top in self._runs:
|
|
368
461
|
self._runs[top]["tool_calls"].append(tool_call)
|
|
462
|
+
self._runs[top]["perf_tool_calls"].append({
|
|
463
|
+
"name": state.get("tool_name", "unknown"),
|
|
464
|
+
"duration_ms": (end_t - start_t) * 1000,
|
|
465
|
+
"start_time": start_t,
|
|
466
|
+
"end_time": end_t,
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
# ------------------------------------------------------------------
|
|
470
|
+
# Retriever lifecycle
|
|
471
|
+
# ------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
def on_retriever_start(
|
|
474
|
+
self,
|
|
475
|
+
serialized: Dict[str, Any],
|
|
476
|
+
query: str,
|
|
477
|
+
*,
|
|
478
|
+
run_id: UUID,
|
|
479
|
+
parent_run_id: Optional[UUID] = None,
|
|
480
|
+
**kwargs,
|
|
481
|
+
) -> None:
|
|
482
|
+
self._parents[run_id] = parent_run_id
|
|
483
|
+
self._retrieval_starts[run_id] = {"start": time.time(), "query": query}
|
|
484
|
+
|
|
485
|
+
def on_retriever_end(
|
|
486
|
+
self,
|
|
487
|
+
documents: Any,
|
|
488
|
+
*,
|
|
489
|
+
run_id: UUID,
|
|
490
|
+
parent_run_id: Optional[UUID] = None,
|
|
491
|
+
**kwargs,
|
|
492
|
+
) -> None:
|
|
493
|
+
state = self._retrieval_starts.pop(run_id, None)
|
|
494
|
+
self._parents.pop(run_id, None)
|
|
495
|
+
if state is None:
|
|
496
|
+
return
|
|
497
|
+
end_t = time.time()
|
|
498
|
+
start_t = state["start"]
|
|
499
|
+
query: Optional[str] = state["query"] or None
|
|
500
|
+
doc_count = len(documents) if hasattr(documents, "__len__") else None
|
|
501
|
+
step: Dict[str, Any] = {
|
|
502
|
+
"name": "Retrieval 1", # renumbered below
|
|
503
|
+
"duration_ms": (end_t - start_t) * 1000,
|
|
504
|
+
"start_time": start_t,
|
|
505
|
+
"end_time": end_t,
|
|
506
|
+
}
|
|
507
|
+
if query:
|
|
508
|
+
step["query"] = query
|
|
509
|
+
if doc_count is not None:
|
|
510
|
+
step["doc_count"] = doc_count
|
|
511
|
+
|
|
512
|
+
top = self._find_top_ancestor(parent_run_id)
|
|
513
|
+
if top and top in self._runs:
|
|
514
|
+
# Retriever ran inside an active chain — attach directly
|
|
515
|
+
retrievals = self._runs[top]["retrieval_steps"]
|
|
516
|
+
step["name"] = f"Retrieval {len(retrievals) + 1}"
|
|
517
|
+
retrievals.append(step)
|
|
518
|
+
else:
|
|
519
|
+
# Retriever ran before the chain started (pre-run RAG pattern)
|
|
520
|
+
step["name"] = f"Retrieval {len(self._pending_retrieval_steps) + 1}"
|
|
521
|
+
self._pending_retrieval_steps.append(step)
|
|
522
|
+
|
|
523
|
+
def on_retriever_error(
|
|
524
|
+
self,
|
|
525
|
+
error: Union[Exception, KeyboardInterrupt],
|
|
526
|
+
*,
|
|
527
|
+
run_id: UUID,
|
|
528
|
+
parent_run_id: Optional[UUID] = None,
|
|
529
|
+
**kwargs,
|
|
530
|
+
) -> None:
|
|
531
|
+
self._retrieval_starts.pop(run_id, None)
|
|
532
|
+
self._parents.pop(run_id, None)
|
|
369
533
|
|
|
370
534
|
# ------------------------------------------------------------------
|
|
371
535
|
# Helpers
|
|
@@ -20,6 +20,7 @@ from datetime import datetime, timezone
|
|
|
20
20
|
from typing import Any, Dict, List, Optional
|
|
21
21
|
|
|
22
22
|
from agentx.tracing.tracer import Tracer, _safe_serialize
|
|
23
|
+
from agentx.integrations._perf import build_performance_summary
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
def _iso_to_ts(iso: Optional[str]) -> Optional[float]:
|
|
@@ -143,6 +144,10 @@ class AgentXTracingProcessor:
|
|
|
143
144
|
"output": None,
|
|
144
145
|
"tool_calls": [],
|
|
145
146
|
"model": None,
|
|
147
|
+
"execution_steps": [],
|
|
148
|
+
"perf_tool_calls": [],
|
|
149
|
+
"input_tokens": 0,
|
|
150
|
+
"output_tokens": 0,
|
|
146
151
|
}
|
|
147
152
|
|
|
148
153
|
def on_trace_end(self, trace: Any) -> None:
|
|
@@ -151,6 +156,11 @@ class AgentXTracingProcessor:
|
|
|
151
156
|
if state is None:
|
|
152
157
|
return
|
|
153
158
|
latency_ms = int((time.time() - state["start"]) * 1000)
|
|
159
|
+
perf = build_performance_summary(
|
|
160
|
+
total_duration_ms=latency_ms,
|
|
161
|
+
execution_steps=state["execution_steps"],
|
|
162
|
+
tool_call_steps=state["perf_tool_calls"],
|
|
163
|
+
)
|
|
154
164
|
self._tracer._send(
|
|
155
165
|
name=state["name"],
|
|
156
166
|
input=state.get("input"),
|
|
@@ -161,6 +171,9 @@ class AgentXTracingProcessor:
|
|
|
161
171
|
tool_calls=state["tool_calls"] or None,
|
|
162
172
|
metadata=self._metadata,
|
|
163
173
|
session_id=self._session_id,
|
|
174
|
+
performance_summary=perf,
|
|
175
|
+
input_tokens=state["input_tokens"] or None,
|
|
176
|
+
output_tokens=state["output_tokens"] or None,
|
|
164
177
|
)
|
|
165
178
|
|
|
166
179
|
def on_span_start(self, span: Any) -> None:
|
|
@@ -178,6 +191,9 @@ class AgentXTracingProcessor:
|
|
|
178
191
|
state = self._spans[trace_id]
|
|
179
192
|
span_type = getattr(span_data, "type", None)
|
|
180
193
|
|
|
194
|
+
t0 = _iso_to_ts(getattr(span, "started_at", None))
|
|
195
|
+
t1 = _iso_to_ts(getattr(span, "ended_at", None))
|
|
196
|
+
|
|
181
197
|
if span_type == "generation":
|
|
182
198
|
# Capture input from the first generation span
|
|
183
199
|
if state["input"] is None and span_data.input:
|
|
@@ -188,6 +204,20 @@ class AgentXTracingProcessor:
|
|
|
188
204
|
# Capture model name
|
|
189
205
|
if not state["model"] and span_data.model:
|
|
190
206
|
state["model"] = str(span_data.model)
|
|
207
|
+
# Execution step
|
|
208
|
+
if t0 is not None and t1 is not None:
|
|
209
|
+
steps = state["execution_steps"]
|
|
210
|
+
steps.append({
|
|
211
|
+
"name": f"LLM Call {len(steps) + 1}",
|
|
212
|
+
"duration_ms": (t1 - t0) * 1000,
|
|
213
|
+
"start_time": t0,
|
|
214
|
+
"end_time": t1,
|
|
215
|
+
})
|
|
216
|
+
# Token counts — usage is a dict with "input_tokens" / "output_tokens"
|
|
217
|
+
usage = getattr(span_data, "usage", None)
|
|
218
|
+
if isinstance(usage, dict):
|
|
219
|
+
state["input_tokens"] += int(usage.get("input_tokens") or 0)
|
|
220
|
+
state["output_tokens"] += int(usage.get("output_tokens") or 0)
|
|
191
221
|
|
|
192
222
|
elif span_type == "response":
|
|
193
223
|
# Responses API path — extract from the response object
|
|
@@ -204,18 +234,43 @@ class AgentXTracingProcessor:
|
|
|
204
234
|
model = getattr(response, "model", None)
|
|
205
235
|
if model:
|
|
206
236
|
state["model"] = str(model)
|
|
237
|
+
# Token counts from response.usage or span_data.usage
|
|
238
|
+
usage = getattr(response, "usage", None) or getattr(span_data, "usage", None)
|
|
239
|
+
if isinstance(usage, dict):
|
|
240
|
+
state["input_tokens"] += int(usage.get("input_tokens") or 0)
|
|
241
|
+
state["output_tokens"] += int(usage.get("output_tokens") or 0)
|
|
242
|
+
elif usage is not None:
|
|
243
|
+
state["input_tokens"] += int(getattr(usage, "input_tokens", None) or 0)
|
|
244
|
+
state["output_tokens"] += int(getattr(usage, "output_tokens", None) or 0)
|
|
245
|
+
# Execution step
|
|
246
|
+
if t0 is not None and t1 is not None:
|
|
247
|
+
steps = state["execution_steps"]
|
|
248
|
+
steps.append({
|
|
249
|
+
"name": f"LLM Call {len(steps) + 1}",
|
|
250
|
+
"duration_ms": (t1 - t0) * 1000,
|
|
251
|
+
"start_time": t0,
|
|
252
|
+
"end_time": t1,
|
|
253
|
+
})
|
|
207
254
|
|
|
208
255
|
elif span_type == "function":
|
|
209
256
|
# Tool / function call
|
|
257
|
+
latency = _span_latency_ms(span)
|
|
210
258
|
tool_entry: Dict[str, Any] = {
|
|
211
259
|
"name": span_data.name,
|
|
212
260
|
"input": span_data.input,
|
|
213
261
|
"output": str(span_data.output)[:500] if span_data.output is not None else None,
|
|
214
262
|
}
|
|
215
|
-
latency = _span_latency_ms(span)
|
|
216
263
|
if latency is not None:
|
|
217
264
|
tool_entry["latency_ms"] = latency
|
|
218
265
|
state["tool_calls"].append(tool_entry)
|
|
266
|
+
# Perf tool call with timestamps
|
|
267
|
+
if t0 is not None and t1 is not None:
|
|
268
|
+
state["perf_tool_calls"].append({
|
|
269
|
+
"name": span_data.name,
|
|
270
|
+
"duration_ms": (t1 - t0) * 1000,
|
|
271
|
+
"start_time": t0,
|
|
272
|
+
"end_time": t1,
|
|
273
|
+
})
|
|
219
274
|
|
|
220
275
|
def force_flush(self) -> None:
|
|
221
276
|
self._tracer.flush()
|
|
@@ -143,8 +143,9 @@ class _TraceSpan:
|
|
|
143
143
|
def _wrap_sync(self, fn: F) -> F:
|
|
144
144
|
@functools.wraps(fn)
|
|
145
145
|
def wrapper(*args, **kwargs):
|
|
146
|
+
from agentx.integrations._perf import build_performance_summary
|
|
146
147
|
captured_input = _capture_fn_input(fn, args, kwargs)
|
|
147
|
-
|
|
148
|
+
start_t = time.time()
|
|
148
149
|
error: Optional[str] = None
|
|
149
150
|
output = None
|
|
150
151
|
try:
|
|
@@ -154,16 +155,28 @@ class _TraceSpan:
|
|
|
154
155
|
error = str(exc)
|
|
155
156
|
raise
|
|
156
157
|
finally:
|
|
158
|
+
end_t = time.time()
|
|
159
|
+
latency_ms = int((end_t - start_t) * 1000)
|
|
157
160
|
self._tracer._send(
|
|
158
161
|
name=self.name,
|
|
159
162
|
input=captured_input,
|
|
160
163
|
output=_safe_serialize(output) if output is not None else None,
|
|
161
|
-
latency_ms=
|
|
164
|
+
latency_ms=latency_ms,
|
|
162
165
|
error=error,
|
|
163
166
|
metadata=self._metadata,
|
|
164
167
|
framework=self._framework,
|
|
165
168
|
model=self._model,
|
|
166
169
|
session_id=self._session_id,
|
|
170
|
+
performance_summary=build_performance_summary(
|
|
171
|
+
total_duration_ms=latency_ms,
|
|
172
|
+
execution_steps=[{
|
|
173
|
+
"name": "LLM Call 1",
|
|
174
|
+
"duration_ms": latency_ms,
|
|
175
|
+
"start_time": start_t,
|
|
176
|
+
"end_time": end_t,
|
|
177
|
+
}],
|
|
178
|
+
has_errors=error is not None,
|
|
179
|
+
),
|
|
167
180
|
)
|
|
168
181
|
|
|
169
182
|
return wrapper # type: ignore[return-value]
|
|
@@ -171,8 +184,9 @@ class _TraceSpan:
|
|
|
171
184
|
def _wrap_async(self, fn: F) -> F:
|
|
172
185
|
@functools.wraps(fn)
|
|
173
186
|
async def wrapper(*args, **kwargs):
|
|
187
|
+
from agentx.integrations._perf import build_performance_summary
|
|
174
188
|
captured_input = _capture_fn_input(fn, args, kwargs)
|
|
175
|
-
|
|
189
|
+
start_t = time.time()
|
|
176
190
|
error: Optional[str] = None
|
|
177
191
|
output = None
|
|
178
192
|
try:
|
|
@@ -182,16 +196,28 @@ class _TraceSpan:
|
|
|
182
196
|
error = str(exc)
|
|
183
197
|
raise
|
|
184
198
|
finally:
|
|
199
|
+
end_t = time.time()
|
|
200
|
+
latency_ms = int((end_t - start_t) * 1000)
|
|
185
201
|
self._tracer._send(
|
|
186
202
|
name=self.name,
|
|
187
203
|
input=captured_input,
|
|
188
204
|
output=_safe_serialize(output) if output is not None else None,
|
|
189
|
-
latency_ms=
|
|
205
|
+
latency_ms=latency_ms,
|
|
190
206
|
error=error,
|
|
191
207
|
metadata=self._metadata,
|
|
192
208
|
framework=self._framework,
|
|
193
209
|
model=self._model,
|
|
194
210
|
session_id=self._session_id,
|
|
211
|
+
performance_summary=build_performance_summary(
|
|
212
|
+
total_duration_ms=latency_ms,
|
|
213
|
+
execution_steps=[{
|
|
214
|
+
"name": "LLM Call 1",
|
|
215
|
+
"duration_ms": latency_ms,
|
|
216
|
+
"start_time": start_t,
|
|
217
|
+
"end_time": end_t,
|
|
218
|
+
}],
|
|
219
|
+
has_errors=error is not None,
|
|
220
|
+
),
|
|
195
221
|
)
|
|
196
222
|
|
|
197
223
|
return wrapper # type: ignore[return-value]
|
|
@@ -462,4 +488,10 @@ class Tracer:
|
|
|
462
488
|
wire["metadata"] = payload["metadata"]
|
|
463
489
|
if "session_id" in payload:
|
|
464
490
|
wire["session_id"] = payload["session_id"]
|
|
491
|
+
if "performance_summary" in payload:
|
|
492
|
+
wire["performance_summary"] = payload["performance_summary"]
|
|
493
|
+
if "input_tokens" in payload:
|
|
494
|
+
wire["input_tokens"] = payload["input_tokens"]
|
|
495
|
+
if "output_tokens" in payload:
|
|
496
|
+
wire["output_tokens"] = payload["output_tokens"]
|
|
465
497
|
self._client.enqueue(wire)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = "0.6.1"
|
|
@@ -21,6 +21,7 @@ agentx/evaluations/adapters/http_endpoint.py
|
|
|
21
21
|
agentx/evaluations/adapters/precomputed.py
|
|
22
22
|
agentx/evaluations/adapters/raw.py
|
|
23
23
|
agentx/integrations/__init__.py
|
|
24
|
+
agentx/integrations/_perf.py
|
|
24
25
|
agentx/integrations/anthropic.py
|
|
25
26
|
agentx/integrations/crewai.py
|
|
26
27
|
agentx/integrations/google_adk.py
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
VERSION = "0.5.1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|