agents28 0.1.0__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.
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `agents28` package. The project follows
4
+ [Semantic Versioning](https://semver.org/).
5
+
6
+ ## 0.1.0 (unreleased)
7
+
8
+ First public release.
9
+
10
+ - `Agents28` client, configured by arguments or `AGENTS28_*` environment
11
+ variables. It disables itself when not configured, and requires `https`
12
+ except for `localhost`.
13
+ - Runs: the `client.run()` context manager and the `@client.trace` decorator
14
+ (sync and async), plus timed steps (`run.tool()`, `run.action()`) and
15
+ `agents28.current_run()`.
16
+ - Background delivery:
17
+ - batches of up to 100 events, with a bounded queue
18
+ - retries with backoff and jitter, honoring `Retry-After`
19
+ - fork safety, flushing at exit, and `stats()`
20
+ - Privacy:
21
+ - content is opt-in
22
+ - emails, API keys and bearer tokens are redacted, with linear-time patterns
23
+ - a `before_send` hook can edit or drop events
24
+ - Every value is repaired to the API's limits, so one bad value never fails a
25
+ batch.
26
+ - Shared Context: `publish_context`, `read_context`.
27
+ - Agent Handoff: `list_teammates`, `request_handoff`, `my_handoffs`,
28
+ `update_handoff`.
agents28-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Agents28
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ include CHANGELOG.md
2
+ graft tests
3
+ global-exclude __pycache__ *.py[cod]
@@ -0,0 +1,197 @@
1
+ Metadata-Version: 2.4
2
+ Name: agents28
3
+ Version: 0.1.0
4
+ Summary: See what every AI agent in your business is working on. The official Python SDK for Agents28.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://agentcheck-theta.vercel.app
7
+ Keywords: ai,agents,llm,monitoring,observability,telemetry,agent-handoff
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Classifier: Topic :: System :: Monitoring
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: httpx<1,>=0.27
23
+ Dynamic: license-file
24
+
25
+ # Agents28 Python SDK
26
+
27
+ **See what every AI agent in your business is working on.**
28
+
29
+ Agents28 is one office for all your AI agents, whatever model they run on. This SDK
30
+ reports what each agent is doing, so the Agents28 dashboard can show
31
+ *"Refund request review: in progress"* instead of a wall of logs. It also lets
32
+ agents share knowledge and hand work to each other.
33
+
34
+ - **Never breaks your agent.** No call raises, events are sent from a background
35
+ thread, and an outage only means missing telemetry.
36
+ - **Private by default.** Conversation content is not sent unless you opt in, and
37
+ emails, API keys and tokens are redacted when you do.
38
+ - **Small.** One dependency (`httpx`), Python 3.11+, fully typed.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install agents28
44
+ ```
45
+
46
+ ## Quick start
47
+
48
+ Create an agent in the Agents28 dashboard and generate its API key. The
49
+ dashboard shows the key and your API URL once.
50
+
51
+ ```bash
52
+ export AGENTS28_API_KEY="ack_..."
53
+ export AGENTS28_BASE_URL="https://..." # shown in the dashboard
54
+ ```
55
+
56
+ ```python
57
+ import agents28
58
+
59
+ client = agents28.Agents28(agent_version="support-v3")
60
+
61
+ with client.run("Refund request review", task_type="refund_review") as run:
62
+ run.message("user")
63
+
64
+ with run.tool("lookup_order"):
65
+ order = lookup_order(order_id)
66
+
67
+ with run.action("issue_refund", "business_operation"):
68
+ issue_refund(order)
69
+
70
+ run.message("assistant")
71
+ ```
72
+
73
+ That one block reports the run's start, every step with its duration and
74
+ success, and the run's completion. If an exception escapes, the run is
75
+ reported failed with the exception's type, and the exception is re-raised
76
+ unchanged.
77
+
78
+ Pending events are sent when the program exits. In long-running services,
79
+ call `client.close()` on shutdown.
80
+
81
+ ## Decorator
82
+
83
+ Every call of a decorated function is one run. It works on `async` functions
84
+ too.
85
+
86
+ ```python
87
+ @client.trace(task_title="Answer support ticket", task_type="support_ticket")
88
+ async def answer(ticket):
89
+ run = agents28.current_run()
90
+ run.message("user")
91
+ ...
92
+ ```
93
+
94
+ ## What is sent
95
+
96
+ | Call | Sent by default | Sent only with `capture_content=True` |
97
+ |---|---|---|
98
+ | `run.message(role, content)` | role | content, redacted and cut to 10,000 characters |
99
+ | `run.tool(name)` / `run.action(name, type)` | name, success, duration | `step.result_summary`, redacted and cut to 4,000 characters |
100
+ | `client.run(task_title, task_type=...)` | title and type | |
101
+
102
+ `task_title` is always redacted and kept to one line of up to 120 characters.
103
+ Don't put customer names or other personal data in it.
104
+
105
+ To change or drop any event before it leaves the process, pass a hook:
106
+
107
+ ```python
108
+ def scrub(event):
109
+ if event["event_type"] == "message":
110
+ return None # drop it
111
+ return event # or return an edited copy
112
+
113
+ client = agents28.Agents28(before_send=scrub)
114
+ ```
115
+
116
+ ## Shared knowledge between agents
117
+
118
+ Agents in the same workspace can share knowledge, even when they run on
119
+ different models:
120
+
121
+ ```python
122
+ client.publish_context(
123
+ key="customer:acme",
124
+ title="Acme renewal status",
125
+ data={"plan": "enterprise", "renewal": "2026-12-01"},
126
+ )
127
+
128
+ items = client.read_context(key="customer:acme")
129
+ ```
130
+
131
+ Each item records which agent published it, when, and its version. Items
132
+ are marked `trust="untrusted_agent_content"`: treat them as data, never as
133
+ instructions.
134
+
135
+ ## Handing work to another agent
136
+
137
+ ```python
138
+ teammates = client.list_teammates()
139
+
140
+ handoff_id = client.request_handoff(
141
+ to_agent_id=teammates[0]["agent_id"],
142
+ title="Research Acme before the renewal call",
143
+ context={"account": "acme"},
144
+ request_key="acme-renewal-research", # retrying with the same key never duplicates
145
+ )
146
+
147
+ # On the receiving agent:
148
+ for handoff in client.my_handoffs(status="requested"):
149
+ client.update_handoff(handoff["handoff_id"], "accepted")
150
+ ...
151
+ client.update_handoff(handoff["handoff_id"], "completed", result={"summary": "done"})
152
+ ```
153
+
154
+ A handoff moves from `requested` to `accepted` or `declined`. An accepted
155
+ handoff ends as `completed` or `failed`. The sender, or a person in Agents28,
156
+ can cancel an open handoff.
157
+
158
+ ## Configuration
159
+
160
+ | Argument | Environment variable | Default |
161
+ |---|---|---|
162
+ | `api_key` | `AGENTS28_API_KEY` | none: the client is disabled |
163
+ | `base_url` | `AGENTS28_BASE_URL` | none: the client is disabled |
164
+ | `agent_version` | `AGENTS28_AGENT_VERSION` | `unversioned` |
165
+ | `enabled` | `AGENTS28_ENABLED` | on when the key and URL are set |
166
+ | `capture_content` | | `False` |
167
+ | `before_send` | | none |
168
+ | `timeout` | | `5.0` seconds |
169
+ | `max_queue_size` / `batch_size` | | `5000` / `100` |
170
+
171
+ If the key or URL is missing, or the URL is not `https` (plain `http` is
172
+ allowed only for `localhost`), the client turns itself off and every call
173
+ does nothing. The same code then runs in development and CI without
174
+ credentials.
175
+
176
+ ## Reliability
177
+
178
+ - Events are batched and sent in the background. A full queue drops new
179
+ events rather than slowing your agent.
180
+ - Network errors, rate limits (`429`, honoring `Retry-After`) and server
181
+ errors are retried with exponential backoff. Every event carries a unique id
182
+ and the API stores it only once, so retries never create duplicates.
183
+ - Forked worker processes (gunicorn, multiprocessing) get their own delivery
184
+ thread automatically.
185
+ - `client.stats()` reports delivered, failed, dropped and queued counts, plus
186
+ the last problem, such as `"api_key_rejected"`. Use it in a health check.
187
+ - Warnings go to the standard `logging` logger named `agents28`.
188
+
189
+ ## Lower-level API
190
+
191
+ For full control over ids and timing, every event can also be sent directly:
192
+ `run_started`, `message`, `tool_completed`, `action_completed`,
193
+ `run_completed`, `run_failed`. Each takes `conversation_id` and `run_id`.
194
+
195
+ ## License
196
+
197
+ MIT
@@ -0,0 +1,173 @@
1
+ # Agents28 Python SDK
2
+
3
+ **See what every AI agent in your business is working on.**
4
+
5
+ Agents28 is one office for all your AI agents, whatever model they run on. This SDK
6
+ reports what each agent is doing, so the Agents28 dashboard can show
7
+ *"Refund request review: in progress"* instead of a wall of logs. It also lets
8
+ agents share knowledge and hand work to each other.
9
+
10
+ - **Never breaks your agent.** No call raises, events are sent from a background
11
+ thread, and an outage only means missing telemetry.
12
+ - **Private by default.** Conversation content is not sent unless you opt in, and
13
+ emails, API keys and tokens are redacted when you do.
14
+ - **Small.** One dependency (`httpx`), Python 3.11+, fully typed.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install agents28
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ Create an agent in the Agents28 dashboard and generate its API key. The
25
+ dashboard shows the key and your API URL once.
26
+
27
+ ```bash
28
+ export AGENTS28_API_KEY="ack_..."
29
+ export AGENTS28_BASE_URL="https://..." # shown in the dashboard
30
+ ```
31
+
32
+ ```python
33
+ import agents28
34
+
35
+ client = agents28.Agents28(agent_version="support-v3")
36
+
37
+ with client.run("Refund request review", task_type="refund_review") as run:
38
+ run.message("user")
39
+
40
+ with run.tool("lookup_order"):
41
+ order = lookup_order(order_id)
42
+
43
+ with run.action("issue_refund", "business_operation"):
44
+ issue_refund(order)
45
+
46
+ run.message("assistant")
47
+ ```
48
+
49
+ That one block reports the run's start, every step with its duration and
50
+ success, and the run's completion. If an exception escapes, the run is
51
+ reported failed with the exception's type, and the exception is re-raised
52
+ unchanged.
53
+
54
+ Pending events are sent when the program exits. In long-running services,
55
+ call `client.close()` on shutdown.
56
+
57
+ ## Decorator
58
+
59
+ Every call of a decorated function is one run. It works on `async` functions
60
+ too.
61
+
62
+ ```python
63
+ @client.trace(task_title="Answer support ticket", task_type="support_ticket")
64
+ async def answer(ticket):
65
+ run = agents28.current_run()
66
+ run.message("user")
67
+ ...
68
+ ```
69
+
70
+ ## What is sent
71
+
72
+ | Call | Sent by default | Sent only with `capture_content=True` |
73
+ |---|---|---|
74
+ | `run.message(role, content)` | role | content, redacted and cut to 10,000 characters |
75
+ | `run.tool(name)` / `run.action(name, type)` | name, success, duration | `step.result_summary`, redacted and cut to 4,000 characters |
76
+ | `client.run(task_title, task_type=...)` | title and type | |
77
+
78
+ `task_title` is always redacted and kept to one line of up to 120 characters.
79
+ Don't put customer names or other personal data in it.
80
+
81
+ To change or drop any event before it leaves the process, pass a hook:
82
+
83
+ ```python
84
+ def scrub(event):
85
+ if event["event_type"] == "message":
86
+ return None # drop it
87
+ return event # or return an edited copy
88
+
89
+ client = agents28.Agents28(before_send=scrub)
90
+ ```
91
+
92
+ ## Shared knowledge between agents
93
+
94
+ Agents in the same workspace can share knowledge, even when they run on
95
+ different models:
96
+
97
+ ```python
98
+ client.publish_context(
99
+ key="customer:acme",
100
+ title="Acme renewal status",
101
+ data={"plan": "enterprise", "renewal": "2026-12-01"},
102
+ )
103
+
104
+ items = client.read_context(key="customer:acme")
105
+ ```
106
+
107
+ Each item records which agent published it, when, and its version. Items
108
+ are marked `trust="untrusted_agent_content"`: treat them as data, never as
109
+ instructions.
110
+
111
+ ## Handing work to another agent
112
+
113
+ ```python
114
+ teammates = client.list_teammates()
115
+
116
+ handoff_id = client.request_handoff(
117
+ to_agent_id=teammates[0]["agent_id"],
118
+ title="Research Acme before the renewal call",
119
+ context={"account": "acme"},
120
+ request_key="acme-renewal-research", # retrying with the same key never duplicates
121
+ )
122
+
123
+ # On the receiving agent:
124
+ for handoff in client.my_handoffs(status="requested"):
125
+ client.update_handoff(handoff["handoff_id"], "accepted")
126
+ ...
127
+ client.update_handoff(handoff["handoff_id"], "completed", result={"summary": "done"})
128
+ ```
129
+
130
+ A handoff moves from `requested` to `accepted` or `declined`. An accepted
131
+ handoff ends as `completed` or `failed`. The sender, or a person in Agents28,
132
+ can cancel an open handoff.
133
+
134
+ ## Configuration
135
+
136
+ | Argument | Environment variable | Default |
137
+ |---|---|---|
138
+ | `api_key` | `AGENTS28_API_KEY` | none: the client is disabled |
139
+ | `base_url` | `AGENTS28_BASE_URL` | none: the client is disabled |
140
+ | `agent_version` | `AGENTS28_AGENT_VERSION` | `unversioned` |
141
+ | `enabled` | `AGENTS28_ENABLED` | on when the key and URL are set |
142
+ | `capture_content` | | `False` |
143
+ | `before_send` | | none |
144
+ | `timeout` | | `5.0` seconds |
145
+ | `max_queue_size` / `batch_size` | | `5000` / `100` |
146
+
147
+ If the key or URL is missing, or the URL is not `https` (plain `http` is
148
+ allowed only for `localhost`), the client turns itself off and every call
149
+ does nothing. The same code then runs in development and CI without
150
+ credentials.
151
+
152
+ ## Reliability
153
+
154
+ - Events are batched and sent in the background. A full queue drops new
155
+ events rather than slowing your agent.
156
+ - Network errors, rate limits (`429`, honoring `Retry-After`) and server
157
+ errors are retried with exponential backoff. Every event carries a unique id
158
+ and the API stores it only once, so retries never create duplicates.
159
+ - Forked worker processes (gunicorn, multiprocessing) get their own delivery
160
+ thread automatically.
161
+ - `client.stats()` reports delivered, failed, dropped and queued counts, plus
162
+ the last problem, such as `"api_key_rejected"`. Use it in a health check.
163
+ - Warnings go to the standard `logging` logger named `agents28`.
164
+
165
+ ## Lower-level API
166
+
167
+ For full control over ids and timing, every event can also be sent directly:
168
+ `run_started`, `message`, `tool_completed`, `action_completed`,
169
+ `run_completed`, `run_failed`. Each takes `conversation_id` and `run_id`.
170
+
171
+ ## License
172
+
173
+ MIT
@@ -0,0 +1,20 @@
1
+ """Agents28: see what every AI agent in your business is working on.
2
+
3
+ Quick start::
4
+
5
+ import agents28
6
+
7
+ client = agents28.Agents28() # reads AGENTS28_API_KEY and AGENTS28_BASE_URL
8
+
9
+ with client.run("Refund request review") as run:
10
+ run.message("user")
11
+ with run.tool("lookup_order"):
12
+ ...
13
+ """
14
+
15
+ from ._run import Run, Step, current_run
16
+ from ._sanitize import redact
17
+ from ._version import __version__
18
+ from .client import Agents28
19
+
20
+ __all__ = ["Agents28", "Run", "Step", "current_run", "redact", "__version__"]
@@ -0,0 +1,206 @@
1
+ """Background delivery of events, off the agent's critical path.
2
+
3
+ Events go into a bounded in-memory queue and a daemon thread sends them in
4
+ batches. The agent never waits for the network and never sees an exception:
5
+
6
+ * A full queue drops the new event and counts it (``dropped``).
7
+ * Network errors, ``429`` and ``5xx`` are retried with exponential backoff and
8
+ jitter; ``429`` honours the server's ``Retry-After``. Retrying is safe because
9
+ the API ignores an ``event_id`` it has already stored.
10
+ * Other ``4xx`` answers are not retried: the batch is counted as ``failed`` and
11
+ the reason is kept in ``last_error``.
12
+ * After ``os.fork()`` the child process starts its own queue and thread.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import os
19
+ import queue
20
+ import random
21
+ import threading
22
+ import time
23
+ from typing import Any
24
+
25
+ import httpx
26
+
27
+ logger = logging.getLogger("agents28")
28
+
29
+ MAX_BATCH_SIZE = 100 # API limit per request
30
+ _BACKOFF_BASE_SECONDS = 0.5
31
+ _BACKOFF_MAX_SECONDS = 8.0
32
+ _RETRY_AFTER_MAX_SECONDS = 30.0
33
+
34
+
35
+ def _backoff(attempt: int) -> float:
36
+ delay = min(_BACKOFF_MAX_SECONDS, _BACKOFF_BASE_SECONDS * (2.0**attempt))
37
+ return delay * (0.5 + random.random() / 2) # full jitter, at least half
38
+
39
+
40
+ def _retry_after(response: httpx.Response) -> float | None:
41
+ try:
42
+ seconds = float(response.headers.get("retry-after", ""))
43
+ except ValueError:
44
+ return None
45
+ return max(0.0, min(_RETRY_AFTER_MAX_SECONDS, seconds))
46
+
47
+
48
+ class Delivery:
49
+ def __init__(
50
+ self,
51
+ url: str,
52
+ headers: dict[str, str],
53
+ *,
54
+ timeout: float,
55
+ max_queue_size: int,
56
+ batch_size: int,
57
+ max_retries: int = 2,
58
+ transport: httpx.BaseTransport | None = None,
59
+ ) -> None:
60
+ self.url = url
61
+ self.headers = headers
62
+ self.timeout = timeout
63
+ self.max_queue_size = max(1, int(max_queue_size))
64
+ self.batch_size = max(1, min(MAX_BATCH_SIZE, int(batch_size)))
65
+ self.max_retries = max(0, int(max_retries))
66
+ self.transport = transport
67
+
68
+ self.delivered = 0
69
+ self.failed = 0
70
+ self.dropped = 0
71
+ self.last_error: str | None = None
72
+ self._counter_lock = threading.Lock()
73
+ self._warned: set[str] = set()
74
+ self._closed = False
75
+ self._start()
76
+
77
+ # -- lifecycle -----------------------------------------------------------
78
+
79
+ def _start(self) -> None:
80
+ self._pid = os.getpid()
81
+ self._queue: queue.Queue[dict[str, Any]] = queue.Queue(
82
+ maxsize=self.max_queue_size
83
+ )
84
+ self._stop = threading.Event()
85
+ self._thread = threading.Thread(
86
+ target=self._run, daemon=True, name="agents28-delivery"
87
+ )
88
+ self._thread.start()
89
+
90
+ def _ensure_started(self) -> None:
91
+ if self._pid != os.getpid():
92
+ # Forked: the parent's thread does not exist in this process.
93
+ self._start()
94
+
95
+ def submit(self, event: dict[str, Any]) -> bool:
96
+ if self._closed:
97
+ self._count("dropped", 1)
98
+ return False
99
+ self._ensure_started()
100
+ try:
101
+ self._queue.put_nowait(event)
102
+ return True
103
+ except queue.Full:
104
+ self._count("dropped", 1)
105
+ self._warn_once(
106
+ "queue_full", "Event queue is full; new events are being dropped."
107
+ )
108
+ return False
109
+
110
+ def flush(self, timeout: float) -> bool:
111
+ deadline = time.monotonic() + max(0.0, timeout)
112
+ while self._queue.unfinished_tasks:
113
+ if time.monotonic() >= deadline:
114
+ return False
115
+ time.sleep(0.01)
116
+ return True
117
+
118
+ def close(self, timeout: float) -> bool:
119
+ if self._closed:
120
+ return True
121
+ completed = self.flush(timeout)
122
+ self._closed = True
123
+ self._stop.set()
124
+ self._thread.join(timeout=1)
125
+ return completed
126
+
127
+ @property
128
+ def queued(self) -> int:
129
+ return self._queue.unfinished_tasks
130
+
131
+ # -- worker --------------------------------------------------------------
132
+
133
+ def _run(self) -> None:
134
+ with httpx.Client(
135
+ timeout=self.timeout, headers=self.headers, transport=self.transport
136
+ ) as http:
137
+ while not self._stop.is_set() or not self._queue.empty():
138
+ try:
139
+ first = self._queue.get(timeout=0.05)
140
+ except queue.Empty:
141
+ continue
142
+ batch = [first]
143
+ while len(batch) < self.batch_size:
144
+ try:
145
+ batch.append(self._queue.get_nowait())
146
+ except queue.Empty:
147
+ break
148
+ try:
149
+ ok = self._send(http, batch)
150
+ except Exception: # never let the thread die
151
+ logger.debug("Unexpected delivery error", exc_info=True)
152
+ ok = False
153
+ finally:
154
+ for _ in batch:
155
+ self._queue.task_done()
156
+ self._count("delivered" if ok else "failed", len(batch))
157
+
158
+ def _send(self, http: httpx.Client, batch: list[dict[str, Any]]) -> bool:
159
+ for attempt in range(self.max_retries + 1):
160
+ try:
161
+ response = http.post(self.url, json={"events": batch})
162
+ except httpx.HTTPError as error:
163
+ self.last_error = f"network: {type(error).__name__}"
164
+ wait = _backoff(attempt)
165
+ else:
166
+ status = response.status_code
167
+ if status == 202:
168
+ return True
169
+ if status == 429:
170
+ self.last_error = "rate_limited"
171
+ wait = _retry_after(response) or _backoff(attempt)
172
+ elif status >= 500:
173
+ self.last_error = f"server_error: {status}"
174
+ wait = _backoff(attempt)
175
+ elif status in (401, 403):
176
+ self.last_error = "api_key_rejected"
177
+ self._warn_once(
178
+ "auth",
179
+ "The API key was rejected (HTTP %s); events are not stored.",
180
+ status,
181
+ )
182
+ return False
183
+ else:
184
+ self.last_error = f"rejected: {status}"
185
+ self._warn_once(
186
+ f"status_{status}",
187
+ "Events were rejected by the API (HTTP %s).",
188
+ status,
189
+ )
190
+ return False
191
+
192
+ if attempt < self.max_retries:
193
+ # Shutting down: retry right away instead of waiting.
194
+ self._stop.wait(wait)
195
+ return False
196
+
197
+ # -- helpers -------------------------------------------------------------
198
+
199
+ def _count(self, field: str, amount: int) -> None:
200
+ with self._counter_lock:
201
+ setattr(self, field, getattr(self, field) + amount)
202
+
203
+ def _warn_once(self, key: str, message: str, *args: object) -> None:
204
+ if key not in self._warned:
205
+ self._warned.add(key)
206
+ logger.warning("Agents28: " + message, *args)