agentx-python 0.8.19__tar.gz → 0.8.20__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 (96) hide show
  1. {agentx_python-0.8.19/agentx_python.egg-info → agentx_python-0.8.20}/PKG-INFO +1 -1
  2. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/__init__.py +4 -5
  3. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/evaluations/client.py +28 -12
  4. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/evaluations/datasets.py +14 -2
  5. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/evaluations/reporting.py +4 -2
  6. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/evaluations/runner.py +8 -0
  7. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/integrations/anthropic.py +1 -1
  8. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/integrations/autogen.py +3 -0
  9. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/integrations/crewai.py +18 -3
  10. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/integrations/langchain.py +34 -21
  11. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/integrations/llamaindex.py +43 -5
  12. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/integrations/openai_agents.py +14 -1
  13. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/monitor/client.py +13 -8
  14. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/monitor/scorer_groups.py +2 -1
  15. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/monitor/scorers.py +9 -2
  16. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/monitor/sessions.py +17 -1
  17. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/tracing/ci_types.py +17 -11
  18. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/tracing/ingest_client.py +48 -21
  19. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/tracing/tracer.py +57 -15
  20. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/version.py +2 -2
  21. {agentx_python-0.8.19 → agentx_python-0.8.20/agentx_python.egg-info}/PKG-INFO +1 -1
  22. {agentx_python-0.8.19 → agentx_python-0.8.20}/tests/test_span_tree.py +41 -0
  23. {agentx_python-0.8.19 → agentx_python-0.8.20}/LICENSE +0 -0
  24. {agentx_python-0.8.19 → agentx_python-0.8.20}/README.md +0 -0
  25. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/agentx.py +0 -0
  26. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/cli.py +0 -0
  27. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/evaluations/__init__.py +0 -0
  28. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/evaluations/_term.py +0 -0
  29. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/evaluations/adapters/__init__.py +0 -0
  30. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/evaluations/adapters/http_endpoint.py +0 -0
  31. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/evaluations/adapters/precomputed.py +0 -0
  32. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/evaluations/adapters/raw.py +0 -0
  33. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/evaluations/evaluation_settings.py +0 -0
  34. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/evaluations/models.py +0 -0
  35. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/evaluations/prompts.py +0 -0
  36. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/evaluations/results.py +0 -0
  37. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/evaluations/tool_schemas.py +0 -0
  38. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/evaluations/tracing.py +0 -0
  39. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/exceptions.py +0 -0
  40. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/export.py +0 -0
  41. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/feedback.py +0 -0
  42. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/integrations/__init__.py +0 -0
  43. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/integrations/_traced_call.py +0 -0
  44. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/integrations/databricks.py +0 -0
  45. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/integrations/google_adk.py +0 -0
  46. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/integrations/google_genai.py +0 -0
  47. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/integrations/litellm.py +0 -0
  48. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/integrations/moveworks.py +0 -0
  49. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/integrations/openai.py +0 -0
  50. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/monitor/__init__.py +0 -0
  51. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/monitor/agents.py +0 -0
  52. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/monitor/improvement_groups.py +0 -0
  53. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/monitor/judge_scorers.py +0 -0
  54. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/monitor/models.py +0 -0
  55. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/monitor/online_evaluators.py +0 -0
  56. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/monitor/patterns.py +0 -0
  57. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/monitor/profile.py +0 -0
  58. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/monitor/review_queue.py +0 -0
  59. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/monitor/rules.py +0 -0
  60. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/monitor/signals.py +0 -0
  61. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/outcomes.py +0 -0
  62. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/projects.py +0 -0
  63. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/py.typed +0 -0
  64. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/resources/__init__.py +0 -0
  65. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/resources/agent.py +0 -0
  66. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/resources/conversation.py +0 -0
  67. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/resources/workforce.py +0 -0
  68. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/testing.py +0 -0
  69. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/traces.py +0 -0
  70. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/tracing/__init__.py +0 -0
  71. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/tracing/eval_scope.py +0 -0
  72. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/tracing/framework_detect.py +0 -0
  73. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx/util.py +0 -0
  74. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx_python.egg-info/SOURCES.txt +0 -0
  75. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx_python.egg-info/dependency_links.txt +0 -0
  76. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx_python.egg-info/entry_points.txt +0 -0
  77. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx_python.egg-info/not-zip-safe +0 -0
  78. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx_python.egg-info/requires.txt +0 -0
  79. {agentx_python-0.8.19 → agentx_python-0.8.20}/agentx_python.egg-info/top_level.txt +0 -0
  80. {agentx_python-0.8.19 → agentx_python-0.8.20}/setup.cfg +0 -0
  81. {agentx_python-0.8.19 → agentx_python-0.8.20}/setup.py +0 -0
  82. {agentx_python-0.8.19 → agentx_python-0.8.20}/tests/test_cli_launcher.py +0 -0
  83. {agentx_python-0.8.19 → agentx_python-0.8.20}/tests/test_deep_dive_fixes.py +0 -0
  84. {agentx_python-0.8.19 → agentx_python-0.8.20}/tests/test_docs_match_sdk.py +0 -0
  85. {agentx_python-0.8.19 → agentx_python-0.8.20}/tests/test_eval_scope.py +0 -0
  86. {agentx_python-0.8.19 → agentx_python-0.8.20}/tests/test_framework_detect.py +0 -0
  87. {agentx_python-0.8.19 → agentx_python-0.8.20}/tests/test_integration.py +0 -0
  88. {agentx_python-0.8.19 → agentx_python-0.8.20}/tests/test_integrations.py +0 -0
  89. {agentx_python-0.8.19 → agentx_python-0.8.20}/tests/test_judge_scorers.py +0 -0
  90. {agentx_python-0.8.19 → agentx_python-0.8.20}/tests/test_multi_judge.py +0 -0
  91. {agentx_python-0.8.19 → agentx_python-0.8.20}/tests/test_pairwise.py +0 -0
  92. {agentx_python-0.8.19 → agentx_python-0.8.20}/tests/test_review_queue.py +0 -0
  93. {agentx_python-0.8.19 → agentx_python-0.8.20}/tests/test_runner_features.py +0 -0
  94. {agentx_python-0.8.19 → agentx_python-0.8.20}/tests/test_selfhost_analysis_fallback.py +0 -0
  95. {agentx_python-0.8.19 → agentx_python-0.8.20}/tests/test_selfhost_compat.py +0 -0
  96. {agentx_python-0.8.19 → agentx_python-0.8.20}/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.19
3
+ Version: 0.8.20
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
@@ -12,11 +12,10 @@ from agentx.exceptions import (
12
12
  CIGateFailure,
13
13
  )
14
14
 
15
- logging.basicConfig(
16
- level=logging.INFO,
17
- format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
18
- datefmt="%Y-%m-%d %H:%M:%S %Z",
19
- )
15
+ # Library logging hygiene: a library must never call logging.basicConfig - it hijacks the
16
+ # host application's root logger (format AND level) and turns the app's own later basicConfig
17
+ # into a no-op. Consumers opt into our logs with logging.getLogger("agentx").setLevel(...).
18
+ logging.getLogger("agentx").addHandler(logging.NullHandler())
20
19
 
21
20
  __all__ = [
22
21
  "AgentX",
@@ -348,7 +348,9 @@ class EvaluationsClient:
348
348
  payload["scorerGroupId"] = scorer_group_id
349
349
  if split:
350
350
  payload["split"] = split
351
- data = self._request("POST", "/runs", json=self._with_workspace(payload))
351
+ # Server-side write: a timeout after the run row was created would be
352
+ # retried into a duplicate run, so no transport retry.
353
+ data = self._request("POST", "/runs", json=self._with_workspace(payload), retry=False)
352
354
  return EvaluationRun(**data)
353
355
 
354
356
  def append_results(
@@ -432,8 +434,12 @@ class EvaluationsClient:
432
434
 
433
435
  if not self._analysis_on_dashboard_router:
434
436
  try:
437
+ # The self-host route runs the analysis SYNCHRONOUSLY (engine
438
+ # routes/evaluations.ts) - a short timeout with retries re-billed the whole
439
+ # multi-judge analysis up to 4x while the first was still running. Full
440
+ # analysis timeout, no transport retry.
435
441
  return self._request(
436
- "POST", f"/runs/{run_id}/analyze", json=payload, timeout=30
442
+ "POST", f"/runs/{run_id}/analyze", json=payload, timeout=1800, retry=False
437
443
  )
438
444
  except AgentXEvaluationsError as exc:
439
445
  if not self._note_missing_analysis_route(exc, "analyze"):
@@ -554,13 +560,17 @@ class EvaluationsClient:
554
560
  if isinstance(dataset_id, dict): # populated reference, not a bare id
555
561
  dataset_id = dataset_id.get("_id") or dataset_id.get("id")
556
562
 
557
- return Report(
558
- runId=run_id,
559
- datasetId=dataset_id or "",
560
- status=envelope.get("status") or "completed",
561
- statistics=envelope.get("statistics"),
563
+ # Built as one merged dict (explicit keys last, so they win) - passing the
564
+ # explicit keys as keyword arguments alongside **body raises "got multiple
565
+ # values" whenever the analysis body itself carries runId/datasetId/status/
566
+ # statistics.
567
+ return Report(**{
562
568
  **body,
563
- )
569
+ "runId": run_id,
570
+ "datasetId": dataset_id or "",
571
+ "status": envelope.get("status") or "completed",
572
+ "statistics": envelope.get("statistics"),
573
+ })
564
574
 
565
575
  # ------------------------------------------------------------------
566
576
  # Prompt improvement loop (examples -> propose -> publish). These ride the engine's
@@ -589,8 +599,11 @@ class EvaluationsClient:
589
599
  payload["reasoning"] = reasoning
590
600
  if based_on_version is not None:
591
601
  payload["basedOnVersion"] = based_on_version
602
+ # Server-side write: a timeout after the version was stored would be
603
+ # retried into a duplicate version, so no transport retry.
592
604
  return self._request(
593
- "POST", f"/evaluate/prompts/{prompt_id}/versions", base=self._api_root, json=payload
605
+ "POST", f"/evaluate/prompts/{prompt_id}/versions", base=self._api_root, json=payload,
606
+ retry=False,
594
607
  )
595
608
 
596
609
  # ------------------------------------------------------------------
@@ -628,7 +641,7 @@ class EvaluationsClient:
628
641
  payload["judgeModel"] = judge_model
629
642
  if both_orders:
630
643
  payload["bothOrders"] = True
631
- response = self._request("POST", "/evaluate/runs/pairwise", json=payload, base=self._api_root)
644
+ response = self._request("POST", "/evaluate/runs/pairwise", json=payload, base=self._api_root, timeout=900, retry=False,)
632
645
  return PairwiseComparison(**response["comparison"])
633
646
 
634
647
  def get_pairwise(self, batch_id: str) -> PairwiseComparison:
@@ -662,7 +675,8 @@ class EvaluationsClient:
662
675
  payload: dict = {"name": name, "definition": definition}
663
676
  if description is not None:
664
677
  payload["description"] = description
665
- return self._request("POST", "/evaluate/tool-schemas", base=self._api_root, json=payload)
678
+ # Server-side write - no transport retry (see init_run's comment).
679
+ return self._request("POST", "/evaluate/tool-schemas", base=self._api_root, json=payload, retry=False)
666
680
 
667
681
  def get_tool_schema_examples(self, tool_schema_id: str, window: Optional[str] = None) -> dict:
668
682
  params = {"window": window} if window else None
@@ -686,8 +700,10 @@ class EvaluationsClient:
686
700
  payload["reasoning"] = reasoning
687
701
  if based_on_version is not None:
688
702
  payload["basedOnVersion"] = based_on_version
703
+ # Server-side write - no transport retry (see init_run's comment).
689
704
  return self._request(
690
- "POST", f"/evaluate/tool-schemas/{tool_schema_id}/versions", base=self._api_root, json=payload
705
+ "POST", f"/evaluate/tool-schemas/{tool_schema_id}/versions", base=self._api_root, json=payload,
706
+ retry=False,
691
707
  )
692
708
 
693
709
  # ------------------------------------------------------------------
@@ -16,7 +16,13 @@ _REQUIRED_CSV_COLS = {"query"}
16
16
 
17
17
 
18
18
  class DatasetBuilder:
19
- """Fluent builder for creating a Custom Agent Evaluations dataset."""
19
+ """Fluent builder for creating a Custom Agent Evaluations dataset.
20
+
21
+ ``judge_prompt``/``judge_model`` are LLM-as-judge overrides for the dataset's grading
22
+ config. NOTE (self-host): the engine's dataset-create route currently ignores both -
23
+ 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
+ """
20
26
 
21
27
  def __init__(
22
28
  self,
@@ -50,6 +56,8 @@ class DatasetBuilder:
50
56
  # LLM-as-judge overrides for this dataset's own grading config. Omit either to keep the
51
57
  # server default (raw prompt template / gpt-5.6-luna, see EVALUATIONS.md). judge_model
52
58
  # must be one of client.evaluations.list_models() (OpenAI or Anthropic).
59
+ # Self-host: the dataset-create route currently IGNORES judgePrompt/judgeModel - set
60
+ # them on a judge scorer / the evaluation settings instead (see class docstring).
53
61
  if judge_prompt is not None:
54
62
  self._payload["judgePrompt"] = judge_prompt
55
63
  if judge_model is not None:
@@ -84,7 +92,8 @@ class DatasetBuilder:
84
92
  if rouge_score:
85
93
  self._payload["rougeScore"] = {"enabled": True}
86
94
  # Sovereignty & Portability - the models to compare on this dataset (use
87
- # client.evaluations.list_models() to discover valid ids).
95
+ # client.evaluations.list_models() to discover valid ids). Self-host: accepted on
96
+ # the wire but not acted on by the engine (see class docstring).
88
97
  if sovereignty_models:
89
98
  self._payload["sovereigntyIndex"] = {
90
99
  "enabled": True,
@@ -354,10 +363,13 @@ class DatasetClient:
354
363
  "acceptanceCriteria",
355
364
  "rejectionCriteria",
356
365
  "evaluationCriteria",
366
+ "judgePrompt",
367
+ "judgeModel",
357
368
  "vectorSimilarity",
358
369
  "jaccardSimilarity",
359
370
  "bleuScore",
360
371
  "rougeScore",
372
+ "sovereigntyIndex",
361
373
  "codeScorers",
362
374
  ):
363
375
  if wire.get(key) is not None:
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from typing import Optional
4
+
3
5
  from agentx.evaluations.models import Report
4
6
  from agentx.evaluations._term import (
5
7
  bold,
@@ -21,14 +23,14 @@ _RATING_ICONS = {"high": "●", "medium": "◑", "low": "○"}
21
23
  _PRI_COLORS = {"high": red, "medium": yellow, "low": dim}
22
24
 
23
25
 
24
- def _rating_badge(rating: str | None) -> str:
26
+ def _rating_badge(rating: Optional[str]) -> str:
25
27
  icon = _RATING_ICONS.get(rating or "", "·")
26
28
  color = _RATING_COLORS.get(rating or "", dim)
27
29
  label = (rating or "").upper()
28
30
  return color(f"{icon} {label}") if label else dim(icon)
29
31
 
30
32
 
31
- def _section(title: str, rating: str | None = None) -> None:
33
+ def _section(title: str, rating: Optional[str] = None) -> None:
32
34
  badge = f" {_rating_badge(rating)}" if rating else ""
33
35
  print(f"\n{bold(title)}{badge}")
34
36
  print(dim(_THIN))
@@ -3,6 +3,8 @@ from __future__ import annotations
3
3
  import logging
4
4
  import os
5
5
  import time
6
+
7
+ import requests
6
8
  import uuid
7
9
  from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Union
8
10
 
@@ -304,6 +306,12 @@ class EvaluationRunContext:
304
306
  resp.failed_validation,
305
307
  )
306
308
  return
309
+ except requests.Timeout as exc:
310
+ # A read timeout means the engine may STILL be scoring this batch - a
311
+ # retry re-POSTs it and double-bills every judge call (idempotency keys
312
+ # protect rows already inserted, not judge work mid-flight). Fail loud.
313
+ last_exc = exc
314
+ break
307
315
  except Exception as exc:
308
316
  last_exc = exc
309
317
  if attempt == 1:
@@ -274,7 +274,7 @@ def _patch_stream(
274
274
  return iter(ctx)
275
275
 
276
276
  def __aiter__(self_inner):
277
- return aiter(ctx)
277
+ return ctx.__aiter__() # aiter() builtin is 3.10+; python_requires is >=3.9
278
278
 
279
279
  def __getattr__(self_inner, item):
280
280
  return getattr(ctx, item)
@@ -167,6 +167,9 @@ class AgentXAutoGenObserver:
167
167
  "end_time": end_t,
168
168
  "input": pending["input"] if pending else None,
169
169
  "output": f"ERROR: {output}" if is_error else (str(output) if output is not None else None),
170
+ # The engine's failure test is success === false; without
171
+ # this a failed tool call would read as passing.
172
+ "success": not is_error,
170
173
  })
171
174
  continue
172
175
 
@@ -127,6 +127,16 @@ class AgentXCrewObserver:
127
127
  except ImportError:
128
128
  return task_timings, lambda: None
129
129
 
130
+ # Double-instrumentation guard (bus-keyed latch, the same idea as the
131
+ # other integrations' _agentx_patched flag): the event bus is a global
132
+ # singleton, so a notebook re-run or an overlapping kickoff that
133
+ # already has AgentX listeners registered would otherwise get a second
134
+ # set and duplicate every task span. When already attached, this
135
+ # kickoff just falls back to the evenly-divided timing approximation.
136
+ if getattr(crewai_event_bus, "_agentx_attached", False):
137
+ return task_timings, lambda: None
138
+ crewai_event_bus._agentx_attached = True
139
+
130
140
  def on_task_started(source: Any, event: Any) -> None:
131
141
  task_id = getattr(event, "task_id", None)
132
142
  if task_id is None:
@@ -154,9 +164,14 @@ class AgentXCrewObserver:
154
164
  crewai_event_bus.on(TaskFailedEvent)(on_task_failed)
155
165
 
156
166
  def unregister() -> None:
157
- crewai_event_bus.off(TaskStartedEvent, on_task_started)
158
- crewai_event_bus.off(TaskCompletedEvent, on_task_completed)
159
- crewai_event_bus.off(TaskFailedEvent, on_task_failed)
167
+ try:
168
+ crewai_event_bus.off(TaskStartedEvent, on_task_started)
169
+ crewai_event_bus.off(TaskCompletedEvent, on_task_completed)
170
+ crewai_event_bus.off(TaskFailedEvent, on_task_failed)
171
+ finally:
172
+ # Clear the latch even if .off() raises, so a later kickoff
173
+ # can re-attach instead of being locked out forever.
174
+ crewai_event_bus._agentx_attached = False
160
175
 
161
176
  return task_timings, unregister
162
177
 
@@ -339,6 +339,16 @@ class AgentXCallbackHandler(BaseCallbackHandler):
339
339
  self._retrieval_starts.pop(run_id, None)
340
340
  self._parents.pop(run_id, None)
341
341
 
342
+ # Pre-run retrieval steps waiting for a top-level chain that never came
343
+ # (e.g. retriever.invoke() called but agent.invoke() aborted before
344
+ # on_chain_start). Each step carries its own start_time, so drop the
345
+ # pre-cutoff ones just like the run_id-keyed structures above.
346
+ with self._state_lock:
347
+ if self._pending_retrieval_steps:
348
+ self._pending_retrieval_steps[:] = [
349
+ step for step in self._pending_retrieval_steps if step.get("start_time", 0) >= cutoff
350
+ ]
351
+
342
352
  # ------------------------------------------------------------------
343
353
  # Chain lifecycle
344
354
  # ------------------------------------------------------------------
@@ -359,8 +369,9 @@ class AgentXCallbackHandler(BaseCallbackHandler):
359
369
  self._prune_stale_entries()
360
370
  # Consume any retrieval steps that ran before this chain started
361
371
  # (pre-run RAG: retriever.invoke() called before agent.invoke())
362
- pending = self._pending_retrieval_steps[:]
363
- self._pending_retrieval_steps.clear()
372
+ with self._state_lock:
373
+ pending = self._pending_retrieval_steps[:]
374
+ self._pending_retrieval_steps.clear()
364
375
  self._runs[run_id] = {
365
376
  "start": time.time(),
366
377
  "input": _extract_input(inputs),
@@ -670,7 +681,7 @@ class AgentXCallbackHandler(BaseCallbackHandler):
670
681
  "input": _extract_llm_input(prompts=prompts, messages=messages),
671
682
  }
672
683
  top = self._find_top_ancestor(parent_run_id)
673
- if top and not self._runs[top].get("model") and model:
684
+ if top and top in self._runs and not self._runs[top].get("model") and model:
674
685
  self._runs[top]["model"] = model
675
686
 
676
687
  def on_llm_start(
@@ -882,15 +893,16 @@ class AgentXCallbackHandler(BaseCallbackHandler):
882
893
  step["output"] = "\n\n---\n\n".join(contents)
883
894
 
884
895
  top = self._find_top_ancestor(parent_run_id)
885
- if top and top in self._runs:
886
- # Retriever ran inside an active chain - attach directly
887
- retrievals = self._runs[top]["retrieval_steps"]
888
- step["name"] = f"Retrieval {len(retrievals) + 1}"
889
- retrievals.append(step)
890
- else:
891
- # Retriever ran before the chain started (pre-run RAG pattern)
892
- step["name"] = f"Retrieval {len(self._pending_retrieval_steps) + 1}"
893
- self._pending_retrieval_steps.append(step)
896
+ with self._state_lock:
897
+ if top and top in self._runs:
898
+ # Retriever ran inside an active chain - attach directly
899
+ retrievals = self._runs[top]["retrieval_steps"]
900
+ step["name"] = f"Retrieval {len(retrievals) + 1}"
901
+ retrievals.append(step)
902
+ else:
903
+ # Retriever ran before the chain started (pre-run RAG pattern)
904
+ step["name"] = f"Retrieval {len(self._pending_retrieval_steps) + 1}"
905
+ self._pending_retrieval_steps.append(step)
894
906
 
895
907
  def on_retriever_error(
896
908
  self,
@@ -918,15 +930,16 @@ class AgentXCallbackHandler(BaseCallbackHandler):
918
930
  step["query"] = query
919
931
 
920
932
  top = self._find_top_ancestor(parent_run_id)
921
- if top and top in self._runs:
922
- # Retriever ran inside an active chain - attach directly
923
- retrievals = self._runs[top]["retrieval_steps"]
924
- step["name"] = f"Retrieval {len(retrievals) + 1}"
925
- retrievals.append(step)
926
- else:
927
- # Retriever ran before the chain started (pre-run RAG pattern)
928
- step["name"] = f"Retrieval {len(self._pending_retrieval_steps) + 1}"
929
- self._pending_retrieval_steps.append(step)
933
+ with self._state_lock:
934
+ if top and top in self._runs:
935
+ # Retriever ran inside an active chain - attach directly
936
+ retrievals = self._runs[top]["retrieval_steps"]
937
+ step["name"] = f"Retrieval {len(retrievals) + 1}"
938
+ retrievals.append(step)
939
+ else:
940
+ # Retriever ran before the chain started (pre-run RAG pattern)
941
+ step["name"] = f"Retrieval {len(self._pending_retrieval_steps) + 1}"
942
+ self._pending_retrieval_steps.append(step)
930
943
 
931
944
  # ------------------------------------------------------------------
932
945
  # Helpers
@@ -107,18 +107,51 @@ class AgentXLlamaIndexHandler(BaseCallbackHandler):
107
107
  name: str = "llamaindex-agent",
108
108
  metadata: Optional[Dict[str, Any]] = None,
109
109
  session_id: Optional[str] = None,
110
+ max_run_age_seconds: float = 900.0,
110
111
  ) -> None:
111
112
  super().__init__(event_starts_to_ignore=[], event_ends_to_ignore=[])
112
113
  self._tracer = tracer
113
114
  self._name = name
114
115
  self._metadata = metadata
115
116
  self._session_id = session_id
117
+ # Safety net mirroring langchain.py's _prune_stale_entries: state is
118
+ # normally popped in on_event_end, but an event whose end callback never
119
+ # fires (hard crash, integration bug) would leak forever in this
120
+ # long-lived singleton handler. Entries older than this are swept out
121
+ # at the top of on_event_start.
122
+ self._max_run_age_seconds = max_run_age_seconds
116
123
 
117
124
  self._parents: Dict[str, Optional[str]] = {}
118
125
  self._roots: Dict[str, bool] = {}
119
126
  self._runs: Dict[str, Dict[str, Any]] = {}
120
127
  self._starts: Dict[str, Dict[str, Any]] = {}
121
128
 
129
+ def _prune_stale_entries(self) -> None:
130
+ """Sweep out event_id entries older than max_run_age_seconds - see __init__'s comment."""
131
+ cutoff = time.time() - self._max_run_age_seconds
132
+
133
+ # Every live event_id has a _starts entry (set in on_event_start and
134
+ # popped with _parents/_roots in on_event_end), each carrying its own
135
+ # start timestamp.
136
+ stale_event_ids = [
137
+ event_id for event_id, info in self._starts.items() if info.get("start", 0) < cutoff
138
+ ]
139
+ for event_id in stale_event_ids:
140
+ self._starts.pop(event_id, None)
141
+ self._parents.pop(event_id, None)
142
+ self._roots.pop(event_id, None)
143
+ self._runs.pop(event_id, None)
144
+
145
+ # Root runs outlive their own _starts entry until the root's end event
146
+ # fires - sweep those by the run state's own start timestamp.
147
+ stale_run_ids = [
148
+ event_id for event_id, state in self._runs.items() if state.get("start", 0) < cutoff
149
+ ]
150
+ for event_id in stale_run_ids:
151
+ self._runs.pop(event_id, None)
152
+ self._roots.pop(event_id, None)
153
+ self._parents.pop(event_id, None)
154
+
122
155
  # ------------------------------------------------------------------
123
156
  # BaseCallbackHandler protocol
124
157
  # ------------------------------------------------------------------
@@ -138,6 +171,7 @@ class AgentXLlamaIndexHandler(BaseCallbackHandler):
138
171
  **kwargs: Any,
139
172
  ) -> str:
140
173
  payload = payload or {}
174
+ self._prune_stale_entries()
141
175
  self._parents[event_id] = parent_id
142
176
 
143
177
  root_id = self._find_root(parent_id)
@@ -251,11 +285,16 @@ class AgentXLlamaIndexHandler(BaseCallbackHandler):
251
285
  tool_output = payload.get(EventPayload.FUNCTION_OUTPUT)
252
286
  state["tool_call_steps"].append({
253
287
  "name": tool_name,
254
- "duration_ms": (end_t - start_t) * 1000,
288
+ # tracer._merge_child_run's tool_calls loop reads "latency_ms"
289
+ # (not "duration_ms" like execution/retrieval steps).
290
+ "latency_ms": int((end_t - start_t) * 1000),
255
291
  "start_time": start_t,
256
292
  "end_time": end_t,
257
293
  "input": _safe_serialize(tool_input) if tool_input is not None else None,
258
294
  "output": f"ERROR: {exception}" if exception else (str(tool_output) if tool_output is not None else None),
295
+ # The engine's failure test is success === false; without this a
296
+ # failed tool call would read as passing.
297
+ "success": exception is None,
259
298
  })
260
299
 
261
300
  if is_root:
@@ -277,10 +316,9 @@ class AgentXLlamaIndexHandler(BaseCallbackHandler):
277
316
  return None
278
317
 
279
318
  def _send_trace(self, state: Dict[str, Any]) -> None:
280
- # tool_call_steps entries carry start_time/end_time (unlike langchain.py's leaner
281
- # wire-shaped tool_calls list) - _merge_child_run's tool_calls loop falls back to
282
- # computing duration from those when no explicit latency_ms is present, so each tool
283
- # call still positions correctly in the tree panel instead of defaulting to offset 0.
319
+ # tool_call_steps entries carry latency_ms AND start_time/end_time - _merge_child_run's
320
+ # tool_calls loop reads latency_ms for duration and the timestamps for position, so each
321
+ # tool call lands correctly in the tree panel instead of defaulting to offset 0.
284
322
  with self._tracer.trace(
285
323
  self._name, metadata=self._metadata, session_id=self._session_id, framework="llamaindex"
286
324
  ) as span:
@@ -16,6 +16,8 @@ Requires: ``pip install "agentx-python[openai-agents]"``
16
16
  from __future__ import annotations
17
17
 
18
18
  from datetime import datetime, timezone
19
+ import time
20
+ from uuid import uuid4
19
21
  from typing import Any, Dict, List, Optional
20
22
 
21
23
  from agentx.tracing.tracer import Tracer, _safe_serialize
@@ -145,7 +147,15 @@ class AgentXTracingProcessor:
145
147
  metadata=self._metadata,
146
148
  session_id=self._session_id,
147
149
  )
148
- root_span.__enter__()
150
+ # Deliberately NOT root_span.__enter__(): enter pushes onto the CALLING thread's
151
+ # active-span stack, but the Agents SDK fires on_trace_end on whatever thread it
152
+ # likes - the pop then no-ops there, the entry never drains, and every later
153
+ # unrelated trace on this thread is mis-filed as a child of this dead run (and
154
+ # inherits its session). Start time and session are set by hand instead; on_span_end
155
+ # already parents via child_span() on this exact reference, no stack involved.
156
+ root_span._start = time.time()
157
+ if root_span._session_id is None:
158
+ root_span._session_id = f"sdk_{uuid4().hex}"
149
159
  self._spans[trace_id] = {
150
160
  "root_span": root_span,
151
161
  "llm_call_count": 0,
@@ -173,6 +183,9 @@ class AgentXTracingProcessor:
173
183
  root_span._output_tokens = state["output_tokens"]
174
184
  if state.get("error"):
175
185
  root_span.set_error(state["error"])
186
+ # Close WITHOUT touching the thread-local stack (see on_trace_start). __exit__'s only
187
+ # stack interaction is the pop, which is a no-op for a never-pushed span - safe to call
188
+ # directly for its send/flush behavior.
176
189
  root_span.__exit__(None, None, None)
177
190
 
178
191
  def on_span_start(self, span: Any) -> None:
@@ -28,7 +28,12 @@ _RETRY_BACKOFF = [1.0, 2.0, 4.0]
28
28
 
29
29
 
30
30
  class AgentXMonitorError(Exception):
31
- pass
31
+ """``status_code`` carries the HTTP status when the error came from a server
32
+ response; it is ``None`` for transport-level failures and retry exhaustion."""
33
+
34
+ def __init__(self, message: str, status_code: Optional[int] = None) -> None:
35
+ super().__init__(message)
36
+ self.status_code = status_code
32
37
 
33
38
 
34
39
  class AgentXAuthError(AgentXMonitorError):
@@ -208,17 +213,17 @@ class MonitorClient:
208
213
  continue
209
214
 
210
215
  if resp.status_code == 401:
211
- raise AgentXAuthError("Invalid or missing API key")
216
+ raise AgentXAuthError("Invalid or missing API key", status_code=401)
212
217
  if resp.status_code == 422:
213
- raise AgentXValidationError(resp.text)
218
+ raise AgentXValidationError(resp.text, status_code=422)
214
219
  if retry and resp.status_code in _RETRYABLE_STATUS and attempt < _MAX_RETRIES - 1:
215
220
  logger.debug(
216
221
  "Retryable status %d (attempt %d)", resp.status_code, attempt + 1
217
222
  )
218
- last_exc = AgentXMonitorError(f"HTTP {resp.status_code}")
223
+ last_exc = AgentXMonitorError(f"HTTP {resp.status_code}", status_code=resp.status_code)
219
224
  continue
220
225
  if not resp.ok:
221
- raise AgentXMonitorError(f"HTTP {resp.status_code}: {resp.text}")
226
+ raise AgentXMonitorError(f"HTTP {resp.status_code}: {resp.text}", status_code=resp.status_code)
222
227
  try:
223
228
  return resp.json()
224
229
  except Exception:
@@ -481,7 +486,7 @@ class MonitorClient:
481
486
  def propose_online_evaluator_tuning(self, evaluator_id: str, window: str = "7d") -> dict:
482
487
  data = self._request(
483
488
  "POST", f"/agent-monitoring/online-evaluators/{evaluator_id}/tune",
484
- base=self._api_root(), json={"window": window}, timeout=300,
489
+ base=self._api_root(), json={"window": window}, timeout=300, retry=False,
485
490
  )
486
491
  return data.get("proposal", data) if isinstance(data, dict) else data
487
492
 
@@ -490,7 +495,7 @@ class MonitorClient:
490
495
  ) -> dict:
491
496
  return self._request(
492
497
  "POST", f"/agent-monitoring/online-evaluators/{evaluator_id}/tune/validate",
493
- base=self._api_root(), json={**criteria, "window": window}, timeout=600,
498
+ base=self._api_root(), json={**criteria, "window": window}, timeout=600, retry=False,
494
499
  )
495
500
 
496
501
  def publish_online_evaluator_tuning(
@@ -505,7 +510,7 @@ class MonitorClient:
505
510
  payload["force"] = True
506
511
  return self._request(
507
512
  "POST", f"/agent-monitoring/online-evaluators/{evaluator_id}/tune/publish",
508
- base=self._api_root(), json=payload, timeout=60,
513
+ base=self._api_root(), json=payload, timeout=60, retry=False,
509
514
  )
510
515
 
511
516
  def update_profile(self, agent_id: str, payload: dict) -> MonitorProfile:
@@ -49,7 +49,8 @@ class ScorerGroupsClient:
49
49
  )
50
50
  if response.status_code >= 400:
51
51
  raise AgentXScorerGroupsError(f"HTTP {response.status_code}: {response.text}")
52
- return response.json()
52
+ # DELETE (and any other empty 2xx) has no body - .json() on it raises.
53
+ return response.json() if response.text else {}
53
54
 
54
55
  def list(self) -> List[ScorerGroup]:
55
56
  return [ScorerGroup(g) for g in self._request("GET", self._base).get("scorerGroups", [])]
@@ -65,11 +65,18 @@ class ScorersClient:
65
65
  return [p for p in patterns if p.get("source") == "builtIn"]
66
66
 
67
67
  def _enabled_template_keys(self) -> List[str]:
68
- return [p["key"] for p in self.templates() if p.get("enabled")]
68
+ # .get("key"): defensive against a template row missing its key (the wire owns this
69
+ # shape, not the SDK) - a keyless row is skipped rather than KeyError-ing the sweep.
70
+ return [p.get("key") for p in self.templates() if p.get("enabled") and p.get("key")]
69
71
 
70
72
  def enable(self, keys: Sequence[str]) -> List[str]:
71
73
  """Enable template scorers by key (e.g. ``["pii-in-response"]``), preserving what is
72
- already on. Returns the resulting enabled-key list."""
74
+ already on. Returns the resulting enabled-key list.
75
+
76
+ Note: enable()/disable() are a read-modify-write over the project's single
77
+ enabledBuiltinPatterns list - two concurrent callers (or a dashboard edit racing an
78
+ SDK call) can lose one side's change. There is no engine-side merge; serialize
79
+ catalog edits if that matters."""
73
80
  merged = sorted(set(self._enabled_template_keys()) | set(keys))
74
81
  self._request("PUT", "/settings/monitoring-defaults", json={"enabledBuiltinPatterns": merged})
75
82
  return merged
@@ -27,8 +27,24 @@ class MonitorSessionClient:
27
27
  Baseline Judge existed - branch defensively on unknown kinds."""
28
28
  return self._client.list_session_scores(session_id)
29
29
 
30
+ def judge(self, session_id: str, evaluator_id: str, *, if_stale: bool = False) -> dict:
31
+ """Judge one session with one session-scoped evaluator, now (one judge call).
32
+ ``if_stale=True`` skips re-judging a session that was already scored since its
33
+ last activity - the engine then answers ``{"skipped": True}`` without spending
34
+ another judge call. Returns the score row (or that skip marker)."""
35
+ data = self._client._request(
36
+ "POST",
37
+ f"/agent-monitoring/sessions/{session_id}/judge/{evaluator_id}",
38
+ base=self._client._api_root(),
39
+ timeout=120,
40
+ retry=False,
41
+ params={"ifStale": "true"} if if_stale else None,
42
+ )
43
+ return data.get("score", data) if isinstance(data, dict) else data
44
+
30
45
  def run_sweep(self) -> dict:
31
46
  """Trigger the idle-session sweep once (normally automatic, every minute) - scores
32
47
  idle multi-turn sessions with every enabled session-scoped evaluator and scorer
33
- group. Returns ``{"judged": n}``."""
48
+ group. Returns ``{"judged": n}``; the response may instead carry ``skipped: true``
49
+ when another sweep is already in flight."""
34
50
  return self._client.run_session_sweep()