agentx-python 0.8.23__tar.gz → 0.8.24__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 (99) hide show
  1. {agentx_python-0.8.23 → agentx_python-0.8.24}/PKG-INFO +1 -1
  2. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/agentx.py +28 -5
  3. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/client.py +19 -9
  4. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/datasets.py +19 -5
  5. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/evaluation_settings.py +12 -2
  6. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/models.py +10 -15
  7. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/reporting.py +1 -1
  8. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/runner.py +27 -9
  9. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/crewai.py +19 -9
  10. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/google_adk.py +110 -15
  11. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/langchain.py +57 -28
  12. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/litellm.py +17 -2
  13. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/llamaindex.py +97 -88
  14. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/openai_agents.py +3 -0
  15. agentx_python-0.8.24/agentx/monitor/__init__.py +39 -0
  16. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/agents.py +13 -2
  17. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/client.py +25 -9
  18. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/improvement_groups.py +39 -4
  19. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/judge_scorers.py +55 -4
  20. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/models.py +4 -0
  21. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/patterns.py +34 -1
  22. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/profile.py +2 -0
  23. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/review_queue.py +10 -2
  24. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/rules.py +6 -2
  25. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/scorer_groups.py +33 -4
  26. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/scorers.py +37 -4
  27. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/tracing/ingest_client.py +22 -5
  28. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/tracing/tracer.py +114 -29
  29. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/version.py +2 -2
  30. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx_python.egg-info/PKG-INFO +1 -1
  31. {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_judge_scorers.py +15 -2
  32. {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_span_tree.py +73 -0
  33. {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_wire_models.py +43 -0
  34. agentx_python-0.8.23/agentx/monitor/__init__.py +0 -17
  35. {agentx_python-0.8.23 → agentx_python-0.8.24}/LICENSE +0 -0
  36. {agentx_python-0.8.23 → agentx_python-0.8.24}/README.md +0 -0
  37. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/__init__.py +0 -0
  38. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/cli.py +0 -0
  39. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/__init__.py +0 -0
  40. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/_term.py +0 -0
  41. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/adapters/__init__.py +0 -0
  42. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/adapters/http_endpoint.py +0 -0
  43. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/adapters/precomputed.py +0 -0
  44. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/adapters/raw.py +0 -0
  45. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/prompts.py +0 -0
  46. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/results.py +0 -0
  47. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/tool_schemas.py +0 -0
  48. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/tracing.py +0 -0
  49. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/exceptions.py +0 -0
  50. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/export.py +0 -0
  51. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/feedback.py +0 -0
  52. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/__init__.py +0 -0
  53. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/_traced_call.py +0 -0
  54. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/anthropic.py +0 -0
  55. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/autogen.py +0 -0
  56. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/databricks.py +0 -0
  57. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/google_genai.py +0 -0
  58. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/moveworks.py +0 -0
  59. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/openai.py +0 -0
  60. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/online_evaluators.py +0 -0
  61. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/sessions.py +0 -0
  62. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/signals.py +0 -0
  63. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/outcomes.py +0 -0
  64. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/projects.py +0 -0
  65. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/py.typed +0 -0
  66. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/resources/__init__.py +0 -0
  67. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/resources/agent.py +0 -0
  68. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/resources/conversation.py +0 -0
  69. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/resources/workforce.py +0 -0
  70. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/testing.py +0 -0
  71. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/traces.py +0 -0
  72. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/tracing/__init__.py +0 -0
  73. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/tracing/ci_types.py +0 -0
  74. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/tracing/eval_scope.py +0 -0
  75. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/tracing/framework_detect.py +0 -0
  76. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/util.py +0 -0
  77. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx_python.egg-info/SOURCES.txt +0 -0
  78. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx_python.egg-info/dependency_links.txt +0 -0
  79. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx_python.egg-info/entry_points.txt +0 -0
  80. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx_python.egg-info/not-zip-safe +0 -0
  81. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx_python.egg-info/requires.txt +0 -0
  82. {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx_python.egg-info/top_level.txt +0 -0
  83. {agentx_python-0.8.23 → agentx_python-0.8.24}/setup.cfg +0 -0
  84. {agentx_python-0.8.23 → agentx_python-0.8.24}/setup.py +0 -0
  85. {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_cli_launcher.py +0 -0
  86. {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_deep_dive_fixes.py +0 -0
  87. {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_docs_match_sdk.py +0 -0
  88. {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_error_taxonomy.py +0 -0
  89. {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_eval_scope.py +0 -0
  90. {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_framework_detect.py +0 -0
  91. {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_integration.py +0 -0
  92. {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_integrations.py +0 -0
  93. {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_multi_judge.py +0 -0
  94. {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_pairwise.py +0 -0
  95. {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_review_queue.py +0 -0
  96. {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_runner_features.py +0 -0
  97. {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_selfhost_analysis_fallback.py +0 -0
  98. {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_selfhost_compat.py +0 -0
  99. {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_testing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentx-python
3
- Version: 0.8.23
3
+ Version: 0.8.24
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
@@ -4,6 +4,7 @@ import os
4
4
  import logging
5
5
 
6
6
  from agentx.util import get_headers, api_base, normalize_base
7
+ from agentx.exceptions import AgentXError
7
8
  from agentx.resources.agent import Agent
8
9
  from agentx.resources.workforce import Workforce
9
10
 
@@ -136,6 +137,11 @@ class AgentX:
136
137
  return cls(base_url=base_url) if base_url else cls()
137
138
 
138
139
  def get_agent(self, id: str) -> Agent:
140
+ """Fetch one hosted-platform agent by id.
141
+
142
+ Hosted platform only - the self-host engine does not serve /access/agents;
143
+ use ``client.monitor.agents.list()`` for self-host agent rows instead.
144
+ """
139
145
  url = f"{self.base_url or api_base()}/access/agents/{id}"
140
146
  # Make a GET request to the AgentX API
141
147
  response = requests.get(url, headers=get_headers(self.api_key))
@@ -143,9 +149,17 @@ class AgentX:
143
149
  if response.status_code == 200:
144
150
  return Agent(**response.json())
145
151
  else:
146
- raise Exception(f"Failed to retrieve agent: {response.reason}")
152
+ raise AgentXError(
153
+ f"Failed to retrieve agent: {response.reason}. This endpoint is "
154
+ "hosted-platform only - on self-host use client.monitor.agents.list()."
155
+ )
147
156
 
148
157
  def list_agents(self) -> List[Agent]:
158
+ """List the hosted platform's agents.
159
+
160
+ Hosted platform only - the self-host engine does not serve /access/agents;
161
+ use ``client.monitor.agents.list()`` for self-host agent rows instead.
162
+ """
149
163
  url = f"{self.base_url or api_base()}/access/agents"
150
164
  # Make a GET request to the AgentX API
151
165
  response = requests.get(url, headers=get_headers(self.api_key))
@@ -153,7 +167,10 @@ class AgentX:
153
167
  if response.status_code == 200:
154
168
  return [Agent(**agent) for agent in response.json()]
155
169
  else:
156
- raise Exception(f"Failed to list agents: {response.reason}")
170
+ raise AgentXError(
171
+ f"Failed to list agents: {response.reason}. This endpoint is "
172
+ "hosted-platform only - on self-host use client.monitor.agents.list()."
173
+ )
157
174
 
158
175
  @staticmethod
159
176
  def list_workforces() -> List["Workforce"]:
@@ -214,12 +231,18 @@ class AgentX:
214
231
  return {"ok": True, "base_url": base}
215
232
 
216
233
  def get_profile(self):
217
- """Get the current user's profile information."""
234
+ """Get the current user's profile information.
235
+
236
+ Hosted platform only - the self-host engine does not serve /access/getProfile;
237
+ self-host agent/monitoring data lives under ``client.monitor`` (e.g.
238
+ ``client.monitor.agents.list()``).
239
+ """
218
240
  url = f"{self.base_url or api_base()}/access/getProfile"
219
241
  response = requests.get(url, headers=get_headers(self.api_key))
220
242
  if response.status_code == 200:
221
243
  return response.json()
222
244
  else:
223
- raise Exception(
224
- f"Failed to get profile: {response.status_code} - {response.reason}"
245
+ raise AgentXError(
246
+ f"Failed to get profile: {response.status_code} - {response.reason}. "
247
+ "This endpoint is hosted-platform only - on self-host use client.monitor."
225
248
  )
@@ -35,7 +35,6 @@ _DEFAULT_BASE_URL = f"{_UTIL_API_BASE}/custom-agent-evaluations"
35
35
  SDK_NAME = "agentx-python"
36
36
 
37
37
  _RETRYABLE_STATUS = {429, 500, 502, 503, 504}
38
- _MAX_RETRIES = 3
39
38
  _RETRY_BACKOFF = [1.0, 2.0, 4.0]
40
39
 
41
40
  # The self-host analyze route judges every result before it responds, so the client has to
@@ -182,8 +181,8 @@ class EvaluationsClient:
182
181
  if resp.status_code == 422:
183
182
  raise AgentXValidationError(resp.text)
184
183
  # Gate on the schedule itself so HTTP-status retries walk the SAME full backoff
185
- # schedule connection errors do - the old `attempt < _MAX_RETRIES - 1` gate left
186
- # the schedule's last entry unreachable for HTTP retries (ingest_client precedent).
184
+ # schedule connection errors do - an earlier fixed retry-count gate left the
185
+ # schedule's last entry unreachable for HTTP retries (ingest_client precedent).
187
186
  if (
188
187
  resp.status_code in _RETRYABLE_STATUS
189
188
  and retry
@@ -248,7 +247,9 @@ class EvaluationsClient:
248
247
  """Deletes the dataset, its grading config, and both version histories. Past runs are
249
248
  kept (their dataset reference degrades to a bare id). The engine refuses (409) when the
250
249
  dataset's config is attached to a live scorer."""
251
- self._request("DELETE", f"/datasets/{dataset_id}")
250
+ # retry=False: a lost response + transport retry would turn a successful
251
+ # delete into a spurious 404.
252
+ self._request("DELETE", f"/datasets/{dataset_id}", retry=False)
252
253
 
253
254
  def list_datasets(self) -> List[Dataset]:
254
255
  data = self._request("GET", "/datasets", params=self._workspace_params())
@@ -440,6 +441,12 @@ class EvaluationsClient:
440
441
  quality_mode: Optional[str] = None,
441
442
  judges: Optional[List[str]] = None,
442
443
  ) -> Dict[str, Any]:
444
+ """Start the qualitative AI-analysis job for a run.
445
+
446
+ ``mode`` ("auto"/"sync"/"batch") is hosted-only: self-host engines run the analysis
447
+ synchronously and ignore it - check the response's mode field for what actually ran
448
+ (mirrors EvaluationRun.analyze's docstring).
449
+ """
443
450
  # Starts the durable analysis job and returns immediately (e.g. {"jobId": ..., "status":
444
451
  # "pending"}); poll get_analysis_status() until it reaches a terminal status, then call
445
452
  # get_report(). mode/quality_mode/judges mirror the dashboard's AnalyzeEvaluationRequest.
@@ -506,9 +513,10 @@ class EvaluationsClient:
506
513
  return self._report_from_dashboard(run_id)
507
514
 
508
515
  def get_missing_results(self, run_id: str) -> List[Dict[str, Any]]:
509
- """Deprecated: on self-host the route's response body has no top-level list, so this
510
- always returns ``[]``. Use :meth:`get_submitted_keys` - the same route's
511
- ``submittedKeys`` - to find out what a run still needs."""
516
+ """Deprecated: on self-host the route returns ``missing: []`` deliberately empty -
517
+ the engine cannot know the client's case list - so this always returns ``[]``.
518
+ Use :meth:`get_submitted_keys` - the same route's ``submittedKeys`` - to find out
519
+ what a run still needs."""
512
520
  import warnings
513
521
 
514
522
  warnings.warn(
@@ -517,8 +525,10 @@ class EvaluationsClient:
517
525
  DeprecationWarning,
518
526
  stacklevel=2,
519
527
  )
520
- data = self._request("GET", f"/runs/{run_id}/missing-results")
521
- return data if isinstance(data, list) else data.get("missing", [])
528
+ # No request at all: the route returns `missing: []` deliberately empty (the engine
529
+ # cannot know the client's case list), so the round-trip only ever bought an empty
530
+ # result.
531
+ return []
522
532
 
523
533
  def get_submitted_keys(self, run_id: str) -> List[str]:
524
534
  """Idempotency keys this run has already accepted - what execute() uses to resume a
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import csv
4
4
  import logging
5
+ import warnings
5
6
  from pathlib import Path
6
7
  from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union
7
8
 
@@ -21,7 +22,8 @@ class DatasetBuilder:
21
22
  ``judge_prompt``/``judge_model`` are LLM-as-judge overrides for the dataset's grading
22
23
  config. NOTE (self-host): the engine's dataset-create route currently ignores both -
23
24
  set them on a judge scorer / the evaluation settings instead. ``sovereignty_models``
24
- is accepted on the wire but not acted on by the self-host engine.
25
+ is dropped by the self-host engine on this route - use
26
+ ``client.monitor.judge_scorers.builder(sovereignty_models=...)`` which persists it.
25
27
  """
26
28
 
27
29
  def __init__(
@@ -93,8 +95,9 @@ class DatasetBuilder:
93
95
  if rouge_score:
94
96
  self._payload["rougeScore"] = {"enabled": True}
95
97
  # Sovereignty & Portability - the models to compare on this dataset (use
96
- # client.evaluations.list_models() to discover valid ids). Self-host: accepted on
97
- # the wire but not acted on by the engine (see class docstring).
98
+ # client.evaluations.list_models() to discover valid ids). Self-host: dropped by
99
+ # the engine on this route - use client.monitor.judge_scorers.builder(
100
+ # sovereignty_models=...) which persists it (see class docstring).
98
101
  if sovereignty_models:
99
102
  self._payload["sovereigntyIndex"] = {
100
103
  "enabled": True,
@@ -161,9 +164,11 @@ class DatasetBuilder:
161
164
  main["smokeTest"] = {"enabled": True, "count": smoke_test_count}
162
165
  if smoke_test_guidance:
163
166
  main["smokeTest"]["guidance"] = smoke_test_guidance
164
- if expected_tools:
167
+ # `is not None`, not truthiness: an explicit empty list is a real assertion (an empty
168
+ # expectedTrajectory means "this case calls no tools") and must reach the wire.
169
+ if expected_tools is not None:
165
170
  main["expectedTrajectory"] = {"tools": expected_tools, "mode": trajectory_match_mode}
166
- if expected_retrieval_context:
171
+ if expected_retrieval_context is not None:
167
172
  main["expectedRetrievalContext"] = expected_retrieval_context
168
173
  if splits:
169
174
  main["splits"] = splits
@@ -178,6 +183,15 @@ class DatasetBuilder:
178
183
  def publish(self) -> Dataset:
179
184
  if not self._payload["questions"]:
180
185
  raise ValueError("Dataset must have at least one case before publishing")
186
+ # Warn at publish time, where the request is known: the engine's dataset-create
187
+ # route drops sovereigntyIndex, so comparison models set here never persist.
188
+ sov = self._payload.get("sovereigntyIndex")
189
+ if isinstance(sov, dict) and sov.get("models"):
190
+ warnings.warn(
191
+ "Self-host ignores sovereigntyIndex on datasets/grading configs - use "
192
+ "judge_scorers.builder(sovereignty_models=...) for model comparison runs.",
193
+ stacklevel=2,
194
+ )
181
195
  logger.info(
182
196
  "Publishing dataset '%s' with %d case(s)",
183
197
  self._payload["name"],
@@ -64,8 +64,9 @@ class EvaluationSettingsBuilder:
64
64
  if rouge_score:
65
65
  self._payload["rougeScore"] = {"enabled": True}
66
66
  # Sovereignty & Portability - the models to compare when this config runs
67
- # (use client.evaluations.list_models() to discover valid ids). Self-host: accepted
68
- # on the wire but not acted on by the engine (same caveat as DatasetBuilder's).
67
+ # (use client.evaluations.list_models() to discover valid ids). Self-host: dropped
68
+ # by the engine on this route - use client.monitor.judge_scorers.builder(
69
+ # sovereignty_models=...) which persists it (same caveat as DatasetBuilder's).
69
70
  if sovereignty_models:
70
71
  self._payload["sovereigntyIndex"] = {
71
72
  "enabled": True,
@@ -91,6 +92,15 @@ class EvaluationSettingsBuilder:
91
92
  ]
92
93
 
93
94
  def publish(self) -> EvaluationSettings:
95
+ # Warn at publish time, where the request is known: the engine's settings-create
96
+ # route drops sovereigntyIndex, so comparison models set here never persist.
97
+ sov = self._payload.get("sovereigntyIndex")
98
+ if isinstance(sov, dict) and sov.get("models"):
99
+ warnings.warn(
100
+ "Self-host ignores sovereigntyIndex on datasets/grading configs - use "
101
+ "judge_scorers.builder(sovereignty_models=...) for model comparison runs.",
102
+ stacklevel=2,
103
+ )
94
104
  logger.info("Publishing evaluation settings '%s'", self._payload["name"])
95
105
  return self._client.create_evaluation_settings(self._payload)
96
106
 
@@ -179,27 +179,22 @@ class Prompt(BaseModel):
179
179
  # Evaluation subject
180
180
  # ---------------------------------------------------------------------------
181
181
 
182
- FrameworkKind = Literal[
183
- "raw_python",
184
- "openai",
185
- "anthropic",
186
- "google",
187
- "langchain",
188
- "llamaindex",
189
- "crewai",
190
- "autogen",
191
- "n8n",
192
- "flowise",
193
- "other",
194
- ]
195
-
196
182
  RuntimeKind = Literal["local", "ci", "customer_hosted", "low_code"]
197
183
 
198
184
 
199
185
  class EvaluationSubject(BaseModel):
186
+ """Describes the agent under evaluation.
187
+
188
+ ``framework`` is an open string - the engine accepts any label (it also stamps
189
+ values like ``openai-agents``, ``langgraph``, ``google-genai``, ``litellm`` from
190
+ the tracing integrations). Common values: ``raw_python``, ``openai``,
191
+ ``anthropic``, ``google``, ``langchain``, ``llamaindex``, ``crewai``,
192
+ ``autogen``, ``n8n``, ``flowise``, ``other``.
193
+ """
194
+
200
195
  kind: Literal["custom_agent", "agentx_agent", "agentx_team"] = "custom_agent"
201
196
  display_name: Optional[str] = Field(default=None, alias="displayName")
202
- framework: Optional[FrameworkKind] = None
197
+ framework: Optional[str] = None
203
198
  framework_version: Optional[str] = Field(default=None, alias="frameworkVersion")
204
199
  runtime: Optional[RuntimeKind] = "local"
205
200
  agent_instructions: Optional[str] = Field(default=None, alias="agentInstructions")
@@ -172,7 +172,7 @@ def print_report(report: Report) -> None:
172
172
  if report.low_scoring_cases:
173
173
  _section("Low-scoring Cases (rating <= 5)")
174
174
  for case in report.low_scoring_cases[:5]:
175
- q = (case.get("query") or case.get("questionText", ""))[:80]
175
+ q = (case.get("query") or case.get("questionText") or "")[:80]
176
176
  rating = case.get("rating", "?")
177
177
  justification = case.get("justification", "")
178
178
  print(f" {red(f'[{rating}]')} {q}")
@@ -229,6 +229,7 @@ class EvaluationRunContext:
229
229
  )
230
230
  return normalized(case)
231
231
 
232
+ executor = None
232
233
  if concurrency > 1:
233
234
  import concurrent.futures
234
235
  import contextvars
@@ -308,6 +309,12 @@ class EvaluationRunContext:
308
309
  # generator happens to be garbage-collected.
309
310
  if results_iter is not None:
310
311
  results_iter.close()
312
+ # And release the pool itself here too: close() on a NEVER-STARTED generator
313
+ # (e.g. every case was already submitted, so next() was never called) does not
314
+ # run bounded()'s finally - its executor.shutdown would never fire. shutdown()
315
+ # is idempotent, so the double call on the normal path is harmless.
316
+ if executor is not None:
317
+ executor.shutdown(wait=False, cancel_futures=True)
311
318
  # Flush the trailing partial batch HERE, not after the try: a mid-run exception
312
319
  # (agent crash, Ctrl-C) used to discard up to max_batch - 1 already-paid-for
313
320
  # results still waiting in it.
@@ -546,7 +553,7 @@ class EvaluationRunContext:
546
553
  response's mode field for what actually ran.
547
554
  quality_mode: "quality_first" or "balanced" - how many items get a second/third judge.
548
555
  judges: 1-3 model ids from ``client.evaluations.list_models()``, e.g.
549
- ``["gpt-5.6-luna", "claude-opus-4-8"]``. Omit to let the engine score with its
556
+ ``["gpt-5.6-luna", "claude-opus-5"]``. Omit to let the engine score with its
550
557
  platform default model (a single judge, rather than the dashboard's 3-judge
551
558
  default - SDK runs are typically lighter-weight, quick-start evaluations).
552
559
  poll_interval: seconds between status checks while waiting.
@@ -566,16 +573,27 @@ class EvaluationRunContext:
566
573
  judges=judges,
567
574
  )
568
575
  deadline = time.monotonic() + timeout
569
- status = self._client.get_analysis_status(self._run.run_id)
570
- while not status.is_terminal and time.monotonic() < deadline:
571
- level = _ANALYSIS_LEVEL_LABELS.get(status.progress.current_level, "")
572
- spinner.update(
573
- f"Analyzing, {level + ' ' if level else ''}{status.progress.overall_percentage}%"
574
- )
576
+ status = None
577
+ while True:
578
+ try:
579
+ status = self._client.get_analysis_status(self._run.run_id)
580
+ except Exception as poll_exc:
581
+ # One transient status-poll failure (network blip, engine restart)
582
+ # must not abort the whole wait - the job keeps running
583
+ # server-side, so keep polling until the deadline.
584
+ logger.debug("Analysis status poll failed: %s", poll_exc)
585
+ if status is not None and status.is_terminal:
586
+ break
587
+ if time.monotonic() >= deadline:
588
+ break
589
+ if status is not None:
590
+ level = _ANALYSIS_LEVEL_LABELS.get(status.progress.current_level, "")
591
+ spinner.update(
592
+ f"Analyzing, {level + ' ' if level else ''}{status.progress.overall_percentage}%"
593
+ )
575
594
  time.sleep(poll_interval)
576
- status = self._client.get_analysis_status(self._run.run_id)
577
595
 
578
- if not status.is_terminal:
596
+ if status is None or not status.is_terminal:
579
597
  _say(f" {yellow('!')} Still running after {int(timeout)}s, check the dashboard for status")
580
598
  elif status.status == "failed":
581
599
  reason = status.failure_reason.message if status.failure_reason else "unknown error"
@@ -84,7 +84,7 @@ class AgentXCrewObserver:
84
84
  if task_timings:
85
85
  execution_steps, _ = self._build_steps_from_timings(task_timings, task_outputs)
86
86
  elif task_outputs:
87
- execution_steps, _ = self._build_steps_evenly_divided(task_outputs, latency_ms)
87
+ execution_steps, _ = self._build_steps_evenly_divided(task_outputs, latency_ms, start)
88
88
 
89
89
  # Each task becomes its own real child span. tool_calls isn't passed to
90
90
  # _merge_child_run here: _build_steps_from_timings/_build_steps_evenly_divided both
@@ -262,25 +262,33 @@ class AgentXCrewObserver:
262
262
 
263
263
  return execution_steps, tool_calls
264
264
 
265
- def _build_steps_evenly_divided(self, task_outputs: List[Any], latency_ms: float) -> tuple:
265
+ def _build_steps_evenly_divided(self, task_outputs: List[Any], latency_ms: float, start: float) -> tuple:
266
266
  """
267
267
  Fallback for CrewAI versions predating the events module: no
268
268
  per-task timing is available, so attribute the total latency evenly
269
269
  across tasks so the timeline still sums to the measured wall-clock
270
- duration.
270
+ duration. Each synthesized step also gets a real start_time/end_time
271
+ (consecutive per_step_s slices from the kickoff's start) so the
272
+ dashboard timeline can position it, not just size it.
271
273
  """
272
274
  tool_calls: List[Dict[str, Any]] = []
273
275
  execution_steps: List[Dict[str, Any]] = []
274
- for task_out in task_outputs:
276
+ per_step_ms = latency_ms / len(task_outputs)
277
+ per_step_s = per_step_ms / 1000.0
278
+ for i, task_out in enumerate(task_outputs):
275
279
  description = getattr(task_out, "description", "task")
276
280
  name = description[:100]
277
281
  task_output = str(getattr(task_out, "raw", ""))
278
282
  tool_calls.append({"name": name, "input": description, "output": task_output})
279
- execution_steps.append({"name": name, "duration_ms": 0, "input": description, "output": task_output})
280
-
281
- per_step_ms = latency_ms / len(execution_steps)
282
- for step in execution_steps:
283
- step["duration_ms"] = per_step_ms
283
+ step_start = start + i * per_step_s
284
+ execution_steps.append({
285
+ "name": name,
286
+ "duration_ms": per_step_ms,
287
+ "start_time": step_start,
288
+ "end_time": step_start + per_step_s,
289
+ "input": description,
290
+ "output": task_output,
291
+ })
284
292
 
285
293
  return execution_steps, tool_calls
286
294
 
@@ -307,4 +315,6 @@ class AgentXCrewObserver:
307
315
  framework="crewai",
308
316
  session_id=session_id or self._session_id,
309
317
  sync=sync,
318
+ # Same statement kickoff() makes: the root of a crew run is the agent run itself.
319
+ span_kind="agent",
310
320
  )
@@ -20,12 +20,15 @@ Requires: ``pip install "agentx-python[google-adk]"``
20
20
  """
21
21
  from __future__ import annotations
22
22
 
23
+ import logging
23
24
  import time
24
- from typing import Any, Dict, List, Optional
25
+ from typing import Any, Dict, List, Optional, Tuple
25
26
  from uuid import uuid4
26
27
 
27
28
  from agentx.tracing.tracer import Tracer, _safe_serialize
28
29
 
30
+ logger = logging.getLogger(__name__)
31
+
29
32
  try:
30
33
  from google.adk.plugins.base_plugin import BasePlugin
31
34
  except ImportError as exc: # pragma: no cover
@@ -94,23 +97,34 @@ class AgentXADKPlugin(BasePlugin):
94
97
  name: str = "google-adk-agent",
95
98
  metadata: Optional[Dict[str, Any]] = None,
96
99
  session_id: Optional[str] = None,
100
+ max_run_age_seconds: float = 3600.0,
97
101
  ) -> None:
98
102
  super().__init__(name="agentx")
99
103
  self._tracer = tracer
100
104
  self._agent_name = name
101
105
  self._metadata = metadata
102
106
  self._session_id = session_id
107
+ # Safety net mirroring langchain.py's _prune_stale_entries: state is normally popped
108
+ # in after_run_callback, but an invocation whose end callback never fires (hard
109
+ # crash, ADK bug) would leak forever in this long-lived plugin. Entries older than
110
+ # this are swept out at the top of before_run_callback.
111
+ self._max_run_age_seconds = max_run_age_seconds
103
112
  # invocation_id → accumulated run state
104
113
  self._runs: Dict[str, Dict[str, Any]] = {}
105
- # invocation_id → pre-buffered user input text
114
+ # invocation_id → (pre-buffered user input text, buffered-at time)
106
115
  # (on_user_message_callback fires *before* before_run_callback)
107
- self._pending_inputs: Dict[str, str] = {}
108
- # id(tool_context) → start time float
109
- self._tool_starts: Dict[int, float] = {}
110
- # invocation_id stack of model call start times (FIFO)
116
+ self._pending_inputs: Dict[str, Tuple[str, float]] = {}
117
+ # (invocation_id, tool name) → FIFO list of start times, mirroring _model_starts:
118
+ # parallel same-name tool calls each push their own start, so a second start no
119
+ # longer overwrites the first (a scalar here lost the first call's timing). Keyed
120
+ # by invocation_id, not id(tool_context): ADK creates fresh context objects per
121
+ # callback (see _model_starts' comment), and a freed context's id() can be
122
+ # recycled by an unrelated object, pairing a start with the wrong end.
123
+ self._tool_starts: Dict[Tuple[str, str], List[float]] = {}
124
+ # invocation_id → FIFO list of per-call dicts ({"start", "model", "input"})
111
125
  # ADK creates new CallbackContext objects for before/after model callbacks,
112
126
  # so we cannot use id(callback_context) as a key - use invocation_id instead.
113
- self._model_starts: Dict[str, List[float]] = {}
127
+ self._model_starts: Dict[str, List[Dict[str, Any]]] = {}
114
128
 
115
129
  # ------------------------------------------------------------------
116
130
  # Run lifecycle
@@ -124,9 +138,64 @@ class AgentXADKPlugin(BasePlugin):
124
138
  inv_id = invocation_context.invocation_id
125
139
  text = _content_to_text(user_message)
126
140
  if text:
127
- self._pending_inputs[inv_id] = text
141
+ self._pending_inputs[inv_id] = (text, time.time())
142
+
143
+ def _prune_stale_entries(self) -> None:
144
+ """Sweep out invocation entries older than max_run_age_seconds - see __init__'s comment."""
145
+ cutoff = time.time() - self._max_run_age_seconds
146
+ swept = 0
147
+
148
+ stale_inv_ids = [
149
+ inv_id
150
+ for inv_id, state in list(self._runs.items())
151
+ if (getattr(state.get("root_span"), "_start", None) or 0) < cutoff
152
+ ]
153
+ for inv_id in stale_inv_ids:
154
+ self._runs.pop(inv_id, None)
155
+ self._model_starts.pop(inv_id, None)
156
+ self._pending_inputs.pop(inv_id, None)
157
+ for key in [k for k in self._tool_starts if k[0] == inv_id]:
158
+ self._tool_starts.pop(key, None)
159
+ swept += len(stale_inv_ids)
160
+
161
+ # Orphaned per-call state whose invocation state is already gone (or never existed) -
162
+ # each entry carries its own timestamp.
163
+ stale_model_ids = [
164
+ inv_id
165
+ for inv_id, starts in list(self._model_starts.items())
166
+ if inv_id not in self._runs
167
+ and (not starts or max(call.get("start", 0) for call in starts) < cutoff)
168
+ ]
169
+ for inv_id in stale_model_ids:
170
+ self._model_starts.pop(inv_id, None)
171
+ swept += len(stale_model_ids)
172
+
173
+ stale_tool_keys = [
174
+ key
175
+ for key, starts in list(self._tool_starts.items())
176
+ if not starts or max(starts) < cutoff
177
+ ]
178
+ for key in stale_tool_keys:
179
+ self._tool_starts.pop(key, None)
180
+ swept += len(stale_tool_keys)
181
+
182
+ stale_input_ids = [
183
+ inv_id for inv_id, pending in list(self._pending_inputs.items()) if pending[1] < cutoff
184
+ ]
185
+ for inv_id in stale_input_ids:
186
+ self._pending_inputs.pop(inv_id, None)
187
+ swept += len(stale_input_ids)
188
+
189
+ if swept:
190
+ logger.warning(
191
+ "AgentXADKPlugin swept %d in-flight invocation record(s) older than %.0fs - "
192
+ "their end callbacks never fired, so their traces were never sent",
193
+ swept,
194
+ self._max_run_age_seconds,
195
+ )
128
196
 
129
197
  async def before_run_callback(self, *, invocation_context: Any) -> None:
198
+ self._prune_stale_entries()
130
199
  inv_id = invocation_context.invocation_id
131
200
  agent_name = getattr(invocation_context.agent, "name", None) or self._agent_name
132
201
  # Held directly (not relied on via tracer.current_span) - ADK callbacks for one
@@ -134,7 +203,13 @@ class AgentXADKPlugin(BasePlugin):
134
203
  # same thread/task, so state["root_span"] (keyed by invocation_id, same as everything
135
204
  # else here) is the reliable way to address the right parent.
136
205
  root_span = self._tracer.trace(
137
- agent_name, framework="google-adk", metadata=self._metadata, session_id=self._session_id
206
+ agent_name,
207
+ framework="google-adk",
208
+ metadata=self._metadata,
209
+ session_id=self._session_id,
210
+ # The root of a standalone runner invocation is the agent run itself
211
+ # (langchain/llamaindex/crewai/autogen parity).
212
+ span_kind="agent",
138
213
  )
139
214
  # Deliberately NOT root_span.__enter__() - the same reasoning as openai_agents'
140
215
  # on_trace_start: enter pushes onto the CALLING context's active-span stack, but ADK
@@ -146,10 +221,11 @@ class AgentXADKPlugin(BasePlugin):
146
221
  root_span._start = time.time()
147
222
  if root_span._session_id is None:
148
223
  root_span._session_id = f"sdk_{uuid4().hex}"
224
+ pending_input = self._pending_inputs.pop(inv_id, None)
149
225
  self._runs[inv_id] = {
150
226
  "root_span": root_span,
151
227
  "llm_call_count": 0,
152
- "input": self._pending_inputs.pop(inv_id, None),
228
+ "input": pending_input[0] if pending_input else None,
153
229
  "output": None,
154
230
  "model": None,
155
231
  "error": None,
@@ -160,6 +236,10 @@ class AgentXADKPlugin(BasePlugin):
160
236
  async def after_run_callback(self, *, invocation_context: Any) -> None:
161
237
  inv_id = invocation_context.invocation_id
162
238
  state = self._runs.pop(inv_id, None)
239
+ # Drop this invocation's per-call leftovers too - an unpaired before_model_callback
240
+ # (or an input buffered after the run started) would otherwise leak here forever.
241
+ self._model_starts.pop(inv_id, None)
242
+ self._pending_inputs.pop(inv_id, None)
163
243
  if state is None:
164
244
  return
165
245
  # This invocation's own detail already went out as child-span rows via child_span() in
@@ -285,7 +365,10 @@ class AgentXADKPlugin(BasePlugin):
285
365
  async def before_tool_callback(
286
366
  self, *, tool: Any, tool_args: Dict[str, Any], tool_context: Any
287
367
  ) -> None:
288
- self._tool_starts[id(tool_context)] = time.time()
368
+ inv_id = tool_context.get_invocation_context().invocation_id
369
+ # FIFO list per (invocation, tool name) - parallel same-name calls each queue
370
+ # their own start (see __init__'s comment).
371
+ self._tool_starts.setdefault((inv_id, getattr(tool, "name", "unknown")), []).append(time.time())
289
372
 
290
373
  async def after_tool_callback(
291
374
  self,
@@ -299,9 +382,15 @@ class AgentXADKPlugin(BasePlugin):
299
382
  state = self._runs.get(inv_id)
300
383
  if state is None:
301
384
  return
302
- start_t = self._tool_starts.pop(id(tool_context), None)
303
- end_t = time.time()
304
385
  tool_name = getattr(tool, "name", "unknown")
386
+ # Pop the earliest queued start (FIFO, _model_starts' pairing), dropping the
387
+ # key once its list drains so entries don't accumulate.
388
+ key = (inv_id, tool_name)
389
+ starts = self._tool_starts.get(key, [])
390
+ start_t = starts.pop(0) if starts else None
391
+ if not starts:
392
+ self._tool_starts.pop(key, None)
393
+ end_t = time.time()
305
394
  tool_input = _safe_serialize(tool_args)
306
395
  tool_output = str(result) if result is not None else None
307
396
  state["root_span"].child_span(
@@ -331,9 +420,15 @@ class AgentXADKPlugin(BasePlugin):
331
420
  state = self._runs.get(inv_id)
332
421
  if state is None:
333
422
  return
334
- start_t = self._tool_starts.pop(id(tool_context), None)
335
- end_t = time.time()
336
423
  tool_name = getattr(tool, "name", "unknown")
424
+ # Pop the earliest queued start (FIFO, _model_starts' pairing), dropping the
425
+ # key once its list drains so entries don't accumulate.
426
+ key = (inv_id, tool_name)
427
+ starts = self._tool_starts.get(key, [])
428
+ start_t = starts.pop(0) if starts else None
429
+ if not starts:
430
+ self._tool_starts.pop(key, None)
431
+ end_t = time.time()
337
432
  tool_input = _safe_serialize(tool_args)
338
433
  tool_output = f"ERROR: {error}"
339
434
  state["root_span"].child_span(