agentx-python 0.8.24__py3-none-any.whl → 0.8.26__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentx/agentx.py CHANGED
@@ -20,8 +20,8 @@ class AgentX:
20
20
  # The api_key is NOT written back into os.environ (it used to be): every sub-client
21
21
  # below receives it explicitly, and mutating process-global state from a constructor
22
22
  # re-pointed unrelated code - the same leak the base_url write below had (deep-dive
23
- # round 3, bug #1). Static flows that still read the env (AgentX.list_workforces,
24
- # bare get_headers()) now require the caller to set AGENTX_API_KEY themselves.
23
+ # round 3, bug #1). Flows that still read the env (bare get_headers()) now require
24
+ # the caller to set AGENTX_API_KEY themselves.
25
25
  self.api_key = api_key or os.getenv("AGENTX_API_KEY")
26
26
 
27
27
  # base_url overrides AGENTX_API_BASE_URL env var (and the SDK default). It is
@@ -147,7 +147,7 @@ class AgentX:
147
147
  response = requests.get(url, headers=get_headers(self.api_key))
148
148
  # Check if response was successful
149
149
  if response.status_code == 200:
150
- return Agent(**response.json())
150
+ return Agent(**response.json())._bind(self.api_key, self.base_url)
151
151
  else:
152
152
  raise AgentXError(
153
153
  f"Failed to retrieve agent: {response.reason}. This endpoint is "
@@ -165,22 +165,26 @@ class AgentX:
165
165
  response = requests.get(url, headers=get_headers(self.api_key))
166
166
  # Check if response was successful
167
167
  if response.status_code == 200:
168
- return [Agent(**agent) for agent in response.json()]
168
+ return [Agent(**agent)._bind(self.api_key, self.base_url) for agent in response.json()]
169
169
  else:
170
170
  raise AgentXError(
171
171
  f"Failed to list agents: {response.reason}. This endpoint is "
172
172
  "hosted-platform only - on self-host use client.monitor.agents.list()."
173
173
  )
174
174
 
175
- @staticmethod
176
- def list_workforces() -> List["Workforce"]:
177
- """List all workforces/teams. Static, so it reads AGENTX_API_KEY from the environment
178
- directly - the constructor no longer writes ``api_key`` into os.environ, so set the
179
- env var yourself before calling this."""
180
- url = f"{api_base()}/access/teams"
181
- response = requests.get(url, headers=get_headers())
175
+ def list_workforces(self) -> List["Workforce"]:
176
+ """List all workforces/teams, each bound to this client's credentials - including each
177
+ workforce's ``manager`` and ``agents``, so their calls authenticate the same way.
178
+
179
+ This used to be documented as a static call (``AgentX.list_workforces()``); that form
180
+ was broken (the old staticmethod body referenced ``self`` and raised NameError on any
181
+ non-empty response). Construct a client instead - ``AgentX().list_workforces()`` picks
182
+ up AGENTX_API_KEY / AGENTX_API_BASE_URL from the environment, which is what the static
183
+ form effectively did."""
184
+ url = f"{self.base_url or api_base()}/access/teams"
185
+ response = requests.get(url, headers=get_headers(self.api_key))
182
186
  if response.status_code == 200:
183
- return [Workforce(**workforce) for workforce in response.json()]
187
+ return [Workforce(**workforce)._bind(self.api_key, self.base_url) for workforce in response.json()]
184
188
  else:
185
189
  raise Exception(
186
190
  f"Failed to list workforces: {response.status_code} - {response.reason}"
@@ -7,4 +7,5 @@
7
7
  # from agentx.integrations.anthropic import patch_anthropic_client
8
8
  # from agentx.integrations.google_adk import AgentXADKPlugin
9
9
  # from agentx.integrations.google_genai import patch_genai_client
10
+ # from agentx.integrations.nvidia_nim import patch_nim_client
10
11
  # from agentx.integrations.moveworks import MoveworksImporter # Data API pull sync, not in-process
@@ -144,14 +144,14 @@ class AgentXCrewObserver:
144
144
  TaskFailedEvent,
145
145
  TaskStartedEvent,
146
146
  )
147
- except ImportError:
147
+ except Exception: # noqa: BLE001 - crewai import raises TypeError (PEP 604) on py3.9, not just ImportError
148
148
  from crewai.utilities.events import crewai_event_bus
149
149
  from crewai.utilities.events.task_events import (
150
150
  TaskCompletedEvent,
151
151
  TaskFailedEvent,
152
152
  TaskStartedEvent,
153
153
  )
154
- except ImportError:
154
+ except Exception: # noqa: BLE001 - crewai import raises TypeError (PEP 604) on py3.9, not just ImportError
155
155
  if not _warned_no_event_bus:
156
156
  _warned_no_event_bus = True
157
157
  logger.warning(
@@ -0,0 +1,70 @@
1
+ """
2
+ NVIDIA NIM integration for AgentX production tracing.
3
+
4
+ NIM (NVIDIA Inference Microservices) serves models behind an OpenAI-compatible
5
+ ``/v1/chat/completions`` API, so the client you patch is the ordinary ``openai``
6
+ Python client pointed at a NIM endpoint - a local NIM container
7
+ (``http://localhost:8000/v1``) or NVIDIA's hosted API
8
+ (``https://integrate.api.nvidia.com/v1``). This module reuses the OpenAI patch
9
+ machinery verbatim and differs in exactly one way: traces are stamped
10
+ ``framework="nvidia-nim"``, so NIM traffic gets its own row in Monitor's
11
+ Platforms chart and the framework filters instead of blending into "openai".
12
+
13
+ Usage::
14
+
15
+ from agentx.integrations.nvidia_nim import patch_nim_client
16
+ import openai
17
+
18
+ nim = openai.OpenAI(
19
+ base_url="http://localhost:8000/v1", # or https://integrate.api.nvidia.com/v1
20
+ api_key=os.environ.get("NVIDIA_API_KEY", "not-needed-for-local-nim"),
21
+ )
22
+ patch_nim_client(nim, agentx.tracer, name="nim-agent")
23
+
24
+ # All subsequent nim.chat.completions.create() calls are now traced.
25
+
26
+ Works with both ``openai.OpenAI`` and ``openai.AsyncOpenAI`` clients. Token
27
+ usage comes straight off the response's OpenAI-shaped ``usage`` block; NIM
28
+ reports no prompt-cache fields, so cache token counts stay unset.
29
+
30
+ Streaming calls (``stream=True``) are passed through untouched and are not
31
+ currently traced - same posture as ``patch_openai_client``, see its docstring.
32
+
33
+ Requires: ``pip install "agentx-python[nvidia-nim]"`` (installs the ``openai``
34
+ client package; there is no separate NIM SDK dependency).
35
+ """
36
+ from __future__ import annotations
37
+
38
+ from typing import Any, Dict, Optional
39
+
40
+ from agentx.tracing.tracer import Tracer
41
+ from agentx.integrations.openai import _patch_chat_completions_create
42
+
43
+ NIM_FRAMEWORK = "nvidia-nim"
44
+
45
+
46
+ def patch_nim_client(
47
+ client: Any,
48
+ tracer: Tracer,
49
+ name: str = "nim-agent",
50
+ metadata: Optional[Dict[str, Any]] = None,
51
+ session_id: Optional[str] = None,
52
+ ) -> None:
53
+ """
54
+ Monkey-patch ``client.chat.completions.create`` on an OpenAI-compatible
55
+ client pointed at a NIM endpoint, sending a trace for every non-streaming
56
+ call with ``framework="nvidia-nim"``.
57
+
58
+ The original method is still called and its return value passed through
59
+ unchanged. Sync and async clients both work; ``stream=True`` calls pass
60
+ through untraced. Patching is idempotent - and because it shares the guard
61
+ with ``patch_openai_client``, whichever of the two patched a given client
62
+ first wins (patch each client with the integration that matches where its
63
+ ``base_url`` actually points).
64
+ """
65
+ chat = getattr(client, "chat", None)
66
+ completions = getattr(chat, "completions", None) if chat is not None else None
67
+ if completions is None:
68
+ raise ValueError("Provided client does not have a .chat.completions attribute")
69
+
70
+ _patch_chat_completions_create(completions, tracer, name, metadata, session_id, framework=NIM_FRAMEWORK)
@@ -113,7 +113,11 @@ def _patch_chat_completions_create(
113
113
  name: str,
114
114
  metadata: Optional[Dict[str, Any]],
115
115
  session_id: Optional[str],
116
+ framework: str = "openai",
116
117
  ) -> None:
118
+ # `framework` exists for OpenAI-compatible endpoints served by other vendors
119
+ # (agentx.integrations.nvidia_nim stamps "nvidia-nim" through here) - the request/response
120
+ # shapes are identical, so they share this machinery instead of duplicating it.
117
121
  original = completions_resource.create
118
122
  if getattr(original, "_agentx_patched", False):
119
123
  return # already patched
@@ -148,7 +152,7 @@ def _patch_chat_completions_create(
148
152
  finish_llm_call(
149
153
  tracer,
150
154
  name=name,
151
- framework="openai",
155
+ framework=framework,
152
156
  metadata=metadata,
153
157
  session_id=session_id,
154
158
  start_t=start_t,
@@ -10,6 +10,9 @@ from agentx.monitor.judge_scorers import (
10
10
  from agentx.monitor.models import MonitorPattern, MonitorProfile, MonitorSignal, SignalOccurrence
11
11
  from agentx.monitor.patterns import MonitorPatternBuilder, MonitorPatternClient
12
12
  from agentx.monitor.profile import MonitorProfileClient
13
+ from agentx.monitor.review_queue import ReviewQueueClient, ReviewQueueItem
14
+ from agentx.monitor.rules import MonitorRule, MonitorRulesClient
15
+ from agentx.monitor.scorers import AgentXScorersError, ScorersClient
13
16
  from agentx.monitor.scorer_groups import AgentXScorerGroupsError, ScorerGroup, ScorerGroupsClient
14
17
  from agentx.monitor.sessions import MonitorSessionClient
15
18
  from agentx.monitor.signals import MonitorSignalClient
@@ -19,6 +22,7 @@ __all__ = [
19
22
  "AgentXJudgeScorersError",
20
23
  "AgentXMonitorError",
21
24
  "AgentXScorerGroupsError",
25
+ "AgentXScorersError",
22
26
  "ImprovementGroupsClient",
23
27
  "JudgeScorer",
24
28
  "JudgeScorerBuilder",
@@ -30,10 +34,15 @@ __all__ = [
30
34
  "MonitorPatternClient",
31
35
  "MonitorProfile",
32
36
  "MonitorProfileClient",
37
+ "MonitorRule",
38
+ "MonitorRulesClient",
33
39
  "MonitorSessionClient",
34
40
  "MonitorSignal",
35
41
  "MonitorSignalClient",
42
+ "ReviewQueueClient",
43
+ "ReviewQueueItem",
36
44
  "ScorerGroup",
45
+ "ScorersClient",
37
46
  "ScorerGroupsClient",
38
47
  "SignalOccurrence",
39
48
  ]
@@ -0,0 +1,48 @@
1
+ """Shared HTTP transport for the monitor sub-clients that own their ``_request`` (scorers,
2
+ judge_scorers, scorer_groups, improvement_groups): one retry schedule mirroring
3
+ ``MonitorClient._request``, so ``retry=False`` means the same thing everywhere the client.py
4
+ comment promises it ("retry=False for ANY non-idempotent write")."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import time
10
+ from typing import Any, Optional
11
+
12
+ import requests
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Same schedule as MonitorClient._request (agentx/monitor/client.py).
17
+ _RETRYABLE_STATUS = {429, 500, 502, 503, 504}
18
+ _RETRY_BACKOFF = [1.0, 2.0, 4.0]
19
+
20
+
21
+ def request_with_retries(
22
+ method: str, url: str, *, retry: bool = True, **kwargs: Any
23
+ ) -> requests.Response:
24
+ """``requests.request`` with MonitorClient's transport posture: when ``retry`` is true,
25
+ connection errors and retryable statuses (429/5xx) walk the backoff schedule; the last
26
+ response (whatever its status) is returned for the caller's own error taxonomy.
27
+
28
+ ``retry=False`` is single-shot - for non-idempotent writes (creates, deletes) and
29
+ judge-billing POSTs, where a client-side timeout must not fire the same work twice.
30
+ Transport errors keep their ``requests`` exception type (callers guard on
31
+ ``requests.Timeout`` for judge-billing endpoints)."""
32
+ schedule = [0.0] + _RETRY_BACKOFF if retry else [0.0]
33
+ last_exc: Optional[Exception] = None
34
+ for attempt, wait in enumerate(schedule):
35
+ if wait:
36
+ time.sleep(wait)
37
+ try:
38
+ resp = requests.request(method, url, **kwargs)
39
+ except requests.RequestException as e:
40
+ last_exc = e
41
+ logger.debug("Request error (attempt %d): %s", attempt + 1, e)
42
+ continue
43
+ if retry and resp.status_code in _RETRYABLE_STATUS and attempt < len(schedule) - 1:
44
+ logger.debug("Retryable status %d (attempt %d)", resp.status_code, attempt + 1)
45
+ continue
46
+ return resp
47
+ assert last_exc is not None # every non-raising path returned above
48
+ raise last_exc
agentx/monitor/client.py CHANGED
@@ -205,9 +205,11 @@ class MonitorClient:
205
205
  def _request(
206
206
  self, method: str, path: str, timeout: int = 30, base: Optional[str] = None, retry: bool = True, **kwargs
207
207
  ) -> Any:
208
- # retry=False for non-idempotent judge-spending POSTs (sweep, coherence, portability,
209
- # tuning): a client-side timeout must not fire the same LLM-billing work a second time
210
- # while the first invocation is still running server-side. Same precedent as
208
+ # retry=False for ANY non-idempotent write - duplicating creates, deletes (a lost
209
+ # response + retry turns success into a spurious 404), and judge-spending POSTs
210
+ # (sweep, coherence, portability, tuning: a client-side timeout must not fire the
211
+ # same LLM-billing work twice while the first invocation still runs server-side).
212
+ # The judge list is the example set, not the rule. Same precedent as
211
213
  # EvaluationsClient._request / analyze_run.
212
214
  url = f"{base or self._base_url}{path}"
213
215
  last_exc: Optional[Exception] = None
@@ -4,6 +4,7 @@ from typing import Any, Dict, List, Optional
4
4
 
5
5
  import requests
6
6
 
7
+ from agentx.monitor._transport import request_with_retries
7
8
  from agentx.util import api_base, get_headers
8
9
  from agentx.exceptions import AgentXError, AgentXAuthError, AgentXValidationError
9
10
 
@@ -45,7 +46,9 @@ class ImprovementGroupsClient:
45
46
  self._workspace_id = workspace_id
46
47
  self._base_url = (base_url or api_base()).rstrip("/")
47
48
 
48
- def _request(self, method: str, path: str, json: Any = None, timeout: int = 120) -> Any:
49
+ def _request(
50
+ self, method: str, path: str, json: Any = None, timeout: int = 120, retry: bool = True
51
+ ) -> Any:
49
52
  params = None
50
53
  if self._workspace_id:
51
54
  # Mirrors MonitorClient._workspace_params/_with_workspace: GETs (and DELETEs)
@@ -57,9 +60,12 @@ class ImprovementGroupsClient:
57
60
  json = {**json, "workspaceId": self._workspace_id}
58
61
  else:
59
62
  params = {"workspaceId": self._workspace_id}
60
- resp = requests.request(
63
+ # retry=False for ANY non-idempotent write (member deletes, the report-generating
64
+ # POST) - MonitorClient._request's posture, via the shared monitor transport.
65
+ resp = request_with_retries(
61
66
  method,
62
67
  f"{self._base_url}/agent-monitoring{path}",
68
+ retry=retry,
63
69
  headers={**get_headers(self._api_key), "Content-Type": "application/json"},
64
70
  json=json,
65
71
  params=params,
@@ -92,7 +98,9 @@ class ImprovementGroupsClient:
92
98
 
93
99
  def remove_member(self, group_id: str, member_id: str) -> None:
94
100
  """Prune a member before spending the group (a confirm that turned out uninteresting)."""
95
- self._request("DELETE", f"/improvement-groups/{group_id}/members/{member_id}")
101
+ # retry=False: a lost response + transport retry would turn a successful delete
102
+ # into a spurious 404.
103
+ self._request("DELETE", f"/improvement-groups/{group_id}/members/{member_id}", retry=False)
96
104
 
97
105
  def generate_report(self, group_id: str, model: Optional[str] = None) -> Dict[str, Any]:
98
106
  """Spend the group: one real LLM call clustering the confirmed failures into issues
@@ -101,7 +109,11 @@ class ImprovementGroupsClient:
101
109
  payload: Dict[str, Any] = {}
102
110
  if model is not None:
103
111
  payload["model"] = model
104
- return self._request("POST", f"/improvement-groups/{group_id}/report", json=payload, timeout=300)["report"]
112
+ # retry=False: spends the group (real LLM billing) - a client-side timeout must not
113
+ # fire the same generation twice while the first still runs server-side.
114
+ return self._request(
115
+ "POST", f"/improvement-groups/{group_id}/report", json=payload, timeout=300, retry=False
116
+ )["report"]
105
117
 
106
118
  def list_reports(self) -> List[Dict[str, Any]]:
107
119
  return self._request("GET", "/improvement-reports").get("improvementReports", [])
@@ -5,6 +5,7 @@ from typing import Any, Dict, List, Optional
5
5
 
6
6
  import requests
7
7
 
8
+ from agentx.monitor._transport import request_with_retries
8
9
  from agentx.util import api_base, get_headers
9
10
  from agentx.exceptions import AgentXError, AgentXAuthError, AgentXValidationError
10
11
 
@@ -97,7 +98,9 @@ class JudgeScorersClient:
97
98
  # Captured once at construction so two clients with different bases can coexist.
98
99
  self._base_url = (base_url or api_base()).rstrip("/")
99
100
 
100
- def _request(self, method: str, path: str, json: Any = None, timeout: int = 60) -> Any:
101
+ def _request(
102
+ self, method: str, path: str, json: Any = None, timeout: int = 60, retry: bool = True
103
+ ) -> Any:
101
104
  params = None
102
105
  if self._workspace_id:
103
106
  # Mirrors MonitorClient._workspace_params/_with_workspace: GETs (and DELETEs)
@@ -109,9 +112,13 @@ class JudgeScorersClient:
109
112
  json = {**json, "workspaceId": self._workspace_id}
110
113
  else:
111
114
  params = {"workspaceId": self._workspace_id}
112
- resp = requests.request(
115
+ # retry=False for ANY non-idempotent write (creates, deletes) and judge-spending POST
116
+ # (tune/validate/publish) - MonitorClient._request's posture, via the shared monitor
117
+ # transport.
118
+ resp = request_with_retries(
113
119
  method,
114
120
  f"{self._base_url}/agent-monitoring{path}",
121
+ retry=retry,
115
122
  headers={**get_headers(self._api_key), "Content-Type": "application/json"},
116
123
  json=json,
117
124
  params=params,
@@ -263,7 +270,10 @@ class JudgeScorersClient:
263
270
  payload["offline"] = offline
264
271
  if online is not None:
265
272
  payload["online"] = online
266
- return JudgeScorer(self._request("POST", "/judge-scorers", json=payload)["judgeScorer"])
273
+ # Server-side create: a timeout + transport retry would create the scorer twice.
274
+ return JudgeScorer(
275
+ self._request("POST", "/judge-scorers", json=payload, retry=False)["judgeScorer"]
276
+ )
267
277
 
268
278
  def get(self, scorer_id: str) -> JudgeScorer:
269
279
  return JudgeScorer(self._request("GET", f"/judge-scorers/{scorer_id}")["judgeScorer"])
@@ -300,7 +310,9 @@ class JudgeScorersClient:
300
310
  def delete(self, scorer_id: str) -> None:
301
311
  """Delete the scorer: rubric, version history, and online profile together.
302
312
  Irreversible; refused for the built-in Session Baseline Judge."""
303
- self._request("DELETE", f"/judge-scorers/{scorer_id}")
313
+ # retry=False: a lost response + transport retry would turn a successful delete
314
+ # into a spurious 404.
315
+ self._request("DELETE", f"/judge-scorers/{scorer_id}", retry=False)
304
316
 
305
317
  # ------------------------------------------------------------------
306
318
  # Online-profile pass-throughs (calibration / tuning / ratings / events)
@@ -334,8 +346,14 @@ class JudgeScorersClient:
334
346
  def tune(self, scorer_id: str, window: str = "7d") -> dict:
335
347
  """Propose a rewrite of the rubric from calibration disagreements (LLM call, slow).
336
348
  ``window`` accepts the same values as :meth:`calibration`, including "rubric"."""
349
+ # retry=False (judge-spending POST, MonitorClient.propose_online_evaluator_tuning's
350
+ # posture): a client-side timeout must not fire the same LLM-billing work twice.
337
351
  data = self._request(
338
- "POST", f"/online-evaluators/{self._profile_id(scorer_id)}/tune", json={"window": window}, timeout=300
352
+ "POST",
353
+ f"/online-evaluators/{self._profile_id(scorer_id)}/tune",
354
+ json={"window": window},
355
+ timeout=300,
356
+ retry=False,
339
357
  )
340
358
  # The wire wraps the proposal ({"proposal": {...}}); unwrap like the legacy client so
341
359
  # proposal["reasoning"] / the criteria fields are directly addressable.
@@ -344,11 +362,13 @@ class JudgeScorersClient:
344
362
  def validate_tuning(self, scorer_id: str, criteria: Dict[str, Any], window: str = "7d") -> dict:
345
363
  """Re-judge the disagreement + control cases with candidate criteria (LLM calls, slow)."""
346
364
  # The wire takes the criteria fields at the TOP level of the body, not nested.
365
+ # retry=False (judge-spending POST) - same posture as tune() above.
347
366
  return self._request(
348
367
  "POST",
349
368
  f"/online-evaluators/{self._profile_id(scorer_id)}/tune/validate",
350
369
  json={**criteria, "window": window},
351
370
  timeout=600,
371
+ retry=False,
352
372
  )
353
373
 
354
374
  def publish_tuning(
@@ -382,7 +402,14 @@ class JudgeScorersClient:
382
402
  payload["validation"] = validation_payload
383
403
  if force:
384
404
  payload["force"] = True
385
- return self._request("POST", f"/online-evaluators/{self._profile_id(scorer_id)}/tune/publish", json=payload)
405
+ # retry=False: a non-idempotent write (each publish appends a rubric version) -
406
+ # MonitorClient.publish_online_evaluator_tuning's posture.
407
+ return self._request(
408
+ "POST",
409
+ f"/online-evaluators/{self._profile_id(scorer_id)}/tune/publish",
410
+ json=payload,
411
+ retry=False,
412
+ )
386
413
 
387
414
  def ratings(self, scorer_id: str, window: str = "7d") -> "List[OnlineEvaluatorRatingPoint]":
388
415
  """Bucketed average-rating-over-time for this scorer's live checks - same typed points
@@ -137,16 +137,108 @@ class MonitorPatternClient:
137
137
  retry=False,
138
138
  )
139
139
 
140
+ # snake_case -> wire camelCase, same posture as rules.update: the engine reads only the
141
+ # camelCase key and silently keeps the stored value for anything it does not recognize -
142
+ # update(sample_rate=0.05) used to 200 with the rate unchanged.
143
+ _UPDATE_ALIASES = {
144
+ "sample_rate": "sampleRate",
145
+ "scope_mode": "scopeMode",
146
+ "agent_ids": "agentIds",
147
+ "detector_kind": "detectorKind",
148
+ "include_terms": "includeTerms",
149
+ "exclude_terms": "excludeTerms",
150
+ "regex": "regex",
151
+ "semantic_prompt": "semanticPrompt",
152
+ "match_mode": "matchMode",
153
+ "match_target": "matchTarget",
154
+ }
155
+
156
+ # The engine's PUT rebuilds the pattern's WHOLE conditions array (legacyPayloadToConditions:
157
+ # "a full replace, not a sparse patch") exactly when the body carries one of these - its own
158
+ # sentConditionFields set, minus "conditions". Bodies without any of them keep the stored
159
+ # conditions untouched.
160
+ _TRIGGER_FIELDS = ("includeTerms", "regex", "semanticPrompt")
161
+
162
+ # Sent alone, these look sparse but cannot land: without a trigger field the engine never
163
+ # rebuilds conditions (the values are silently ignored). For excludeTerms/matchMode there is
164
+ # also nothing to merge them over client-side - the wire GET reports display-only
165
+ # placeholders (includeTerms/excludeTerms always [], matchMode always "any"; conditions is
166
+ # the only truth). matchTarget IS reported faithfully, but the engine only reads it during a
167
+ # rebuild, so alone it is the same silent no-op.
168
+ _UNMERGEABLE_ALONE = ("excludeTerms", "matchMode", "matchTarget")
169
+
140
170
  def update(self, pattern_id: str, **fields: Any) -> MonitorPattern:
141
- """Update a pattern's fields in place (wire camelCase keys, passed through
142
- verbatim - e.g. ``enabled=False``, ``conditions=[...]``) and return the
143
- updated :class:`MonitorPattern`. retry=False: a non-idempotent server-side
144
- write must not be re-fired on a lost response."""
171
+ """Update a pattern and return the updated :class:`MonitorPattern`. Accepts snake_case
172
+ kwargs (``sample_rate=0.05``) or the wire's camelCase; an unrecognized snake_case key
173
+ raises instead of silently changing nothing.
174
+
175
+ The real contract on self-host: the stored truth is the pattern's ``conditions`` array,
176
+ and the flat fields the wire GET returns are display-only placeholders (``includeTerms``/
177
+ ``excludeTerms`` always ``[]``, ``matchMode`` always ``"any"``, ``regex``/
178
+ ``semanticPrompt`` omitted). The engine's PUT rebuilds the WHOLE conditions array
179
+ whenever the body carries ``include_terms``, ``regex``, or ``semantic_prompt`` (or an
180
+ explicit ``conditions`` list, which wins outright). Consequences:
181
+
182
+ - ``update(pid, regex=...)`` (or include_terms/semantic_prompt) is a full detector
183
+ rewrite; the detector kind follows the trigger field actually sent (``regex`` ->
184
+ ``"regex"``, ``semantic_prompt`` -> ``"semantic"``, ``include_terms`` ->
185
+ ``"contains"``), so cross-kind updates work, and this client back-fills only
186
+ ``matchTarget`` from the stored pattern so the rebuild keeps its target. It never
187
+ back-fills ``includeTerms``/``excludeTerms``/``matchMode`` - the GET values are
188
+ placeholders, and copying them in would destroy real conditions.
189
+ - ``exclude_terms=``, ``match_mode=``, or ``match_target=`` alone raises ValueError:
190
+ the engine silently ignores them without a rebuild. Pass ``conditions=[...]`` (built
191
+ from ``get(pattern_id).conditions``) instead, or pass them alongside the
192
+ include_terms/regex/semantic_prompt they should be rebuilt with.
193
+ - Everything else stays a sparse metadata edit that leaves the stored conditions
194
+ untouched."""
195
+ payload: Dict[str, Any] = {}
196
+ for key, value in fields.items():
197
+ wire_key = self._UPDATE_ALIASES.get(key, key)
198
+ if "_" in wire_key:
199
+ raise ValueError(
200
+ f"Unknown pattern field {key!r} - the engine reads camelCase keys and would "
201
+ "silently ignore this (see MonitorPattern for the field names)."
202
+ )
203
+ payload[wire_key] = value
204
+ sends_conditions = "conditions" in payload
205
+ triggered = any(k in payload for k in self._TRIGGER_FIELDS)
206
+ if not sends_conditions and not triggered:
207
+ offending = [k for k in self._UNMERGEABLE_ALONE if k in payload]
208
+ if offending:
209
+ raise ValueError(
210
+ f"{' and '.join(offending)} cannot be updated on their own: the engine only "
211
+ "rebuilds a pattern's conditions when includeTerms/regex/semanticPrompt is "
212
+ "sent (alone they are silently ignored), and the wire GET returns display-"
213
+ "only placeholders (includeTerms/excludeTerms always [], matchMode always "
214
+ "'any'), so there is no stored value to merge them over. Pass "
215
+ "conditions=[...] built from get(pattern_id).conditions instead, or send "
216
+ "them alongside the trigger field they should be rebuilt with."
217
+ )
218
+ if triggered and not sends_conditions:
219
+ stored = self.get(pattern_id)
220
+ # The rebuild's detector kind follows the trigger field actually sent - back-filling
221
+ # the STORED kind 400s a kind change (regex= on a "contains" pattern) and silently
222
+ # corrupts the inverse (include_terms= on a regex pattern would write phrase
223
+ # conditions while keeping detectorKind "regex").
224
+ if "regex" in payload:
225
+ inferred_kind = "regex"
226
+ elif "semanticPrompt" in payload:
227
+ inferred_kind = "semantic"
228
+ else:
229
+ inferred_kind = "contains"
230
+ payload.setdefault("detectorKind", inferred_kind)
231
+ # matchTarget is the one field the wire reports faithfully - it rides along so the
232
+ # server-side full-replace rebuild keeps the stored target. NEVER includeTerms/
233
+ # excludeTerms/matchMode - see _UNMERGEABLE_ALONE's comment.
234
+ payload.setdefault("matchTarget", stored.match_target)
145
235
  data = self._client._request(
146
236
  "PUT",
147
237
  f"/agent-monitoring/patterns/{pattern_id}",
148
238
  base=self._client._api_root(),
149
- json=fields,
239
+ json=payload,
240
+ # An idempotent merge server-side (same payload, same result) - but the read-
241
+ # merge-write above is not atomic, so keep the single-shot posture.
150
242
  retry=False,
151
243
  )
152
244
  return MonitorPattern(**data["pattern"])
agentx/monitor/rules.py CHANGED
@@ -70,9 +70,22 @@ class MonitorRulesClient:
70
70
 
71
71
  def update(self, rule_id: str, **fields: Any) -> MonitorRule:
72
72
  """Sparse update. snake_case keys are mapped to the wire (``sample_rate`` ->
73
- ``sampleRate``, ``action_config`` -> ``actionConfig``)."""
73
+ ``sampleRate``, ``action_config`` -> ``actionConfig``); an unrecognized snake_case
74
+ key raises instead of 200ing with the rule unchanged (the engine reads only camelCase
75
+ and silently keeps the stored value for keys it does not know)."""
74
76
  aliases = {"sample_rate": "sampleRate", "action_config": "actionConfig"}
75
- payload = {aliases.get(k, k): v for k, v in fields.items()}
77
+ payload: Dict[str, Any] = {}
78
+ for key, value in fields.items():
79
+ wire_key = aliases.get(key, key)
80
+ if "_" in wire_key:
81
+ raise ValueError(
82
+ f"Unknown rule field {key!r} - the engine reads camelCase keys and would "
83
+ "silently ignore this (see MonitorRule for the field names)."
84
+ )
85
+ payload[wire_key] = value
86
+ # PUT /rules/:id is an idempotent full-body merge (same payload, same result), so the
87
+ # transport's default retry is safe - and skipping it just drops legitimate edits on a
88
+ # transient failure.
76
89
  data = self._request("PUT", f"/agent-monitoring/rules/{rule_id}", json=payload)
77
90
  return MonitorRule(data.get("rule", data))
78
91
 
@@ -10,6 +10,7 @@ from typing import Any, Dict, List, Optional
10
10
  import requests
11
11
 
12
12
  from agentx.exceptions import AgentXError, AgentXAuthError, AgentXValidationError
13
+ from agentx.monitor._transport import request_with_retries
13
14
 
14
15
 
15
16
  class AgentXScorerGroupsError(AgentXError):
@@ -49,7 +50,9 @@ class ScorerGroupsClient:
49
50
  self._workspace_id = workspace_id
50
51
  self._base = base_url.rstrip("/") + "/agent-monitoring/scorer-groups"
51
52
 
52
- def _request(self, method: str, url: str, json: Optional[Dict[str, Any]] = None) -> Any:
53
+ def _request(
54
+ self, method: str, url: str, json: Optional[Dict[str, Any]] = None, retry: bool = True
55
+ ) -> Any:
53
56
  params = None
54
57
  if self._workspace_id:
55
58
  # Mirrors MonitorClient._workspace_params/_with_workspace: GETs (and DELETEs)
@@ -61,9 +64,12 @@ class ScorerGroupsClient:
61
64
  json = {**json, "workspaceId": self._workspace_id}
62
65
  else:
63
66
  params = {"workspaceId": self._workspace_id}
64
- response = requests.request(
67
+ # retry=False for ANY non-idempotent write (creates, deletes) -
68
+ # MonitorClient._request's posture, via the shared monitor transport.
69
+ response = request_with_retries(
65
70
  method,
66
71
  url,
72
+ retry=retry,
67
73
  headers={"x-api-key": self._api_key, "content-type": "application/json"},
68
74
  json=json,
69
75
  params=params,
@@ -106,15 +112,33 @@ class ScorerGroupsClient:
106
112
  payload["description"] = description
107
113
  if online is not None:
108
114
  payload["online"] = online
109
- return ScorerGroup(self._request("POST", self._base, json=payload)["scorerGroup"])
115
+ # Server-side create: a timeout + transport retry would create the group twice.
116
+ return ScorerGroup(
117
+ self._request("POST", self._base, json=payload, retry=False)["scorerGroup"]
118
+ )
110
119
 
111
120
  def update(self, group_id: str, **fields: Any) -> ScorerGroup:
112
- """Sparse update - pass any of name/description/members/online (online=None detaches
113
- live scoring)."""
121
+ """Sparse update - pass any of name/description/members/online. ``online`` itself may be
122
+ partial: ``online={"enabled": False}`` pauses live scoring, the engine merges the patch
123
+ over the stored profile. ``online=None`` detaches live scoring entirely. A partial
124
+ ``online=`` patch on a group with NO stored live profile is rejected by the engine
125
+ (400) - send the full profile the first time (the shape ``create`` documents)."""
126
+ # Same guard patterns.update/rules.update carry: the engine's schema strips keys it
127
+ # does not recognize, so a snake_case key would 200 with the group unchanged.
128
+ for key in fields:
129
+ if "_" in key:
130
+ first, *rest = key.split("_")
131
+ camel = first + "".join(part.capitalize() for part in rest)
132
+ raise ValueError(
133
+ f"Unknown scorer group field {key!r} - the engine reads camelCase keys and "
134
+ f"would silently ignore this; send {camel!r} instead."
135
+ )
114
136
  return ScorerGroup(self._request("PUT", f"{self._base}/{group_id}", json=fields)["scorerGroup"])
115
137
 
116
138
  def delete(self, group_id: str) -> None:
117
- self._request("DELETE", f"{self._base}/{group_id}")
139
+ # retry=False: a lost response + transport retry would turn a successful delete
140
+ # into a spurious 404.
141
+ self._request("DELETE", f"{self._base}/{group_id}", retry=False)
118
142
 
119
143
  def ratings(self, group_id: str, window: str = "7d") -> Dict[str, Any]:
120
144
  """Live score history for a group - ``{"window", "points": [{ts, averageRating, count}]}``,
agentx/monitor/scorers.py CHANGED
@@ -5,6 +5,7 @@ from typing import Any, Dict, List, Optional, Sequence
5
5
 
6
6
  import requests
7
7
 
8
+ from agentx.monitor._transport import request_with_retries
8
9
  from agentx.util import api_base, get_headers
9
10
  from agentx.exceptions import AgentXError, AgentXAuthError, AgentXValidationError
10
11
 
@@ -52,7 +53,9 @@ class ScorersClient:
52
53
  # Captured once at construction (deep-dive round 3, bug #1).
53
54
  self._base_url = (base_url or api_base()).rstrip("/")
54
55
 
55
- def _request(self, method: str, path: str, json: Any = None, params: Any = None) -> Any:
56
+ def _request(
57
+ self, method: str, path: str, json: Any = None, params: Any = None, retry: bool = True
58
+ ) -> Any:
56
59
  if self._workspace_id:
57
60
  # Mirrors MonitorClient._workspace_params/_with_workspace: GETs (and DELETEs)
58
61
  # carry workspaceId as a query param, write bodies carry it as a field.
@@ -63,9 +66,12 @@ class ScorersClient:
63
66
  json = {**json, "workspaceId": self._workspace_id}
64
67
  elif not (params or {}).get("workspaceId"):
65
68
  params = {**(params or {}), "workspaceId": self._workspace_id}
66
- resp = requests.request(
69
+ # retry=False for ANY non-idempotent write (creates, deletes, dry-run executions) -
70
+ # MonitorClient._request's posture, via the shared monitor transport.
71
+ resp = request_with_retries(
67
72
  method,
68
73
  f"{self._base_url}/agent-monitoring{path}",
74
+ retry=retry,
69
75
  headers={**get_headers(self._api_key), "Content-Type": "application/json"},
70
76
  json=json,
71
77
  params=params,
@@ -147,7 +153,8 @@ class ScorersClient:
147
153
  ``None`` to skip; a score below ``alert_below`` raises a signal."""
148
154
  if language not in ("python", "javascript"):
149
155
  raise AgentXScorersError('language must be "python" or "javascript"')
150
- return self._request("POST", "/custom-evaluators", json={
156
+ # Server-side create: a timeout + transport retry would deploy the scorer twice.
157
+ return self._request("POST", "/custom-evaluators", retry=False, json={
151
158
  "name": name,
152
159
  "kind": "code",
153
160
  "language": language,
@@ -173,7 +180,8 @@ class ScorersClient:
173
180
  agent_ids: Optional[Sequence[str]] = None,
174
181
  ) -> Dict[str, Any]:
175
182
  """Register an external scorer endpoint (POSTed the v2 payload per sampled trace)."""
176
- return self._request("POST", "/custom-evaluators", json={
183
+ # Server-side create: a timeout + transport retry would register the scorer twice.
184
+ return self._request("POST", "/custom-evaluators", retry=False, json={
177
185
  "name": name,
178
186
  "url": url,
179
187
  "sampleRate": sample_rate,
@@ -191,7 +199,9 @@ class ScorersClient:
191
199
  return self._request("PUT", f"/custom-evaluators/{scorer_id}", json=wire)["evaluator"]
192
200
 
193
201
  def delete(self, scorer_id: str) -> None:
194
- self._request("DELETE", f"/custom-evaluators/{scorer_id}")
202
+ # retry=False: a lost response + transport retry would turn a successful delete
203
+ # into a spurious 404.
204
+ self._request("DELETE", f"/custom-evaluators/{scorer_id}", retry=False)
195
205
 
196
206
  def events(self, scorer_id: str, window: str = "24h") -> List[Dict[str, Any]]:
197
207
  """The scorer's per-check history (score, matched, justification, trace ids)."""
@@ -201,7 +211,9 @@ class ScorersClient:
201
211
  """Execute a scorer against the built-in sample without persisting: pass either
202
212
  ``url=...`` (external) or ``kind="code", language=..., script=...`` (code)."""
203
213
  wire = {_SNAKE_TO_WIRE.get(k, k): v for k, v in payload.items()}
204
- return self._request("POST", "/custom-evaluators/dry-run", json=wire)
214
+ # Executes real scorer work (and, for external, hits the user's endpoint) - a
215
+ # client-side timeout must not fire it twice.
216
+ return self._request("POST", "/custom-evaluators/dry-run", json=wire, retry=False)
205
217
 
206
218
 
207
219
  _SNAKE_TO_WIRE = {
agentx/resources/agent.py CHANGED
@@ -1,14 +1,12 @@
1
1
  from typing import Optional, List
2
- from pydantic import BaseModel, Field
2
+ from pydantic import BaseModel, PrivateAttr, Field
3
3
  import requests
4
4
  import os
5
5
  import logging
6
- from dataclasses import dataclass
7
6
  from agentx.util import get_headers, api_base
8
7
  from .conversation import Conversation
9
8
 
10
9
 
11
- @dataclass
12
10
  class Agent(BaseModel):
13
11
  id: str = Field(alias="_id")
14
12
  name: str
@@ -16,16 +14,35 @@ class Agent(BaseModel):
16
14
  createdAt: Optional[str] = None
17
15
  updatedAt: Optional[str] = None
18
16
 
17
+
18
+ # Hosted credentials threaded from the constructing AgentX client. The module-level
19
+ # api_base()/get_headers() read only env vars, and the client deliberately stopped
20
+ # writing its constructor args into os.environ - without binding, a client created with
21
+ # api_key=/base_url= issued these calls unauthenticated against the default host.
22
+ _api_key: Optional[str] = PrivateAttr(default=None)
23
+ _base_url: Optional[str] = PrivateAttr(default=None)
24
+
25
+ def _bind(self, api_key: Optional[str], base_url: Optional[str]) -> "Agent":
26
+ self._api_key = api_key
27
+ self._base_url = base_url
28
+ return self
29
+
30
+ def _api_base(self) -> str:
31
+ return self._base_url or api_base()
32
+
33
+ def _headers(self):
34
+ return get_headers(self._api_key)
35
+
19
36
  def __init__(self, **data):
20
37
  super().__init__(**data)
21
38
 
22
39
  def new_conversation(self) -> Conversation:
23
- url = f"{api_base()}/access/agents/{self.id}/conversations/new"
24
- response = requests.post(url, headers=get_headers(), json={"type": "chat"})
40
+ url = f"{self._api_base()}/access/agents/{self.id}/conversations/new"
41
+ response = requests.post(url, headers=self._headers(), json={"type": "chat"})
25
42
  if response.status_code == 200:
26
43
  data = response.json()
27
44
  data["agent_id"] = self.id
28
- return Conversation(**data)
45
+ return Conversation(**data)._bind(self._api_key, self._base_url)
29
46
  else:
30
47
  raise Exception(f"Failed to create conversation: {response.reason}")
31
48
 
@@ -37,8 +54,8 @@ class Agent(BaseModel):
37
54
  )
38
55
 
39
56
  def list_conversations(self) -> List[Conversation]:
40
- url = f"{api_base()}/access/agents/{self.id}/conversations"
41
- response = requests.get(url, headers=get_headers())
57
+ url = f"{self._api_base()}/access/agents/{self.id}/conversations"
58
+ response = requests.get(url, headers=self._headers())
42
59
  if response.status_code == 200:
43
60
  return [
44
61
  Conversation(
@@ -49,7 +66,9 @@ class Agent(BaseModel):
49
66
  agents=conv_res.get("bots"),
50
67
  createdAt=conv_res.get("createdAt"),
51
68
  updatedAt=conv_res.get("updatedAt"),
52
- )
69
+ # Bound to this agent's own credentials (threaded in via Agent._bind) so
70
+ # the conversation's calls authenticate the same way this listing did.
71
+ )._bind(self._api_key, self._base_url)
53
72
  for conv_res in response.json()
54
73
  ]
55
74
  else:
@@ -1,7 +1,7 @@
1
1
  import json
2
2
  import requests
3
3
  from typing import Optional, List, Any, Iterator
4
- from pydantic import BaseModel, Field
4
+ from pydantic import BaseModel, PrivateAttr, Field
5
5
  from agentx.util import get_headers, api_base
6
6
 
7
7
 
@@ -42,28 +42,49 @@ class Conversation(BaseModel):
42
42
  populate_by_name = True
43
43
  extra = "ignore"
44
44
 
45
+
46
+ # Hosted credentials threaded from the constructing AgentX client. The module-level
47
+ # api_base()/get_headers() read only env vars, and the client deliberately stopped
48
+ # writing its constructor args into os.environ - without binding, a client created with
49
+ # api_key=/base_url= issued these calls unauthenticated against the default host.
50
+ _api_key: Optional[str] = PrivateAttr(default=None)
51
+ _base_url: Optional[str] = PrivateAttr(default=None)
52
+
53
+ def _bind(self, api_key: Optional[str], base_url: Optional[str]) -> "Conversation":
54
+ self._api_key = api_key
55
+ self._base_url = base_url
56
+ return self
57
+
58
+ def _api_base(self) -> str:
59
+ return self._base_url or api_base()
60
+
61
+ def _headers(self):
62
+ return get_headers(self._api_key)
63
+
45
64
  def __init__(self, **data):
46
65
  super().__init__(**data)
47
66
 
48
67
  def new_conversation(self) -> "Conversation":
49
- url = f"{api_base()}/access/agents/{self.agent_id}/conversations/new"
68
+ url = f"{self._api_base()}/access/agents/{self.agent_id}/conversations/new"
50
69
  response = requests.post(
51
70
  url,
52
- headers=get_headers(),
71
+ headers=self._headers(),
53
72
  json={"type": "chat"},
54
73
  )
55
74
  if response.status_code == 200:
56
75
  new_conv = response.json()
57
76
  new_conv["agent_id"] = self.agent_id
58
- return Conversation(**new_conv)
77
+ # Bound to this conversation's own credentials - unbound, the sibling
78
+ # conversation fell back to env credentials against the default host.
79
+ return Conversation(**new_conv)._bind(self._api_key, self._base_url)
59
80
  else:
60
81
  raise Exception(
61
82
  f"Failed to create new conversation: {response.status_code} - {response.reason}"
62
83
  )
63
84
 
64
85
  def list_messages(self) -> List[Message]:
65
- url = f"{api_base()}/access/agents/{self.agent_id}/conversations/{self.id}"
66
- response = requests.get(url, headers=get_headers())
86
+ url = f"{self._api_base()}/access/agents/{self.agent_id}/conversations/{self.id}"
87
+ response = requests.get(url, headers=self._headers())
67
88
  if response.status_code == 200:
68
89
  res = response.json()
69
90
  if res.get("messages"):
@@ -83,18 +104,18 @@ class Conversation(BaseModel):
83
104
  )
84
105
 
85
106
  def chat(self, message: str, context: Optional[int] = None):
86
- url = f"{api_base()}/access/conversations/{self.id}/message"
107
+ url = f"{self._api_base()}/access/conversations/{self.id}/message"
87
108
  response = requests.post(
88
109
  url,
89
- headers=get_headers(),
110
+ headers=self._headers(),
90
111
  json={"message": message, "context": context},
91
112
  )
92
113
  return response.json()
93
114
 
94
115
  def chat_stream(self, message: str, context: Optional[int] = None) -> Iterator[ChatResponse]:
95
- url = f"{api_base()}/access/conversations/{self.id}/jsonmessagesse"
116
+ url = f"{self._api_base()}/access/conversations/{self.id}/jsonmessagesse"
96
117
  response = requests.post(
97
- url, headers=get_headers(), json={"message": message, "context": context}
118
+ url, headers=self._headers(), json={"message": message, "context": context}
98
119
  )
99
120
  result = ""
100
121
  if response.status_code == 200:
@@ -1,5 +1,5 @@
1
1
  from typing import Optional, List, Dict, Any, Iterator
2
- from pydantic import BaseModel, Field
2
+ from pydantic import BaseModel, PrivateAttr, Field
3
3
  import requests
4
4
  import os
5
5
  import json
@@ -46,19 +46,44 @@ class Workforce(BaseModel):
46
46
  populate_by_name = True
47
47
  extra = "ignore"
48
48
 
49
+
50
+ # Hosted credentials threaded from the constructing AgentX client. The module-level
51
+ # api_base()/get_headers() read only env vars, and the client deliberately stopped
52
+ # writing its constructor args into os.environ - without binding, a client created with
53
+ # api_key=/base_url= issued these calls unauthenticated against the default host.
54
+ _api_key: Optional[str] = PrivateAttr(default=None)
55
+ _base_url: Optional[str] = PrivateAttr(default=None)
56
+
57
+ def _bind(self, api_key: Optional[str], base_url: Optional[str]) -> "Workforce":
58
+ self._api_key = api_key
59
+ self._base_url = base_url
60
+ # The nested Agent objects issue their own calls (new_conversation,
61
+ # list_conversations) - left unbound they silently fall back to env credentials
62
+ # against the default host, the exact leak _bind exists to close.
63
+ self.manager._bind(api_key, base_url)
64
+ for agent in self.agents:
65
+ agent._bind(api_key, base_url)
66
+ return self
67
+
68
+ def _api_base(self) -> str:
69
+ return self._base_url or api_base()
70
+
71
+ def _headers(self):
72
+ return get_headers(self._api_key)
73
+
49
74
  def new_conversation(self) -> Conversation:
50
75
  """Create a new conversation for this workforce."""
51
- url = f"{api_base()}/access/teams/{self.id}/conversations/new"
76
+ url = f"{self._api_base()}/access/teams/{self.id}/conversations/new"
52
77
  response = requests.post(
53
78
  url,
54
- headers=get_headers(),
79
+ headers=self._headers(),
55
80
  json={"type": "chat"},
56
81
  )
57
82
  if response.status_code == 200:
58
83
  conv_data = response.json()
59
84
  # Set the agent_id to the manager's ID since this is a workforce conversation
60
85
  conv_data["agent_id"] = self.manager.id
61
- return Conversation(**conv_data)
86
+ return Conversation(**conv_data)._bind(self._api_key, self._base_url)
62
87
  else:
63
88
  raise Exception(
64
89
  f"Failed to create new conversation: {response.status_code} - {response.reason}"
@@ -66,14 +91,14 @@ class Workforce(BaseModel):
66
91
 
67
92
  def list_conversations(self) -> List[Conversation]:
68
93
  """List all conversations for this workforce."""
69
- url = f"{api_base()}/access/teams/{self.id}/conversations"
70
- response = requests.get(url, headers=get_headers())
94
+ url = f"{self._api_base()}/access/teams/{self.id}/conversations"
95
+ response = requests.get(url, headers=self._headers())
71
96
  if response.status_code == 200:
72
97
  conversations = []
73
98
  for conv_data in response.json():
74
99
  # Set the agent_id to the manager's ID since this is a workforce conversation
75
100
  conv_data["agent_id"] = self.manager.id
76
- conversations.append(Conversation(**conv_data))
101
+ conversations.append(Conversation(**conv_data)._bind(self._api_key, self._base_url))
77
102
  return conversations
78
103
  else:
79
104
  raise Exception(
@@ -85,10 +110,10 @@ class Workforce(BaseModel):
85
110
  ) -> Iterator[ChatResponse]:
86
111
  """Send a message to a team conversation and stream the response."""
87
112
  url = (
88
- f"{api_base()}/access/teams/conversations/{conversation_id}/jsonmessagesse"
113
+ f"{self._api_base()}/access/teams/conversations/{conversation_id}/jsonmessagesse"
89
114
  )
90
115
  response = requests.post(
91
- url, headers=get_headers(), json={"message": message, "context": context}
116
+ url, headers=self._headers(), json={"message": message, "context": context}
92
117
  )
93
118
  result = ""
94
119
  if response.status_code == 200:
agentx/tracing/tracer.py CHANGED
@@ -931,9 +931,11 @@ class Tracer:
931
931
  Deliberately NOT a retrieval: retrieval spans feed the RAG judges' ``{context}``
932
932
  (knowledge grounding), while memory is recalled state - see the engine's spanKind.ts.
933
933
 
934
- With no active span the record is DROPPED (with a debug log), not queued: the only
935
- pending queue rides the next trace's ``retrieval_steps``, and memory content must
936
- never reach the engine's retrieval-context extraction for RAG judges. Wrap the call
934
+ With no active span the record is DROPPED (warns once per process, then logs at
935
+ debug), not queued. There are two pending queues (tool calls -> ``tool_calls``,
936
+ retrievals -> ``retrieval_steps``) and neither fits: ``retrieval_steps`` feeds the
937
+ engine's RAG ``{context}`` extraction, which recalled state must never reach, and no
938
+ memory-shaped queue has been built yet. Wrap the call
937
939
  in ``tracer.trace()`` to keep it - or, on a worker thread, wrap the worker body in
938
940
  ``tracer.use_span(span)`` - a bare thread starts with an empty span stack.
939
941
  (``record_tool_call``/``record_retrieval`` queue instead - see their docstrings.)
@@ -980,7 +982,7 @@ class Tracer:
980
982
  with tracer.trace_memory("user prefs", operation="read", query=user_id) as m:
981
983
  m.output = memory.search(user_id, question)
982
984
 
983
- With no active span the record is DROPPED (with a debug log), not queued - see
985
+ With no active span the record is DROPPED (warns once per process, then logs at debug), not queued - see
984
986
  :meth:`record_memory`. An exception escaping the block records the operation as
985
987
  failed (error set, output ``ERROR: ...``) and then propagates unchanged - same
986
988
  posture as :meth:`trace_tool_call`.
agentx/util.py CHANGED
@@ -1,6 +1,8 @@
1
1
  import os
2
2
  from typing import Optional
3
3
 
4
+ from agentx.exceptions import AgentXAuthError
5
+
4
6
  _DEFAULT_API_BASE = "https://api.agentx.so/api/v1"
5
7
 
6
8
  _EVALUATIONS_SUFFIX = "/custom-agent-evaluations"
@@ -26,4 +28,10 @@ def api_base() -> str:
26
28
 
27
29
 
28
30
  def get_headers(api_key: Optional[str] = None):
29
- return {"accept": "*/*", "x-api-key": api_key or os.getenv("AGENTX_API_KEY")}
31
+ key = api_key or os.getenv("AGENTX_API_KEY")
32
+ if not key:
33
+ # A None header serializes as the literal string "None" (or drops), turning a config
34
+ # mistake into an opaque 401 from the server - fail loud at the call site instead,
35
+ # with the SDK's canonical auth error so `except agentx.AgentXAuthError` catches it.
36
+ raise AgentXAuthError("No API key: pass api_key= or set AGENTX_API_KEY")
37
+ return {"accept": "*/*", "x-api-key": key}
agentx/version.py CHANGED
@@ -1,7 +1,7 @@
1
- VERSION = "0.8.24"
1
+ VERSION = "0.8.26"
2
2
 
3
3
  # The AgentX-trace-eval release this SDK version is tested against - what `agentx-trace-eval`
4
4
  # installs and converges to (see agentx/cli.py). Bump together with VERSION when releasing, so
5
5
  # every published SDK names a known-good engine+dashboard pair. Users can override with
6
6
  # AGENTX_TRACE_EVAL_VERSION=<tag|latest>.
7
- ENGINE_VERSION = "v0.3.26"
7
+ ENGINE_VERSION = "v0.3.29"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentx-python
3
- Version: 0.8.24
3
+ Version: 0.8.26
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
@@ -23,6 +23,8 @@ Provides-Extra: openai-agents
23
23
  Requires-Dist: openai-agents>=0.0.3; extra == "openai-agents"
24
24
  Provides-Extra: openai
25
25
  Requires-Dist: openai>=1.0.0; extra == "openai"
26
+ Provides-Extra: nvidia-nim
27
+ Requires-Dist: openai>=1.0.0; extra == "nvidia-nim"
26
28
  Provides-Extra: anthropic
27
29
  Requires-Dist: anthropic>=0.25.0; extra == "anthropic"
28
30
  Provides-Extra: google-adk
@@ -108,7 +110,9 @@ Also see [SDK Developer Docs](https://developers.agentx.so), [API Reference Docs
108
110
  pip install --upgrade agentx-python
109
111
  ```
110
112
 
111
- Requires Python 3.9 or newer.
113
+ Requires Python 3.9 or newer for the core SDK. Some integration extras have higher floors set
114
+ by their upstream packages - `[crewai]`, `[autogen]`, and `[databricks]` need Python 3.10+ (`[all]`
115
+ therefore does too); the core tracer and every REST surface stay 3.9-compatible.
112
116
 
113
117
  #### Run the self-host governance suite locally
114
118
 
@@ -248,6 +252,7 @@ extra:
248
252
  | CrewAI | `pip install "agentx-python[crewai]"` | `AgentXCrewObserver` |
249
253
  | OpenAI Agents SDK | `pip install "agentx-python[openai-agents]"` | `AgentXTracingProcessor` |
250
254
  | OpenAI (raw client) | `pip install "agentx-python[openai]"` | `patch_openai_client` |
255
+ | NVIDIA NIM | `pip install "agentx-python[nvidia-nim]"` | `patch_nim_client` |
251
256
  | Anthropic | `pip install "agentx-python[anthropic]"` | `patch_anthropic_client` |
252
257
  | Google ADK | `pip install "agentx-python[google-adk]"` | `AgentXADKPlugin` |
253
258
  | Google GenAI (Gemini) | `pip install "agentx-python[google-genai]"` | `patch_genai_client` |
@@ -1,5 +1,5 @@
1
1
  agentx/__init__.py,sha256=oIxNK1s1Gv7S5gKzhX0NC571TBvinzfn-OWevLMME_g,902
2
- agentx/agentx.py,sha256=2AzrlJsYX6qyzMh0sxNICvuAs9XrmX9floYbYqKrFeQ,11589
2
+ agentx/agentx.py,sha256=IgFIQAHJoo-FyqMCGY6RdRMmHAnjtBXKTcXWlyDkeE8,12040
3
3
  agentx/cli.py,sha256=yawLQLSeZ7KIl7ukgmly-lrYbx9GgKcaI9M4xamiMPw,10468
4
4
  agentx/exceptions.py,sha256=jATpl8mdgdBpRPOLXu8ltdjF6s_V0a5mWdW0K5mDg68,2115
5
5
  agentx/export.py,sha256=nvFRn_d-9W_cHCEsh6rwOrYzVMJosfw493DG8Gi2Ca8,4309
@@ -9,8 +9,8 @@ agentx/projects.py,sha256=5s8_ib5HV5kTlPCCm7VsbSYsPP9JaARsA8VjYNR7m6o,2584
9
9
  agentx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  agentx/testing.py,sha256=0shZEid_vhJgBegpO_loUq6APYDxQrUgD3jvcRdIDV8,7372
11
11
  agentx/traces.py,sha256=Jh07n3GLS1SGRsDoLDTUaAbuMEdvjqxCCMA9gUcSZ0M,2331
12
- agentx/util.py,sha256=i2WYeKIjAUA5bIsw-3kgrTt_0iGl1mINystllwRbF_4,1036
13
- agentx/version.py,sha256=4gGOBy15J6mct2czcxkwQDA2ZAqN7_-mpU1da7rD2yw,366
12
+ agentx/util.py,sha256=ivCuFQ5AGUw9fR9fJy5HrSp3lLwwngsDwppnvyZpq9E,1471
13
+ agentx/version.py,sha256=mHuhKZSPSp8Xp5Pq7q93wgdcMtBUM_OBt-nWG4Qoh6g,366
14
14
  agentx/evaluations/__init__.py,sha256=Erv7RGFlRxGTG4rVb2uHhCqLLEX_iAWKqkZKzK6CumE,262
15
15
  agentx/evaluations/_term.py,sha256=WFpiNzdgDBeJJ-Gg-6X7TwwxllobuE4OqFUTuDQvS3Y,2529
16
16
  agentx/evaluations/client.py,sha256=7c35tS8zM3fuvFfVDQk5etTTEFiIykmPjuJxkuFyFTY,38013
@@ -27,11 +27,11 @@ agentx/evaluations/adapters/__init__.py,sha256=fK8Hx75usbiY03XUSmnLnSrwBXip2CQs2
27
27
  agentx/evaluations/adapters/http_endpoint.py,sha256=-Gika9lBcXjtbnyB-uNR6nYEVJhWEPtDazi9ixLvJtg,2059
28
28
  agentx/evaluations/adapters/precomputed.py,sha256=vxnQELmpiU2NkQATfiVjG7v1ZmmkrEA70Y11ijfYtHw,1208
29
29
  agentx/evaluations/adapters/raw.py,sha256=FkDq_mdf21-xt5EHnGyIGcS6VZxkEkFcmM9VTd-T5sA,1130
30
- agentx/integrations/__init__.py,sha256=W-EcQhHE01-SGPeKTB2AgfvUyRzNP4BqUMMtP2qQ4Q0,647
30
+ agentx/integrations/__init__.py,sha256=p-YHLIudYxQj1bBj77NiVPsP0VQHemX5E59phkI3Y-Q,711
31
31
  agentx/integrations/_traced_call.py,sha256=OuFmSLw5vjI54gpdte7-zOt7gld7faQmvhuoh_3OPlo,6861
32
32
  agentx/integrations/anthropic.py,sha256=z6o9cC9rBzxxbF1UBiMr-kjEgDUO-BCuPOFoqGN3qF4,10451
33
33
  agentx/integrations/autogen.py,sha256=V1kvTjxQqOG9KsYd4dztaRYH7zTknW0_fqzKjIQG5dk,9507
34
- agentx/integrations/crewai.py,sha256=7YZhOGeztIg4F_5gGxUFHYprKCg7rJZFKHHcPcMG5ZM,14170
34
+ agentx/integrations/crewai.py,sha256=SCObRsrS40P8jOGoI_T5oW4IYuoBW5WjBNuc7zUBU70,14346
35
35
  agentx/integrations/databricks.py,sha256=vKXnur-LWMTzctFuIKte2N826sydXVlpi1qFXo2dfio,18044
36
36
  agentx/integrations/google_adk.py,sha256=AHJFpt8ZKHuURqKQ9xVVvFqIm16R4bfRSJtIuWuL110,19213
37
37
  agentx/integrations/google_genai.py,sha256=2patIdgyjEEW70FLP7ng4hcnFY-p0a1jPi1Pof28KBo,13272
@@ -39,36 +39,38 @@ agentx/integrations/langchain.py,sha256=YDeEFT2irhoEvydwt3QjbmTpk_AZZbYPS9euOR18
39
39
  agentx/integrations/litellm.py,sha256=eW8iCaNq0feRbZma8iOvmJ28WiAO7ZaRmlpPjHAtcvo,7072
40
40
  agentx/integrations/llamaindex.py,sha256=ruzMiC2TC3Xhub4xoXKPHjsvzV1sQ3QqhjkAdMxUUi4,18001
41
41
  agentx/integrations/moveworks.py,sha256=IyBswLE5LwMSp1VbIvG5izYV5BvXmv4atZelnrDn8SE,25670
42
- agentx/integrations/openai.py,sha256=1KLs-hJaeW2emHPwNkmC3zcGksL3zd87uWbcLGRW704,6644
42
+ agentx/integrations/nvidia_nim.py,sha256=bvS6ebEnQZT1DVlIjQQ2RKLI4uPPTdlMKINgDwj0Z-o,2961
43
+ agentx/integrations/openai.py,sha256=edJL-HXTO7w-vvIZGQChMu0AnPCY5z0duplIi8z2gXE,6936
43
44
  agentx/integrations/openai_agents.py,sha256=KSOQ55eRPFxxkqm7p4Dsz6HcJ_9uUEwlyywPI8dwbvY,14852
44
- agentx/monitor/__init__.py,sha256=XqK1YV9FPn79gikbNx3_8MVY4czJJ8Q7eBxZzvjrhpw,1366
45
+ agentx/monitor/__init__.py,sha256=Rkk-Xv1ymXwDFbx8fetmcOzXfSU1bvsazI_-x5Ati5g,1715
46
+ agentx/monitor/_transport.py,sha256=ge0pmdvFdUtCJRkTLvAgjuTQVA8YOCYGQNoyRuselRk,2077
45
47
  agentx/monitor/agents.py,sha256=KhsHVhkqa6V0e0lPcuo04fh44_qSaJOaGcD6U6qXSFo,1622
46
- agentx/monitor/client.py,sha256=7ZcGBs3XTLNMEwXrQ7QE2zP8r1G4OZvZ2blQ-zi9cj0,27109
47
- agentx/monitor/improvement_groups.py,sha256=RlNHuDEfy9Cb9ZOnvau5zdB-YFHiqJsf_DI35iUjr3A,5505
48
- agentx/monitor/judge_scorers.py,sha256=ZailnmPJz6SxvyHBMUdvuqRjIX_TgpJKtIzulS8Zu_4,19868
48
+ agentx/monitor/client.py,sha256=JoKp3AmFtQ4UDOsNH5nWEkTjvQtBrrihObGaPVMtBYo,27270
49
+ agentx/monitor/improvement_groups.py,sha256=MrXexaBLPNzZHrqZjiCPfmwa-DSr-RHuFdP73SKrMJM,6143
50
+ agentx/monitor/judge_scorers.py,sha256=Xyoafo9ljeq5RpLe5HxvsL0Y0FebFQ-2CxYz8-E1Wio,21025
49
51
  agentx/monitor/models.py,sha256=mNBQDXdVthGYHUyEeRgpFdKVOC9uMtdUgBlLfep1E14,9298
50
52
  agentx/monitor/online_evaluators.py,sha256=YWnsBmlIbYvj4LCaX4guwx4HS5y3oEzCQfo6ae9VH_w,8693
51
- agentx/monitor/patterns.py,sha256=AEYv34X4xWCUrC0C0WV470PoSz1olvDOZ6vqGeAu79g,5948
53
+ agentx/monitor/patterns.py,sha256=mHYqsigHXKkgUKYjHjj-ENXjqJZh9xpe73jdFgtsXcM,11758
52
54
  agentx/monitor/profile.py,sha256=2ZT0HwTm47lcd_KKpAMfi5SU4vKQLj4C1IRTd5SELOY,3210
53
55
  agentx/monitor/review_queue.py,sha256=i6Dp8ezTkGva0DBRhLOLgBqFychVFdig73jIpS6wo_g,4104
54
- agentx/monitor/rules.py,sha256=2cBLe4_DN8ck37VYxFgs1azkRFBwi0bGTl5crRAJJRM,3366
55
- agentx/monitor/scorer_groups.py,sha256=OS-gBnLURAc3zMcdlAnGM3mrBcp6vxoDDsua11QN7LQ,5568
56
- agentx/monitor/scorers.py,sha256=poYfVl6vsuwSSfDifS222pR6LyER3UudznAJ3jX8GNQ,9649
56
+ agentx/monitor/rules.py,sha256=u5ezzMt8-YQHwH-XJLg4YAAox88iO0VTaC6R4ZdcGUw,4132
57
+ agentx/monitor/scorer_groups.py,sha256=uEtRvGpn2ho3O4AhkHyt_u1h0G5MS1fbUJCk_Pn4T4U,7019
58
+ agentx/monitor/scorers.py,sha256=NCag_CwUKOiDsmriKqKsoEhy6DBS7H0AVW5w8Ma7Nps,10432
57
59
  agentx/monitor/sessions.py,sha256=bJOp-yBgnKczq2J8jsPSrstlViOkdGGN7ixH3oOYb7Q,2472
58
60
  agentx/monitor/signals.py,sha256=Ld11lW2lbg8NHBUHdgoY6iwCvJDK-eg4ge4qQ9y7nGY,1432
59
61
  agentx/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
60
- agentx/resources/agent.py,sha256=ZKpxDYzJbKgOX4-W86U__b4gko7XlbwB4q6L8Az9uo0,2011
61
- agentx/resources/conversation.py,sha256=94pOdM6Pmj67RhikZVBNGkT3V-WKK_ZAD7s27p5-N0Y,4463
62
- agentx/resources/workforce.py,sha256=lfGVkoV9lcOp-lZScjTvwRarJMkhzfMfLm4syJDujGc,4168
62
+ agentx/resources/agent.py,sha256=1WH6v6uMueqOI0TSFPTWQ_Ql3SRcq-uB7MkduPJC2qs,3033
63
+ agentx/resources/conversation.py,sha256=fPqvsmKW7VsHt3RDA__70I_FL6Imwm_ssmU3ObTVhec,5496
64
+ agentx/resources/workforce.py,sha256=cB9baw2ilf7Grw_xz1W0T3LENxo8HhrOjDwwppOrAv8,5424
63
65
  agentx/tracing/__init__.py,sha256=l2mIRILE-wWrCD0fekgrIOQUEvtDObzdrkgpQUtXroU,408
64
66
  agentx/tracing/ci_types.py,sha256=b-W2LowRhNBVUdaoMPgd4bnKQy9AxhfiikftRwGPAqU,1778
65
67
  agentx/tracing/eval_scope.py,sha256=ElMbPxpqpQBVuaUnHNlw9RIQu71oyOpd0yR55bDH8eI,2144
66
68
  agentx/tracing/framework_detect.py,sha256=uV4O7Th-4_2UkdooAyaWCOIA0jwLJbblFQ_ucFDjeJM,2638
67
69
  agentx/tracing/ingest_client.py,sha256=teukTPckFWPg5RhpbGbVDBejBzPIPXUO3_DJcuVh2uc,22660
68
- agentx/tracing/tracer.py,sha256=P1vixRBhkFE-XhDumTFHHc-TW3R4PeZ7MeCqKcA6mSE,68368
69
- agentx_python-0.8.24.dist-info/licenses/LICENSE,sha256=gZVsM-nLsE8vlaY6NXXsVoo6IlCClxkToAWmhgT3y_s,10762
70
- agentx_python-0.8.24.dist-info/METADATA,sha256=9d42oTt7FlCeNdYO3vvwkz068omkH8ZmOUDoYI-tdM8,22561
71
- agentx_python-0.8.24.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
72
- agentx_python-0.8.24.dist-info/entry_points.txt,sha256=rQqF1JTY3T1yfviBU1r8PU-5mBdfOk6P4bZ1L4BskIM,172
73
- agentx_python-0.8.24.dist-info/top_level.txt,sha256=s-q-HB9Gb_QdrZNacSeQyF_c25gQooMy7DlxzgLOHPk,7
74
- agentx_python-0.8.24.dist-info/RECORD,,
70
+ agentx/tracing/tracer.py,sha256=NS0bs5HbDDK3l0Z95KVKV3xCNJT7LINS0EoPFM-wzIk,68543
71
+ agentx_python-0.8.26.dist-info/licenses/LICENSE,sha256=gZVsM-nLsE8vlaY6NXXsVoo6IlCClxkToAWmhgT3y_s,10762
72
+ agentx_python-0.8.26.dist-info/METADATA,sha256=JH0SY8e_dBGzmK92xd_Cas62qpPweH0BoBLbDmE7xPA,22986
73
+ agentx_python-0.8.26.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
74
+ agentx_python-0.8.26.dist-info/entry_points.txt,sha256=rQqF1JTY3T1yfviBU1r8PU-5mBdfOk6P4bZ1L4BskIM,172
75
+ agentx_python-0.8.26.dist-info/top_level.txt,sha256=s-q-HB9Gb_QdrZNacSeQyF_c25gQooMy7DlxzgLOHPk,7
76
+ agentx_python-0.8.26.dist-info/RECORD,,