agentx-python 0.8.24__tar.gz → 0.8.25__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 (102) hide show
  1. {agentx_python-0.8.24/agentx_python.egg-info → agentx_python-0.8.25}/PKG-INFO +4 -2
  2. {agentx_python-0.8.24 → agentx_python-0.8.25}/README.md +3 -1
  3. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/agentx.py +16 -12
  4. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/integrations/crewai.py +2 -2
  5. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/monitor/__init__.py +9 -0
  6. agentx_python-0.8.25/agentx/monitor/_transport.py +48 -0
  7. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/monitor/client.py +5 -3
  8. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/monitor/improvement_groups.py +16 -4
  9. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/monitor/judge_scorers.py +33 -6
  10. agentx_python-0.8.25/agentx/monitor/patterns.py +250 -0
  11. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/monitor/rules.py +15 -2
  12. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/monitor/scorer_groups.py +30 -6
  13. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/monitor/scorers.py +18 -6
  14. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/resources/agent.py +28 -9
  15. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/resources/conversation.py +31 -10
  16. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/resources/workforce.py +34 -9
  17. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/tracing/tracer.py +6 -4
  18. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/util.py +9 -1
  19. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/version.py +2 -2
  20. {agentx_python-0.8.24 → agentx_python-0.8.25/agentx_python.egg-info}/PKG-INFO +4 -2
  21. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx_python.egg-info/SOURCES.txt +4 -1
  22. {agentx_python-0.8.24 → agentx_python-0.8.25}/tests/test_judge_scorers.py +1 -1
  23. agentx_python-0.8.25/tests/test_pattern_update_merge.py +200 -0
  24. {agentx_python-0.8.24 → agentx_python-0.8.25}/tests/test_span_tree.py +30 -1
  25. agentx_python-0.8.25/tests/test_workforce_binding.py +71 -0
  26. agentx_python-0.8.24/agentx/monitor/patterns.py +0 -158
  27. {agentx_python-0.8.24 → agentx_python-0.8.25}/LICENSE +0 -0
  28. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/__init__.py +0 -0
  29. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/cli.py +0 -0
  30. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/evaluations/__init__.py +0 -0
  31. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/evaluations/_term.py +0 -0
  32. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/evaluations/adapters/__init__.py +0 -0
  33. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/evaluations/adapters/http_endpoint.py +0 -0
  34. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/evaluations/adapters/precomputed.py +0 -0
  35. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/evaluations/adapters/raw.py +0 -0
  36. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/evaluations/client.py +0 -0
  37. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/evaluations/datasets.py +0 -0
  38. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/evaluations/evaluation_settings.py +0 -0
  39. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/evaluations/models.py +0 -0
  40. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/evaluations/prompts.py +0 -0
  41. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/evaluations/reporting.py +0 -0
  42. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/evaluations/results.py +0 -0
  43. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/evaluations/runner.py +0 -0
  44. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/evaluations/tool_schemas.py +0 -0
  45. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/evaluations/tracing.py +0 -0
  46. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/exceptions.py +0 -0
  47. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/export.py +0 -0
  48. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/feedback.py +0 -0
  49. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/integrations/__init__.py +0 -0
  50. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/integrations/_traced_call.py +0 -0
  51. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/integrations/anthropic.py +0 -0
  52. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/integrations/autogen.py +0 -0
  53. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/integrations/databricks.py +0 -0
  54. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/integrations/google_adk.py +0 -0
  55. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/integrations/google_genai.py +0 -0
  56. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/integrations/langchain.py +0 -0
  57. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/integrations/litellm.py +0 -0
  58. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/integrations/llamaindex.py +0 -0
  59. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/integrations/moveworks.py +0 -0
  60. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/integrations/openai.py +0 -0
  61. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/integrations/openai_agents.py +0 -0
  62. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/monitor/agents.py +0 -0
  63. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/monitor/models.py +0 -0
  64. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/monitor/online_evaluators.py +0 -0
  65. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/monitor/profile.py +0 -0
  66. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/monitor/review_queue.py +0 -0
  67. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/monitor/sessions.py +0 -0
  68. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/monitor/signals.py +0 -0
  69. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/outcomes.py +0 -0
  70. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/projects.py +0 -0
  71. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/py.typed +0 -0
  72. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/resources/__init__.py +0 -0
  73. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/testing.py +0 -0
  74. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/traces.py +0 -0
  75. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/tracing/__init__.py +0 -0
  76. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/tracing/ci_types.py +0 -0
  77. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/tracing/eval_scope.py +0 -0
  78. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/tracing/framework_detect.py +0 -0
  79. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx/tracing/ingest_client.py +0 -0
  80. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx_python.egg-info/dependency_links.txt +0 -0
  81. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx_python.egg-info/entry_points.txt +0 -0
  82. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx_python.egg-info/not-zip-safe +0 -0
  83. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx_python.egg-info/requires.txt +0 -0
  84. {agentx_python-0.8.24 → agentx_python-0.8.25}/agentx_python.egg-info/top_level.txt +0 -0
  85. {agentx_python-0.8.24 → agentx_python-0.8.25}/setup.cfg +0 -0
  86. {agentx_python-0.8.24 → agentx_python-0.8.25}/setup.py +0 -0
  87. {agentx_python-0.8.24 → agentx_python-0.8.25}/tests/test_cli_launcher.py +0 -0
  88. {agentx_python-0.8.24 → agentx_python-0.8.25}/tests/test_deep_dive_fixes.py +0 -0
  89. {agentx_python-0.8.24 → agentx_python-0.8.25}/tests/test_docs_match_sdk.py +0 -0
  90. {agentx_python-0.8.24 → agentx_python-0.8.25}/tests/test_error_taxonomy.py +0 -0
  91. {agentx_python-0.8.24 → agentx_python-0.8.25}/tests/test_eval_scope.py +0 -0
  92. {agentx_python-0.8.24 → agentx_python-0.8.25}/tests/test_framework_detect.py +0 -0
  93. {agentx_python-0.8.24 → agentx_python-0.8.25}/tests/test_integration.py +0 -0
  94. {agentx_python-0.8.24 → agentx_python-0.8.25}/tests/test_integrations.py +0 -0
  95. {agentx_python-0.8.24 → agentx_python-0.8.25}/tests/test_multi_judge.py +0 -0
  96. {agentx_python-0.8.24 → agentx_python-0.8.25}/tests/test_pairwise.py +0 -0
  97. {agentx_python-0.8.24 → agentx_python-0.8.25}/tests/test_review_queue.py +0 -0
  98. {agentx_python-0.8.24 → agentx_python-0.8.25}/tests/test_runner_features.py +0 -0
  99. {agentx_python-0.8.24 → agentx_python-0.8.25}/tests/test_selfhost_analysis_fallback.py +0 -0
  100. {agentx_python-0.8.24 → agentx_python-0.8.25}/tests/test_selfhost_compat.py +0 -0
  101. {agentx_python-0.8.24 → agentx_python-0.8.25}/tests/test_testing.py +0 -0
  102. {agentx_python-0.8.24 → agentx_python-0.8.25}/tests/test_wire_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentx-python
3
- Version: 0.8.24
3
+ Version: 0.8.25
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
@@ -108,7 +108,9 @@ Also see [SDK Developer Docs](https://developers.agentx.so), [API Reference Docs
108
108
  pip install --upgrade agentx-python
109
109
  ```
110
110
 
111
- Requires Python 3.9 or newer.
111
+ Requires Python 3.9 or newer for the core SDK. Some integration extras have higher floors set
112
+ by their upstream packages - `[crewai]`, `[autogen]`, and `[databricks]` need Python 3.10+ (`[all]`
113
+ therefore does too); the core tracer and every REST surface stay 3.9-compatible.
112
114
 
113
115
  #### Run the self-host governance suite locally
114
116
 
@@ -43,7 +43,9 @@ Also see [SDK Developer Docs](https://developers.agentx.so), [API Reference Docs
43
43
  pip install --upgrade agentx-python
44
44
  ```
45
45
 
46
- Requires Python 3.9 or newer.
46
+ Requires Python 3.9 or newer for the core SDK. Some integration extras have higher floors set
47
+ by their upstream packages - `[crewai]`, `[autogen]`, and `[databricks]` need Python 3.10+ (`[all]`
48
+ therefore does too); the core tracer and every REST surface stay 3.9-compatible.
47
49
 
48
50
  #### Run the self-host governance suite locally
49
51
 
@@ -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}"
@@ -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(
@@ -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
@@ -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
@@ -0,0 +1,250 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any, Dict, List, Optional, TYPE_CHECKING
5
+
6
+ from agentx.monitor.models import MonitorPattern
7
+
8
+ if TYPE_CHECKING:
9
+ from agentx.monitor.client import MonitorClient
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class MonitorPatternBuilder:
15
+ """Fluent builder for creating a custom pattern. ``detector_kind`` selects which of
16
+ ``include_terms``/``regex``/``semantic_prompt`` is used:
17
+
18
+ - ``"contains"`` (default): ``include_terms`` - a match if any (or all, with
19
+ ``match_mode="all"``) phrase appears in the target text.
20
+ - ``"regex"``: ``regex`` - a single regular expression.
21
+ - ``"semantic"``: ``semantic_prompt`` - an LLM judges whether the response violates the
22
+ described rubric.
23
+
24
+ ``conditions`` (self-host) is the engine's full N-condition model - a list of condition
25
+ dicts, each with its own detector kind and match settings - passed through verbatim to
26
+ the create payload; when set, the engine honors it as the pattern's whole rule set and
27
+ the flat fields above are only legacy display metadata.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ client: "MonitorClient",
33
+ name: str,
34
+ description: Optional[str] = None,
35
+ category: Optional[str] = None,
36
+ detector_kind: str = "contains",
37
+ match_target: Optional[List[str]] = None,
38
+ match_mode: str = "any",
39
+ include_terms: Optional[List[str]] = None,
40
+ exclude_terms: Optional[List[str]] = None,
41
+ regex: Optional[str] = None,
42
+ semantic_prompt: Optional[str] = None,
43
+ severity: str = "medium",
44
+ polarity: str = "failure",
45
+ enabled: bool = True,
46
+ sample_rate: float = 1.0,
47
+ scope_mode: str = "all",
48
+ agent_ids: Optional[List[str]] = None,
49
+ conditions: Optional[List[dict]] = None,
50
+ ):
51
+ self._client = client
52
+ self._payload: Dict[str, Any] = {
53
+ "name": name,
54
+ "description": description,
55
+ "category": category,
56
+ "detectorKind": detector_kind,
57
+ "matchTarget": match_target or ["response"],
58
+ "matchMode": match_mode,
59
+ "includeTerms": include_terms or [],
60
+ "excludeTerms": exclude_terms or [],
61
+ "regex": regex,
62
+ "semanticPrompt": semantic_prompt,
63
+ "severity": severity,
64
+ # A "failure" pattern (default) raises a signal to triage; a "proper" pattern logs
65
+ # a healthy tally instead.
66
+ "polarity": polarity,
67
+ "enabled": enabled,
68
+ "sampleRate": sample_rate,
69
+ "scopeMode": scope_mode,
70
+ "agentIds": agent_ids or [],
71
+ }
72
+ # Passed through verbatim - the engine honors body.conditions as the full
73
+ # N-condition model (see the class docstring).
74
+ if conditions is not None:
75
+ self._payload["conditions"] = conditions
76
+
77
+ def publish(self) -> MonitorPattern:
78
+ logger.info("Publishing monitor pattern '%s'", self._payload["name"])
79
+ return self._client.create_pattern(self._payload)
80
+
81
+
82
+ class MonitorPatternClient:
83
+ """Thin wrapper surfaced as ``client.monitor.patterns``."""
84
+
85
+ def __init__(self, client: "MonitorClient"):
86
+ self._client = client
87
+
88
+ def builder(
89
+ self,
90
+ name: str,
91
+ description: Optional[str] = None,
92
+ category: Optional[str] = None,
93
+ detector_kind: str = "contains",
94
+ match_target: Optional[List[str]] = None,
95
+ match_mode: str = "any",
96
+ include_terms: Optional[List[str]] = None,
97
+ exclude_terms: Optional[List[str]] = None,
98
+ regex: Optional[str] = None,
99
+ semantic_prompt: Optional[str] = None,
100
+ severity: str = "medium",
101
+ polarity: str = "failure",
102
+ enabled: bool = True,
103
+ sample_rate: float = 1.0,
104
+ scope_mode: str = "all",
105
+ agent_ids: Optional[List[str]] = None,
106
+ conditions: Optional[List[dict]] = None,
107
+ ) -> MonitorPatternBuilder:
108
+ return MonitorPatternBuilder(
109
+ self._client,
110
+ name=name,
111
+ description=description,
112
+ category=category,
113
+ detector_kind=detector_kind,
114
+ match_target=match_target,
115
+ match_mode=match_mode,
116
+ include_terms=include_terms,
117
+ exclude_terms=exclude_terms,
118
+ regex=regex,
119
+ semantic_prompt=semantic_prompt,
120
+ severity=severity,
121
+ polarity=polarity,
122
+ enabled=enabled,
123
+ sample_rate=sample_rate,
124
+ scope_mode=scope_mode,
125
+ agent_ids=agent_ids,
126
+ conditions=conditions,
127
+ )
128
+
129
+ def delete(self, pattern_id: str) -> None:
130
+ """Delete a pattern. Its historical signals remain as history."""
131
+ # retry=False: a lost response + transport retry would turn a successful
132
+ # delete into a spurious 404.
133
+ self._client._request(
134
+ "DELETE",
135
+ f"/agent-monitoring/patterns/{pattern_id}",
136
+ base=self._client._api_root(),
137
+ retry=False,
138
+ )
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
+
170
+ def update(self, pattern_id: str, **fields: Any) -> MonitorPattern:
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)
235
+ data = self._client._request(
236
+ "PUT",
237
+ f"/agent-monitoring/patterns/{pattern_id}",
238
+ base=self._client._api_root(),
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.
242
+ retry=False,
243
+ )
244
+ return MonitorPattern(**data["pattern"])
245
+
246
+ def get(self, pattern_id: str) -> MonitorPattern:
247
+ return self._client.get_pattern(pattern_id)
248
+
249
+ def list(self) -> List[MonitorPattern]:
250
+ return self._client.list_patterns()
@@ -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