agentx-python 0.6.33__tar.gz → 0.6.35__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 (78) hide show
  1. {agentx_python-0.6.33 → agentx_python-0.6.35}/PKG-INFO +1 -1
  2. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/agentx.py +22 -14
  3. agentx_python-0.6.35/agentx/export.py +99 -0
  4. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/feedback.py +5 -3
  5. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/monitor/client.py +14 -1
  6. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/monitor/scorers.py +4 -2
  7. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/outcomes.py +4 -2
  8. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/projects.py +5 -2
  9. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/traces.py +4 -2
  10. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/tracing/ingest_client.py +46 -6
  11. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/tracing/tracer.py +5 -3
  12. agentx_python-0.6.35/agentx/version.py +1 -0
  13. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx_python.egg-info/PKG-INFO +1 -1
  14. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx_python.egg-info/SOURCES.txt +2 -0
  15. agentx_python-0.6.35/tests/test_deep_dive_fixes.py +85 -0
  16. agentx_python-0.6.33/agentx/version.py +0 -1
  17. {agentx_python-0.6.33 → agentx_python-0.6.35}/LICENSE +0 -0
  18. {agentx_python-0.6.33 → agentx_python-0.6.35}/README.md +0 -0
  19. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/__init__.py +0 -0
  20. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/cli.py +0 -0
  21. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/evaluations/__init__.py +0 -0
  22. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/evaluations/_term.py +0 -0
  23. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/evaluations/adapters/__init__.py +0 -0
  24. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/evaluations/adapters/http_endpoint.py +0 -0
  25. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/evaluations/adapters/precomputed.py +0 -0
  26. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/evaluations/adapters/raw.py +0 -0
  27. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/evaluations/client.py +0 -0
  28. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/evaluations/datasets.py +0 -0
  29. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/evaluations/evaluation_settings.py +0 -0
  30. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/evaluations/models.py +0 -0
  31. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/evaluations/prompts.py +0 -0
  32. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/evaluations/reporting.py +0 -0
  33. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/evaluations/results.py +0 -0
  34. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/evaluations/runner.py +0 -0
  35. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/evaluations/tool_schemas.py +0 -0
  36. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/evaluations/tracing.py +0 -0
  37. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/exceptions.py +0 -0
  38. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/integrations/__init__.py +0 -0
  39. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/integrations/_traced_call.py +0 -0
  40. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/integrations/anthropic.py +0 -0
  41. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/integrations/autogen.py +0 -0
  42. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/integrations/crewai.py +0 -0
  43. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/integrations/databricks.py +0 -0
  44. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/integrations/google_adk.py +0 -0
  45. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/integrations/google_genai.py +0 -0
  46. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/integrations/langchain.py +0 -0
  47. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/integrations/litellm.py +0 -0
  48. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/integrations/llamaindex.py +0 -0
  49. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/integrations/moveworks.py +0 -0
  50. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/integrations/openai.py +0 -0
  51. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/integrations/openai_agents.py +0 -0
  52. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/monitor/__init__.py +0 -0
  53. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/monitor/agents.py +0 -0
  54. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/monitor/models.py +0 -0
  55. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/monitor/online_evaluators.py +0 -0
  56. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/monitor/patterns.py +0 -0
  57. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/monitor/profile.py +0 -0
  58. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/monitor/sessions.py +0 -0
  59. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/monitor/signals.py +0 -0
  60. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/py.typed +0 -0
  61. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/resources/__init__.py +0 -0
  62. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/resources/agent.py +0 -0
  63. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/resources/conversation.py +0 -0
  64. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/resources/workforce.py +0 -0
  65. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/tracing/__init__.py +0 -0
  66. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/tracing/ci_types.py +0 -0
  67. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx/util.py +0 -0
  68. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx_python.egg-info/dependency_links.txt +0 -0
  69. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx_python.egg-info/entry_points.txt +0 -0
  70. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx_python.egg-info/not-zip-safe +0 -0
  71. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx_python.egg-info/requires.txt +0 -0
  72. {agentx_python-0.6.33 → agentx_python-0.6.35}/agentx_python.egg-info/top_level.txt +0 -0
  73. {agentx_python-0.6.33 → agentx_python-0.6.35}/setup.cfg +0 -0
  74. {agentx_python-0.6.33 → agentx_python-0.6.35}/setup.py +0 -0
  75. {agentx_python-0.6.33 → agentx_python-0.6.35}/tests/test_integration.py +0 -0
  76. {agentx_python-0.6.33 → agentx_python-0.6.35}/tests/test_integrations.py +0 -0
  77. {agentx_python-0.6.33 → agentx_python-0.6.35}/tests/test_selfhost_analysis_fallback.py +0 -0
  78. {agentx_python-0.6.33 → agentx_python-0.6.35}/tests/test_span_tree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentx-python
3
- Version: 0.6.33
3
+ Version: 0.6.35
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
@@ -20,10 +20,12 @@ class AgentX:
20
20
  if self.api_key and not os.getenv("AGENTX_API_KEY"):
21
21
  os.environ["AGENTX_API_KEY"] = self.api_key
22
22
 
23
- # base_url overrides AGENTX_API_BASE_URL env var (and the SDK default)
23
+ # base_url overrides AGENTX_API_BASE_URL env var (and the SDK default). It is
24
+ # deliberately NOT written back into os.environ: the constructor used to do that, which
25
+ # made the last-constructed client silently re-point every other client in the process
26
+ # (deep-dive round 3, bug #1). Each sub-client below receives this value explicitly and
27
+ # captures it at construction instead.
24
28
  self.base_url = base_url or os.getenv("AGENTX_API_BASE_URL")
25
- if self.base_url:
26
- os.environ["AGENTX_API_BASE_URL"] = self.base_url
27
29
 
28
30
  self.workspace_id = workspace_id or os.getenv("AGENTX_WORKSPACE_ID")
29
31
 
@@ -58,23 +60,29 @@ class AgentX:
58
60
 
59
61
  # Report real, after-the-fact outcomes ("the ticket got reopened") against traces - the
60
62
  # ground truth behind the dashboard's Judge Calibration card. Self-host only.
61
- self.outcomes = OutcomesClient(api_key=self.api_key)
63
+ self.outcomes = OutcomesClient(api_key=self.api_key, base_url=self.base_url)
62
64
 
63
65
  from agentx.projects import ProjectsClient
64
66
 
65
67
  # Project CRUD (self-host): isolated tenants with their own API keys (P1.1).
66
- self.projects = ProjectsClient(api_key=self.api_key)
68
+ self.projects = ProjectsClient(api_key=self.api_key, base_url=self.base_url)
67
69
 
68
70
  from agentx.traces import TracesClient
69
71
 
70
72
  # The read side of tracing: trace-by-id detail and paginated listing (P1.2).
71
- self.traces = TracesClient(api_key=self.api_key)
73
+ self.traces = TracesClient(api_key=self.api_key, base_url=self.base_url)
74
+
75
+ from agentx.export import ExportClient
76
+
77
+ # Bulk NDJSON egress for backup/migration (P2.1): manifest, per-entity streaming, and
78
+ # directory dumps. Self-host only.
79
+ self.export = ExportClient(api_key=self.api_key, base_url=self.base_url)
72
80
 
73
81
  from agentx.feedback import FeedbackClient
74
82
 
75
83
  # Forward end-user votes ("up"/"down") on traced responses - a "down" raises a signal
76
84
  # directly, and every vote feeds Judge Calibration alongside outcomes. Self-host only.
77
- self.feedback = FeedbackClient(api_key=self.api_key)
85
+ self.feedback = FeedbackClient(api_key=self.api_key, base_url=self.base_url)
78
86
 
79
87
  _ingest_client = IngestClient(
80
88
  api_key=self.api_key,
@@ -90,9 +98,9 @@ class AgentX:
90
98
  return cls()
91
99
 
92
100
  def get_agent(self, id: str) -> Agent:
93
- url = f"{api_base()}/access/agents/{id}"
101
+ url = f"{self.base_url or api_base()}/access/agents/{id}"
94
102
  # Make a GET request to the AgentX API
95
- response = requests.get(url, headers=get_headers())
103
+ response = requests.get(url, headers=get_headers(self.api_key))
96
104
  # Check if response was successful
97
105
  if response.status_code == 200:
98
106
  return Agent(**response.json())
@@ -100,9 +108,9 @@ class AgentX:
100
108
  raise Exception(f"Failed to retrieve agent: {response.reason}")
101
109
 
102
110
  def list_agents(self) -> List[Agent]:
103
- url = f"{api_base()}/access/agents"
111
+ url = f"{self.base_url or api_base()}/access/agents"
104
112
  # Make a GET request to the AgentX API
105
- response = requests.get(url, headers=get_headers())
113
+ response = requests.get(url, headers=get_headers(self.api_key))
106
114
  # Check if response was successful
107
115
  if response.status_code == 200:
108
116
  return [Agent(**agent) for agent in response.json()]
@@ -140,7 +148,7 @@ class AgentX:
140
148
  """
141
149
  from agentx.exceptions import AgentXAPIError, AgentXAuthError, AgentXConnectionError
142
150
 
143
- base = api_base()
151
+ base = (self.base_url or api_base()).rstrip("/")
144
152
  # /monitor/patterns: the cheapest key-authenticated endpoint that exists on both the
145
153
  # hosted API and the self-host engine's SDK-facing router.
146
154
  url = f"{base}/monitor/patterns"
@@ -167,8 +175,8 @@ class AgentX:
167
175
 
168
176
  def get_profile(self):
169
177
  """Get the current user's profile information."""
170
- url = f"{api_base()}/access/getProfile"
171
- response = requests.get(url, headers=get_headers())
178
+ url = f"{self.base_url or api_base()}/access/getProfile"
179
+ response = requests.get(url, headers=get_headers(self.api_key))
172
180
  if response.status_code == 200:
173
181
  return response.json()
174
182
  else:
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ from typing import Any, Dict, Iterator, List, Optional
7
+
8
+ import requests
9
+
10
+ from agentx.util import api_base, get_headers
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class AgentXExportError(Exception):
16
+ pass
17
+
18
+
19
+ class ExportClient:
20
+ """Surfaced as ``client.export``: bulk NDJSON egress for backup and migration (self-host).
21
+
22
+ The engine's ``GET /export`` manifest lists every exportable entity (traces, signals,
23
+ events, runs, feedback, outcomes, scorer config, ...) with live row counts;
24
+ ``GET /export/<entity>`` streams the rows as NDJSON. Everything is scoped to the API key's
25
+ project, so an export can never cross a tenant boundary.
26
+
27
+ Typical uses::
28
+
29
+ client.export.dump("./backup") # full backup, one .ndjson per entity
30
+ client.export.dump("./nightly", since=yesterday) # incremental
31
+ for row in client.export.iter("traces"): # stream without touching disk
32
+ ...
33
+
34
+ Restore paths are documented in the self-host backup runbook: replay traces through
35
+ ``client.tracer`` / ``POST /ingest/traces``, or restore at the database level
36
+ (``pg_dump`` / SQLite file copy). There is deliberately no blind row-import endpoint.
37
+ """
38
+
39
+ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
40
+ self._api_key = api_key
41
+ # Captured once at construction (deep-dive round 3, bug #1).
42
+ self._base_url = (base_url or api_base()).rstrip("/")
43
+
44
+ def manifest(self) -> List[Dict[str, Any]]:
45
+ """The exportable entities with live row counts: ``[{entity, rows, path}, ...]``."""
46
+ resp = requests.get(
47
+ f"{self._base_url}/export", headers=get_headers(self._api_key), timeout=30
48
+ )
49
+ if resp.status_code >= 400:
50
+ raise AgentXExportError(f"Export manifest failed ({resp.status_code}): {resp.text[:200]}")
51
+ return resp.json().get("entities", [])
52
+
53
+ def iter(self, entity: str, since: Optional[str] = None) -> Iterator[Dict[str, Any]]:
54
+ """Stream one entity's rows as dicts without buffering the whole table in memory.
55
+ ``since`` is an ISO-8601 date for incremental pulls (filters on the entity's own
56
+ timestamp column, e.g. ``createdAt`` for traces, ``lastSeenAt`` for signals)."""
57
+ params = {"since": since} if since else None
58
+ resp = requests.get(
59
+ f"{self._base_url}/export/{entity}",
60
+ headers=get_headers(self._api_key),
61
+ params=params,
62
+ stream=True,
63
+ timeout=120,
64
+ )
65
+ if resp.status_code >= 400:
66
+ raise AgentXExportError(f"Export of {entity!r} failed ({resp.status_code}): {resp.text[:200]}")
67
+ for line in resp.iter_lines(decode_unicode=True):
68
+ if line and line.strip():
69
+ yield json.loads(line)
70
+
71
+ def dump(
72
+ self,
73
+ directory: str,
74
+ entities: Optional[List[str]] = None,
75
+ since: Optional[str] = None,
76
+ ) -> Dict[str, int]:
77
+ """Write ``<entity>.ndjson`` files (plus a ``manifest.json``) into ``directory`` and
78
+ return ``{entity: rows_written}``. Defaults to every entity the engine advertises;
79
+ pass ``entities`` to restrict, ``since`` for an incremental snapshot."""
80
+ os.makedirs(directory, exist_ok=True)
81
+ manifest = self.manifest()
82
+ wanted = [e["entity"] for e in manifest] if entities is None else entities
83
+ written: Dict[str, int] = {}
84
+ for entity in wanted:
85
+ path = os.path.join(directory, f"{entity}.ndjson")
86
+ count = 0
87
+ with open(path, "w", encoding="utf-8") as fh:
88
+ for row in self.iter(entity, since=since):
89
+ fh.write(json.dumps(row, ensure_ascii=False) + "\n")
90
+ count += 1
91
+ written[entity] = count
92
+ logger.debug("Exported %d %s rows to %s", count, entity, path)
93
+ with open(os.path.join(directory, "manifest.json"), "w", encoding="utf-8") as fh:
94
+ json.dump(
95
+ {"entities": manifest, "written": written, **({"since": since} if since else {})},
96
+ fh,
97
+ indent=2,
98
+ )
99
+ return written
@@ -26,8 +26,10 @@ class FeedbackClient:
26
26
  reopened", reported by a workflow); feedback is a human vote with up/down semantics.
27
27
  """
28
28
 
29
- def __init__(self, api_key: Optional[str] = None):
29
+ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
30
30
  self._api_key = api_key
31
+ # Captured once at construction (deep-dive round 3, bug #1).
32
+ self._base_url = (base_url or api_base()).rstrip("/")
31
33
 
32
34
  def report(
33
35
  self,
@@ -61,7 +63,7 @@ class FeedbackClient:
61
63
  payload["endUserId"] = end_user_id
62
64
 
63
65
  resp = requests.post(
64
- f"{api_base()}/feedback",
66
+ f"{self._base_url}/feedback",
65
67
  headers={**get_headers(self._api_key), "Content-Type": "application/json"},
66
68
  json=payload,
67
69
  timeout=10,
@@ -79,7 +81,7 @@ class FeedbackClient:
79
81
  """All votes recorded on one trace, oldest first (``GET /feedback/trace/:traceId``) -
80
82
  the same rows the dashboard's trace dialog shows as up/down chips."""
81
83
  resp = requests.get(
82
- f"{api_base()}/feedback/trace/{trace_id}",
84
+ f"{self._base_url}/feedback/trace/{trace_id}",
83
85
  headers=get_headers(self._api_key),
84
86
  timeout=10,
85
87
  )
@@ -85,7 +85,9 @@ class MonitorClient:
85
85
  from agentx.monitor.scorers import ScorersClient
86
86
  # Scorers-catalog administration as code: template enable/disable, code/external scorer
87
87
  # CRUD and dry runs - full parity with the dashboard's Scorers page (P1.3).
88
- self.scorers = ScorersClient(api_key=api_key)
88
+ # Handed this client's own resolved API root, never the process-global default, so a
89
+ # second AgentX() with a different base_url can't re-point it (deep-dive bug #1).
90
+ self.scorers = ScorersClient(api_key=api_key, base_url=self._api_root())
89
91
  self.profile = MonitorProfileClient(self)
90
92
  self.online_evaluators = MonitorOnlineEvaluatorClient(self)
91
93
  from agentx.monitor.sessions import MonitorSessionClient
@@ -213,6 +215,17 @@ class MonitorClient:
213
215
  plus deltas vs the prior window and the run-outcome breakdown."""
214
216
  return self._request("GET", "/kpis", params={"window": window})
215
217
 
218
+ def calibration(self, window: str = "7d") -> dict:
219
+ """Project-level judge calibration over a window ("24h", "7d", or "30d"): how often
220
+ AgentX's own verdicts agreed with real-world ground truth reported later (ops outcomes
221
+ via ``client.outcomes`` and end-user downvotes). Returns the dashboard's Judge
222
+ Calibration numbers: compared count, agreement, falsePositiveRate, falseNegativeRate.
223
+ Per-evaluator calibration lives on ``client.monitor.online_evaluators.calibration``."""
224
+ return self._request(
225
+ "GET", "/agent-monitoring/calibration",
226
+ base=self._api_root(), params={"window": window},
227
+ )
228
+
216
229
  # ------------------------------------------------------------------
217
230
  # Signal endpoints
218
231
  # ------------------------------------------------------------------
@@ -33,13 +33,15 @@ class ScorersClient:
33
33
  wire.
34
34
  """
35
35
 
36
- def __init__(self, api_key: Optional[str] = None):
36
+ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
37
37
  self._api_key = api_key
38
+ # Captured once at construction (deep-dive round 3, bug #1).
39
+ self._base_url = (base_url or api_base()).rstrip("/")
38
40
 
39
41
  def _request(self, method: str, path: str, json: Any = None, params: Any = None) -> Any:
40
42
  resp = requests.request(
41
43
  method,
42
- f"{api_base()}/agent-monitoring{path}",
44
+ f"{self._base_url}/agent-monitoring{path}",
43
45
  headers={**get_headers(self._api_key), "Content-Type": "application/json"},
44
46
  json=json,
45
47
  params=params,
@@ -26,8 +26,10 @@ class OutcomesClient:
26
26
  tracker's webhook, a CRM workflow), and this method is the SDK's way to be that caller.
27
27
  """
28
28
 
29
- def __init__(self, api_key: Optional[str] = None):
29
+ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
30
30
  self._api_key = api_key
31
+ # Captured once at construction (deep-dive round 3, bug #1).
32
+ self._base_url = (base_url or api_base()).rstrip("/")
31
33
 
32
34
  def report(
33
35
  self,
@@ -69,7 +71,7 @@ class OutcomesClient:
69
71
  payload["reportedBy"] = reported_by
70
72
 
71
73
  resp = requests.post(
72
- f"{api_base()}/outcomes",
74
+ f"{self._base_url}/outcomes",
73
75
  headers={**get_headers(self._api_key), "Content-Type": "application/json"},
74
76
  json=payload,
75
77
  timeout=10,
@@ -25,13 +25,16 @@ class ProjectsClient:
25
25
  users; this client covers the default self-host (auth-disabled) mode.
26
26
  """
27
27
 
28
- def __init__(self, api_key: Optional[str] = None):
28
+ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
29
29
  self._api_key = api_key
30
+ # Captured once at construction so two clients with different bases can coexist
31
+ # (deep-dive round 3, bug #1) - never read from process-global state per call.
32
+ self._base_url = (base_url or api_base()).rstrip("/")
30
33
 
31
34
  def _request(self, method: str, path: str, json: Any = None) -> Any:
32
35
  resp = requests.request(
33
36
  method,
34
- f"{api_base()}{path}",
37
+ f"{self._base_url}{path}",
35
38
  headers={**get_headers(self._api_key), "Content-Type": "application/json"},
36
39
  json=json,
37
40
  timeout=15,
@@ -24,12 +24,14 @@ class TracesClient:
24
24
  span-tree read.
25
25
  """
26
26
 
27
- def __init__(self, api_key: Optional[str] = None):
27
+ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
28
28
  self._api_key = api_key
29
+ # Captured once at construction (deep-dive round 3, bug #1).
30
+ self._base_url = (base_url or api_base()).rstrip("/")
29
31
 
30
32
  def _request(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
31
33
  resp = requests.get(
32
- f"{api_base()}{path}",
34
+ f"{self._base_url}{path}",
33
35
  headers=get_headers(self._api_key),
34
36
  params=params or {},
35
37
  timeout=15,
@@ -72,6 +72,11 @@ class IngestClient:
72
72
  # failures stay at DEBUG to avoid log spam. client.ping() is the fail-fast startup check.
73
73
  self._delivery_warning_emitted = False
74
74
 
75
+ # Dropped-trace accounting (deep-dive round 3, bug #5): fire-and-forget may drop traces
76
+ # (queue overflow, retries exhausted while the engine is down), but it must never drop
77
+ # them SILENTLY. First drop warns, then every 50th, with the cumulative count.
78
+ self._dropped = 0
79
+
75
80
  self._queue: queue.Queue[Optional[Dict[str, Any]]] = queue.Queue(maxsize=_QUEUE_MAX)
76
81
  self._worker = threading.Thread(target=self._drain, daemon=True, name="agentx-ingest")
77
82
  self._worker.start()
@@ -90,11 +95,44 @@ class IngestClient:
90
95
  try:
91
96
  self._queue.put_nowait(payload)
92
97
  except queue.Full:
93
- logger.debug("agentx ingest queue full - trace dropped")
98
+ self._record_drop("queue full")
99
+
100
+ def flush(self, timeout: float = 5.0) -> bool:
101
+ """Block until all queued traces have been sent, or until ``timeout`` seconds elapse.
94
102
 
95
- def flush(self, timeout: float = 5.0) -> None:
96
- """Block until all queued traces have been sent (or timeout elapses)."""
97
- self._queue.join()
103
+ Returns ``True`` when the queue fully drained, ``False`` on deadline - undelivered
104
+ traces stay queued and keep sending in the background. The old implementation called
105
+ ``queue.join()``, which has no deadline at all: with the engine down, each queued item
106
+ burned the full retry backoff and a ``flush(timeout=3)`` measurably blocked for over
107
+ two minutes (deep-dive round 3, bug #5). The timeout is now a real wall-clock bound.
108
+ """
109
+ deadline = time.time() + timeout
110
+ with self._queue.all_tasks_done:
111
+ while self._queue.unfinished_tasks:
112
+ remaining = deadline - time.time()
113
+ if remaining <= 0:
114
+ logger.warning(
115
+ "agentx flush(%.1fs) timed out with %d trace(s) still undelivered - "
116
+ "they remain queued and keep sending in the background",
117
+ timeout,
118
+ self._queue.unfinished_tasks,
119
+ )
120
+ return False
121
+ self._queue.all_tasks_done.wait(remaining)
122
+ return True
123
+
124
+ def _record_drop(self, reason: str) -> None:
125
+ self._dropped += 1
126
+ if self._dropped == 1 or self._dropped % 50 == 0:
127
+ logger.warning(
128
+ "agentx dropped a trace (%s) - %d dropped total this process. "
129
+ "Tracing is fire-and-forget: traces that cannot be delivered are not persisted "
130
+ "locally. Use client.ping() at startup to fail fast on a bad endpoint.",
131
+ reason,
132
+ self._dropped,
133
+ )
134
+ else:
135
+ logger.debug("agentx dropped a trace (%s), %d total", reason, self._dropped)
98
136
 
99
137
  def send_trace_sync(self, payload: Dict[str, Any]) -> Optional[str]:
100
138
  """
@@ -372,7 +410,9 @@ class IngestClient:
372
410
  try:
373
411
  self._send(payload)
374
412
  except Exception as exc:
375
- logger.debug("agentx ingest send error: %s", exc)
413
+ # Anything _send didn't classify still cost us this payload - count it as a
414
+ # drop rather than whispering at DEBUG (bug #5's silent half).
415
+ self._record_drop(f"unexpected error: {exc}")
376
416
  finally:
377
417
  self._queue.task_done()
378
418
 
@@ -397,4 +437,4 @@ class IngestClient:
397
437
  return
398
438
 
399
439
  self._warn_delivery(str(last_exc))
400
- logger.debug("agentx ingest failed after retries: %s", last_exc)
440
+ self._record_drop(f"retries exhausted: {last_exc}")
@@ -890,9 +890,11 @@ class Tracer:
890
890
  agent_id=agent_id,
891
891
  )
892
892
 
893
- def flush(self, timeout: float = 5.0) -> None:
894
- """Block until all queued traces have been delivered."""
895
- self._client.flush(timeout)
893
+ def flush(self, timeout: float = 5.0) -> bool:
894
+ """Block until all queued traces have been delivered, or until ``timeout`` seconds
895
+ elapse. Returns ``True`` when everything drained, ``False`` on deadline (a warning is
896
+ logged and undelivered traces keep sending in the background)."""
897
+ return self._client.flush(timeout)
896
898
 
897
899
  # ------------------------------------------------------------------
898
900
  # CI/CD evaluation
@@ -0,0 +1 @@
1
+ VERSION = "0.6.35"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentx-python
3
- Version: 0.6.33
3
+ Version: 0.6.35
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
@@ -5,6 +5,7 @@ agentx/__init__.py
5
5
  agentx/agentx.py
6
6
  agentx/cli.py
7
7
  agentx/exceptions.py
8
+ agentx/export.py
8
9
  agentx/feedback.py
9
10
  agentx/outcomes.py
10
11
  agentx/projects.py
@@ -67,6 +68,7 @@ agentx_python.egg-info/entry_points.txt
67
68
  agentx_python.egg-info/not-zip-safe
68
69
  agentx_python.egg-info/requires.txt
69
70
  agentx_python.egg-info/top_level.txt
71
+ tests/test_deep_dive_fixes.py
70
72
  tests/test_integration.py
71
73
  tests/test_integrations.py
72
74
  tests/test_selfhost_analysis_fallback.py
@@ -0,0 +1,85 @@
1
+ """Regressions for deep-dive round 3's SDK bugs (sample-scripts/eval_framework_deep_dive/
2
+ DEEP_DIVE_REPORT.md): bug #1 (base_url was process-global via os.environ, so the
3
+ last-constructed client silently re-pointed every other client) and bug #5 (flush(timeout)
4
+ called queue.join() with no deadline and dropped traces silently)."""
5
+
6
+ import logging
7
+ import os
8
+ import time
9
+
10
+ import pytest
11
+ import requests
12
+
13
+ from agentx import AgentX
14
+ from agentx.tracing.ingest_client import IngestClient
15
+
16
+
17
+ @pytest.fixture(autouse=True)
18
+ def clean_env(monkeypatch):
19
+ monkeypatch.delenv("AGENTX_API_BASE_URL", raising=False)
20
+ monkeypatch.setenv("AGENTX_API_KEY", "agtx_test_key")
21
+ yield
22
+
23
+
24
+ def test_second_client_does_not_repoint_the_first():
25
+ a = AgentX(api_key="k1", base_url="http://engine-a:1111/api/v1")
26
+ b = AgentX(api_key="k2", base_url="http://engine-b:2222/api/v1")
27
+
28
+ # The constructor must not leak its base into process-global state...
29
+ assert os.environ.get("AGENTX_API_BASE_URL") is None
30
+
31
+ # ...and every per-call-resolving surface of client A must still point at engine A.
32
+ for sub in (a.projects, a.traces, a.export, a.feedback, a.outcomes, a.monitor.scorers):
33
+ assert "engine-a:1111" in sub._base_url, type(sub).__name__
34
+ for sub in (b.projects, b.traces, b.export, b.feedback, b.outcomes, b.monitor.scorers):
35
+ assert "engine-b:2222" in sub._base_url, type(sub).__name__
36
+
37
+
38
+ def test_env_var_still_works_as_a_default(monkeypatch):
39
+ monkeypatch.setenv("AGENTX_API_BASE_URL", "http://from-env:3333/api/v1")
40
+ c = AgentX(api_key="k")
41
+ assert "from-env:3333" in c.projects._base_url
42
+
43
+
44
+ def _stalled_client(monkeypatch, per_call_delay: float) -> IngestClient:
45
+ client = IngestClient(api_key="k", sdk_version="test", base_url="http://localhost:9/api/v1")
46
+
47
+ def slow_post(*args, **kwargs):
48
+ time.sleep(per_call_delay)
49
+ raise ConnectionError("engine down")
50
+
51
+ monkeypatch.setattr(client._session, "post", slow_post)
52
+ return client
53
+
54
+
55
+ def test_flush_timeout_is_a_wall_clock_bound(monkeypatch):
56
+ client = _stalled_client(monkeypatch, per_call_delay=2.0)
57
+ for i in range(5):
58
+ client.enqueue({"name": f"t{i}"})
59
+
60
+ t0 = time.time()
61
+ drained = client.flush(timeout=1.0)
62
+ elapsed = time.time() - t0
63
+
64
+ assert drained is False
65
+ # The old queue.join() implementation blocked for the full retry schedule of every queued
66
+ # item (measured 140s for 20 items); the deadline must now bind within a small margin.
67
+ assert elapsed < 3.0, f"flush(timeout=1.0) blocked for {elapsed:.1f}s"
68
+
69
+
70
+ def test_dropped_traces_warn_instead_of_vanishing(monkeypatch, caplog):
71
+ client = IngestClient(api_key="k", sdk_version="test", base_url="http://localhost:9/api/v1")
72
+
73
+ def refuse(*args, **kwargs):
74
+ raise requests.exceptions.ConnectionError("connection refused")
75
+
76
+ monkeypatch.setattr(client._session, "post", refuse)
77
+ with caplog.at_level(logging.WARNING, logger="agentx.tracing.ingest_client"):
78
+ client.enqueue({"name": "doomed"})
79
+ for _ in range(200): # wait out the retry backoff
80
+ if client._dropped >= 1:
81
+ break
82
+ time.sleep(0.1)
83
+
84
+ assert client._dropped >= 1
85
+ assert any("dropped a trace" in rec.message for rec in caplog.records)
@@ -1 +0,0 @@
1
- VERSION = "0.6.33"
File without changes
File without changes
File without changes
File without changes