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.
- agents28-0.1.0/CHANGELOG.md +28 -0
- agents28-0.1.0/LICENSE +21 -0
- agents28-0.1.0/MANIFEST.in +3 -0
- agents28-0.1.0/PKG-INFO +197 -0
- agents28-0.1.0/README.md +173 -0
- agents28-0.1.0/agents28/__init__.py +20 -0
- agents28-0.1.0/agents28/_delivery.py +206 -0
- agents28-0.1.0/agents28/_run.py +194 -0
- agents28-0.1.0/agents28/_sanitize.py +102 -0
- agents28-0.1.0/agents28/_version.py +1 -0
- agents28-0.1.0/agents28/client.py +516 -0
- agents28-0.1.0/agents28/py.typed +0 -0
- agents28-0.1.0/agents28.egg-info/PKG-INFO +197 -0
- agents28-0.1.0/agents28.egg-info/SOURCES.txt +24 -0
- agents28-0.1.0/agents28.egg-info/dependency_links.txt +1 -0
- agents28-0.1.0/agents28.egg-info/requires.txt +1 -0
- agents28-0.1.0/agents28.egg-info/top_level.txt +1 -0
- agents28-0.1.0/pyproject.toml +40 -0
- agents28-0.1.0/setup.cfg +4 -0
- agents28-0.1.0/tests/.gitkeep +0 -0
- agents28-0.1.0/tests/conftest.py +14 -0
- agents28-0.1.0/tests/fake_api.py +60 -0
- agents28-0.1.0/tests/test_collaboration.py +71 -0
- agents28-0.1.0/tests/test_delivery.py +154 -0
- agents28-0.1.0/tests/test_runs.py +145 -0
- agents28-0.1.0/tests/test_safety.py +115 -0
|
@@ -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.
|
agents28-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
agents28-0.1.0/README.md
ADDED
|
@@ -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)
|