agentx-python 0.5.2__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.
Files changed (47) hide show
  1. {agentx_python-0.5.2/agentx_python.egg-info → agentx_python-0.6.1}/PKG-INFO +1 -1
  2. agentx_python-0.6.1/agentx/integrations/_perf.py +116 -0
  3. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/integrations/anthropic.py +48 -4
  4. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/integrations/google_adk.py +77 -12
  5. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/integrations/google_genai.py +50 -4
  6. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/integrations/langchain.py +184 -20
  7. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/integrations/openai_agents.py +56 -1
  8. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/tracing/tracer.py +36 -4
  9. agentx_python-0.6.1/agentx/version.py +1 -0
  10. {agentx_python-0.5.2 → agentx_python-0.6.1/agentx_python.egg-info}/PKG-INFO +1 -1
  11. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx_python.egg-info/SOURCES.txt +1 -0
  12. agentx_python-0.5.2/agentx/version.py +0 -1
  13. {agentx_python-0.5.2 → agentx_python-0.6.1}/LICENSE +0 -0
  14. {agentx_python-0.5.2 → agentx_python-0.6.1}/README.md +0 -0
  15. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/__init__.py +0 -0
  16. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/agentx.py +0 -0
  17. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/evaluations/__init__.py +0 -0
  18. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/evaluations/_term.py +0 -0
  19. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/evaluations/adapters/__init__.py +0 -0
  20. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/evaluations/adapters/http_endpoint.py +0 -0
  21. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/evaluations/adapters/precomputed.py +0 -0
  22. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/evaluations/adapters/raw.py +0 -0
  23. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/evaluations/client.py +0 -0
  24. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/evaluations/datasets.py +0 -0
  25. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/evaluations/models.py +0 -0
  26. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/evaluations/redaction.py +0 -0
  27. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/evaluations/reporting.py +0 -0
  28. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/evaluations/results.py +0 -0
  29. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/evaluations/runner.py +0 -0
  30. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/evaluations/tracing.py +0 -0
  31. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/exceptions.py +0 -0
  32. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/integrations/__init__.py +0 -0
  33. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/integrations/crewai.py +0 -0
  34. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/resources/__init__.py +0 -0
  35. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/resources/agent.py +0 -0
  36. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/resources/conversation.py +0 -0
  37. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/resources/workforce.py +0 -0
  38. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/tracing/__init__.py +0 -0
  39. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/tracing/ci_types.py +0 -0
  40. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/tracing/ingest_client.py +0 -0
  41. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx/util.py +0 -0
  42. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx_python.egg-info/dependency_links.txt +0 -0
  43. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx_python.egg-info/requires.txt +0 -0
  44. {agentx_python-0.5.2 → agentx_python-0.6.1}/agentx_python.egg-info/top_level.txt +0 -0
  45. {agentx_python-0.5.2 → agentx_python-0.6.1}/setup.cfg +0 -0
  46. {agentx_python-0.5.2 → agentx_python-0.6.1}/setup.py +0 -0
  47. {agentx_python-0.5.2 → agentx_python-0.6.1}/tests/test_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentx-python
3
- Version: 0.5.2
3
+ Version: 0.6.1
4
4
  Summary: Official Python SDK for AgentX (https://www.agentx.so/)
5
5
  Home-page: https://github.com/AgentX-ai/AgentX-python
6
6
  Author: Robin Wang and AgentX Team
@@ -0,0 +1,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
- start = time.time()
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
- latency_ms = int((time.time() - start) * 1000)
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
- start = time.time()
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
- latency_ms = int((time.time() - start) * 1000)
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 latency via the ADK plugin callback hooks.
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
  """
@@ -80,6 +81,10 @@ class AgentXADKPlugin(BasePlugin):
80
81
  self._pending_inputs: Dict[str, str] = {}
81
82
  # id(tool_context) → start time float
82
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]] = {}
83
88
 
84
89
  # ------------------------------------------------------------------
85
90
  # Run lifecycle
@@ -106,6 +111,10 @@ class AgentXADKPlugin(BasePlugin):
106
111
  "model": None,
107
112
  "tool_calls": [],
108
113
  "error": None,
114
+ "execution_steps": [],
115
+ "perf_tool_calls": [],
116
+ "input_tokens": 0,
117
+ "output_tokens": 0,
109
118
  }
110
119
 
111
120
  async def after_run_callback(self, *, invocation_context: Any) -> None:
@@ -114,6 +123,11 @@ class AgentXADKPlugin(BasePlugin):
114
123
  if state is None:
115
124
  return
116
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
+ )
117
131
  self._tracer._send(
118
132
  name=state["name"],
119
133
  input=state["input"],
@@ -124,10 +138,13 @@ class AgentXADKPlugin(BasePlugin):
124
138
  tool_calls=state["tool_calls"] or None,
125
139
  metadata=self._metadata,
126
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,
127
144
  )
128
145
 
129
146
  # ------------------------------------------------------------------
130
- # Model callbacks — capture model name and output
147
+ # Model callbacks — capture model name, output, and LLM step timing
131
148
  # ------------------------------------------------------------------
132
149
 
133
150
  async def before_model_callback(
@@ -135,22 +152,52 @@ class AgentXADKPlugin(BasePlugin):
135
152
  ) -> None:
136
153
  inv_id = callback_context.get_invocation_context().invocation_id
137
154
  state = self._runs.get(inv_id)
138
- if state and not state["model"] and llm_request.model:
139
- state["model"] = str(llm_request.model)
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())
140
165
 
141
166
  async def after_model_callback(
142
167
  self, *, callback_context: Any, llm_response: Any
143
168
  ) -> None:
144
169
  inv_id = callback_context.get_invocation_context().invocation_id
145
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
+
146
176
  if state is None:
147
177
  return
178
+
148
179
  content = getattr(llm_response, "content", None)
149
180
  text = _content_to_text(content)
150
181
  # Keep updating — the last non-empty model reply is the final answer
151
182
  if text:
152
183
  state["output"] = text
153
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
+
154
201
  # ------------------------------------------------------------------
155
202
  # Tool callbacks
156
203
  # ------------------------------------------------------------------
@@ -172,15 +219,24 @@ class AgentXADKPlugin(BasePlugin):
172
219
  state = self._runs.get(inv_id)
173
220
  if state is None:
174
221
  return
175
- start = self._tool_starts.pop(id(tool_context), None)
222
+ start_t = self._tool_starts.pop(id(tool_context), None)
223
+ end_t = time.time()
224
+ tool_name = getattr(tool, "name", "unknown")
176
225
  tool_call: Dict[str, Any] = {
177
- "name": getattr(tool, "name", "unknown"),
226
+ "name": tool_name,
178
227
  "input": _safe_serialize(tool_args),
179
228
  "output": str(result)[:500] if result is not None else None,
180
229
  }
181
- if start is not None:
182
- tool_call["latency_ms"] = max(0, int((time.time() - start) * 1000))
230
+ if start_t is not None:
231
+ tool_call["latency_ms"] = max(0, int((end_t - start_t) * 1000))
183
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
+ })
184
240
 
185
241
  async def on_tool_error_callback(
186
242
  self,
@@ -194,12 +250,21 @@ class AgentXADKPlugin(BasePlugin):
194
250
  state = self._runs.get(inv_id)
195
251
  if state is None:
196
252
  return
197
- start = self._tool_starts.pop(id(tool_context), None)
253
+ start_t = self._tool_starts.pop(id(tool_context), None)
254
+ end_t = time.time()
255
+ tool_name = getattr(tool, "name", "unknown")
198
256
  tool_call: Dict[str, Any] = {
199
- "name": getattr(tool, "name", "unknown"),
257
+ "name": tool_name,
200
258
  "input": _safe_serialize(tool_args),
201
259
  "output": f"ERROR: {error}",
202
260
  }
203
- if start is not None:
204
- tool_call["latency_ms"] = max(0, int((time.time() - start) * 1000))
261
+ if start_t is not None:
262
+ tool_call["latency_ms"] = max(0, int((end_t - start_t) * 1000))
205
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
- start = time.time()
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
- latency_ms = int((time.time() - start) * 1000)
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
- start = time.time()
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
- latency_ms = int((time.time() - start) * 1000)
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._parents[run_id] = parent_run_id
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
- top = self._find_top_ancestor(parent_run_id)
281
- if top and not self._runs[top].get("model"):
282
- # Check all common field locations across langchain versions
283
- kw = serialized.get("kwargs", {})
284
- model = (
285
- kw.get("model_name")
286
- or kw.get("model")
287
- or kwargs.get("invocation_params", {}).get("model")
288
- or kwargs.get("invocation_params", {}).get("model_name")
289
- or serialized.get("name")
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
- latency_ms = int((time.time() - state["start"]) * 1000)
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((time.time() - state["start"]) * 1000),
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
- start = time.time()
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=int((time.time() - start) * 1000),
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
- start = time.time()
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=int((time.time() - start) * 1000),
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentx-python
3
- Version: 0.5.2
3
+ Version: 0.6.1
4
4
  Summary: Official Python SDK for AgentX (https://www.agentx.so/)
5
5
  Home-page: https://github.com/AgentX-ai/AgentX-python
6
6
  Author: Robin Wang and AgentX Team
@@ -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.2"
File without changes
File without changes
File without changes
File without changes